From 65f726fbed0f0dd4ad803465c54c88d117e8ad85 Mon Sep 17 00:00:00 2001 From: Nils Philippsen Date: Mon, 23 Dec 2024 17:56:58 +0100 Subject: [PATCH] BOO --- rpmautospec/minigit2/__init__.py | 13 + rpmautospec/minigit2/constants.py | 8 + rpmautospec/minigit2/exc.py | 25 + rpmautospec/minigit2/native_adaptation.py | 464 ++++++++++++ rpmautospec/minigit2/wrapper.py | 689 ++++++++++++++++++ rpmautospec/pkg_history.py | 3 +- tests/rpmautospec/minigit2/__init__.py | 0 .../minigit2/test_native_adaptation.py | 15 + tests/rpmautospec/minigit2/test_wrapper.py | 317 ++++++++ 9 files changed, 1533 insertions(+), 1 deletion(-) create mode 100644 rpmautospec/minigit2/__init__.py create mode 100644 rpmautospec/minigit2/constants.py create mode 100644 rpmautospec/minigit2/exc.py create mode 100644 rpmautospec/minigit2/native_adaptation.py create mode 100644 rpmautospec/minigit2/wrapper.py create mode 100644 tests/rpmautospec/minigit2/__init__.py create mode 100644 tests/rpmautospec/minigit2/test_native_adaptation.py create mode 100644 tests/rpmautospec/minigit2/test_wrapper.py diff --git a/rpmautospec/minigit2/__init__.py b/rpmautospec/minigit2/__init__.py new file mode 100644 index 0000000..e75c462 --- /dev/null +++ b/rpmautospec/minigit2/__init__.py @@ -0,0 +1,13 @@ +"""Minimal wrapper for libgit2 + +This package wraps the functionality of libgit2 used by rpmautospec (and +only that), aiming to mimic the pygit2 API (of the minimum version +supported, 1.1). + +The reason why we don’t use pygit2 itself is because it makes +bootstrapping rpmautospec hairy (e.g. for a new Python version). +""" + +from .constants import GIT_REPOSITORY_OPEN_NO_SEARCH +from .exc import GitError +from .wrapper import Commit, Repository, Tree diff --git a/rpmautospec/minigit2/constants.py b/rpmautospec/minigit2/constants.py new file mode 100644 index 0000000..5c7fa29 --- /dev/null +++ b/rpmautospec/minigit2/constants.py @@ -0,0 +1,8 @@ +from . import native_adaptation + +GIT_DIFF_OPTIONS_VERSION = 1 + +GIT_OID_RAWSZ = GIT_OID_SHA1_SIZE = 20 +GIT_OID_HEXSZ = GIT_OID_SHA1_HEXSIZE = GIT_OID_SHA1_SIZE * 2 + +GIT_REPOSITORY_OPEN_NO_SEARCH = native_adaptation.git_repository_open_flag_t.NO_SEARCH diff --git a/rpmautospec/minigit2/exc.py b/rpmautospec/minigit2/exc.py new file mode 100644 index 0000000..51345bf --- /dev/null +++ b/rpmautospec/minigit2/exc.py @@ -0,0 +1,25 @@ +"""Minimal wrapper for libgit2 - Exceptions and Warnings""" + + +class Libgit2Error(Exception): + pass + + +class Libgit2NotFoundError(Libgit2Error): + pass + + +class Libgit2VersionError(Libgit2Error): + pass + + +class Libgit2VersionWarning(UserWarning): + pass + + +class GitError(Exception): + pass + + +class GitPeelError(GitError): + pass diff --git a/rpmautospec/minigit2/native_adaptation.py b/rpmautospec/minigit2/native_adaptation.py new file mode 100644 index 0000000..89f6087 --- /dev/null +++ b/rpmautospec/minigit2/native_adaptation.py @@ -0,0 +1,464 @@ +"""Minimal wrapper for libgit2 - Native Adaptation""" + +from ctypes import ( + CDLL, + CFUNCTYPE, + POINTER, + Structure, + c_char, + c_char_p, + c_int, + c_size_t, + c_uint, + c_uint32, + c_uint64, + c_void_p, + cast, +) +from enum import IntEnum, IntFlag, auto + +NULL = cast(POINTER(c_int)(), c_void_p) + + +# Simple types + +git_object_size_t = c_uint64 +git_off_t = c_uint64 + + +class IntEnumMixin: + @classmethod + def from_param(cls, obj): + return int(obj) + + +class git_error_code(IntEnumMixin, IntEnum): + # @staticmethod + def _generate_next_value_(name, start, count, last_values): + last_value = sorted(last_values)[0] + return last_value - 1 + + OK = 0 + ERROR = auto() + + ENOTFOUND = -3 + EEXISTS = auto() + EAMBIGUOUS = auto() + EBUFS = auto() + + EUSER = auto() + + EBAREREPO = auto() + EUNBORNBRANCH = auto() + EUNMERGED = auto() + ENONFASTFORWARD = auto() + EINVALIDSPEC = auto() + ECONFLICT = auto() + ELOCKED = auto() + EMODIFIED = auto() + EAUTH = auto() + ECERTIFICATE = auto() + EAPPLIED = auto() + EPEEL = auto() + EEOF = auto() + EINVALID = auto() + EUNCOMMITTED = auto() + EDIRECTORY = auto() + EMERGECONFLICT = auto() + + PASSTHROUGH = -30 + ITEROVER = auto() + RETRY = auto() + EMISMATCH = auto() + EINDEXDIRTY = auto() + EAPPLYFAIL = auto() + + +class git_error_t(IntEnumMixin, IntEnum): + NONE = 0 + NOMEMORY = auto() + OS = auto() + INVALID = auto() + REFERENCE = auto() + ZLIB = auto() + REPOSITORY = auto() + CONFIG = auto() + REGEX = auto() + ODB = auto() + INDEX = auto() + OBJECT = auto() + NET = auto() + TAG = auto() + TREE = auto() + INDEXER = auto() + SSL = auto() + SUBMODULE = auto() + THREAD = auto() + STASH = auto() + CHECKOUT = auto() + FETCHHEAD = auto() + MERGE = auto() + SSH = auto() + FILTER = auto() + REVERT = auto() + CALLBACK = auto() + CHERRYPICK = auto() + DESCRIBE = auto() + REBASE = auto() + FILESYSTEM = auto() + PATCH = auto() + WORKTREE = auto() + SHA = auto() + HTTP = auto() + INTERNAL = auto() + + +class git_repository_item_t(IntEnumMixin, IntEnum): + GITDIR = 0 + WORKDIR = auto() + COMMONDIR = auto() + INDEX = auto() + OBJECTS = auto() + REFS = auto() + PACKED_REFS = auto() + REMOTES = auto() + CONFIG = auto() + INFO = auto() + HOOKS = auto() + LOGS = auto() + MODULES = auto() + WORKTREES = auto() + + +class git_repository_open_flag_t(IntEnumMixin, IntFlag): + NO_SEARCH = 1 << 0 + CROSS_FS = auto() + BARE = auto() + NO_DOTGIT = auto() + FROM_ENV = auto() + + +class git_reference_t(IntEnumMixin, IntFlag): + INVALID = 0 + DIRECT = auto() + SYMBOLIC = auto() + ALL = auto() + + +class git_object_t(IntEnumMixin, IntEnum): + ANY = -2 + INVALID = -1 + COMMIT = 1 + TREE = auto() + BLOB = auto() + TAG = auto() + OFS_DELTA = auto() + REF_DELTA = auto() + + +class git_diff_option_t(IntEnumMixin, IntFlag): + NORMAL = 0 + REVERSE = auto() + INCLUDE_IGNORED = auto() + RECURSE_IGNORED_DIRS = auto() + INCLUDE_UNTRACKED = auto() + RECURSE_UNTRACKED_DIRS = auto() + INCLUDE_UNMODIFIED = auto() + INCLUDE_TYPECHANGE = auto() + INCLUDE_TYPECHANGE_TREES = auto() + IGNORE_FILEMODE = auto() + IGNORE_SUBMODULES = auto() + IGNORE_CASE = auto() + INCLUDE_CASECHANGE = auto() + DISABLE_PATHSPEC_MATCH = auto() + SKIP_BINARY_CHECK = auto() + ENABLE_FAST_UNTRACKED_DIRS = auto() + UPDATE_INDEX = auto() + INCLUDE_UNREADABLE = auto() + INCLUDE_UNREADABLE_AS_UNTRACKED = auto() + INDENT_HEURISTIC = auto() + IGNORE_BLANK_LINES = auto() + FORCE_TEXT = auto() + FORCE_BINARY = auto() + IGNORE_WHITESPACE = auto() + IGNORE_WHITESPACE_CHANGE = auto() + IGNORE_WHITESPACE_EOL = auto() + SHOW_UNTRACKED_CONTENT = auto() + SHOW_UNMODIFIED = auto() + PATIENCE = auto() + MINIMAL = auto() + SHOW_BINARY = auto() + + +class git_submodule_ignore_t(IntEnumMixin, IntEnum): + UNSPECIFIED = -1 + NONE = 1 + UNTRACKED = auto() + DIRTY = auto() + ALL = auto() + + +class git_oid_t(IntEnumMixin, IntEnum): + SHA1 = 1 + + +class git_sort_t(IntEnumMixin, IntFlag): + NONE = 0 + TOPOLOGICAL = auto() + TIME = auto() + REVERSE = auto() + + +# Compound types + + +class git_error(Structure): + _fields_ = ( + ("message", c_char_p), + ("klass", c_int), + ) + + +git_error_p = POINTER(git_error) +git_error_p_p = POINTER(git_error_p) + + +class git_buf(Structure): + _fields_ = ( + ("ptr", c_char_p), + ("reserved", c_size_t), + ("size", c_size_t), + ) + + +git_buf_p = POINTER(git_buf) +git_buf_p_p = POINTER(git_buf_p) + + +class git_strarray(Structure): + _fields_ = ( + ("strings", POINTER(c_char_p)), + ("count", c_size_t), + ) + + +git_strarray_p = POINTER(git_strarray) +git_strarray_p_p = POINTER(git_strarray_p) + + +class git_revwalk(Structure): + pass + + +git_revwalk_p = POINTER(git_revwalk) +git_revwalk_p_p = POINTER(git_revwalk_p) + + +class git_repository(Structure): + pass + + +git_repository_p = POINTER(git_repository) +git_repository_p_p = POINTER(git_repository_p) + + +class git_oid(Structure): + _fields_ = (("id", c_char * 20),) + + +git_oid_p = POINTER(git_oid) +git_oid_p_p = POINTER(git_oid_p) + + +class git_reference(Structure): + pass + + +git_reference_p = POINTER(git_reference) +git_reference_p_p = POINTER(git_reference_p) + + +class git_object(Structure): + pass + + +git_object_p = POINTER(git_object) +git_object_p_p = POINTER(git_object_p) + + +class git_commit(Structure): + pass + + +git_commit_p = POINTER(git_commit) +git_commit_p_p = POINTER(git_commit_p) + + +class git_tree(Structure): + pass + + +git_tree_p = POINTER(git_tree) +git_tree_p_p = POINTER(git_tree_p) + + +class git_tree_entry(Structure): + pass + + +git_tree_entry_p = POINTER(git_tree_entry) +git_tree_entry_p_p = POINTER(git_tree_entry_p) + + +class git_tag(Structure): + pass + + +git_tag_p = POINTER(git_tag) +git_tag_p_p = POINTER(git_tag_p) + + +class git_blob(Structure): + pass + + +git_blob_p = POINTER(git_blob) +git_blob_p_p = POINTER(git_blob_p) + + +class git_diff_stats(Structure): + pass + + +git_diff_stats_p = POINTER(git_diff_stats) +git_diff_stats_p_p = POINTER(git_diff_stats_p) + + +class git_diff(Structure): + pass + + +git_diff_p = POINTER(git_diff) +git_diff_p_p = POINTER(git_diff_p) + + +class git_diff_delta(Structure): + pass + + +git_diff_delta_p = POINTER(git_diff_delta) +git_diff_delta_p_p = POINTER(git_diff_delta_p) + + +git_diff_notify_cb = CFUNCTYPE(c_int, git_diff_p, git_diff_delta_p, c_char_p, c_void_p) +git_diff_progress_cb = CFUNCTYPE(c_int, git_diff_p, c_char_p, c_char_p, c_void_p) + + +class git_diff_options(Structure): + _fields_ = ( + ("version", c_uint), + ("flags", c_uint32), + ("ignore_submodules", c_int), + ("pathspec", git_strarray), + ("notify_cb", git_diff_notify_cb), + ("progress_cb", git_diff_progress_cb), + ("payload", c_void_p), + ("context_lines", c_uint32), + ("interhunk_lines", c_uint32), + ("oid_type", c_int), # Added in libgit2 v1.7.0 + ("id_abbrev", c_uint32), + ("max_size", git_off_t), + ("old_prefix", c_char_p), + ("new_prefix", c_char_p), + ) + + +git_diff_options_p = POINTER(git_diff_options) +git_diff_options_p_p = POINTER(git_diff_options_p) + + +class git_index(Structure): + pass + + +git_index_p = POINTER(git_index) +git_index_p_p = POINTER(git_index_p) + + +# Native function declarations + +FUNC_DECLS = { + "git_blob_create_from_buffer": (c_int, (git_oid_p, git_repository_p, c_void_p, c_size_t)), + "git_blob_free": (None, (git_blob_p,)), + "git_blob_rawcontent": (c_void_p, (git_blob_p,)), + "git_blob_rawsize": (git_object_size_t, (git_blob_p,)), + "git_commit_free": (None, (git_commit_p,)), + "git_commit_lookup": (c_int, (git_commit_p_p, git_repository_p, git_oid_p)), + "git_commit_message": (c_char_p, (git_commit_p,)), + "git_commit_message_encoding": (c_char_p, (git_commit_p,)), + "git_commit_parent": (c_int, (git_commit_p_p, git_commit_p, c_uint)), + "git_commit_parentcount": (c_uint, (git_commit_p,)), + "git_commit_tree": (c_int, (git_tree_p_p, git_commit_p)), + "git_diff_get_stats": (c_int, (git_diff_stats_p_p, git_diff_p)), + "git_diff_options_init": (c_int, (git_diff_options_p, c_uint)), + "git_diff_stats_free": (None, (git_diff_stats_p,)), + "git_diff_tree_to_tree": ( + c_int, + (git_diff_p_p, git_repository_p, git_tree_p, git_tree_p, git_diff_options_p), + ), + "git_diff_tree_to_workdir": ( + c_int, (git_diff_p_p, git_repository_p, git_tree_p, git_diff_options_p) + ), + "git_error_last": (git_error_p, ()), + "git_index_free": (None, (git_index_p,)), + "git_libgit2_init": (c_int, ()), + "git_object_free": (None, (git_object_p,)), + "git_object_id": (git_oid_p, (git_object_p,)), + "git_object_lookup_prefix": ( + c_int, + (git_object_p_p, git_repository_p, git_oid_p, c_size_t, git_object_t), + ), + "git_object_peel": (c_int, (git_object_p_p, git_object_p, git_object_t)), + "git_object_short_id": (c_int, (git_buf_p, git_object_p)), + "git_object_type": (git_object_t, (git_object_p,)), + "git_oid_fmt": (c_int, (c_char_p, git_oid_p)), + "git_oid_fromstrp": (c_int, (git_oid_p, c_char_p)), + "git_reference_symbolic_target": (c_char_p, (git_reference_p,)), + "git_reference_target": (git_oid_p, (git_reference_p,)), + "git_reference_type": (git_reference_t, (git_reference_p,)), + "git_repository_free": (None, (git_repository_p,)), + "git_repository_head": (c_int, (git_reference_p_p, git_repository_p)), + "git_repository_index": (c_int, (git_index_p_p, git_repository_p)), + "git_repository_item_path": (c_int, (git_buf_p, git_repository_p, git_repository_item_t)), + "git_repository_open_ext": (c_int, (git_repository_p_p, c_char_p, c_uint, c_char_p)), + "git_revparse_single": (c_int, (git_object_p_p, git_repository_p, c_char_p)), + "git_revwalk_free": (None, (git_revwalk_p,)), + "git_revwalk_new": (c_int, (git_revwalk_p_p, git_repository_p)), + "git_revwalk_next": (c_int, (git_oid_p, git_revwalk_p)), + "git_revwalk_push": (c_int, (git_revwalk_p, git_oid_p)), + "git_revwalk_sorting": (c_int, (git_revwalk_p, c_uint)), + "git_tag_free": (None, (git_tag_p,)), + "git_tree_entry_bypath": (c_int, (git_tree_entry_p_p, git_tree_p, c_char_p)), + "git_tree_entry_free": (None, (git_tree_entry_p,)), + "git_tree_entry_to_object": (c_int, (git_object_p_p, git_repository_p, git_tree_entry_p)), + "git_tree_free": (None, (git_tree_p,)), +} + + +# Functions to set up the wrapper + + +def apply_version_compat(version: tuple[int]): + if version < (1, 7, 0): + git_diff_options._fields_ = tuple( + (memname, memtype) + for memname, memtype in git_diff_options._fields_ + if memname != "oid_type" + ) + + +def install_func_decls(lib: CDLL) -> None: + for func_name, (restype, argtypes) in FUNC_DECLS.items(): + func = getattr(lib, func_name) + func.restype = restype + func.argtypes = argtypes diff --git a/rpmautospec/minigit2/wrapper.py b/rpmautospec/minigit2/wrapper.py new file mode 100644 index 0000000..61a6200 --- /dev/null +++ b/rpmautospec/minigit2/wrapper.py @@ -0,0 +1,689 @@ +"""Minimal wrapper for libgit2 - High Level Wrappers""" + +import re +from collections.abc import Iterator, Sequence +from ctypes import ( + CDLL, + _CFuncPtr, + _SimpleCData, + byref, + c_char, + c_char_p, + c_uint, + cast, + memmove, + sizeof, +) +from ctypes.util import find_library +from functools import cached_property +from pathlib import Path +from typing import Any, Literal, Optional, Union, overload +from warnings import warn + +from .constants import GIT_DIFF_OPTIONS_VERSION, GIT_OID_SHA1_HEXSIZE +from .exc import ( + GitError, + GitPeelError, + Libgit2NotFoundError, + Libgit2VersionError, + Libgit2VersionWarning, +) +from .native_adaptation import ( + NULL, + git_blob_p, + git_buf, + git_commit_p, + git_diff_option_t, + git_diff_options, + git_diff_p, + git_diff_stats_p, + git_error_code, + git_index_p, + git_object_p, + git_object_t, + git_oid, + git_oid_p, + git_reference_p, + git_reference_t, + git_repository_p, + git_revwalk_p, + git_sort_t, + git_tag_p, + git_tree_entry_p, + git_tree_p, + install_func_decls, +) + +LIBGIT2_MIN_VERSION = (1, 1) +LIBGIT2_MAX_VERSION = (1, 8) +LIBGIT2_MIN_VERSION_STR = ".".join(str(x) for x in LIBGIT2_MIN_VERSION) +LIBGIT2_MAX_VERSION_STR = ".".join(str(x) for x in LIBGIT2_MAX_VERSION) + + +class WrapperOfWrappings: + """Base class wrapping pieces of libgit2.""" + + _soname: Optional[str] = None + _libgit2: Optional[CDLL] = None + _libgit2_obj_destructor: Optional[Union[_CFuncPtr, str]] = None + + _obj: Optional[Any] = None + + def __del__(self) -> None: + if self._libgit2_obj_destructor and self._obj: + cls = type(self) + if isinstance(cls._libgit2_obj_destructor, str): + cls._libgit2_obj_destructor = getattr(self._lib, cls._libgit2_obj_destructor) + cls._libgit2_obj_destructor(self._obj) + self._obj = None + + @classmethod + def _get_library(cls) -> CDLL: + """Discover and load libgit2. + + This caches the loaded library object in the class. + + :return: The loaded library + """ + if not WrapperOfWrappings._libgit2: + soname = find_library("git2") + if not soname: + raise Libgit2NotFoundError("libgit2 not found") + if not (match := re.match(r"libgit2\.so\.(?P\d+(?:\.\d+)*)", soname)): + raise Libgit2VersionError(f"Can’t parse libgit2 version: {soname}") + version = match.group("version") + version_tuple = tuple(int(x) for x in match.group("version").split(".")) + if LIBGIT2_MIN_VERSION > version_tuple[: len(LIBGIT2_MIN_VERSION)]: + raise Libgit2VersionError( + f"Version {version} of libgit2 too low (must be >= {LIBGIT2_MIN_VERSION_STR})" + ) + if LIBGIT2_MAX_VERSION < version_tuple[: len(LIBGIT2_MAX_VERSION)]: + warn( + f"Version {version} of libgit2 unknown (latest known is" + + f" {LIBGIT2_MIN_VERSION_STR}.)", + Libgit2VersionWarning, + ) + + WrapperOfWrappings._soname = soname + WrapperOfWrappings._libgit2 = CDLL(soname) + install_func_decls(WrapperOfWrappings._libgit2) + WrapperOfWrappings._libgit2.git_libgit2_init() + + return WrapperOfWrappings._libgit2 + + @cached_property + def _lib(self) -> CDLL: + """The loaded library.""" + return self._get_library() + + @classmethod + def raise_if_error( + cls, error_code: int, exc_msg_tmpl: Optional[str] = None, exc_class: Exception = GitError + ) -> None: + if not error_code: + return + + error_p = cls._get_library().git_error_last() + message = error_p.contents.message.decode("utf-8", errors="replace") + if exc_msg_tmpl: + message = exc_msg_tmpl.format(message=message) + raise exc_class(message) + + +OidTypes = Union["Oid", str, bytes] + + +class Oid(WrapperOfWrappings): + """Represent a git oid.""" + + _obj: Optional[git_oid] = None + + def __init__( + self, *, native: Optional[git_oid_p] = None, oid: Optional[OidTypes] = None + ) -> None: + if bool(native) is bool(oid): + raise ValueError("Exactly one of native or oid has to be specified") + + if native: + src = native + native = git_oid() + dst = byref(native) + memmove(dst, src, sizeof(git_oid)) + else: + assert oid + if isinstance(oid, Oid): + native = oid._obj + else: + if isinstance(oid, str): + oid = oid.encode("ascii") + native = git_oid() + error_code = self._lib.git_oid_fromstrp(native, oid) + self.raise_if_error(error_code, "Error creating Oid: {message}") + + self._obj = native + + def __eq__(self, other: "Oid") -> bool: + return self._obj.id == other._obj.id + + @cached_property + def hexb(self) -> bytes: + buf = (c_char * GIT_OID_SHA1_HEXSIZE)() + error_code = self._lib.git_oid_fmt(buf, self._obj) + self.raise_if_error(error_code, "Can’t format Oid: {message}") + return buf.value + + @cached_property + def hex(self) -> str: + return self.hexb.decode("ascii") + + def __str__(self) -> str: + return self.hex + + +class Repository(WrapperOfWrappings): + """Represent a git repository.""" + + _libgit2_obj_destructor = "git_repository_free" + + _obj: Optional[git_repository_p] = None + + def __init__(self, path: Union[str, Path], flags: int = 0) -> None: + if isinstance(path, Path): + path = str(path) + path_c = c_char_p(path.encode("utf-8")) + + self._obj = git_repository_p() + + error_code = self._lib.git_repository_open_ext( + self._obj, path_c, c_uint(flags), cast(NULL, c_char_p) + ) + + self.raise_if_error(error_code, "Can’t open repository: {message}") + + def __getitem__(self, oid: OidTypes) -> "Object": + return Object(repo=self, oid=oid).wrap() + + def _coerce_to_object_and_peel( + self, + obj: Optional[Union["Object", str, bytes, Oid]], + peel_types: Sequence[git_object_t] = (git_object_t.BLOB, git_object_t.TREE), + ) -> Optional["Object"]: + if obj is None: + return + + _obj = obj + + if isinstance(obj, (str, bytes)): + obj = self.revparse_single(obj) + elif isinstance(obj, Oid): + obj = self[obj] + + for peel_type in peel_types: + try: + obj = obj.peel(target_type=peel_type) + except GitError: + pass + else: + break + else: + raise TypeError(f'unexpected "{type(_obj)}"') + + return obj.wrap() + + @property + def head(self) -> "Reference": + head_ref_p = git_reference_p() + error_code = self._lib.git_repository_head(head_ref_p, self._obj) + self.raise_if_error(error_code, "Can’t resolve HEAD: {message}") + return Reference(repo=self, native=head_ref_p) + + @property + def index(self) -> "Index": + index_p = git_index_p() + error_code = self._lib.git_repository_index(index_p, self._obj) + self.raise_if_error(error_code, "Error getting repository index: {message}") + return Index(repo=self, native=index_p) + + def revparse_single(self, revision: Union[str, bytes]) -> "Object": + if isinstance(revision, str): + revision = revision.encode("utf-8") + + git_object = git_object_p() + error_code = self._lib.git_revparse_single(git_object, self._obj, revision) + self.raise_if_error(error_code, "Error parsing revision: {message}") + return Object(repo=self, native=git_object) + + def diff( + self, + a: Optional[Union["Commit", "Reference"]] = None, + b: Optional[Union["Commit", "Reference"]] = None, + cached: bool = False, + flags: git_diff_option_t = git_diff_option_t.NORMAL, + context_lines: int = 3, + interhunk_lines: int = 0, + ) -> ...: + a = self._coerce_to_object_and_peel(a) + b = self._coerce_to_object_and_peel(b) + + opts = { + "flags": int(flags), + "context_lines": context_lines, + "interhunk_lines": interhunk_lines, + } + + if isinstance(a, Tree) and isinstance(b, Tree): + return a.diff_to_tree(b, **opts) + elif a is None and b is None: + return self.index.diff_to_workdir(*opts.values()) + elif isinstance(a, Tree) and b is None: + if cached: + return a.diff_to_index(self.index, *opts.values()) + else: + return a.diff_to_workdir(*opts.values()) + elif isinstance(a, Blob) and isinstance(b, Blob): + return a.diff(b) + + raise ValueError("Only blobs and treeish can be diffed") + + def walk(self, oid: Optional[Oid] = None, sort: git_sort_t = git_sort_t.NONE) -> "RevWalk": + revwalk = git_revwalk_p() + error_code = self._lib.git_revwalk_new(revwalk, self._obj) + self.raise_if_error(error_code, "Can’t allocate revwalk: {message}") + + error_code = self._lib.git_revwalk_sorting(revwalk, sort) + self.raise_if_error(error_code, "Can’t set sorting on revwalk: {message}") + + error_code = self._lib.git_revwalk_push(revwalk, oid._obj) + self.raise_if_error(error_code, "Can’t set revwalk to Oid: {message}") + + return RevWalk(repo=self, native=revwalk) + + +class Index(WrapperOfWrappings): + """Represent the git index.""" + + _libgit2_obj_destructor = "git_index_free" + + _repo: Repository + _obj: Optional[git_index_p] + + def __init__(self, repo: Repository, native: git_index_p) -> None: + self._repo = repo + self._obj = native + + +class Reference(WrapperOfWrappings): + """Represent a git reference.""" + + _libgit2_obj_destructor = "git_reference_free" + + _repo: Repository + _obj: Optional[git_reference_p] = None + + def __init__(self, repo: Repository, native: git_reference_p) -> None: + self._repo = repo + self._obj = native + + @property + def target(self) -> Union[Oid, str]: + if self._lib.git_reference_type(self._obj) == git_reference_t.DIRECT: + return Oid(native=self._lib.git_reference_target(self._obj)) + + if not (name := self._lib.git_reference_symbolic_target(self._obj)): + raise ValueError("no target available") + + return name.value + + +class DiffStats(WrapperOfWrappings): + """Represent diff statistics.""" + + _libgit2_obj_destructor = "git_diff_stats_free" + + _diff: "Diff" + _obj: Optional[git_diff_stats_p] = None + + def __init__(self, diff: "Diff", native: git_diff_stats_p) -> None: + self._diff = diff + self._obj = native + + @property + def files_changed(self) -> int: + return self._lib.git_diff_stats_files_changed(self._obj) + + +class Diff(WrapperOfWrappings): + """Represent a diff.""" + + _object_type = git_diff_p + + _repo: Repository + _obj: Optional[git_diff_p] = None + + def __init__(self, repo: Repository, native: git_diff_p) -> None: + self._repo = repo + self._obj = native + + @cached_property + def stats(self) -> DiffStats: + native = git_diff_stats_p() + error_code = self._lib.git_diff_get_stats(native, self._obj) + self.raise_if_error(error_code, "Can’t get diff stats: {message}") + return DiffStats(diff=self, native=native) + + +ObjectTypes = Union[git_object_p, git_commit_p, git_tree_p, git_tag_p, git_blob_p] + + +class Object(WrapperOfWrappings): + """Represent a generic git object.""" + + _libgit2_obj_destructor = "git_object_free" + + _object_type: _SimpleCData = git_object_p + _object_t: git_object_t + _object_t_to_cls: dict[git_object_t, "Object"] = {} + + _repo: Repository + _obj: Optional[ObjectTypes] = None + _delegate: Optional["Object"] = None + + def __init_subclass__(cls): + if not hasattr(cls, "_libgit2_obj_destructor"): + cls._libgit2_obj_destructor = None + if cls._object_t in cls._object_t_to_cls: # pragma: no cover + raise TypeError(f"Object type already registered: {cls._object_t.name}") + cls._object_t_to_cls[cls._object_t] = cls + super().__init_subclass__() + + def __init__( + self, + repo: Repository, + *, + native: Optional[ObjectTypes] = None, + oid: Optional[OidTypes] = None, + _delegate: Optional["Object"] = None, + ) -> None: + if bool(native) is bool(oid): + raise ValueError("Exactly one of native or oid has to be specified") + + if oid: + oid = Oid(oid=oid) + native = self._object_type() + error_code = self._lib.git_object_lookup_prefix( + native, repo._obj, oid._obj, len(oid.hexb), git_object_t.ANY + ) + self.raise_if_error(error_code, "Can’t lookup object: {message}") + + self._repo = repo + self._obj = cast(native, self._object_type) + self._delegate = _delegate + + def __del__(self) -> None: + if not self._delegate: + super().__del__() + + def __repr__(self) -> str: + return f"{self.__class__.__name__}(oid={self.short_id!r})" + + def __eq__(self, other: "Object") -> bool: + return isinstance(other, Object) and self.id == other.id + + def __hash__(self) -> int: + return hash(self.id.hex) + + @cached_property + def id(self) -> Oid: + return Oid(native=self._lib.git_object_id(cast(self._obj, git_object_p))) + + @cached_property + def short_id(self) -> str: + buf = git_buf() + buf_p = byref(buf) + error_code = self._lib.git_object_short_id(buf_p, cast(self._obj, git_object_p)) + self.raise_if_error(error_code, "Error determining short id: {message}") + return buf.ptr.decode("ascii") + + def wrap(self) -> "Object": + object_t = self._lib.git_object_type(cast(self._obj, git_object_p)) + try: + concrete_class = self._object_t_to_cls[object_t] + except KeyError: # pragma: no cover + raise TypeError(f"unexpected object type: {object_t.name}") + return concrete_class(repo=self._repo, native=self._obj, _delegate=self._delegate or self) + + @overload + def peel(self, target_type: Literal[git_object_t.COMMIT]) -> "Commit": ... + + @overload + def peel(self, target_type: Literal[git_object_t.TREE]) -> "Tree": ... + + @overload + def peel(self, target_type: Literal[git_object_t.TAG]) -> "Tag": ... + + @overload + def peel(self, target_type: Literal[git_object_t.BLOB]) -> "Blob": ... + + @overload + def peel(self, target_type: None) -> "Union[Commit, Tree, Blob]": ... + + def peel(self, target_type: Optional[git_object_t]) -> "Union[Commit, Tree, Tag, Blob]": + if not target_type: + target_type = git_object_t.ANY + + peeled = git_object_p() + error_code = self._lib.git_object_peel(peeled, cast(self._obj, git_object_p), target_type) + self.raise_if_error(error_code, "Can’t peel object: {message}", exc_class=GitPeelError) + + return Object(repo=self._repo, native=peeled).wrap() + + +CommitTypes = Union[git_commit_p, Oid, str, bytes] + + +class Commit(Object): + """Represent a git commit.""" + + _libgit2_obj_destructor = "git_commit_free" + + _object_type = git_commit_p + _object_t = git_object_t.COMMIT + + _obj: Optional[git_commit_p] = None + + @cached_property + def parents(self) -> list["Commit"]: + n_parents = self._lib.git_commit_parentcount(self._obj) + parents = [] + for n in range(n_parents): + native = git_commit_p() + error_code = self._lib.git_commit_parent(native, self._obj, n) + self.raise_if_error(error_code, "Error getting parent: {message}") + parents.append(Commit(repo=self._repo, native=native)) + return parents + + @cached_property + def tree(self) -> "Tree": + native = git_tree_p() + error_code = self._lib.git_commit_tree(native, self._obj) + self.raise_if_error(error_code, "Error retrieving tree: {message}") + return Tree(repo=self._repo, native=native) + + @cached_property + def message(self) -> "str": + encoding = self._lib.git_commit_message_encoding(self._obj) + if encoding: + encoding = encoding.decode("ascii") + else: + encoding = "utf-8" + message = self._lib.git_commit_message(self._obj) + return message.decode(encoding=encoding, errors="replace") + + +class Tree(Object): + """Represent a git tree.""" + + _libgit2_obj_destructor = "git_tree_free" + + _object_type = git_tree_p + _object_t = git_object_t.TREE + + _obj: Optional[git_tree_p] = None + + def diff_to_tree( + self, + tree: "Tree", + flags: git_diff_option_t = git_diff_option_t.NORMAL, + context_lines: int = 3, + interhunk_lines: int = 0, + swap: bool = False, + ) -> Diff: + diff_options = git_diff_options() + error_code = self._lib.git_diff_options_init(diff_options, GIT_DIFF_OPTIONS_VERSION) + self.raise_if_error(error_code, "Can’t initialize diff options: {message}") + + diff_options.flags = flags + diff_options.context_lines = context_lines + diff_options.interhunk_lines = interhunk_lines + + diff_p = git_diff_p() + + if swap: + a, b = tree._obj, self._obj + else: + a, b = self._obj, tree._obj + + error_code = self._lib.git_diff_tree_to_tree(diff_p, self._repo._obj, a, b, diff_options) + self.raise_if_error(error_code, "Error diffing tree to tree: {message}") + + return Diff(self._repo, diff_p) + + def diff_to_workdir( + self, + flags: git_diff_option_t = git_diff_option_t.NORMAL, + context_lines: int = 3, + interhunk_lines: int = 0, + ) -> Diff: + diff_options = git_diff_options() + error_code = self._lib.git_diff_options_init(diff_options, GIT_DIFF_OPTIONS_VERSION) + self.raise_if_error(error_code, "Can’t initialize diff options: {message}") + + diff_options.flags = flags + diff_options.context_lines = context_lines + diff_options.interhunk_lines = interhunk_lines + + diff_p = git_diff_p() + + error_code = self._lib.git_diff_tree_to_workdir( + diff_p, self._repo._obj, self._obj, diff_options + ) + self.raise_if_error(error_code, "Error diffing tree to workdir: {message}") + + return Diff(self._repo, diff_p) + + def _lookup_tree_entry(self, path: Union[str, bytes]) -> "_TreeEntry": + if isinstance(path, str): + path = path.encode("utf-8") + + native = git_tree_entry_p() + error_code = self._lib.git_tree_entry_bypath(native, self._obj, path) + if error_code == git_error_code.ENOTFOUND: + raise KeyError + self.raise_if_error(error_code, "Error looking up file in tree: {message}") + + return _TreeEntry(repo=self._repo, native=native) + + def __contains__(self, path: Union[str, bytes]) -> bool: + try: + self._lookup_tree_entry(path) + except KeyError: + return False + else: + return True + + def __getitem__(self, path: Union[str, bytes]) -> "_TreeEntry": + return self._lookup_tree_entry(path).wrap() + + +class _TreeEntry(WrapperOfWrappings): + """Represent an entry in a git tree.""" + + _libgit2_obj_destructor = "git_tree_entry_free" + + _repo: Repository + _obj: Optional[git_tree_entry_p] + + def __init__(self, repo: Repository, native: git_tree_entry_p) -> None: + self._repo = repo + self._obj = native + + def wrap(self) -> Object: + native = git_object_p() + error_code = self._lib.git_tree_entry_to_object(native, self._repo._obj, self._obj) + self.raise_if_error(error_code, "Error accessing object for tree entry: {message}") + + obj = Object(repo=self._repo, native=native) + return obj.wrap() + + +class Tag(Object): + """Represent a git tag.""" + + _libgit2_obj_destructor = "git_tag_free" + + _object_type = git_tag_p + _object_t = git_object_t.TAG + + _obj: Optional[git_tag_p] = None + + +class Blob(Object): + """Represent a git blob.""" + + _libgit2_obj_destructor = "git_blob_free" + + _object_type = git_blob_p + _object_t = git_object_t.BLOB + + _obj: Optional[git_blob_p] = None + + @cached_property + def data(self) -> bytes: + rawsize = self._lib.git_blob_rawsize(self._obj) + rawcontent_p = self._lib.git_blob_rawcontent(self._obj) + self.raise_if_error(not rawcontent_p, "Error accessing blob content: {message}") + + buf = (c_char * rawsize)() + memmove(buf, rawcontent_p, rawsize) + return bytes(buf) + + +class RevWalk(WrapperOfWrappings, Iterator): + """Represent a walk over commits in a repository.""" + + _libgit2_obj_destructor = "git_revwalk_free" + + _repo: Repository + _obj = Optional[git_revwalk_p] + + def __init__(self, repo: Repository, native: git_revwalk_p) -> None: + self._repo = repo + self._obj = native + + def __iter__(self) -> Iterator[Commit]: + return self + + def __next__(self) -> Commit: + oid = git_oid() + oid_p = byref(oid) + + error_code = self._lib.git_revwalk_next(oid_p, self._obj) + if error_code == git_error_code.ITEROVER: + raise StopIteration + self.raise_if_error(error_code, "Can’t find next oid to walk: {message}") + + commit = git_commit_p() + error_code = self._lib.git_commit_lookup(commit, self._repo._obj, oid_p) + self.raise_if_error(error_code, "Can’t lookup commit for oid: {message}") + + return Commit(repo=self._repo, native=commit) diff --git a/rpmautospec/pkg_history.py b/rpmautospec/pkg_history.py index 6527ffe..f2797f6 100644 --- a/rpmautospec/pkg_history.py +++ b/rpmautospec/pkg_history.py @@ -11,7 +11,7 @@ from tempfile import NamedTemporaryFile, TemporaryDirectory from typing import Any, Optional, Sequence, Union -import pygit2 +# import pygit2 import rpm try: @@ -20,6 +20,7 @@ from .compat import MinimalBlobIO as BlobIO from rpmautospec_core import AUTORELEASE_MACRO +from . import minigit2 as pygit2 from .changelog import ChangelogEntry from .magic_comments import parse_magic_comments diff --git a/tests/rpmautospec/minigit2/__init__.py b/tests/rpmautospec/minigit2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/rpmautospec/minigit2/test_native_adaptation.py b/tests/rpmautospec/minigit2/test_native_adaptation.py new file mode 100644 index 0000000..f7ca5a0 --- /dev/null +++ b/tests/rpmautospec/minigit2/test_native_adaptation.py @@ -0,0 +1,15 @@ +from unittest import mock + +from rpmautospec.minigit2 import native_adaptation + + +class TestNativeAdaptation: + def test_install_func_decls(self) -> None: + lib = mock.Mock() + + native_adaptation.install_func_decls(lib) + + for func_name, (restype, argtypes) in native_adaptation.FUNC_DECLS.items(): + func = getattr(lib, func_name) + assert func.restype == restype + assert func.argtypes == argtypes diff --git a/tests/rpmautospec/minigit2/test_wrapper.py b/tests/rpmautospec/minigit2/test_wrapper.py new file mode 100644 index 0000000..4466e5c --- /dev/null +++ b/tests/rpmautospec/minigit2/test_wrapper.py @@ -0,0 +1,317 @@ +import ctypes +import ctypes.util +import re +import subprocess +from contextlib import nullcontext +from pathlib import Path +from random import randbytes +from typing import Optional +from unittest import mock + +import pytest + +from rpmautospec.minigit2 import constants, exc, native_adaptation, wrapper + + +def get_param_id_from_request(request: pytest.FixtureRequest) -> str: + node = request.node + name = node.name + originalname = node.originalname + if not (match := re.match(rf"^{originalname}\[(?P[^\]]+)\]", name)): + raise ValueError(f"Can’t extract parameter id from request: {name}") + return match.group("id") + + +@pytest.fixture +def uncache_libgit2() -> None: + with ( + mock.patch.object(wrapper.WrapperOfWrappings, "_libgit2"), + mock.patch.object(wrapper.WrapperOfWrappings, "_soname"), + ): + try: + wrapper.WrapperOfWrappings._libgit2 = None + wrapper.WrapperOfWrappings._soname = None + except AttributeError: + pass + + yield + + +@pytest.fixture +def repo_root(tmp_path) -> Path: + repo_root = tmp_path / "git_repo" + repo_root.mkdir() + repo_root_str = str(repo_root) + subprocess.run(["git", "-C", repo_root_str, "init"]) + + a_file = repo_root / "a_file" + a_file.write_text("A file.\n") + subprocess.run(["git", "-C", repo_root_str, "add", str(a_file)]) + subprocess.run(["git", "-C", repo_root_str, "commit", "-m", "Add a file"]) + + return repo_root + + +@pytest.fixture +def repo(repo_root) -> wrapper.Repository: + return wrapper.Repository(repo_root) + + +class TestWrapperOfWrappings: + @pytest.mark.parametrize( + "success, cache_is_hot, found, soname", + ( + pytest.param(True, False, True, "reallib", id="success-real-lib"), + pytest.param(True, False, True, "libgit2.so.1.8", id="success"), + pytest.param( + True, False, True, "libgit2.so.1.8.5", id="success-max-version-with-minor" + ), + pytest.param(True, False, True, "libgit2.so.1.9", id="success-version-unknown"), + pytest.param(True, True, None, None, id="success-cache-hot"), + pytest.param(False, False, False, None, id="failure-libgit2-not-found"), + pytest.param(False, False, True, "LIBGIT2.DLL", id="failure-illegal-soname"), + pytest.param(False, False, True, "libgit2.so.1.0", id="failure-version-too-low"), + ), + ) + @pytest.mark.usefixtures("uncache_libgit2") + def test__get_library( + self, + success: bool, + cache_is_hot: bool, + found: bool, + soname: Optional[str], + request: pytest.FixtureRequest, + ) -> None: + test_case = get_param_id_from_request(request) + + CDLL_wraps = ctypes.CDLL if not soname else None + + with ( + mock.patch.object( + wrapper, "find_library", wraps=ctypes.util.find_library + ) as find_library, + mock.patch.object(wrapper, "CDLL", wraps=CDLL_wraps) as CDLL, + mock.patch.object(wrapper, "install_func_decls") as install_func_decls, + ): + if cache_is_hot: + wrapper.WrapperOfWrappings._libgit2 = lib_sentinel = object() + + if success: + if "version-unknown" in test_case: + expectation = pytest.warns(exc.Libgit2VersionWarning) + else: + expectation = nullcontext() + else: + if "libgit2-not-found" in test_case: + expectation = pytest.raises(exc.Libgit2NotFoundError) + elif "illegal-soname" in test_case or "version-too-low" in test_case: + expectation = pytest.raises(exc.Libgit2VersionError) + + if soname != "reallib": + find_library.return_value = soname + + with expectation: + retval = wrapper.WrapperOfWrappings._get_library() + + if success: + if cache_is_hot: + assert retval is lib_sentinel + return + + CDLL.assert_called_once() + install_func_decls.assert_called_once_with(wrapper.WrapperOfWrappings._libgit2) + + if CDLL_wraps: + assert isinstance(retval, ctypes.CDLL) + assert retval._name.startswith("libgit2.so.") + + def test__lib(self) -> None: + obj = wrapper.WrapperOfWrappings() + with mock.patch.object(wrapper.WrapperOfWrappings, "_get_library") as _get_library: + _get_library.return_value = lib_sentinel = object() + assert obj._lib is lib_sentinel + _get_library.assert_called_once_with() + + @pytest.mark.parametrize( + "error_code, exc_msg_tmpl", + ( + pytest.param(0, None, id="without-error-code"), + pytest.param(-1, "Something happened: {message}", id="with-error-code-template"), + pytest.param(-1, None, id="with-error-code-without-template"), + ), + ) + def test_raise_if_error(self, error_code: int, exc_msg_tmpl: Optional[str]): + if error_code: + expectation = pytest.raises(exc.GitError) + else: + expectation = nullcontext() + + with mock.patch.object(wrapper.WrapperOfWrappings, "_get_library") as _get_library: + _get_library.return_value = lib = mock.Mock() + lib.git_error_last.return_value = error_p = mock.Mock() + error_p.contents.message = b"BOO!" + + with expectation as excinfo: + wrapper.WrapperOfWrappings.raise_if_error(error_code, exc_msg_tmpl) + + if not error_code: + lib.git_error_last.assert_not_called() + else: + lib.git_error_last.assert_called_once_with() + exc_str = str(excinfo.value) + assert "BOO!" in exc_str + if exc_msg_tmpl: + assert "Something happened:" in exc_str + else: + assert "Something happened:" not in exc_str + + +class TestOid: + @pytest.mark.parametrize( + "test_case", + ("native", "oid", "oid-as-str", "oid-as-bytes", "none", "native-and-oid"), + ) + def test___init__(self, test_case: str): + native_in = oid_in = None + success = True + + oid_bytes = randbytes(constants.GIT_OID_SHA1_SIZE) + oid_hex = "".join(f"{x:02x}" for x in oid_bytes) + + if "native" in test_case: + oid_bytearray = bytearray(oid_bytes) + native_oid = native_adaptation.git_oid.from_buffer_copy(oid_bytearray) + native_in = ctypes.byref(native_oid) + + if "oid" in test_case: + oid_in = oid_hex + if "oid-as-bytes" in test_case: + oid_in = oid_in.encode("ascii") + elif "oid-as-str" not in test_case: + oid_in = wrapper.Oid(oid=oid_in) + + if "none" in test_case or "and" in test_case: + expectation = pytest.raises( + ValueError, match="Exactly one of native or oid has to be specified" + ) + success = False + else: + expectation = nullcontext() + + with expectation: + oid = wrapper.Oid(native=native_in, oid=oid_in) + + if success: + assert oid.hex == str(oid) == oid_hex + assert oid.hexb == oid_hex.encode("ascii") + + +class TestRepository: + @pytest.mark.parametrize( + "path_type, exists", + ( + pytest.param(str, True, id="str"), + pytest.param(Path, True, id="Path"), + pytest.param(str, False, id="missing"), + ), + ) + def test___init__(self, path_type: type, exists: bool, repo_root: Path, tmp_path: Path) -> None: + if exists: + path = path_type(repo_root) + expectation = nullcontext() + else: + not_a_repo = tmp_path / "not_a_repo" + not_a_repo.mkdir() + path = path_type(not_a_repo) + expectation = pytest.raises(exc.GitError) + + with expectation as excinfo: + repo = wrapper.Repository(path) + + if exists: + buf = native_adaptation.git_buf() + buf_p = ctypes.byref(buf) + assert not repo._lib.git_repository_item_path( + buf_p, repo._obj, native_adaptation.git_repository_item_t.WORKDIR + ) + assert repo_root == Path(buf.ptr.decode("utf-8", errors="replace")) + repo._lib.git_buf_dispose(buf_p) + else: + assert "Can’t open repository" in str(excinfo.value) + assert str(path) in str(excinfo.value) + + def test___get_item__(self, repo: wrapper.Repository): + assert isinstance(repo[repo.head.target], wrapper.Commit) + assert repo.head.target.hex == repo[repo.head.target].oid.hex + + @pytest.mark.parametrize( + "obj_type, expected", + ( + pytest.param(wrapper.Object, True, id="Object"), + pytest.param(str, True, id="str"), + pytest.param(bytes, True, id="bytes"), + pytest.param(wrapper.Oid, True, id="Oid"), + pytest.param(None, True, id="None"), + pytest.param(wrapper.Blob, False, id="unexpected"), + ), + ) + def test__coerce_to_object_and_peel( + self, obj_type: type, expected: bool, repo: wrapper.Repository + ): + if obj_type is None: + obj = None + elif obj_type is wrapper.Object: + obj = repo[repo.head.target] + elif obj_type is str: + obj = repo.head.target.hex + elif obj_type is bytes: + obj = repo.head.target.hexb + elif obj_type is wrapper.Oid: + obj = repo.head.target + elif obj_type is wrapper.Blob: + content = b"BLOB\n" + buf = ctypes.c_char_p(content) + oid = native_adaptation.git_oid() + error_code = repo._lib.git_blob_create_from_buffer( + oid, repo._obj, buf, len(content) + 1 + ) + repo.raise_if_error(error_code) + obj = oid + + if expected: + peel_types = (native_adaptation.git_object_t.BLOB, native_adaptation.git_object_t.TREE) + expectation = nullcontext() + else: + peel_types = (native_adaptation.git_object_t.BLOB,) + expectation = pytest.raises(TypeError, match="unexpected") + + with expectation: + peeled = repo._coerce_to_object_and_peel(obj=obj, peel_types=peel_types) + + if not expected: + return + + if obj_type is None: + assert peeled is None + return + + assert isinstance(peeled, wrapper.Tree) + + def test_head(self, repo: wrapper.Repository): + assert isinstance(repo.head, wrapper.Reference) + assert repo.head.target.hex == repo[repo.head.target].oid.hex + + def test_index(self, repo: wrapper.Repository): + assert isinstance(repo.index, wrapper.Index) + + def test_diff(self, repo_root: Path, repo: wrapper.Repository): + initial_commit = repo[repo.head.target] + + repo_root_str = str(repo_root) + a_file = repo_root / "a_file" + a_file.write_text("New content.\n") + + subprocess.run(["git", "-C", repo_root_str, "add", str(a_file)]) + subprocess.run(["git", "-C", repo_root_str, "commit", "-m", "Change a file"]) + + second_commit = repo[repo.head.target]