From 570cdeff4dbb4344466b97d7be697de83d35a9a2 Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Thu, 17 Jul 2025 18:15:28 +0200 Subject: [PATCH 001/225] improve typing: add constants this reduces the mypy errors from 280 to 52 --- pygit2/_pygit2.pyi | 247 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 242 insertions(+), 5 deletions(-) diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index 2cf8e39e0..5a2a68e5b 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -28,14 +28,251 @@ GIT_OBJ_BLOB = Literal[3] GIT_OBJ_COMMIT = Literal[1] GIT_OBJ_TAG = Literal[4] GIT_OBJ_TREE = Literal[2] -GIT_OID_HEXSZ: int -GIT_OID_HEX_ZERO: str -GIT_OID_MINPREFIXLEN: int -GIT_OID_RAWSZ: int -LIBGIT2_VERSION: str + LIBGIT2_VER_MAJOR: int LIBGIT2_VER_MINOR: int LIBGIT2_VER_REVISION: int +LIBGIT2_VERSION: str +GIT_OPT_GET_MWINDOW_SIZE: int +GIT_OPT_SET_MWINDOW_SIZE: int +GIT_OPT_GET_MWINDOW_MAPPED_LIMIT: int +GIT_OPT_SET_MWINDOW_MAPPED_LIMIT: int +GIT_OPT_GET_SEARCH_PATH: int +GIT_OPT_SET_SEARCH_PATH: int +GIT_OPT_SET_CACHE_OBJECT_LIMIT: int +GIT_OPT_SET_CACHE_MAX_SIZE: int +GIT_OPT_ENABLE_CACHING: int +GIT_OPT_GET_CACHED_MEMORY: int +GIT_OPT_GET_TEMPLATE_PATH: int +GIT_OPT_SET_TEMPLATE_PATH: int +GIT_OPT_SET_SSL_CERT_LOCATIONS: int +GIT_OPT_SET_USER_AGENT: int +GIT_OPT_ENABLE_STRICT_OBJECT_CREATION: int +GIT_OPT_ENABLE_STRICT_SYMBOLIC_REF_CREATION: int +GIT_OPT_SET_SSL_CIPHERS: int +GIT_OPT_GET_USER_AGENT: int +GIT_OPT_ENABLE_OFS_DELTA: int +GIT_OPT_ENABLE_FSYNC_GITDIR: int +GIT_OPT_GET_WINDOWS_SHAREMODE: int +GIT_OPT_SET_WINDOWS_SHAREMODE: int +GIT_OPT_ENABLE_STRICT_HASH_VERIFICATION: int +GIT_OPT_SET_ALLOCATOR: int +GIT_OPT_ENABLE_UNSAVED_INDEX_SAFETY: int +GIT_OPT_GET_PACK_MAX_OBJECTS: int +GIT_OPT_SET_PACK_MAX_OBJECTS: int +GIT_OPT_DISABLE_PACK_KEEP_FILE_CHECKS: int +GIT_OPT_GET_OWNER_VALIDATION: int +GIT_OPT_SET_OWNER_VALIDATION: int +GIT_OPT_GET_MWINDOW_FILE_LIMIT: int +GIT_OPT_SET_MWINDOW_FILE_LIMIT: int +GIT_OID_RAWSZ: int +GIT_OID_HEXSZ: int +GIT_OID_HEX_ZERO: str +GIT_OID_MINPREFIXLEN: int +GIT_OBJECT_ANY: int +GIT_OBJECT_INVALID: int +GIT_OBJECT_COMMIT: int +GIT_OBJECT_TREE: int +GIT_OBJECT_BLOB: int +GIT_OBJECT_TAG: int +GIT_OBJECT_OFS_DELTA: int +GIT_OBJECT_REF_DELTA: int +GIT_FILEMODE_UNREADABLE: int +GIT_FILEMODE_TREE: int +GIT_FILEMODE_BLOB: int +GIT_FILEMODE_BLOB_EXECUTABLE: int +GIT_FILEMODE_LINK: int +GIT_FILEMODE_COMMIT: int +GIT_SORT_NONE: int +GIT_SORT_TOPOLOGICAL: int +GIT_SORT_TIME: int +GIT_SORT_REVERSE: int +GIT_RESET_SOFT: int +GIT_RESET_MIXED: int +GIT_RESET_HARD: int +GIT_REFERENCES_ALL: int +GIT_REFERENCES_BRANCHES: int +GIT_REFERENCES_TAGS: int +GIT_REVSPEC_SINGLE: int +GIT_REVSPEC_RANGE: int +GIT_REVSPEC_MERGE_BASE: int +GIT_BRANCH_LOCAL: int +GIT_BRANCH_REMOTE: int +GIT_BRANCH_ALL: int +GIT_STATUS_CURRENT: int +GIT_STATUS_INDEX_NEW: int +GIT_STATUS_INDEX_MODIFIED: int +GIT_STATUS_INDEX_DELETED: int +GIT_STATUS_INDEX_RENAMED: int +GIT_STATUS_INDEX_TYPECHANGE: int +GIT_STATUS_WT_NEW: int +GIT_STATUS_WT_MODIFIED: int +GIT_STATUS_WT_DELETED: int +GIT_STATUS_WT_TYPECHANGE: int +GIT_STATUS_WT_RENAMED: int +GIT_STATUS_WT_UNREADABLE: int +GIT_STATUS_IGNORED: int +GIT_STATUS_CONFLICTED: int +GIT_CHECKOUT_NONE: int +GIT_CHECKOUT_SAFE: int +GIT_CHECKOUT_FORCE: int +GIT_CHECKOUT_RECREATE_MISSING: int +GIT_CHECKOUT_ALLOW_CONFLICTS: int +GIT_CHECKOUT_REMOVE_UNTRACKED: int +GIT_CHECKOUT_REMOVE_IGNORED: int +GIT_CHECKOUT_UPDATE_ONLY: int +GIT_CHECKOUT_DONT_UPDATE_INDEX: int +GIT_CHECKOUT_NO_REFRESH: int +GIT_CHECKOUT_SKIP_UNMERGED: int +GIT_CHECKOUT_USE_OURS: int +GIT_CHECKOUT_USE_THEIRS: int +GIT_CHECKOUT_DISABLE_PATHSPEC_MATCH: int +GIT_CHECKOUT_SKIP_LOCKED_DIRECTORIES: int +GIT_CHECKOUT_DONT_OVERWRITE_IGNORED: int +GIT_CHECKOUT_CONFLICT_STYLE_MERGE: int +GIT_CHECKOUT_CONFLICT_STYLE_DIFF3: int +GIT_CHECKOUT_DONT_REMOVE_EXISTING: int +GIT_CHECKOUT_DONT_WRITE_INDEX: int +GIT_CHECKOUT_DRY_RUN: int +GIT_CHECKOUT_CONFLICT_STYLE_ZDIFF3: int +GIT_DIFF_NORMAL: int +GIT_DIFF_REVERSE: int +GIT_DIFF_INCLUDE_IGNORED: int +GIT_DIFF_RECURSE_IGNORED_DIRS: int +GIT_DIFF_INCLUDE_UNTRACKED: int +GIT_DIFF_RECURSE_UNTRACKED_DIRS: int +GIT_DIFF_INCLUDE_UNMODIFIED: int +GIT_DIFF_INCLUDE_TYPECHANGE: int +GIT_DIFF_INCLUDE_TYPECHANGE_TREES: int +GIT_DIFF_IGNORE_FILEMODE: int +GIT_DIFF_IGNORE_SUBMODULES: int +GIT_DIFF_IGNORE_CASE: int +GIT_DIFF_INCLUDE_CASECHANGE: int +GIT_DIFF_DISABLE_PATHSPEC_MATCH: int +GIT_DIFF_SKIP_BINARY_CHECK: int +GIT_DIFF_ENABLE_FAST_UNTRACKED_DIRS: int +GIT_DIFF_UPDATE_INDEX: int +GIT_DIFF_INCLUDE_UNREADABLE: int +GIT_DIFF_INCLUDE_UNREADABLE_AS_UNTRACKED: int +GIT_DIFF_INDENT_HEURISTIC: int +GIT_DIFF_IGNORE_BLANK_LINES: int +GIT_DIFF_FORCE_TEXT: int +GIT_DIFF_FORCE_BINARY: int +GIT_DIFF_IGNORE_WHITESPACE: int +GIT_DIFF_IGNORE_WHITESPACE_CHANGE: int +GIT_DIFF_IGNORE_WHITESPACE_EOL: int +GIT_DIFF_SHOW_UNTRACKED_CONTENT: int +GIT_DIFF_SHOW_UNMODIFIED: int +GIT_DIFF_PATIENCE: int +GIT_DIFF_MINIMAL: int +GIT_DIFF_SHOW_BINARY: int +GIT_DIFF_STATS_NONE: int +GIT_DIFF_STATS_FULL: int +GIT_DIFF_STATS_SHORT: int +GIT_DIFF_STATS_NUMBER: int +GIT_DIFF_STATS_INCLUDE_SUMMARY: int +GIT_DIFF_FIND_BY_CONFIG: int +GIT_DIFF_FIND_RENAMES: int +GIT_DIFF_FIND_RENAMES_FROM_REWRITES: int +GIT_DIFF_FIND_COPIES: int +GIT_DIFF_FIND_COPIES_FROM_UNMODIFIED: int +GIT_DIFF_FIND_REWRITES: int +GIT_DIFF_BREAK_REWRITES: int +GIT_DIFF_FIND_AND_BREAK_REWRITES: int +GIT_DIFF_FIND_FOR_UNTRACKED: int +GIT_DIFF_FIND_ALL: int +GIT_DIFF_FIND_IGNORE_LEADING_WHITESPACE: int +GIT_DIFF_FIND_IGNORE_WHITESPACE: int +GIT_DIFF_FIND_DONT_IGNORE_WHITESPACE: int +GIT_DIFF_FIND_EXACT_MATCH_ONLY: int +GIT_DIFF_BREAK_REWRITES_FOR_RENAMES_ONLY: int +GIT_DIFF_FIND_REMOVE_UNMODIFIED: int +GIT_DIFF_FLAG_BINARY: int +GIT_DIFF_FLAG_NOT_BINARY: int +GIT_DIFF_FLAG_VALID_ID: int +GIT_DIFF_FLAG_EXISTS: int +GIT_DIFF_FLAG_VALID_SIZE: int +GIT_DELTA_UNMODIFIED: int +GIT_DELTA_ADDED: int +GIT_DELTA_DELETED: int +GIT_DELTA_MODIFIED: int +GIT_DELTA_RENAMED: int +GIT_DELTA_COPIED: int +GIT_DELTA_IGNORED: int +GIT_DELTA_UNTRACKED: int +GIT_DELTA_TYPECHANGE: int +GIT_DELTA_UNREADABLE: int +GIT_DELTA_CONFLICTED: int +GIT_CONFIG_LEVEL_PROGRAMDATA: int +GIT_CONFIG_LEVEL_SYSTEM: int +GIT_CONFIG_LEVEL_XDG: int +GIT_CONFIG_LEVEL_GLOBAL: int +GIT_CONFIG_LEVEL_LOCAL: int +GIT_CONFIG_LEVEL_WORKTREE: int +GIT_CONFIG_LEVEL_APP: int +GIT_CONFIG_HIGHEST_LEVEL: int +GIT_BLAME_NORMAL: int +GIT_BLAME_TRACK_COPIES_SAME_FILE: int +GIT_BLAME_TRACK_COPIES_SAME_COMMIT_MOVES: int +GIT_BLAME_TRACK_COPIES_SAME_COMMIT_COPIES: int +GIT_BLAME_TRACK_COPIES_ANY_COMMIT_COPIES: int +GIT_BLAME_FIRST_PARENT: int +GIT_BLAME_USE_MAILMAP: int +GIT_BLAME_IGNORE_WHITESPACE: int +GIT_MERGE_ANALYSIS_NONE: int +GIT_MERGE_ANALYSIS_NORMAL: int +GIT_MERGE_ANALYSIS_UP_TO_DATE: int +GIT_MERGE_ANALYSIS_FASTFORWARD: int +GIT_MERGE_ANALYSIS_UNBORN: int +GIT_MERGE_PREFERENCE_NONE: int +GIT_MERGE_PREFERENCE_NO_FASTFORWARD: int +GIT_MERGE_PREFERENCE_FASTFORWARD_ONLY: int +GIT_DESCRIBE_DEFAULT: int +GIT_DESCRIBE_TAGS: int +GIT_DESCRIBE_ALL: int +GIT_STASH_DEFAULT: int +GIT_STASH_KEEP_INDEX: int +GIT_STASH_INCLUDE_UNTRACKED: int +GIT_STASH_INCLUDE_IGNORED: int +GIT_STASH_KEEP_ALL: int +GIT_STASH_APPLY_DEFAULT: int +GIT_STASH_APPLY_REINSTATE_INDEX: int +GIT_APPLY_LOCATION_WORKDIR: int +GIT_APPLY_LOCATION_INDEX: int +GIT_APPLY_LOCATION_BOTH: int +GIT_SUBMODULE_IGNORE_UNSPECIFIED: int +GIT_SUBMODULE_IGNORE_NONE: int +GIT_SUBMODULE_IGNORE_UNTRACKED: int +GIT_SUBMODULE_IGNORE_DIRTY: int +GIT_SUBMODULE_IGNORE_ALL: int +GIT_SUBMODULE_STATUS_IN_HEAD: int +GIT_SUBMODULE_STATUS_IN_INDEX: int +GIT_SUBMODULE_STATUS_IN_CONFIG: int +GIT_SUBMODULE_STATUS_IN_WD: int +GIT_SUBMODULE_STATUS_INDEX_ADDED: int +GIT_SUBMODULE_STATUS_INDEX_DELETED: int +GIT_SUBMODULE_STATUS_INDEX_MODIFIED: int +GIT_SUBMODULE_STATUS_WD_UNINITIALIZED: int +GIT_SUBMODULE_STATUS_WD_ADDED: int +GIT_SUBMODULE_STATUS_WD_DELETED: int +GIT_SUBMODULE_STATUS_WD_MODIFIED: int +GIT_SUBMODULE_STATUS_WD_INDEX_MODIFIED: int +GIT_SUBMODULE_STATUS_WD_WD_MODIFIED: int +GIT_SUBMODULE_STATUS_WD_UNTRACKED: int +GIT_BLOB_FILTER_CHECK_FOR_BINARY: int +GIT_BLOB_FILTER_NO_SYSTEM_ATTRIBUTES: int +GIT_BLOB_FILTER_ATTRIBUTES_FROM_HEAD: int +GIT_BLOB_FILTER_ATTRIBUTES_FROM_COMMIT: int +GIT_FILTER_DRIVER_PRIORITY: int +GIT_FILTER_TO_WORKTREE: int +GIT_FILTER_SMUDGE: int +GIT_FILTER_TO_ODB: int +GIT_FILTER_CLEAN: int +GIT_FILTER_DEFAULT: int +GIT_FILTER_ALLOW_UNSAFE: int +GIT_FILTER_NO_SYSTEM_ATTRIBUTES: int +GIT_FILTER_ATTRIBUTES_FROM_HEAD: int +GIT_FILTER_ATTRIBUTES_FROM_COMMIT: int class Object: _pointer: bytes From 0df1e17206800b4a98bb5991c754d5474c489844 Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Fri, 18 Jul 2025 09:05:15 +0200 Subject: [PATCH 002/225] typing: submodules --- pygit2/submodules.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pygit2/submodules.py b/pygit2/submodules.py index d8506d20f..ce83777ba 100644 --- a/pygit2/submodules.py +++ b/pygit2/submodules.py @@ -39,6 +39,9 @@ class Submodule: + _repo: BaseRepository + _subm: object + @classmethod def _from_c(cls, repo: BaseRepository, cptr): subm = cls.__new__(cls) @@ -74,7 +77,10 @@ def init(self, overwrite: bool = False): check_error(err) def update( - self, init: bool = False, callbacks: RemoteCallbacks = None, depth: int = 0 + self, + init: bool = False, + callbacks: Optional[RemoteCallbacks] = None, + depth: int = 0, ): """ Update a submodule. This will clone a missing submodule and checkout From 0e95587a0d9f381f83a5125506b8661953a4a271 Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Mon, 21 Jul 2025 21:27:46 +0200 Subject: [PATCH 003/225] fix and silence typing in repository --- docs/objects.rst | 2 +- pygit2/_pygit2.pyi | 14 +++++++----- pygit2/repository.py | 53 ++++++++++++++++++++++++-------------------- 3 files changed, 38 insertions(+), 31 deletions(-) diff --git a/docs/objects.rst b/docs/objects.rst index 7d323a3a1..9aed0282a 100644 --- a/docs/objects.rst +++ b/docs/objects.rst @@ -33,7 +33,7 @@ implements a subset of the mapping interface. >>> repo = Repository('path/to/pygit2') >>> obj = repo.get("101715bf37440d32291bde4f58c3142bcf7d8adb") >>> obj - <_pygit2.Commit object at 0x7ff27a6b60f0> + .. method:: Repository.__getitem__(id) diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index 5a2a68e5b..6178f884d 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -284,15 +284,17 @@ class Object: type: 'Literal[GIT_OBJ_COMMIT] | Literal[GIT_OBJ_TREE] | Literal[GIT_OBJ_TAG] | Literal[GIT_OBJ_BLOB]' type_str: "Literal['commit'] | Literal['tree'] | Literal['tag'] | Literal['blob']" @overload - def peel(self, target_type: 'Literal[GIT_OBJ_COMMIT]') -> 'Commit': ... + def peel( + self, target_type: 'Literal[GIT_OBJ_COMMIT] | Type[Commit]' + ) -> 'Commit': ... @overload - def peel(self, target_type: 'Literal[GIT_OBJ_TREE]') -> 'Tree': ... + def peel(self, target_type: 'Literal[GIT_OBJ_TREE] | Type[Tree]') -> 'Tree': ... @overload - def peel(self, target_type: 'Literal[GIT_OBJ_TAG]') -> 'Tag': ... + def peel(self, target_type: 'Literal[GIT_OBJ_TAG] | Type[Tag]') -> 'Tag': ... @overload - def peel(self, target_type: 'Literal[GIT_OBJ_BLOB]') -> 'Blob': ... + def peel(self, target_type: 'Literal[GIT_OBJ_BLOB] | Type[Blob]') -> 'Blob': ... @overload - def peel(self, target_type: 'None') -> 'Commit|Tree|Blob': ... + def peel(self, target_type: 'None') -> 'Commit|Tree|Tag|Blob': ... def read_raw(self) -> bytes: ... def __eq__(self, other) -> bool: ... def __ge__(self, other) -> bool: ... @@ -664,7 +666,7 @@ class Repository: def TreeBuilder(self, src: Tree | _OidArg = ...) -> TreeBuilder: ... def _disown(self, *args, **kwargs) -> None: ... def _from_c(self, *args, **kwargs) -> None: ... - def __getitem__(self, key: str | bytes | Oid | Reference) -> Commit: ... + def __getitem__(self, key: str | Oid) -> Object: ... def add_worktree(self, name: str, path: str, ref: Reference = ...) -> Worktree: ... def applies( self, diff --git a/pygit2/repository.py b/pygit2/repository.py index 37a6d2c52..4c2cf26ff 100644 --- a/pygit2/repository.py +++ b/pygit2/repository.py @@ -29,10 +29,11 @@ from time import time import tarfile import typing +from typing import Optional # Import from pygit2 from ._pygit2 import Repository as _Repository, init_file_backend -from ._pygit2 import Oid, GIT_OID_HEXSZ, GIT_OID_MINPREFIXLEN +from ._pygit2 import Oid, GIT_OID_HEXSZ, GIT_OID_MINPREFIXLEN, Object from ._pygit2 import Reference, Tree, Commit, Blob, Signature from ._pygit2 import InvalidSpecError @@ -187,20 +188,20 @@ def __iter__(self): # # Mapping interface # - def get(self, key, default=None): + def get(self, key: str, default: Optional[Commit] = None) -> Object: value = self.git_object_lookup_prefix(key) return value if (value is not None) else default - def __getitem__(self, key): + def __getitem__(self, key: str | Oid) -> Object: value = self.git_object_lookup_prefix(key) if value is None: raise KeyError(key) return value - def __contains__(self, key): + def __contains__(self, key: str | Oid) -> bool: return self.git_object_lookup_prefix(key) is not None - def __repr__(self): + def __repr__(self) -> str: return f'pygit2.Repository({repr(self.path)})' # @@ -529,22 +530,22 @@ def diff( # Case 1: Diff tree to tree if isinstance(a, Tree) and isinstance(b, Tree): - return a.diff_to_tree(b, **options) + return a.diff_to_tree(b, **options) # type: ignore[arg-type] # Case 2: Index to workdir elif a is None and b is None: - return self.index.diff_to_workdir(**options) + return self.index.diff_to_workdir(**options) # type: ignore[arg-type] # Case 3: Diff tree to index or workdir elif isinstance(a, Tree) and b is None: if cached: - return a.diff_to_index(self.index, **options) + return a.diff_to_index(self.index, **options) # type: ignore[arg-type] else: - return a.diff_to_workdir(**options) + return a.diff_to_workdir(**options) # type: ignore[arg-type] # Case 4: Diff blob to blob if isinstance(a, Blob) and isinstance(b, Blob): - return a.diff(b, **options) + return a.diff(b, **options) # type: ignore[arg-type] raise ValueError('Only blobs and treeish can be diffed') @@ -559,7 +560,7 @@ def state(self) -> RepositoryState: return RepositoryState(cstate) except ValueError: # Some value not in the IntEnum - newer libgit2 version? - return cstate + return cstate # type: ignore[return-value] def state_cleanup(self): """Remove all the metadata associated with an ongoing command like @@ -770,9 +771,15 @@ def merge_commits( cindex = ffi.new('git_index **') if isinstance(ours, (str, Oid)): - ours = self[ours] + ours_object = self[ours] + if not isinstance(ours_object, Commit): + raise TypeError(f'expected Commit, got {type(ours_object)}') + ours = ours_object if isinstance(theirs, (str, Oid)): - theirs = self[theirs] + theirs_object = self[theirs] + if not isinstance(theirs_object, Commit): + raise TypeError(f'expected Commit, got {type(theirs_object)}') + theirs = theirs_object ours = ours.peel(Commit) theirs = theirs.peel(Commit) @@ -827,16 +834,9 @@ def merge_trees( theirs_ptr = ffi.new('git_tree **') cindex = ffi.new('git_index **') - if isinstance(ancestor, (str, Oid)): - ancestor = self[ancestor] - if isinstance(ours, (str, Oid)): - ours = self[ours] - if isinstance(theirs, (str, Oid)): - theirs = self[theirs] - - ancestor = ancestor.peel(Tree) - ours = ours.peel(Tree) - theirs = theirs.peel(Tree) + ancestor = self.__ensure_tree(ancestor) + ours = self.__ensure_tree(ours) + theirs = self.__ensure_tree(theirs) opts = self._merge_options(favor, flags, file_flags) @@ -890,7 +890,7 @@ def merge( if isinstance(source, Reference): # Annotated commit from ref cptr = ffi.new('struct git_reference **') - ffi.buffer(cptr)[:] = source._pointer[:] + ffi.buffer(cptr)[:] = source._pointer[:] # type: ignore[attr-defined] commit_ptr = ffi.new('git_annotated_commit **') err = C.git_annotated_commit_from_ref(commit_ptr, self._repo, cptr[0]) check_error(err) @@ -1615,6 +1615,11 @@ def amend_commit( return Oid(raw=bytes(ffi.buffer(coid)[:])) + def __ensure_tree(self, maybe_tree: str | Oid | Tree) -> Tree: + if isinstance(maybe_tree, Tree): + return maybe_tree + return self[maybe_tree].peel(Tree) + class Repository(BaseRepository): def __init__( From 650141d4e2d7c6ca4d5be8928712cd3c0090ee48 Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Tue, 22 Jul 2025 19:19:38 +0200 Subject: [PATCH 004/225] fix mypy issue in pygit2/filter.py --- pygit2/_pygit2.pyi | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index 6178f884d..71bafe27f 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -448,6 +448,10 @@ class DiffStats: insertions: int def format(self, format: DiffStatsFormat, width: int) -> str: ... +class FilterSource: + # probably incomplete + pass + class GitError(Exception): ... class InvalidSpecError(ValueError): ... From 5911b0cbca0644c9e51b59d22087735f8ef86b1c Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Tue, 22 Jul 2025 19:24:40 +0200 Subject: [PATCH 005/225] typing: ignore some errors --- pygit2/_run.py | 6 +++--- pygit2/config.py | 2 +- pygit2/ffi.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pygit2/_run.py b/pygit2/_run.py index 815910ecc..ea921d915 100644 --- a/pygit2/_run.py +++ b/pygit2/_run.py @@ -33,11 +33,11 @@ import sys # Import from cffi -from cffi import FFI +from cffi import FFI # type: ignore # Import from pygit2 try: - from _build import get_libgit2_paths + from _build import get_libgit2_paths # type: ignore except ImportError: from ._build import get_libgit2_paths @@ -85,7 +85,7 @@ ] h_source = [] for h_file in h_files: - h_file = dir_path / 'decl' / h_file + h_file = dir_path / 'decl' / h_file # type: ignore with codecs.open(h_file, 'r', 'utf-8') as f: h_source.append(f.read()) diff --git a/pygit2/config.py b/pygit2/config.py index 3b739840b..cba72911f 100644 --- a/pygit2/config.py +++ b/pygit2/config.py @@ -26,7 +26,7 @@ try: from functools import cached_property except ImportError: - from cached_property import cached_property + from cached_property import cached_property # type: ignore # Import from pygit2 from .errors import check_error diff --git a/pygit2/ffi.py b/pygit2/ffi.py index 04ffefa00..a9a0c6152 100644 --- a/pygit2/ffi.py +++ b/pygit2/ffi.py @@ -24,4 +24,4 @@ # Boston, MA 02110-1301, USA. # Import from pygit2 -from ._libgit2 import ffi, lib as C # noqa: F401 +from ._libgit2 import ffi, lib as C # type: ignore # noqa: F401 From 4503f5ac3a25001aad1875d3213042dfb5992350 Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Tue, 22 Jul 2025 19:34:46 +0200 Subject: [PATCH 006/225] fix typing in pygit2/blob.py --- pygit2/_pygit2.pyi | 14 +++++++++++++- pygit2/blob.py | 4 ++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index 71bafe27f..2d25e1df3 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -1,9 +1,12 @@ from typing import Iterator, Literal, Optional, overload, Type, TypedDict -from io import IOBase +from io import IOBase, DEFAULT_BUFFER_SIZE +from queue import Queue +from threading import Event from . import Index from .enums import ( ApplyLocation, BranchType, + BlobFilter, DeltaStatus, DiffFind, DiffFlag, @@ -355,6 +358,15 @@ class Blob(Object): old_as_path: str = ..., buffer_as_path: str = ..., ) -> Patch: ... + def _write_to_queue( + self, + queue: Queue, + closed: Event, + chunk_size: int = DEFAULT_BUFFER_SIZE, + as_path: Optional[str] = None, + flags: BlobFilter = BlobFilter.CHECK_FOR_BINARY, + commit_id: Optional[Oid] = None, + ) -> None: ... class Branch(Reference): branch_name: str diff --git a/pygit2/blob.py b/pygit2/blob.py index d9f4de897..23e8a9da6 100644 --- a/pygit2/blob.py +++ b/pygit2/blob.py @@ -26,7 +26,7 @@ def __init__( ): super().__init__() self._blob = blob - self._queue = Queue(maxsize=1) + self._queue: Optional[Queue] = Queue(maxsize=1) self._ready = threading.Event() self._writer_closed = threading.Event() self._chunk: Optional[bytes] = None @@ -45,7 +45,7 @@ def __init__( def __exit__(self, exc_type, exc_value, traceback): self.close() - def isatty(): + def isatty(self): return False def readable(self): From 7fbdc1d2bd6f9a6e65a29552e12b0f72e5600cca Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Tue, 22 Jul 2025 19:51:55 +0200 Subject: [PATCH 007/225] fix typing in pygit2/__init__.py --- pygit2/_pygit2.pyi | 19 +++++++++++++++++++ pygit2/callbacks.py | 6 +++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index 2d25e1df3..fc8470c4c 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -379,6 +379,24 @@ class Branch(Reference): def is_head(self) -> bool: ... def rename(self, name: str, force: bool = False) -> 'Branch': ... # type: ignore[override] +class FetchOptions: + # incomplete + depth: int + proxy_opts: ProxyOpts + +class CloneOptions: + # incomplete + version: int + checkout_opts: object + fetch_opts: FetchOptions + bare: int + local: object + checkout_branch: object + repository_cb: object + repository_cb_payload: object + remote_cb: object + remote_cb_payload: object + class Commit(Object): author: Signature commit_time: int @@ -904,5 +922,6 @@ def init_file_backend(path: str, flags: int = 0) -> object: ... def option(opt: Option, *args) -> None: ... def reference_is_valid_name(refname: str) -> bool: ... def tree_entry_cmp(a: Object, b: Object) -> int: ... +def _cache_enums() -> None: ... _OidArg = str | Oid diff --git a/pygit2/callbacks.py b/pygit2/callbacks.py index c0c3249fd..60f14f1c7 100644 --- a/pygit2/callbacks.py +++ b/pygit2/callbacks.py @@ -79,7 +79,7 @@ if TYPE_CHECKING: from .remotes import TransferProgress - from ._pygit2 import ProxyOpts, PushOptions + from ._pygit2 import ProxyOpts, PushOptions, CloneOptions # # The payload is the way to pass information from the pygit2 API, through # libgit2, to the Python callbacks. And back. @@ -87,6 +87,10 @@ class Payload: + repository: Callable | None + remote: Callable | None + clone_options: 'CloneOptions' + def __init__(self, **kw: object) -> None: for key, value in kw.items(): setattr(self, key, value) From 1978e14d0fe2ae01ccf9a83303cc6774079d2342 Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Tue, 22 Jul 2025 20:08:33 +0200 Subject: [PATCH 008/225] add mypy ci step --- .github/workflows/lint.yml | 3 +++ build.sh | 10 ++++++++++ requirements-test.txt | 1 + 3 files changed, 14 insertions(+) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index cf81c382c..5c4f145fb 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -24,3 +24,6 @@ jobs: - name: Check code style with ruff run: ruff format --diff + + - name: Check typing with mypy + run: LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.1 /bin/sh build.sh mypy diff --git a/build.sh b/build.sh index 1a415a23a..4327c1aea 100644 --- a/build.sh +++ b/build.sh @@ -262,6 +262,16 @@ if [ "$1" = "test" ]; then $PREFIX/bin/pytest --cov=pygit2 fi +# Type checking +if [ "$1" = "mypy" ]; then + shift + if [ -n "$WHEELDIR" ]; then + $PREFIX/bin/pip install $WHEELDIR/pygit2*-$PYTHON_TAG-*.whl + fi + $PREFIX/bin/pip install -r requirements-test.txt + $PREFIX/bin/mypy pygit2 +fi + # Test .pyi stub file if [ "$1" = "stubtest" ]; then shift diff --git a/requirements-test.txt b/requirements-test.txt index 9955deccd..a10711068 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,2 +1,3 @@ pytest pytest-cov +mypy From ffe28ac049140c4b75ab7ccd0551b8836ad6b9bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Sat, 26 Jul 2025 12:03:19 +0200 Subject: [PATCH 009/225] Release 1.18.1 --- AUTHORS.md | 6 +++++- CHANGELOG.md | 6 +++++- docs/conf.py | 2 +- pygit2/_build.py | 2 +- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/AUTHORS.md b/AUTHORS.md index 84cfc4390..5ee1cfceb 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -16,6 +16,7 @@ Authors: Richo Healey Christian Boos Julien Miotte + Benedikt Seidl Nick Hynes Richard Möhn Xu Tao @@ -34,6 +35,7 @@ Authors: Xavier Delannoy Michael Jones Saugat Pachhai + Andrej730 Bernardo Heynemann John Szakmeister Nabijacz Leweli @@ -66,6 +68,7 @@ Authors: Assaf Nativ Bob Carroll Christian Häggström + Edmundo Carmona Antoranz Erik Johnson Filip Rindler Fraser Tweedale @@ -133,6 +136,7 @@ Authors: Albin Söderström Alexandru Fikl Andrew Chin + Andrew McNulty Andrey Trubachev András Veres-Szentkirályi Ash Berlin @@ -163,7 +167,6 @@ Authors: David Six Dennis Schwertel Devaev Maxim - Edmundo Carmona Antoranz Eric Davis Erik Meusel Erik van Zijst @@ -220,6 +223,7 @@ Authors: Rui Chen Sandro Jäckel Saul Pwanson + Sebastian Hamann Shane Turner Sheeo Simone Mosciatti diff --git a/CHANGELOG.md b/CHANGELOG.md index f26b92bf3..132e21a9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,13 @@ -# 1.18.1 (UNRELEASED) +# 1.18.1 (2025-07-26) - Update wheels to libgit2 1.9.1 and OpenSSL 3.3 - New `Index.remove_directory(...)` [#1377](https://github.com/libgit2/pygit2/pull/1377) +- New `Index.add_conflict(...)` + [#1382](https://github.com/libgit2/pygit2/pull/1382) + - Now `Repository.merge_file_from_index(...)` returns a `MergeFileResult` object when called with `use_deprecated=False` [#1376](https://github.com/libgit2/pygit2/pull/1376) @@ -15,6 +18,7 @@ [#1371](https://github.com/libgit2/pygit2/pull/1371) [#1373](https://github.com/libgit2/pygit2/pull/1373) [#1384](https://github.com/libgit2/pygit2/pull/1384) + [#1386](https://github.com/libgit2/pygit2/pull/1386) Deprecations: diff --git a/docs/conf.py b/docs/conf.py index d44dcc827..6978c4ea5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,7 +23,7 @@ # author = '' # The full version, including alpha/beta/rc tags -release = '1.18.0' +release = '1.18.1' # -- General configuration --------------------------------------------------- diff --git a/pygit2/_build.py b/pygit2/_build.py index 23d38e5ef..4232605bc 100644 --- a/pygit2/_build.py +++ b/pygit2/_build.py @@ -34,7 +34,7 @@ # # The version number of pygit2 # -__version__ = '1.18.0' +__version__ = '1.18.1' # From 6569d6614e897f3b9647dac4c7a9f7ae2b31c6e6 Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Wed, 23 Jul 2025 20:20:12 +0200 Subject: [PATCH 010/225] fix typing in test folder also include test folder in mypy check in ci --- build.sh | 2 +- pygit2/_pygit2.pyi | 8 ++++++-- test/test_refs.py | 2 ++ 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/build.sh b/build.sh index 4327c1aea..31fea2443 100644 --- a/build.sh +++ b/build.sh @@ -269,7 +269,7 @@ if [ "$1" = "mypy" ]; then $PREFIX/bin/pip install $WHEELDIR/pygit2*-$PYTHON_TAG-*.whl fi $PREFIX/bin/pip install -r requirements-test.txt - $PREFIX/bin/mypy pygit2 + $PREFIX/bin/mypy pygit2 test fi # Test .pyi stub file diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index fc8470c4c..20c704d5c 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -286,6 +286,8 @@ class Object: short_id: str type: 'Literal[GIT_OBJ_COMMIT] | Literal[GIT_OBJ_TREE] | Literal[GIT_OBJ_TAG] | Literal[GIT_OBJ_BLOB]' type_str: "Literal['commit'] | Literal['tree'] | Literal['tag'] | Literal['blob']" + author: Signature + tree: Tree @overload def peel( self, target_type: 'Literal[GIT_OBJ_COMMIT] | Type[Commit]' @@ -675,9 +677,11 @@ class Branches: def __getitem__(self, name: str) -> Branch: ... def get(self, key: str) -> Branch: ... def __iter__(self) -> Iterator[str]: ... - def create(self, name: str, commit: Commit, force: bool = False) -> Branch: ... + def create( + self, name: str, commit: Object | Commit, force: bool = False + ) -> Branch: ... def delete(self, name: str) -> None: ... - def with_commit(self, commit: Commit | _OidArg | None) -> 'Branches': ... + def with_commit(self, commit: Object | Commit | _OidArg | None) -> 'Branches': ... def __contains__(self, name: _OidArg) -> bool: ... class Repository: diff --git a/test/test_refs.py b/test/test_refs.py index 50dfa96e4..a2fe0ef5d 100644 --- a/test/test_refs.py +++ b/test/test_refs.py @@ -61,6 +61,7 @@ def test_refs_list(testrepo: Repository) -> None: def test_head(testrepo: Repository) -> None: head = testrepo.head assert LAST_COMMIT == testrepo[head.target].id + assert not isinstance(head.raw_target, bytes) assert LAST_COMMIT == testrepo[head.raw_target].id @@ -248,6 +249,7 @@ def test_refs_create_symbolic(testrepo: Repository) -> None: def test_refs_peel(testrepo: Repository) -> None: ref = testrepo.references.get('refs/heads/master') assert testrepo[ref.target].id == ref.peel().id + assert not isinstance(ref.raw_target, bytes) assert testrepo[ref.raw_target].id == ref.peel().id commit = ref.peel(Commit) From a59944b6445d127fc7d9d3f0a852de80cf8ced74 Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Wed, 23 Jul 2025 20:47:33 +0200 Subject: [PATCH 011/225] improve types according to test/test_submodule.py --- pygit2/_pygit2.pyi | 16 ++++++++++++- pygit2/submodules.py | 10 ++++---- test/test_submodule.py | 52 ++++++++++++++++++++++-------------------- test/utils.py | 13 ++++++++--- 4 files changed, 58 insertions(+), 33 deletions(-) diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index 20c704d5c..9d022967e 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -7,6 +7,7 @@ from .enums import ( ApplyLocation, BranchType, BlobFilter, + CheckoutStrategy, DeltaStatus, DiffFind, DiffFlag, @@ -25,7 +26,9 @@ from .enums import ( from collections.abc import Generator from .repository import BaseRepository +from .submodules import SubmoduleCollection, Submodule from .remotes import Remote +from .callbacks import CheckoutCallbacks GIT_OBJ_BLOB = Literal[3] GIT_OBJ_COMMIT = Literal[1] @@ -700,10 +703,12 @@ class Repository: references: References remotes: RemoteCollection branches: Branches + submodules: SubmoduleCollection + index: Index def __init__(self, *args, **kwargs) -> None: ... def TreeBuilder(self, src: Tree | _OidArg = ...) -> TreeBuilder: ... def _disown(self, *args, **kwargs) -> None: ... - def _from_c(self, *args, **kwargs) -> None: ... + def _from_c(self, *args, **kwargs) -> 'Repository': ... def __getitem__(self, key: str | Oid) -> Object: ... def add_worktree(self, name: str, path: str, ref: Reference = ...) -> Worktree: ... def applies( @@ -715,6 +720,15 @@ class Repository: def apply( self, diff: Diff, location: ApplyLocation = ApplyLocation.WORKDIR ) -> None: ... + def checkout( + self, + refname: Optional[_OidArg], + *, + strategy: CheckoutStrategy | None = None, + directory: str | None = None, + paths: list[str] | None = None, + callbacks: CheckoutCallbacks | None = None, + ) -> None: ... def cherrypick(self, id: _OidArg) -> None: ... def compress_references(self) -> None: ... def create_blob(self, data: bytes) -> Oid: ... diff --git a/pygit2/submodules.py b/pygit2/submodules.py index ce83777ba..2aec25ec5 100644 --- a/pygit2/submodules.py +++ b/pygit2/submodules.py @@ -25,6 +25,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, Iterable, Iterator, Optional, Union +from pathlib import Path from ._pygit2 import Oid from .callbacks import git_fetch_options, RemoteCallbacks @@ -36,6 +37,7 @@ # Need BaseRepository for type hints, but don't let it cause a circular dependency if TYPE_CHECKING: from .repository import BaseRepository + from pygit2 import Repository class Submodule: @@ -51,10 +53,10 @@ def _from_c(cls, repo: BaseRepository, cptr): return subm - def __del__(self): + def __del__(self) -> None: C.git_submodule_free(self._subm) - def open(self): + def open(self) -> Repository: """Open the repository for a submodule.""" crepo = ffi.new('git_repository **') err = C.git_submodule_open(crepo, self._subm) @@ -62,7 +64,7 @@ def open(self): return self._repo._from_c(crepo[0], True) - def init(self, overwrite: bool = False): + def init(self, overwrite: bool = False) -> None: """ Just like "git submodule init", this copies information about the submodule into ".git/config". @@ -173,7 +175,7 @@ class SubmoduleCollection: def __init__(self, repository: BaseRepository): self._repository = repository - def __getitem__(self, name: str) -> Submodule: + def __getitem__(self, name: str | Path) -> Submodule: """ Look up submodule information by name or path. Raises KeyError if there is no such submodule. diff --git a/test/test_submodule.py b/test/test_submodule.py index 235fed669..dcfc58291 100644 --- a/test/test_submodule.py +++ b/test/test_submodule.py @@ -26,8 +26,10 @@ """Tests for Submodule objects.""" from pathlib import Path +from typing import Generator import pygit2 +from pygit2 import Repository, Submodule import pytest from . import utils @@ -42,48 +44,48 @@ @pytest.fixture -def repo(tmp_path): +def repo(tmp_path: Path) -> Generator[Repository, None, None]: with utils.TemporaryRepository('submodulerepo.zip', tmp_path) as path: yield pygit2.Repository(path) -def test_lookup_submodule(repo): - s = repo.submodules[SUBM_PATH] +def test_lookup_submodule(repo: Repository) -> None: + s: Submodule | None = repo.submodules[SUBM_PATH] assert s is not None s = repo.submodules.get(SUBM_PATH) assert s is not None -def test_lookup_submodule_aspath(repo): +def test_lookup_submodule_aspath(repo: Repository) -> None: s = repo.submodules[Path(SUBM_PATH)] assert s is not None -def test_lookup_missing_submodule(repo): +def test_lookup_missing_submodule(repo: Repository) -> None: with pytest.raises(KeyError): repo.submodules['does-not-exist'] assert repo.submodules.get('does-not-exist') is None -def test_listall_submodules(repo): +def test_listall_submodules(repo: Repository) -> None: submodules = repo.listall_submodules() assert len(submodules) == 1 assert submodules[0] == SUBM_PATH -def test_contains_submodule(repo): +def test_contains_submodule(repo: Repository) -> None: assert SUBM_PATH in repo.submodules assert 'does-not-exist' not in repo.submodules -def test_submodule_iterator(repo): +def test_submodule_iterator(repo: Repository) -> None: for s in repo.submodules: assert isinstance(s, pygit2.Submodule) assert s.path == repo.submodules[s.path].path @utils.requires_network -def test_submodule_open(repo): +def test_submodule_open(repo: Repository) -> None: s = repo.submodules[SUBM_PATH] repo.submodules.init() repo.submodules.update() @@ -93,7 +95,7 @@ def test_submodule_open(repo): @utils.requires_network -def test_submodule_open_from_repository_subclass(repo): +def test_submodule_open_from_repository_subclass(repo: Repository) -> None: class CustomRepoClass(pygit2.Repository): pass @@ -106,22 +108,22 @@ class CustomRepoClass(pygit2.Repository): assert r.head.target == SUBM_HEAD_SHA -def test_name(repo): +def test_name(repo: Repository) -> None: s = repo.submodules[SUBM_PATH] assert SUBM_NAME == s.name -def test_path(repo): +def test_path(repo: Repository) -> None: s = repo.submodules[SUBM_PATH] assert SUBM_PATH == s.path -def test_url(repo): +def test_url(repo: Repository) -> None: s = repo.submodules[SUBM_PATH] assert SUBM_URL == s.url -def test_missing_url(repo): +def test_missing_url(repo: Repository) -> None: # Remove "url" from .gitmodules with open(Path(repo.workdir, '.gitmodules'), 'wt') as f: f.write('[submodule "TestGitRepository"]\n') @@ -131,7 +133,7 @@ def test_missing_url(repo): @utils.requires_network -def test_init_and_update(repo): +def test_init_and_update(repo: Repository) -> None: subrepo_file_path = Path(repo.workdir) / SUBM_PATH / 'master.txt' assert not subrepo_file_path.exists() @@ -148,7 +150,7 @@ def test_init_and_update(repo): @utils.requires_network -def test_specified_update(repo): +def test_specified_update(repo: Repository) -> None: subrepo_file_path = Path(repo.workdir) / SUBM_PATH / 'master.txt' assert not subrepo_file_path.exists() repo.submodules.init(submodules=['TestGitRepository']) @@ -157,7 +159,7 @@ def test_specified_update(repo): @utils.requires_network -def test_update_instance(repo): +def test_update_instance(repo: Repository) -> None: subrepo_file_path = Path(repo.workdir) / SUBM_PATH / 'master.txt' assert not subrepo_file_path.exists() sm = repo.submodules['TestGitRepository'] @@ -168,7 +170,7 @@ def test_update_instance(repo): @utils.requires_network @pytest.mark.parametrize('depth', [0, 1]) -def test_oneshot_update(repo, depth): +def test_oneshot_update(repo: Repository, depth: int) -> None: status = repo.submodules.status(SUBM_NAME) assert status == (SS.IN_HEAD | SS.IN_INDEX | SS.IN_CONFIG | SS.WD_UNINITIALIZED) @@ -190,7 +192,7 @@ def test_oneshot_update(repo, depth): @utils.requires_network @pytest.mark.parametrize('depth', [0, 1]) -def test_oneshot_update_instance(repo, depth): +def test_oneshot_update_instance(repo: Repository, depth: int) -> None: subrepo_file_path = Path(repo.workdir) / SUBM_PATH / 'master.txt' assert not subrepo_file_path.exists() sm = repo.submodules[SUBM_NAME] @@ -206,12 +208,12 @@ def test_oneshot_update_instance(repo, depth): @utils.requires_network -def test_head_id(repo): +def test_head_id(repo: Repository) -> None: assert repo.submodules[SUBM_PATH].head_id == SUBM_HEAD_SHA @utils.requires_network -def test_head_id_null(repo): +def test_head_id_null(repo: Repository) -> None: gitmodules_newlines = ( '\n' '[submodule "uncommitted_submodule"]\n' @@ -230,7 +232,7 @@ def test_head_id_null(repo): @utils.requires_network @pytest.mark.parametrize('depth', [0, 1]) -def test_add_submodule(repo, depth): +def test_add_submodule(repo: Repository, depth: int) -> None: sm_repo_path = 'test/testrepo' sm = repo.submodules.add(SUBM_URL, sm_repo_path, depth=depth) @@ -250,7 +252,7 @@ def test_add_submodule(repo, depth): @utils.requires_network -def test_submodule_status(repo): +def test_submodule_status(repo: Repository) -> None: common_status = SS.IN_HEAD | SS.IN_INDEX | SS.IN_CONFIG # Submodule needs initializing @@ -302,7 +304,7 @@ def test_submodule_status(repo): ) -def test_submodule_cache(repo): +def test_submodule_cache(repo: Repository) -> None: # When the cache is turned on, looking up the same submodule twice must return the same git_submodule object repo.submodules.cache_all() sm1 = repo.submodules[SUBM_NAME] @@ -317,7 +319,7 @@ def test_submodule_cache(repo): assert sm3._subm != sm4._subm -def test_submodule_reload(repo): +def test_submodule_reload(repo: Repository) -> None: sm = repo.submodules[SUBM_NAME] assert sm.url == 'https://github.com/libgit2/TestGitRepository' diff --git a/test/utils.py b/test/utils.py index 7c840f13d..645a95bb1 100644 --- a/test/utils.py +++ b/test/utils.py @@ -31,6 +31,8 @@ import stat import sys import zipfile +from typing import Optional +from types import TracebackType # Requirements import pytest @@ -94,11 +96,11 @@ def rmtree(path): class TemporaryRepository: - def __init__(self, name, tmp_path): + def __init__(self, name: str, tmp_path: Path) -> None: self.name = name self.tmp_path = tmp_path - def __enter__(self): + def __enter__(self) -> Path: path = Path(__file__).parent / 'data' / self.name temp_repo_path = Path(self.tmp_path) / path.stem if path.suffix == '.zip': @@ -111,7 +113,12 @@ def __enter__(self): return temp_repo_path - def __exit__(self, exc_type, exc_value, traceback): + def __exit__( + self, + exc_type: Optional[type[BaseException]], + exc_value: Optional[BaseException], + traceback: Optional[TracebackType], + ) -> None: pass From 7528441a9d38096f776b33ff210942c61bcf422d Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Wed, 23 Jul 2025 22:03:01 +0200 Subject: [PATCH 012/225] improve types according to tests/test_apply_diff.py --- pygit2/_pygit2.pyi | 9 ++++++++ test/test_apply_diff.py | 48 +++++++++++++++++++++++++++-------------- 2 files changed, 41 insertions(+), 16 deletions(-) diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index 9d022967e..38c7545b5 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -779,6 +779,15 @@ class Repository: def create_tag( self, name: str, oid: _OidArg, type: ObjectType, tagger: Signature, message: str ) -> Oid: ... + def diff( + self, + a: None | str | Reference = None, + b: None | str | Reference = None, + cached: bool = False, + flags: DiffOption = DiffOption.NORMAL, + context_lines: int = 3, + interhunk_lines: int = 0, + ) -> Diff: ... def descendant_of(self, oid1: _OidArg, oid2: _OidArg) -> bool: ... def expand_id(self, hex: str) -> Oid: ... def free(self) -> None: ... diff --git a/test/test_apply_diff.py b/test/test_apply_diff.py index 87e766ddb..e50393245 100644 --- a/test/test_apply_diff.py +++ b/test/test_apply_diff.py @@ -24,6 +24,7 @@ # Boston, MA 02110-1301, USA. import pygit2 +from pygit2 import Repository, Diff from pygit2.enums import ApplyLocation, CheckoutStrategy, FileStatus import pytest @@ -31,31 +32,32 @@ from pathlib import Path -def read_content(testrepo): +def read_content(testrepo: Repository) -> str: with (Path(testrepo.workdir) / 'hello.txt').open('rb') as f: return f.read().decode('utf-8') @pytest.fixture -def new_content(): - content = ['bye world', 'adiós', 'au revoir monde'] - content = ''.join(x + os.linesep for x in content) +def new_content() -> str: + content_list = ['bye world', 'adiós', 'au revoir monde'] + content = ''.join(x + os.linesep for x in content_list) return content @pytest.fixture -def old_content(testrepo): +def old_content(testrepo: Repository) -> str: with (Path(testrepo.workdir) / 'hello.txt').open('rb') as f: return f.read().decode('utf-8') @pytest.fixture -def patch_diff(testrepo, new_content): +def patch_diff(testrepo: Repository, new_content: str) -> Diff: # Create the patch with (Path(testrepo.workdir) / 'hello.txt').open('wb') as f: f.write(new_content.encode('utf-8')) patch = testrepo.diff().patch + assert patch is not None # Rollback all changes testrepo.checkout('HEAD', strategy=CheckoutStrategy.FORCE) @@ -65,7 +67,7 @@ def patch_diff(testrepo, new_content): @pytest.fixture -def foreign_patch_diff(): +def foreign_patch_diff() -> Diff: patch_contents = """diff --git a/this_file_does_not_exist b/this_file_does_not_exist index 7f129fd..af431f2 100644 --- a/this_file_does_not_exist @@ -77,13 +79,15 @@ def foreign_patch_diff(): return pygit2.Diff.parse_diff(patch_contents) -def test_apply_type_error(testrepo): +def test_apply_type_error(testrepo: Repository) -> None: # Check apply type error with pytest.raises(TypeError): - testrepo.apply('HEAD') + testrepo.apply('HEAD') # type: ignore -def test_apply_diff_to_workdir(testrepo, new_content, patch_diff): +def test_apply_diff_to_workdir( + testrepo: Repository, new_content: str, patch_diff: Diff +) -> None: # Apply the patch and compare testrepo.apply(patch_diff, ApplyLocation.WORKDIR) @@ -91,7 +95,9 @@ def test_apply_diff_to_workdir(testrepo, new_content, patch_diff): assert testrepo.status_file('hello.txt') == FileStatus.WT_MODIFIED -def test_apply_diff_to_index(testrepo, old_content, patch_diff): +def test_apply_diff_to_index( + testrepo: Repository, old_content: str, patch_diff: Diff +) -> None: # Apply the patch and compare testrepo.apply(patch_diff, ApplyLocation.INDEX) @@ -99,7 +105,9 @@ def test_apply_diff_to_index(testrepo, old_content, patch_diff): assert testrepo.status_file('hello.txt') & FileStatus.INDEX_MODIFIED -def test_apply_diff_to_both(testrepo, new_content, patch_diff): +def test_apply_diff_to_both( + testrepo: Repository, new_content: str, patch_diff: Diff +) -> None: # Apply the patch and compare testrepo.apply(patch_diff, ApplyLocation.BOTH) @@ -107,7 +115,9 @@ def test_apply_diff_to_both(testrepo, new_content, patch_diff): assert testrepo.status_file('hello.txt') & FileStatus.INDEX_MODIFIED -def test_diff_applies_to_workdir(testrepo, old_content, patch_diff): +def test_diff_applies_to_workdir( + testrepo: Repository, old_content: str, patch_diff: Diff +) -> None: # See if patch applies assert testrepo.applies(patch_diff, ApplyLocation.WORKDIR) @@ -122,7 +132,9 @@ def test_diff_applies_to_workdir(testrepo, old_content, patch_diff): assert testrepo.applies(patch_diff, ApplyLocation.INDEX) -def test_diff_applies_to_index(testrepo, old_content, patch_diff): +def test_diff_applies_to_index( + testrepo: Repository, old_content: str, patch_diff: Diff +) -> None: # See if patch applies assert testrepo.applies(patch_diff, ApplyLocation.INDEX) @@ -137,7 +149,9 @@ def test_diff_applies_to_index(testrepo, old_content, patch_diff): assert testrepo.applies(patch_diff, ApplyLocation.WORKDIR) -def test_diff_applies_to_both(testrepo, old_content, patch_diff): +def test_diff_applies_to_both( + testrepo: Repository, old_content: str, patch_diff: Diff +) -> None: # See if patch applies assert testrepo.applies(patch_diff, ApplyLocation.BOTH) @@ -151,7 +165,9 @@ def test_diff_applies_to_both(testrepo, old_content, patch_diff): assert not testrepo.applies(patch_diff, ApplyLocation.INDEX) -def test_applies_error(testrepo, old_content, patch_diff, foreign_patch_diff): +def test_applies_error( + testrepo: Repository, old_content: str, patch_diff: Diff, foreign_patch_diff: Diff +) -> None: # Try to apply a "foreign" patch that affects files that aren't in the repo; # ensure we get OSError about the missing file (due to raise_error) with pytest.raises(OSError): From f40d129ea49efb746ccb3cb1d9891df78bd29dce Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Wed, 23 Jul 2025 22:40:51 +0200 Subject: [PATCH 013/225] improve types according to test/test_archive.py --- pygit2/_pygit2.pyi | 9 +++++++++ pygit2/index.py | 8 ++++---- test/test_archive.py | 10 ++++++---- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index 38c7545b5..207be3221 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -1,6 +1,7 @@ from typing import Iterator, Literal, Optional, overload, Type, TypedDict from io import IOBase, DEFAULT_BUFFER_SIZE from queue import Queue +import tarfile from threading import Event from . import Index from .enums import ( @@ -290,6 +291,7 @@ class Object: type: 'Literal[GIT_OBJ_COMMIT] | Literal[GIT_OBJ_TREE] | Literal[GIT_OBJ_TAG] | Literal[GIT_OBJ_BLOB]' type_str: "Literal['commit'] | Literal['tree'] | Literal['tag'] | Literal['blob']" author: Signature + committer: Signature tree: Tree @overload def peel( @@ -838,6 +840,13 @@ class Repository: def walk( self, oid: _OidArg | None, sort_mode: SortMode = SortMode.NONE ) -> Walker: ... + def write_archive( + self, + treeish: str | Tree | Object | Oid, + archive: tarfile.TarFile, + timestamp: int | None = None, + prefix: str = '', + ) -> None: ... class RevSpec: flags: int diff --git a/pygit2/index.py b/pygit2/index.py index 2238fa9d4..0d485ae48 100644 --- a/pygit2/index.py +++ b/pygit2/index.py @@ -42,7 +42,7 @@ class Index: # a proper implementation in some places: e.g. checking the index type # from C code (see Tree_diff_to_index) - def __init__(self, path=None): + def __init__(self, path: str | None = None) -> None: """Create a new Index If path is supplied, the read and write methods will use that path @@ -69,13 +69,13 @@ def from_c(cls, repo, ptr): def _pointer(self): return bytes(ffi.buffer(self._cindex)[:]) - def __del__(self): + def __del__(self) -> None: C.git_index_free(self._index) - def __len__(self): + def __len__(self) -> int: return C.git_index_entrycount(self._index) - def __contains__(self, path): + def __contains__(self, path) -> bool: err = C.git_index_find(ffi.NULL, self._index, to_bytes(path)) if err == C.GIT_ENOTFOUND: return False diff --git a/test/test_archive.py b/test/test_archive.py index 7e2454f10..e11242448 100644 --- a/test/test_archive.py +++ b/test/test_archive.py @@ -26,14 +26,16 @@ from pathlib import Path import tarfile -from pygit2 import Index, Oid, Tree, Object +from pygit2 import Index, Oid, Tree, Object, Repository TREE_HASH = 'fd937514cb799514d4b81bb24c5fcfeb6472b245' COMMIT_HASH = '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98' -def check_writing(repo, treeish, timestamp=None): +def check_writing( + repo: Repository, treeish: str | Tree | Oid | Object, timestamp: int | None = None +) -> None: archive = tarfile.open('foo.tar', mode='w') repo.write_archive(treeish, archive) @@ -55,13 +57,13 @@ def check_writing(repo, treeish, timestamp=None): path.unlink() -def test_write_tree(testrepo): +def test_write_tree(testrepo: Repository) -> None: check_writing(testrepo, TREE_HASH) check_writing(testrepo, Oid(hex=TREE_HASH)) check_writing(testrepo, testrepo[TREE_HASH]) -def test_write_commit(testrepo): +def test_write_commit(testrepo: Repository) -> None: commit_timestamp = testrepo[COMMIT_HASH].committer.time check_writing(testrepo, COMMIT_HASH, commit_timestamp) check_writing(testrepo, Oid(hex=COMMIT_HASH), commit_timestamp) From 11f9a67664979224000446ccfc5b25256c6ecfb7 Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Thu, 24 Jul 2025 21:48:10 +0200 Subject: [PATCH 014/225] imporve types accoding to test/test_attributes.py --- pygit2/_pygit2.pyi | 9 +++++++++ test/test_attributes.py | 6 ++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index 207be3221..5f0ed970f 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -1,10 +1,12 @@ from typing import Iterator, Literal, Optional, overload, Type, TypedDict from io import IOBase, DEFAULT_BUFFER_SIZE +from pathlib import Path from queue import Queue import tarfile from threading import Event from . import Index from .enums import ( + AttrCheck, ApplyLocation, BranchType, BlobFilter, @@ -793,6 +795,13 @@ class Repository: def descendant_of(self, oid1: _OidArg, oid2: _OidArg) -> bool: ... def expand_id(self, hex: str) -> Oid: ... def free(self) -> None: ... + def get_attr( + self, + path: str | bytes | Path, + name: str | bytes, + flags: AttrCheck = AttrCheck.FILE_THEN_INDEX, + commit: _OidArg | None = None, + ) -> bool | None | str: ... def git_object_lookup_prefix(self, oid: _OidArg) -> Object: ... def list_worktrees(self) -> list[str]: ... def listall_branches(self, flag: BranchType = BranchType.LOCAL) -> list[str]: ... diff --git a/test/test_attributes.py b/test/test_attributes.py index 00ac91add..12f9106ba 100644 --- a/test/test_attributes.py +++ b/test/test_attributes.py @@ -26,8 +26,10 @@ # Standard Library from pathlib import Path +from pygit2 import Repository -def test_no_attr(testrepo): + +def test_no_attr(testrepo: Repository) -> None: assert testrepo.get_attr('file', 'foo') is None with (Path(testrepo.workdir) / '.gitattributes').open('w+') as f: @@ -41,7 +43,7 @@ def test_no_attr(testrepo): assert 'lf' == testrepo.get_attr('file.sh', 'eol') -def test_no_attr_aspath(testrepo): +def test_no_attr_aspath(testrepo: Repository) -> None: with (Path(testrepo.workdir) / '.gitattributes').open('w+') as f: print('*.py text\n', file=f) From 0d7e6f9d32c6107883950f45bc94c2dbf0e7384f Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Thu, 24 Jul 2025 23:00:06 +0200 Subject: [PATCH 015/225] improve type according to test/test_blame.py this change introduces a new interface file: _pygit2_c.pyi the idea is to create python interfaces for c structs --- pygit2/_pygit2.pyi | 16 +++++++++++++- pygit2/_pygit2_c.pyi | 36 +++++++++++++++++++++++++++++++ pygit2/blame.py | 51 +++++++++++++++++++++++++++----------------- pygit2/utils.py | 19 +++++++++++++---- test/test_blame.py | 21 +++++++++--------- 5 files changed, 107 insertions(+), 36 deletions(-) create mode 100644 pygit2/_pygit2_c.pyi diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index 5f0ed970f..cdd7a3db0 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -10,6 +10,7 @@ from .enums import ( ApplyLocation, BranchType, BlobFilter, + BlameFlag, CheckoutStrategy, DeltaStatus, DiffFind, @@ -28,10 +29,13 @@ from .enums import ( ) from collections.abc import Generator +from ._pygit2_c import GitSignatureC, _Pointer + from .repository import BaseRepository from .submodules import SubmoduleCollection, Submodule from .remotes import Remote from .callbacks import CheckoutCallbacks +from .blame import Blame GIT_OBJ_BLOB = Literal[3] GIT_OBJ_COMMIT = Literal[1] @@ -724,6 +728,16 @@ class Repository: def apply( self, diff: Diff, location: ApplyLocation = ApplyLocation.WORKDIR ) -> None: ... + def blame( + self, + path: str, + flags: BlameFlag = BlameFlag.NORMAL, + min_match_characters: int | None = None, + newest_commit: _OidArg | None = None, + oldest_commit: _OidArg | None = None, + min_line: int | None = None, + max_line: int | None = None, + ) -> Blame: ... def checkout( self, refname: Optional[_OidArg], @@ -864,7 +878,7 @@ class RevSpec: class Signature: _encoding: str | None - _pointer: bytes + _pointer: _Pointer[GitSignatureC] email: str name: str offset: int diff --git a/pygit2/_pygit2_c.pyi b/pygit2/_pygit2_c.pyi new file mode 100644 index 000000000..c6208553a --- /dev/null +++ b/pygit2/_pygit2_c.pyi @@ -0,0 +1,36 @@ +from typing import NewType, Generic, Literal, TypeVar, overload + +T = TypeVar('T') + +char = NewType('char', object) +char_pointer = NewType('char_pointer', object) + +class _Pointer(Generic[T]): + @overload + def __getitem__(self, item: Literal[0]) -> T: ... + @overload + def __getitem__(self, item: slice[None, None, None]) -> bytes: ... + +class GitTimeC: + # incomplete + time: int + offset: int + +class GitSignatureC: + name: char_pointer + email: char_pointer + when: GitTimeC + +class GitHunkC: + # incomplete + boundary: char + final_start_line_number: int + final_signature: GitSignatureC + orig_signature: GitSignatureC + orig_start_line_number: int + orig_path: str + lines_in_hunk: int + +class GitBlameC: + # incomplete + pass diff --git a/pygit2/blame.py b/pygit2/blame.py index a1b8e42e9..d364e96d1 100644 --- a/pygit2/blame.py +++ b/pygit2/blame.py @@ -23,13 +23,18 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. +from typing import Iterator, TYPE_CHECKING + # Import from pygit2 from .ffi import ffi, C from .utils import GenericIterator -from ._pygit2 import Signature, Oid +from ._pygit2 import Signature, Oid, Repository + +if TYPE_CHECKING: + from ._pygit2_c import GitSignatureC, GitHunkC, GitBlameC -def wrap_signature(csig): +def wrap_signature(csig: 'GitSignatureC') -> None | Signature: if not csig: return None @@ -43,88 +48,94 @@ def wrap_signature(csig): class BlameHunk: + _blame: 'Blame' + _hunk: 'GitHunkC' + @classmethod - def _from_c(cls, blame, ptr): + def _from_c(cls, blame: 'Blame', ptr: 'GitHunkC') -> 'BlameHunk': hunk = cls.__new__(cls) hunk._blame = blame hunk._hunk = ptr return hunk @property - def lines_in_hunk(self): + def lines_in_hunk(self) -> int: """Number of lines""" return self._hunk.lines_in_hunk @property - def boundary(self): + def boundary(self) -> bool: """Tracked to a boundary commit""" # Casting directly to bool via cffi does not seem to work return int(ffi.cast('int', self._hunk.boundary)) != 0 @property - def final_start_line_number(self): + def final_start_line_number(self) -> int: """Final start line number""" return self._hunk.final_start_line_number @property - def final_committer(self): + def final_committer(self) -> None | Signature: """Final committer""" return wrap_signature(self._hunk.final_signature) @property - def final_commit_id(self): + def final_commit_id(self) -> Oid: return Oid( raw=bytes(ffi.buffer(ffi.addressof(self._hunk, 'final_commit_id'))[:]) ) @property - def orig_start_line_number(self): + def orig_start_line_number(self) -> int: """Origin start line number""" return self._hunk.orig_start_line_number @property - def orig_committer(self): + def orig_committer(self) -> None | Signature: """Original committer""" return wrap_signature(self._hunk.orig_signature) @property - def orig_commit_id(self): + def orig_commit_id(self) -> Oid: return Oid( raw=bytes(ffi.buffer(ffi.addressof(self._hunk, 'orig_commit_id'))[:]) ) @property - def orig_path(self): + def orig_path(self) -> None | str: """Original path""" path = self._hunk.orig_path if not path: return None - return ffi.string(path).decode('utf-8') + return ffi.string(path).decode('utf-8') # type: ignore[no-any-return] class Blame: + _repo: Repository + _blame: 'GitBlameC' + @classmethod - def _from_c(cls, repo, ptr): + def _from_c(cls, repo: Repository, ptr: 'GitBlameC') -> 'Blame': blame = cls.__new__(cls) blame._repo = repo blame._blame = ptr return blame - def __del__(self): + def __del__(self) -> None: C.git_blame_free(self._blame) - def __len__(self): - return C.git_blame_get_hunk_count(self._blame) + def __len__(self) -> int: + return C.git_blame_get_hunk_count(self._blame) # type: ignore[no-any-return] - def __getitem__(self, index): + def __getitem__(self, index: int) -> BlameHunk: chunk = C.git_blame_get_hunk_byindex(self._blame, index) if not chunk: raise IndexError return BlameHunk._from_c(self, chunk) - def for_line(self, line_no): + def for_line(self, line_no: int) -> BlameHunk: """ Returns the object for a given line given its number in the current Blame. @@ -143,5 +154,5 @@ def for_line(self, line_no): return BlameHunk._from_c(self, chunk) - def __iter__(self): + def __iter__(self) -> Iterator[BlameHunk]: return GenericIterator(self) diff --git a/pygit2/utils.py b/pygit2/utils.py index 1139b23cf..0298b3ce1 100644 --- a/pygit2/utils.py +++ b/pygit2/utils.py @@ -29,6 +29,8 @@ # Import from pygit2 from .ffi import ffi, C +from typing import Protocol, Iterator, TypeVar, Generic + def maybe_string(ptr): if not ptr: @@ -153,22 +155,31 @@ def assign_to(self, git_strarray): git_strarray.count = len(self.__strings) -class GenericIterator: +T = TypeVar('T') +U = TypeVar('U', covariant=True) + + +class SequenceProtocol(Protocol[U]): + def __len__(self) -> int: ... + def __getitem__(self, index: int) -> U: ... + + +class GenericIterator(Generic[T]): """Helper to easily implement an iterator. The constructor gets a container which must implement __len__ and __getitem__ """ - def __init__(self, container): + def __init__(self, container: SequenceProtocol[T]) -> None: self.container = container self.length = len(container) self.idx = 0 - def __iter__(self): + def __iter__(self) -> Iterator[T]: return self - def __next__(self): + def __next__(self) -> T: idx = self.idx if idx >= self.length: raise StopIteration diff --git a/test/test_blame.py b/test/test_blame.py index 251f7e6db..84b7d9026 100644 --- a/test/test_blame.py +++ b/test/test_blame.py @@ -27,7 +27,7 @@ import pytest -from pygit2 import Signature, Oid +from pygit2 import Signature, Oid, Repository from pygit2.enums import BlameFlag @@ -61,7 +61,7 @@ ] -def test_blame_index(testrepo): +def test_blame_index(testrepo: Repository) -> None: blame = testrepo.blame(PATH) assert len(blame) == 3 @@ -78,7 +78,7 @@ def test_blame_index(testrepo): assert HUNKS[i][3] == hunk.boundary -def test_blame_flags(blameflagsrepo): +def test_blame_flags(blameflagsrepo: Repository) -> None: blame = blameflagsrepo.blame(PATH, flags=BlameFlag.IGNORE_WHITESPACE) assert len(blame) == 3 @@ -95,7 +95,7 @@ def test_blame_flags(blameflagsrepo): assert HUNKS[i][3] == hunk.boundary -def test_blame_with_invalid_index(testrepo): +def test_blame_with_invalid_index(testrepo: Repository) -> None: blame = testrepo.blame(PATH) def test(): @@ -106,7 +106,7 @@ def test(): test() -def test_blame_for_line(testrepo): +def test_blame_for_line(testrepo: Repository) -> None: blame = testrepo.blame(PATH) for i, line in zip(range(0, 2), range(1, 3)): @@ -123,19 +123,18 @@ def test_blame_for_line(testrepo): assert HUNKS[i][3] == hunk.boundary -def test_blame_with_invalid_line(testrepo): +def test_blame_with_invalid_line(testrepo: Repository) -> None: blame = testrepo.blame(PATH) - def test(): + with pytest.raises(IndexError): blame.for_line(0) + with pytest.raises(IndexError): blame.for_line(100000) - blame.for_line(-1) - with pytest.raises(IndexError): - test() + blame.for_line(-1) -def test_blame_newest(testrepo): +def test_blame_newest(testrepo: Repository) -> None: revs = [ ('master^2', 3), ('master^2^', 2), From 58d12711e85636a5824e452d8e9f61a46b04a8d3 Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Sat, 26 Jul 2025 12:46:14 +0200 Subject: [PATCH 016/225] create type interface for _libgit2.ffi --- pygit2/__init__.py | 2 +- pygit2/_libgit2/ffi.pyi | 273 ++++++++++++++++++++++++++++++++++++++++ pygit2/_pygit2.pyi | 46 ++++--- pygit2/_pygit2_c.pyi | 36 ------ pygit2/blame.py | 4 +- pygit2/callbacks.py | 7 +- pygit2/repository.py | 11 +- pygit2/submodules.py | 5 +- pygit2/utils.py | 10 +- 9 files changed, 330 insertions(+), 64 deletions(-) create mode 100644 pygit2/_libgit2/ffi.pyi delete mode 100644 pygit2/_pygit2_c.pyi diff --git a/pygit2/__init__.py b/pygit2/__init__.py index cf83557ce..6440723e6 100644 --- a/pygit2/__init__.py +++ b/pygit2/__init__.py @@ -164,7 +164,7 @@ def clone_repository( callbacks: RemoteCallbacks | None = None, depth: int = 0, proxy: None | bool | str = None, -): +) -> Repository: """ Clones a new Git repository from *url* in the given *path*. diff --git a/pygit2/_libgit2/ffi.pyi b/pygit2/_libgit2/ffi.pyi new file mode 100644 index 000000000..fdcd29284 --- /dev/null +++ b/pygit2/_libgit2/ffi.pyi @@ -0,0 +1,273 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file 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; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +from typing import Literal, overload, NewType, TypeVar, Generic, Any, SupportsIndex +from typing import NewType, Literal, TypeVar, Generic, overload +from pygit2._pygit2 import Repository + +T = TypeVar('T') + +NULL_TYPE = NewType('NULL_TYPE', object) +NULL: NULL_TYPE = ... + +char = NewType('char', object) +char_pointer = NewType('char_pointer', object) + +class _Pointer(Generic[T]): + def __setitem__(self, item: Literal[0], a: T) -> None: ... + @overload + def __getitem__(self, item: Literal[0]) -> T: ... + @overload + def __getitem__(self, item: slice[None, None, None]) -> bytes: ... + +class GitTimeC: + # incomplete + time: int + offset: int + +class GitSignatureC: + name: char_pointer + email: char_pointer + when: GitTimeC + +class GitHunkC: + # incomplete + boundary: char + final_start_line_number: int + final_signature: GitSignatureC + orig_signature: GitSignatureC + orig_start_line_number: int + orig_path: char_pointer + lines_in_hunk: int + +class GitRepositoryC: + # incomplete + # TODO: this has to be unified with pygit2._pygit2(pyi).Repository + def _from_c(cls, ptr: 'GitRepositoryC', owned: bool) -> 'Repository': ... + +class GitFetchOptionsC: + # TODO: FetchOptions exist in _pygit2.pyi + # incomplete + depth: int + +class GitSubmoduleC: + pass + +class GitSubmoduleUpdateOptionsC: + fetch_opts: GitFetchOptionsC + +class UnsignedIntC: + def __getitem__(self, item: Literal[0]) -> int: ... + +class GitOidC: + id: _Pointer[bytes] + +class GitBlameOptionsC: + flags: int + min_match_characters: int + newest_commit: object + oldest_commit: object + min_line: int + max_line: int + +class GitBlameC: + # incomplete + pass + +class GitMergeOptionsC: + file_favor: int + flags: int + file_flags: int + +class GitAnnotatedCommitC: + pass + +class GitAttrOptionsC: + # incomplete + version: int + flags: int + +class GitBufC: + ptr: char_pointer + +class GitCheckoutOptionsC: + # incomplete + checkout_strategy: int + +class GitCommitC: + pass + +class GitConfigC: + pass + +class GitDescribeFormatOptionsC: + version: int + abbreviated_size: int + always_use_long_format: int + dirty_suffix: char_pointer + +class GitDescribeOptionsC: + version: int + max_candidates_tags: int + describe_strategy: int + pattern: char_pointer + only_follow_first_parent: int + show_commit_oid_as_fallback: int + +class GitDescribeResultC: + pass + +class GitIndexC: + pass + +class GitMergeFileResultC: + pass + +class GitObjectC: + pass + +class GitStashSaveOptionsC: + version: int + flags: int + stasher: GitSignatureC + message: char_pointer + paths: GitStrrayC + +class GitStrrayC: + pass + +class GitTreeC: + pass + +class GitRepositoryInitOptionsC: + version: int + flags: int + mode: int + workdir_path: char_pointer + description: char_pointer + template_path: char_pointer + initial_head: char_pointer + origin_url: char_pointer + +class GitCloneOptionsC: + pass + +class GitProxyTC: + pass + +class GitProxyOptionsC: + version: int + type: GitProxyTC + url: char_pointer + # credentials + # certificate_check + # payload + +class GitRemoteC: + pass + +class GitReferenceC: + pass + +def string(a: char_pointer) -> bytes: ... +@overload +def new(a: Literal['git_repository **']) -> _Pointer[GitRepositoryC]: ... +@overload +def new(a: Literal['git_remote **']) -> _Pointer[GitRemoteC]: ... +@overload +def new(a: Literal['git_repository_init_options *']) -> GitRepositoryInitOptionsC: ... +@overload +def new(a: Literal['git_submodule_update_options *']) -> GitSubmoduleUpdateOptionsC: ... +@overload +def new(a: Literal['git_submodule **']) -> _Pointer[GitSubmoduleC]: ... +@overload +def new(a: Literal['unsigned int *']) -> UnsignedIntC: ... +@overload +def new(a: Literal['git_proxy_options *']) -> GitProxyOptionsC: ... +@overload +def new(a: Literal['git_oid *']) -> GitOidC: ... +@overload +def new(a: Literal['git_blame **']) -> _Pointer[GitBlameC]: ... +@overload +def new(a: Literal['git_clone_options *']) -> GitCloneOptionsC: ... +@overload +def new(a: Literal['git_merge_options *']) -> GitMergeOptionsC: ... +@overload +def new(a: Literal['git_blame_options *']) -> GitBlameOptionsC: ... +@overload +def new(a: Literal['git_annotated_commit **']) -> _Pointer[GitAnnotatedCommitC]: ... +@overload +def new(a: Literal['git_attr_options *']) -> GitAttrOptionsC: ... +@overload +def new(a: Literal['git_buf *']) -> GitBufC: ... +@overload +def new(a: Literal['git_checkout_options *']) -> GitCheckoutOptionsC: ... +@overload +def new(a: Literal['git_commit **']) -> _Pointer[GitCommitC]: ... +@overload +def new(a: Literal['git_config *']) -> GitConfigC: ... +@overload +def new(a: Literal['git_describe_format_options *']) -> GitDescribeFormatOptionsC: ... +@overload +def new(a: Literal['git_describe_options *']) -> GitDescribeOptionsC: ... +@overload +def new(a: Literal['git_describe_result *']) -> GitDescribeResultC: ... +@overload +def new(a: Literal['git_describe_result **']) -> _Pointer[GitDescribeResultC]: ... +@overload +def new(a: Literal['struct git_reference **']) -> _Pointer[GitReferenceC]: ... +@overload +def new(a: Literal['git_index **']) -> _Pointer[GitIndexC]: ... +@overload +def new(a: Literal['git_merge_file_result *']) -> GitMergeFileResultC: ... +@overload +def new(a: Literal['git_object *']) -> GitObjectC: ... +@overload +def new(a: Literal['git_object **']) -> _Pointer[GitObjectC]: ... +@overload +def new(a: Literal['git_signature *']) -> GitSignatureC: ... +@overload +def new(a: Literal['git_signature **']) -> _Pointer[GitSignatureC]: ... +@overload +def new(a: Literal['git_stash_save_options *']) -> GitStashSaveOptionsC: ... +@overload +def new(a: Literal['git_tree **']) -> _Pointer[GitTreeC]: ... +@overload +def new(a: Literal['git_buf *'], b: tuple[NULL_TYPE, Literal[0]]) -> GitBufC: ... +@overload +def new(a: Literal['char **']) -> _Pointer[char_pointer]: ... +@overload +def new(a: Literal['char[]', 'char []'], b: bytes | NULL_TYPE) -> char_pointer: ... +def addressof(a: object, attribute: str) -> _Pointer[object]: ... + +class buffer(bytes): + def __init__(self, a: object) -> None: ... + def __setitem__(self, item: slice[None, None, None], value: bytes) -> None: ... + @overload + def __getitem__(self, item: SupportsIndex) -> int: ... + @overload + def __getitem__(self, item: slice[Any, Any, Any]) -> bytes: ... + +def cast(a: Literal['int'], b: object) -> int: ... diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index cdd7a3db0..48c362db1 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -1,4 +1,13 @@ -from typing import Iterator, Literal, Optional, overload, Type, TypedDict +from typing import ( + Iterator, + Literal, + Optional, + overload, + Type, + TypedDict, + TypeVar, + Generic, +) from io import IOBase, DEFAULT_BUFFER_SIZE from pathlib import Path from queue import Queue @@ -29,7 +38,14 @@ from .enums import ( ) from collections.abc import Generator -from ._pygit2_c import GitSignatureC, _Pointer +from ._libgit2.ffi import ( + _Pointer, + GitObjectC, + GitCommitC, + GitRepositoryC, + GitProxyOptionsC, + GitSignatureC, +) from .repository import BaseRepository from .submodules import SubmoduleCollection, Submodule @@ -287,8 +303,10 @@ GIT_FILTER_NO_SYSTEM_ATTRIBUTES: int GIT_FILTER_ATTRIBUTES_FROM_HEAD: int GIT_FILTER_ATTRIBUTES_FROM_COMMIT: int -class Object: - _pointer: bytes +T = TypeVar('T') + +class _ObjectBase(Generic[T]): + _pointer: _Pointer[T] filemode: FileMode id: Oid name: str | None @@ -320,6 +338,9 @@ class Object: def __lt__(self, other) -> bool: ... def __ne__(self, other) -> bool: ... +class Object(_ObjectBase[GitObjectC]): + pass + class Reference: name: str raw_name: bytes @@ -395,7 +416,7 @@ class Branch(Reference): class FetchOptions: # incomplete depth: int - proxy_opts: ProxyOpts + proxy_opts: GitProxyOptionsC class CloneOptions: # incomplete @@ -410,7 +431,8 @@ class CloneOptions: remote_cb: object remote_cb_payload: object -class Commit(Object): +class Commit(_ObjectBase[GitCommitC]): + _pointer: _Pointer[GitCommitC] author: Signature commit_time: int commit_time_offset: int @@ -640,16 +662,11 @@ class _StrArray: # incomplete count: int -class ProxyOpts: - # incomplete - type: object - url: str - class PushOptions: version: int pb_parallelism: int callbacks: object # TODO - proxy_opts: ProxyOpts + proxy_opts: GitProxyOptionsC follow_redirects: object # TODO custom_headers: _StrArray remote_push_options: _StrArray @@ -696,7 +713,7 @@ class Branches: def __contains__(self, name: _OidArg) -> bool: ... class Repository: - _pointer: bytes + _pointer: GitRepositoryC default_signature: Signature head: Reference head_is_detached: bool @@ -716,7 +733,8 @@ class Repository: def __init__(self, *args, **kwargs) -> None: ... def TreeBuilder(self, src: Tree | _OidArg = ...) -> TreeBuilder: ... def _disown(self, *args, **kwargs) -> None: ... - def _from_c(self, *args, **kwargs) -> 'Repository': ... + @classmethod + def _from_c(cls, ptr: 'GitRepositoryC', owned: bool) -> 'Repository': ... def __getitem__(self, key: str | Oid) -> Object: ... def add_worktree(self, name: str, path: str, ref: Reference = ...) -> Worktree: ... def applies( diff --git a/pygit2/_pygit2_c.pyi b/pygit2/_pygit2_c.pyi deleted file mode 100644 index c6208553a..000000000 --- a/pygit2/_pygit2_c.pyi +++ /dev/null @@ -1,36 +0,0 @@ -from typing import NewType, Generic, Literal, TypeVar, overload - -T = TypeVar('T') - -char = NewType('char', object) -char_pointer = NewType('char_pointer', object) - -class _Pointer(Generic[T]): - @overload - def __getitem__(self, item: Literal[0]) -> T: ... - @overload - def __getitem__(self, item: slice[None, None, None]) -> bytes: ... - -class GitTimeC: - # incomplete - time: int - offset: int - -class GitSignatureC: - name: char_pointer - email: char_pointer - when: GitTimeC - -class GitHunkC: - # incomplete - boundary: char - final_start_line_number: int - final_signature: GitSignatureC - orig_signature: GitSignatureC - orig_start_line_number: int - orig_path: str - lines_in_hunk: int - -class GitBlameC: - # incomplete - pass diff --git a/pygit2/blame.py b/pygit2/blame.py index d364e96d1..66d513541 100644 --- a/pygit2/blame.py +++ b/pygit2/blame.py @@ -31,7 +31,7 @@ from ._pygit2 import Signature, Oid, Repository if TYPE_CHECKING: - from ._pygit2_c import GitSignatureC, GitHunkC, GitBlameC + from ._libgit2.ffi import GitSignatureC, GitHunkC, GitBlameC def wrap_signature(csig: 'GitSignatureC') -> None | Signature: @@ -108,7 +108,7 @@ def orig_path(self) -> None | str: if not path: return None - return ffi.string(path).decode('utf-8') # type: ignore[no-any-return] + return ffi.string(path).decode('utf-8') class Blame: diff --git a/pygit2/callbacks.py b/pygit2/callbacks.py index 60f14f1c7..af141aaff 100644 --- a/pygit2/callbacks.py +++ b/pygit2/callbacks.py @@ -79,7 +79,8 @@ if TYPE_CHECKING: from .remotes import TransferProgress - from ._pygit2 import ProxyOpts, PushOptions, CloneOptions + from ._pygit2 import PushOptions, CloneOptions + from pygit2._libgit2.ffi import GitProxyOptionsC # # The payload is the way to pass information from the pygit2 API, through # libgit2, to the Python callbacks. And back. @@ -390,9 +391,9 @@ def git_fetch_options(payload, opts=None): @contextmanager def git_proxy_options( payload: object, - opts: Optional['ProxyOpts'] = None, + opts: Optional['GitProxyOptionsC'] = None, proxy: None | bool | str = None, -) -> Generator['ProxyOpts', None, None]: +) -> Generator['GitProxyOptionsC', None, None]: if opts is None: opts = ffi.new('git_proxy_options *') C.git_proxy_options_init(opts, C.GIT_PROXY_OPTIONS_VERSION) diff --git a/pygit2/repository.py b/pygit2/repository.py index 4c2cf26ff..2a2856910 100644 --- a/pygit2/repository.py +++ b/pygit2/repository.py @@ -29,7 +29,7 @@ from time import time import tarfile import typing -from typing import Optional +from typing import Optional, TYPE_CHECKING # Import from pygit2 from ._pygit2 import Repository as _Repository, init_file_backend @@ -64,6 +64,9 @@ from .submodules import SubmoduleCollection from .utils import to_bytes, StrArray +if TYPE_CHECKING: + from pygit2._libgit2.ffi import GitRepositoryC + class BaseRepository(_Repository): def __init__(self, *args, **kwargs): @@ -167,6 +170,7 @@ def hashfile( """ c_path = to_bytes(path) + c_as_path: ffi.NULL_TYPE | bytes if as_path is None: c_as_path = ffi.NULL else: @@ -615,6 +619,7 @@ def blame( """ options = ffi.new('git_blame_options *') + C.git_blame_options_init(options, C.GIT_BLAME_OPTIONS_VERSION) if flags: options.flags = int(flags) @@ -1658,10 +1663,10 @@ def __init__( super().__init__() @classmethod - def _from_c(cls, ptr, owned): + def _from_c(cls, ptr: 'GitRepositoryC', owned: bool) -> 'Repository': cptr = ffi.new('git_repository **') cptr[0] = ptr repo = cls.__new__(cls) - BaseRepository._from_c(repo, bytes(ffi.buffer(cptr)[:]), owned) + BaseRepository._from_c(repo, bytes(ffi.buffer(cptr)[:]), owned) # type: ignore repo._common_init() return repo diff --git a/pygit2/submodules.py b/pygit2/submodules.py index 2aec25ec5..4d621a5fa 100644 --- a/pygit2/submodules.py +++ b/pygit2/submodules.py @@ -38,14 +38,15 @@ if TYPE_CHECKING: from .repository import BaseRepository from pygit2 import Repository + from pygit2._libgit2.ffi import GitSubmoduleC class Submodule: _repo: BaseRepository - _subm: object + _subm: 'GitSubmoduleC' @classmethod - def _from_c(cls, repo: BaseRepository, cptr): + def _from_c(cls, repo: BaseRepository, cptr: 'GitSubmoduleC') -> 'Submodule': subm = cls.__new__(cls) subm._repo = repo diff --git a/pygit2/utils.py b/pygit2/utils.py index 0298b3ce1..430b64455 100644 --- a/pygit2/utils.py +++ b/pygit2/utils.py @@ -29,7 +29,7 @@ # Import from pygit2 from .ffi import ffi, C -from typing import Protocol, Iterator, TypeVar, Generic +from typing import Protocol, Iterator, TypeVar, Generic, Union def maybe_string(ptr): @@ -39,7 +39,11 @@ def maybe_string(ptr): return ffi.string(ptr).decode('utf8', errors='surrogateescape') -def to_bytes(s, encoding='utf-8', errors='strict'): +def to_bytes( + s: Union[str, bytes, 'ffi.NULL_TYPE', os.PathLike[str], None], + encoding: str = 'utf-8', + errors: str = 'strict', +) -> Union[bytes, 'ffi.NULL_TYPE']: if s == ffi.NULL or s is None: return ffi.NULL @@ -49,7 +53,7 @@ def to_bytes(s, encoding='utf-8', errors='strict'): if isinstance(s, bytes): return s - return s.encode(encoding, errors) + return s.encode(encoding, errors) # type: ignore[union-attr] def to_str(s): From 3955534c12080166956687598f545101332cbca4 Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Sat, 26 Jul 2025 13:41:19 +0200 Subject: [PATCH 017/225] sort imports with "ruff check --select I --fix" --- pygit2/__init__.py | 20 +++++++------- pygit2/_libgit2/ffi.pyi | 4 +-- pygit2/_pygit2.pyi | 49 +++++++++++++++++----------------- pygit2/_run.py | 2 +- pygit2/blame.py | 9 ++++--- pygit2/blob.py | 2 +- pygit2/branches.py | 3 ++- pygit2/callbacks.py | 17 ++++++------ pygit2/config.py | 2 +- pygit2/credentials.py | 1 - pygit2/errors.py | 3 +-- pygit2/ffi.py | 3 ++- pygit2/index.py | 7 +++-- pygit2/packbuilder.py | 2 +- pygit2/references.py | 1 + pygit2/refspec.py | 2 +- pygit2/remotes.py | 10 ++++--- pygit2/repository.py | 30 ++++++++++++++------- pygit2/submodules.py | 12 +++++---- pygit2/utils.py | 5 ++-- setup.py | 12 ++++----- test/conftest.py | 4 ++- test/test_apply_diff.py | 11 ++++---- test/test_archive.py | 5 ++-- test/test_blame.py | 3 +-- test/test_blob.py | 4 +-- test/test_branch.py | 9 ++++--- test/test_branch_empty.py | 2 +- test/test_cherrypick.py | 1 + test/test_commit.py | 4 +-- test/test_commit_trailer.py | 3 ++- test/test_config.py | 2 +- test/test_credentials.py | 6 ++--- test/test_describe.py | 2 +- test/test_diff.py | 3 +-- test/test_filter.py | 3 ++- test/test_index.py | 3 ++- test/test_mailmap.py | 1 - test/test_merge.py | 2 +- test/test_nonunicode.py | 2 +- test/test_note.py | 2 +- test/test_object.py | 3 +-- test/test_odb.py | 2 +- test/test_odb_backend.py | 2 +- test/test_oid.py | 2 +- test/test_packbuilder.py | 1 + test/test_patch.py | 2 +- test/test_patch_encoding.py | 1 - test/test_refdb_backend.py | 3 ++- test/test_refs.py | 13 ++++++--- test/test_remote.py | 6 ++--- test/test_remote_utf8.py | 4 ++- test/test_repository.py | 14 +++++++--- test/test_repository_bare.py | 2 +- test/test_repository_custom.py | 1 + test/test_revparse.py | 3 ++- test/test_revwalk.py | 1 - test/test_submodule.py | 7 ++--- test/test_tag.py | 1 - test/test_tree.py | 2 +- test/utils.py | 5 ++-- 61 files changed, 189 insertions(+), 154 deletions(-) diff --git a/pygit2/__init__.py b/pygit2/__init__.py index 6440723e6..f97daa870 100644 --- a/pygit2/__init__.py +++ b/pygit2/__init__.py @@ -30,26 +30,29 @@ import os import typing -# Low level API -from ._pygit2 import * -from ._pygit2 import _cache_enums - # High level API from . import enums from ._build import __version__ + +# Low level API +from ._pygit2 import * +from ._pygit2 import _cache_enums from .blame import Blame, BlameHunk from .blob import BlobIO -from .callbacks import Payload, RemoteCallbacks, CheckoutCallbacks, StashApplyCallbacks from .callbacks import ( + CheckoutCallbacks, + Payload, + RemoteCallbacks, + StashApplyCallbacks, + get_credentials, git_clone_options, git_fetch_options, git_proxy_options, - get_credentials, ) from .config import Config from .credentials import * -from .errors import check_error, Passthrough -from .ffi import ffi, C +from .errors import Passthrough, check_error +from .ffi import C, ffi from .filter import Filter from .index import Index, IndexEntry from .legacyenums import * @@ -60,7 +63,6 @@ from .submodules import Submodule from .utils import to_bytes, to_str - # Features features = enums.Feature(C.git_libgit2_features()) diff --git a/pygit2/_libgit2/ffi.pyi b/pygit2/_libgit2/ffi.pyi index fdcd29284..39b36b84d 100644 --- a/pygit2/_libgit2/ffi.pyi +++ b/pygit2/_libgit2/ffi.pyi @@ -23,8 +23,8 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. -from typing import Literal, overload, NewType, TypeVar, Generic, Any, SupportsIndex -from typing import NewType, Literal, TypeVar, Generic, overload +from typing import Any, Generic, Literal, NewType, SupportsIndex, TypeVar, overload + from pygit2._pygit2 import Repository T = TypeVar('T') diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index 48c362db1..29c99084b 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -1,25 +1,37 @@ +import tarfile +from collections.abc import Generator +from io import DEFAULT_BUFFER_SIZE, IOBase +from pathlib import Path +from queue import Queue +from threading import Event from typing import ( + Generic, Iterator, Literal, Optional, - overload, Type, TypedDict, TypeVar, - Generic, + overload, ) -from io import IOBase, DEFAULT_BUFFER_SIZE -from pathlib import Path -from queue import Queue -import tarfile -from threading import Event + from . import Index +from ._libgit2.ffi import ( + GitCommitC, + GitObjectC, + GitProxyOptionsC, + GitRepositoryC, + GitSignatureC, + _Pointer, +) +from .blame import Blame +from .callbacks import CheckoutCallbacks from .enums import ( - AttrCheck, ApplyLocation, - BranchType, - BlobFilter, + AttrCheck, BlameFlag, + BlobFilter, + BranchType, CheckoutStrategy, DeltaStatus, DiffFind, @@ -36,22 +48,9 @@ from .enums import ( ResetMode, SortMode, ) -from collections.abc import Generator - -from ._libgit2.ffi import ( - _Pointer, - GitObjectC, - GitCommitC, - GitRepositoryC, - GitProxyOptionsC, - GitSignatureC, -) - -from .repository import BaseRepository -from .submodules import SubmoduleCollection, Submodule from .remotes import Remote -from .callbacks import CheckoutCallbacks -from .blame import Blame +from .repository import BaseRepository +from .submodules import Submodule, SubmoduleCollection GIT_OBJ_BLOB = Literal[3] GIT_OBJ_COMMIT = Literal[1] diff --git a/pygit2/_run.py b/pygit2/_run.py index ea921d915..bb8238c93 100644 --- a/pygit2/_run.py +++ b/pygit2/_run.py @@ -29,8 +29,8 @@ # Import from the Standard Library import codecs -from pathlib import Path import sys +from pathlib import Path # Import from cffi from cffi import FFI # type: ignore diff --git a/pygit2/blame.py b/pygit2/blame.py index 66d513541..c5e982b3c 100644 --- a/pygit2/blame.py +++ b/pygit2/blame.py @@ -23,15 +23,16 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. -from typing import Iterator, TYPE_CHECKING +from typing import TYPE_CHECKING, Iterator + +from ._pygit2 import Oid, Repository, Signature # Import from pygit2 -from .ffi import ffi, C +from .ffi import C, ffi from .utils import GenericIterator -from ._pygit2 import Signature, Oid, Repository if TYPE_CHECKING: - from ._libgit2.ffi import GitSignatureC, GitHunkC, GitBlameC + from ._libgit2.ffi import GitBlameC, GitHunkC, GitSignatureC def wrap_signature(csig: 'GitSignatureC') -> None | Signature: diff --git a/pygit2/blob.py b/pygit2/blob.py index 23e8a9da6..1232fb2fa 100644 --- a/pygit2/blob.py +++ b/pygit2/blob.py @@ -2,8 +2,8 @@ import threading import time from contextlib import AbstractContextManager -from typing import Optional from queue import Queue +from typing import Optional from ._pygit2 import Blob, Oid from .enums import BlobFilter diff --git a/pygit2/branches.py b/pygit2/branches.py index c6323a1f8..9f5ac6f1c 100644 --- a/pygit2/branches.py +++ b/pygit2/branches.py @@ -24,10 +24,11 @@ # Boston, MA 02110-1301, USA. from __future__ import annotations + from typing import TYPE_CHECKING -from .enums import BranchType, ReferenceType from ._pygit2 import Commit, Oid +from .enums import BranchType, ReferenceType # Need BaseRepository for type hints, but don't let it cause a circular dependency if TYPE_CHECKING: diff --git a/pygit2/callbacks.py b/pygit2/callbacks.py index af141aaff..e2b861d98 100644 --- a/pygit2/callbacks.py +++ b/pygit2/callbacks.py @@ -65,22 +65,23 @@ # Standard Library from contextlib import contextmanager from functools import wraps -from typing import Optional, Union, TYPE_CHECKING, Callable, Generator +from typing import TYPE_CHECKING, Callable, Generator, Optional, Union # pygit2 -from ._pygit2 import Oid, DiffFile +from ._pygit2 import DiffFile, Oid +from .credentials import Keypair, Username, UserPass from .enums import CheckoutNotify, CheckoutStrategy, CredentialType, StashApplyProgress -from .errors import check_error, Passthrough -from .ffi import ffi, C -from .utils import maybe_string, to_bytes, ptr_to_bytes, StrArray -from .credentials import Username, UserPass, Keypair +from .errors import Passthrough, check_error +from .ffi import C, ffi +from .utils import StrArray, maybe_string, ptr_to_bytes, to_bytes _Credentials = Username | UserPass | Keypair if TYPE_CHECKING: - from .remotes import TransferProgress - from ._pygit2 import PushOptions, CloneOptions from pygit2._libgit2.ffi import GitProxyOptionsC + + from ._pygit2 import CloneOptions, PushOptions + from .remotes import TransferProgress # # The payload is the way to pass information from the pygit2 API, through # libgit2, to the Python callbacks. And back. diff --git a/pygit2/config.py b/pygit2/config.py index cba72911f..e8619eda0 100644 --- a/pygit2/config.py +++ b/pygit2/config.py @@ -30,7 +30,7 @@ # Import from pygit2 from .errors import check_error -from .ffi import ffi, C +from .ffi import C, ffi from .utils import to_bytes diff --git a/pygit2/credentials.py b/pygit2/credentials.py index 2b307f948..52edc8ce4 100644 --- a/pygit2/credentials.py +++ b/pygit2/credentials.py @@ -29,7 +29,6 @@ from .enums import CredentialType - if TYPE_CHECKING: from pathlib import Path diff --git a/pygit2/errors.py b/pygit2/errors.py index 3ecef9df4..8bfa8ff51 100644 --- a/pygit2/errors.py +++ b/pygit2/errors.py @@ -24,9 +24,8 @@ # Boston, MA 02110-1301, USA. # Import from pygit2 -from .ffi import ffi, C from ._pygit2 import GitError - +from .ffi import C, ffi value_errors = set([C.GIT_EEXISTS, C.GIT_EINVALIDSPEC, C.GIT_EAMBIGUOUS]) diff --git a/pygit2/ffi.py b/pygit2/ffi.py index a9a0c6152..6fe791a51 100644 --- a/pygit2/ffi.py +++ b/pygit2/ffi.py @@ -24,4 +24,5 @@ # Boston, MA 02110-1301, USA. # Import from pygit2 -from ._libgit2 import ffi, lib as C # type: ignore # noqa: F401 +from ._libgit2 import ffi # type: ignore # noqa: F401 +from ._libgit2 import lib as C diff --git a/pygit2/index.py b/pygit2/index.py index 0d485ae48..e6ae65934 100644 --- a/pygit2/index.py +++ b/pygit2/index.py @@ -28,12 +28,11 @@ from dataclasses import dataclass # Import from pygit2 -from ._pygit2 import Oid, Tree, Diff +from ._pygit2 import Diff, Oid, Tree from .enums import DiffOption, FileMode from .errors import check_error -from .ffi import ffi, C -from .utils import to_bytes, to_str -from .utils import GenericIterator, StrArray +from .ffi import C, ffi +from .utils import GenericIterator, StrArray, to_bytes, to_str class Index: diff --git a/pygit2/packbuilder.py b/pygit2/packbuilder.py index b9844d52e..5551e1fd6 100644 --- a/pygit2/packbuilder.py +++ b/pygit2/packbuilder.py @@ -26,7 +26,7 @@ # Import from pygit2 from .errors import check_error -from .ffi import ffi, C +from .ffi import C, ffi from .utils import to_bytes diff --git a/pygit2/references.py b/pygit2/references.py index ca1d23dcc..630c3fe5c 100644 --- a/pygit2/references.py +++ b/pygit2/references.py @@ -24,6 +24,7 @@ # Boston, MA 02110-1301, USA. from __future__ import annotations + from typing import TYPE_CHECKING from .enums import ReferenceFilter diff --git a/pygit2/refspec.py b/pygit2/refspec.py index 447cf7dc3..70b0f4999 100644 --- a/pygit2/refspec.py +++ b/pygit2/refspec.py @@ -25,7 +25,7 @@ # Import from pygit2 from .errors import check_error -from .ffi import ffi, C +from .ffi import C, ffi from .utils import to_bytes diff --git a/pygit2/remotes.py b/pygit2/remotes.py index 7e91f6aef..195d48226 100644 --- a/pygit2/remotes.py +++ b/pygit2/remotes.py @@ -24,22 +24,24 @@ # Boston, MA 02110-1301, USA. from __future__ import annotations + from typing import TYPE_CHECKING, Any +from . import utils + # Import from pygit2 from ._pygit2 import Oid from .callbacks import ( git_fetch_options, - git_push_options, git_proxy_options, + git_push_options, git_remote_callbacks, ) from .enums import FetchPrune from .errors import check_error -from .ffi import ffi, C +from .ffi import C, ffi from .refspec import Refspec -from . import utils -from .utils import maybe_string, to_bytes, strarray_to_strings, StrArray +from .utils import StrArray, maybe_string, strarray_to_strings, to_bytes # Need BaseRepository for type hints, but don't let it cause a circular dependency if TYPE_CHECKING: diff --git a/pygit2/repository.py b/pygit2/repository.py index 2a2856910..0c2db04e3 100644 --- a/pygit2/repository.py +++ b/pygit2/repository.py @@ -22,21 +22,31 @@ # along with this program; see the file COPYING. If not, write to # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. +import tarfile +import typing import warnings from io import BytesIO from os import PathLike from string import hexdigits from time import time -import tarfile -import typing -from typing import Optional, TYPE_CHECKING +from typing import TYPE_CHECKING, Optional + +from ._pygit2 import ( + GIT_OID_HEXSZ, + GIT_OID_MINPREFIXLEN, + Blob, + Commit, + InvalidSpecError, + Object, + Oid, + Reference, + Signature, + Tree, + init_file_backend, +) # Import from pygit2 -from ._pygit2 import Repository as _Repository, init_file_backend -from ._pygit2 import Oid, GIT_OID_HEXSZ, GIT_OID_MINPREFIXLEN, Object -from ._pygit2 import Reference, Tree, Commit, Blob, Signature -from ._pygit2 import InvalidSpecError - +from ._pygit2 import Repository as _Repository from .blame import Blame from .branches import Branches from .callbacks import git_checkout_options, git_stash_apply_options @@ -56,13 +66,13 @@ RepositoryState, ) from .errors import check_error -from .ffi import ffi, C +from .ffi import C, ffi from .index import Index, IndexEntry, MergeFileResult from .packbuilder import PackBuilder from .references import References from .remotes import RemoteCollection from .submodules import SubmoduleCollection -from .utils import to_bytes, StrArray +from .utils import StrArray, to_bytes if TYPE_CHECKING: from pygit2._libgit2.ffi import GitRepositoryC diff --git a/pygit2/submodules.py b/pygit2/submodules.py index 4d621a5fa..0f98e4f41 100644 --- a/pygit2/submodules.py +++ b/pygit2/submodules.py @@ -24,22 +24,24 @@ # Boston, MA 02110-1301, USA. from __future__ import annotations -from typing import TYPE_CHECKING, Iterable, Iterator, Optional, Union + from pathlib import Path +from typing import TYPE_CHECKING, Iterable, Iterator, Optional, Union from ._pygit2 import Oid -from .callbacks import git_fetch_options, RemoteCallbacks +from .callbacks import RemoteCallbacks, git_fetch_options from .enums import SubmoduleIgnore, SubmoduleStatus from .errors import check_error -from .ffi import ffi, C -from .utils import to_bytes, maybe_string +from .ffi import C, ffi +from .utils import maybe_string, to_bytes # Need BaseRepository for type hints, but don't let it cause a circular dependency if TYPE_CHECKING: - from .repository import BaseRepository from pygit2 import Repository from pygit2._libgit2.ffi import GitSubmoduleC + from .repository import BaseRepository + class Submodule: _repo: BaseRepository diff --git a/pygit2/utils.py b/pygit2/utils.py index 430b64455..427986842 100644 --- a/pygit2/utils.py +++ b/pygit2/utils.py @@ -25,11 +25,10 @@ import contextlib import os +from typing import Generic, Iterator, Protocol, TypeVar, Union # Import from pygit2 -from .ffi import ffi, C - -from typing import Protocol, Iterator, TypeVar, Generic, Union +from .ffi import C, ffi def maybe_string(ptr): diff --git a/setup.py b/setup.py index 88d1536d0..1ad618218 100644 --- a/setup.py +++ b/setup.py @@ -24,15 +24,15 @@ # Boston, MA 02110-1301, USA. # Import setuptools before distutils to avoid user warning -from setuptools import setup, Extension - +import os +import sys +from distutils import log from distutils.command.build import build from distutils.command.sdist import sdist -from distutils import log -import os from pathlib import Path -from subprocess import Popen, PIPE -import sys +from subprocess import PIPE, Popen + +from setuptools import Extension, setup # Import stuff from pygit2/_utils.py without loading the whole pygit2 package sys.path.insert(0, 'pygit2') diff --git a/test/conftest.py b/test/conftest.py index 1c6d7b8f5..6feb9d239 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,8 +1,10 @@ -from pathlib import Path import platform +from pathlib import Path import pytest + import pygit2 + from . import utils diff --git a/test/test_apply_diff.py b/test/test_apply_diff.py index e50393245..9277d7eb1 100644 --- a/test/test_apply_diff.py +++ b/test/test_apply_diff.py @@ -23,14 +23,15 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. -import pygit2 -from pygit2 import Repository, Diff -from pygit2.enums import ApplyLocation, CheckoutStrategy, FileStatus -import pytest - import os from pathlib import Path +import pytest + +import pygit2 +from pygit2 import Diff, Repository +from pygit2.enums import ApplyLocation, CheckoutStrategy, FileStatus + def read_content(testrepo: Repository) -> str: with (Path(testrepo.workdir) / 'hello.txt').open('rb') as f: diff --git a/test/test_archive.py b/test/test_archive.py index e11242448..bd8ef8644 100644 --- a/test/test_archive.py +++ b/test/test_archive.py @@ -23,11 +23,10 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. -from pathlib import Path import tarfile +from pathlib import Path -from pygit2 import Index, Oid, Tree, Object, Repository - +from pygit2 import Index, Object, Oid, Repository, Tree TREE_HASH = 'fd937514cb799514d4b81bb24c5fcfeb6472b245' COMMIT_HASH = '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98' diff --git a/test/test_blame.py b/test/test_blame.py index 84b7d9026..4f1d8e2b0 100644 --- a/test/test_blame.py +++ b/test/test_blame.py @@ -27,10 +27,9 @@ import pytest -from pygit2 import Signature, Oid, Repository +from pygit2 import Oid, Repository, Signature from pygit2.enums import BlameFlag - PATH = 'hello.txt' HUNKS = [ diff --git a/test/test_blob.py b/test/test_blob.py index c9025f498..63e97c00d 100644 --- a/test/test_blob.py +++ b/test/test_blob.py @@ -27,15 +27,15 @@ import io from pathlib import Path -from threading import Event from queue import Queue +from threading import Event import pytest import pygit2 from pygit2.enums import ObjectType -from . import utils +from . import utils BLOB_SHA = 'a520c24d85fbfc815d385957eed41406ca5a860b' BLOB_CONTENT = """hello world diff --git a/test/test_branch.py b/test/test_branch.py index 1128a1b1d..3cb3316da 100644 --- a/test/test_branch.py +++ b/test/test_branch.py @@ -25,12 +25,13 @@ """Tests for branch methods.""" -import pygit2 -import pytest import os -from pygit2.enums import BranchType -from pygit2 import Repository +import pytest + +import pygit2 +from pygit2 import Repository +from pygit2.enums import BranchType LAST_COMMIT = '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98' I18N_LAST_COMMIT = '5470a671a80ac3789f1a6a8cefbcf43ce7af0563' diff --git a/test/test_branch_empty.py b/test/test_branch_empty.py index c1d079705..56198535b 100644 --- a/test/test_branch_empty.py +++ b/test/test_branch_empty.py @@ -24,8 +24,8 @@ # Boston, MA 02110-1301, USA. import pytest -from pygit2.enums import BranchType +from pygit2.enums import BranchType ORIGIN_MASTER_COMMIT = '784855caf26449a1914d2cf62d12b9374d76ae78' diff --git a/test/test_cherrypick.py b/test/test_cherrypick.py index 136e7d26d..d1aebbe18 100644 --- a/test/test_cherrypick.py +++ b/test/test_cherrypick.py @@ -26,6 +26,7 @@ """Tests for merging and information about it.""" from pathlib import Path + import pytest import pygit2 diff --git a/test/test_commit.py b/test/test_commit.py index 8967e5cda..a38559dac 100644 --- a/test/test_commit.py +++ b/test/test_commit.py @@ -29,10 +29,10 @@ import pytest -from pygit2 import Signature, Oid, GitError +from pygit2 import GitError, Oid, Signature from pygit2.enums import ObjectType -from . import utils +from . import utils COMMIT_SHA = '5fe808e8953c12735680c257f56600cb0de44b10' COMMIT_SHA_TO_AMEND = ( diff --git a/test/test_commit_trailer.py b/test/test_commit_trailer.py index d7236cd8f..055047599 100644 --- a/test/test_commit_trailer.py +++ b/test/test_commit_trailer.py @@ -23,9 +23,10 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. -import pygit2 import pytest +import pygit2 + from . import utils diff --git a/test/test_config.py b/test/test_config.py index 0284d76f8..71b2b5345 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -28,8 +28,8 @@ import pytest from pygit2 import Config -from . import utils +from . import utils CONFIG_FILENAME = 'test_config' diff --git a/test/test_credentials.py b/test/test_credentials.py index e9578b36a..2cfe48220 100644 --- a/test/test_credentials.py +++ b/test/test_credentials.py @@ -23,16 +23,16 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. -from pathlib import Path import platform +from pathlib import Path import pytest import pygit2 -from pygit2 import Username, UserPass, Keypair, KeypairFromAgent, KeypairFromMemory +from pygit2 import Keypair, KeypairFromAgent, KeypairFromMemory, Username, UserPass from pygit2.enums import CredentialType -from . import utils +from . import utils REMOTE_NAME = 'origin' REMOTE_URL = 'git://github.com/libgit2/pygit2.git' diff --git a/test/test_describe.py b/test/test_describe.py index 22650a5df..f24c60a9b 100644 --- a/test/test_describe.py +++ b/test/test_describe.py @@ -27,8 +27,8 @@ import pytest -from pygit2.enums import DescribeStrategy, ObjectType import pygit2 +from pygit2.enums import DescribeStrategy, ObjectType def add_tag(repo, name, target): diff --git a/test/test_diff.py b/test/test_diff.py index f73a4c64e..5deb3481a 100644 --- a/test/test_diff.py +++ b/test/test_diff.py @@ -25,15 +25,14 @@ """Tests for Diff objects.""" -from itertools import chain import textwrap +from itertools import chain import pytest import pygit2 from pygit2.enums import DeltaStatus, DiffFlag, DiffOption, DiffStatsFormat, FileMode - COMMIT_SHA1_1 = '5fe808e8953c12735680c257f56600cb0de44b10' COMMIT_SHA1_2 = 'c2792cfa289ae6321ecf2cd5806c2194b0fd070c' COMMIT_SHA1_3 = '2cdae28389c059815e951d0bb9eed6533f61a46b' diff --git a/test/test_filter.py b/test/test_filter.py index f37f9e1c4..5fedb22bf 100644 --- a/test/test_filter.py +++ b/test/test_filter.py @@ -1,5 +1,6 @@ -from io import BytesIO import codecs +from io import BytesIO + import pytest import pygit2 diff --git a/test/test_index.py b/test/test_index.py index 0fb0586f8..b1fccba90 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -30,8 +30,9 @@ import pytest import pygit2 -from pygit2 import Repository, Index, Oid, IndexEntry +from pygit2 import Index, IndexEntry, Oid, Repository from pygit2.enums import FileMode + from . import utils diff --git a/test/test_mailmap.py b/test/test_mailmap.py index 3cdef0564..e5a3b90e6 100644 --- a/test/test_mailmap.py +++ b/test/test_mailmap.py @@ -27,7 +27,6 @@ from pygit2 import Mailmap - TEST_MAILMAP = """\ # Simple Comment line diff --git a/test/test_merge.py b/test/test_merge.py index 959854b3d..538070f06 100644 --- a/test/test_merge.py +++ b/test/test_merge.py @@ -30,7 +30,7 @@ import pytest import pygit2 -from pygit2.enums import FileStatus, MergeAnalysis, MergeFavor, MergeFlag, MergeFileFlag +from pygit2.enums import FileStatus, MergeAnalysis, MergeFavor, MergeFileFlag, MergeFlag @pytest.mark.parametrize('id', [None, 42]) diff --git a/test/test_nonunicode.py b/test/test_nonunicode.py index 26b446801..3daef1a26 100644 --- a/test/test_nonunicode.py +++ b/test/test_nonunicode.py @@ -32,8 +32,8 @@ import pytest import pygit2 -from . import utils +from . import utils # FIXME Detect the filesystem rather than the operating system works_in_linux = pytest.mark.xfail( diff --git a/test/test_note.py b/test/test_note.py index 2a1719240..1f95e2a43 100644 --- a/test/test_note.py +++ b/test/test_note.py @@ -25,9 +25,9 @@ """Tests for note objects.""" -from pygit2 import Signature import pytest +from pygit2 import Signature NOTE = ('6c8980ba963cad8b25a9bcaf68d4023ee57370d8', 'note message') diff --git a/test/test_object.py b/test/test_object.py index 668d2d666..95b534582 100644 --- a/test/test_object.py +++ b/test/test_object.py @@ -27,10 +27,9 @@ import pytest -from pygit2 import Tree, Tag +from pygit2 import Tag, Tree from pygit2.enums import ObjectType - BLOB_SHA = 'a520c24d85fbfc815d385957eed41406ca5a860b' BLOB_CONTENT = """hello world hola mundo diff --git a/test/test_odb.py b/test/test_odb.py index c7e60f22b..ce07c937d 100644 --- a/test/test_odb.py +++ b/test/test_odb.py @@ -34,8 +34,8 @@ # pygit2 from pygit2 import Odb, Oid from pygit2.enums import ObjectType -from . import utils +from . import utils BLOB_HEX = 'af431f20fc541ed6d5afede3e2dc7160f6f01f16' BLOB_RAW = binascii.unhexlify(BLOB_HEX.encode('ascii')) diff --git a/test/test_odb_backend.py b/test/test_odb_backend.py index 026834c30..c1995f268 100644 --- a/test/test_odb_backend.py +++ b/test/test_odb_backend.py @@ -34,8 +34,8 @@ # pygit2 import pygit2 from pygit2.enums import ObjectType -from . import utils +from . import utils BLOB_HEX = 'af431f20fc541ed6d5afede3e2dc7160f6f01f16' BLOB_RAW = binascii.unhexlify(BLOB_HEX.encode('ascii')) diff --git a/test/test_oid.py b/test/test_oid.py index c6cbf3e8b..41170226e 100644 --- a/test/test_oid.py +++ b/test/test_oid.py @@ -28,9 +28,9 @@ # Standard Library from binascii import unhexlify -from pygit2 import Oid import pytest +from pygit2 import Oid HEX = '15b648aec6ed045b5ca6f57f8b7831a8b4757298' RAW = unhexlify(HEX.encode('ascii')) diff --git a/test/test_packbuilder.py b/test/test_packbuilder.py index 6d4ed0d95..cea99b395 100644 --- a/test/test_packbuilder.py +++ b/test/test_packbuilder.py @@ -29,6 +29,7 @@ import pygit2 from pygit2 import PackBuilder + from . import utils diff --git a/test/test_patch.py b/test/test_patch.py index 5620f9b58..b6b7c6308 100644 --- a/test/test_patch.py +++ b/test/test_patch.py @@ -23,9 +23,9 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. -import pygit2 import pytest +import pygit2 BLOB_OLD_SHA = 'a520c24d85fbfc815d385957eed41406ca5a860b' BLOB_NEW_SHA = '3b18e512dba79e4c8300dd08aeb37f8e728b8dad' diff --git a/test/test_patch_encoding.py b/test/test_patch_encoding.py index 23f4aca1b..30072cf4d 100644 --- a/test/test_patch_encoding.py +++ b/test/test_patch_encoding.py @@ -25,7 +25,6 @@ import pygit2 - expected_diff = b"""diff --git a/iso-8859-1.txt b/iso-8859-1.txt index e84e339..201e0c9 100644 --- a/iso-8859-1.txt diff --git a/test/test_refdb_backend.py b/test/test_refdb_backend.py index a7f10cf55..cec430c28 100644 --- a/test/test_refdb_backend.py +++ b/test/test_refdb_backend.py @@ -27,9 +27,10 @@ from pathlib import Path -import pygit2 import pytest +import pygit2 + # Note: the refdb abstraction from libgit2 is meant to provide information # which libgit2 transforms into something more useful, and in general YMMV by diff --git a/test/test_refs.py b/test/test_refs.py index a2fe0ef5d..4ffdca8a8 100644 --- a/test/test_refs.py +++ b/test/test_refs.py @@ -29,11 +29,18 @@ import pytest -from pygit2 import Commit, Signature, Tree, reference_is_valid_name, Repository -from pygit2 import AlreadyExistsError, GitError, InvalidSpecError +from pygit2 import ( + AlreadyExistsError, + Commit, + GitError, + InvalidSpecError, + Repository, + Signature, + Tree, + reference_is_valid_name, +) from pygit2.enums import ReferenceType - LAST_COMMIT = '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98' diff --git a/test/test_remote.py b/test/test_remote.py index e3cc21469..e0e4318ce 100644 --- a/test/test_remote.py +++ b/test/test_remote.py @@ -24,16 +24,16 @@ # Boston, MA 02110-1301, USA. import sys -from pathlib import Path from collections.abc import Generator +from pathlib import Path import pytest import pygit2 -from pygit2 import Repository, Remote +from pygit2 import Remote, Repository from pygit2.remotes import TransferProgress -from . import utils +from . import utils REMOTE_NAME = 'origin' REMOTE_URL = 'https://github.com/libgit2/pygit2.git' diff --git a/test/test_remote_utf8.py b/test/test_remote_utf8.py index cf58a8d53..1307e8979 100644 --- a/test/test_remote_utf8.py +++ b/test/test_remote_utf8.py @@ -23,8 +23,10 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. -import pygit2 import pytest + +import pygit2 + from . import utils diff --git a/test/test_repository.py b/test/test_repository.py index d48aa7acc..3dd47bbc5 100644 --- a/test/test_repository.py +++ b/test/test_repository.py @@ -23,28 +23,34 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. -from pathlib import Path import shutil import tempfile +from pathlib import Path import pytest # pygit2 import pygit2 -from pygit2 import init_repository, clone_repository, discover_repository, IndexEntry -from pygit2 import Oid +from pygit2 import ( + IndexEntry, + Oid, + clone_repository, + discover_repository, + init_repository, +) from pygit2.enums import ( CheckoutNotify, CheckoutStrategy, + FileMode, FileStatus, ObjectType, RepositoryOpenFlag, RepositoryState, ResetMode, StashApplyProgress, - FileMode, ) from pygit2.index import MergeFileResult + from . import utils diff --git a/test/test_repository_bare.py b/test/test_repository_bare.py index 0274a4018..833921550 100644 --- a/test/test_repository_bare.py +++ b/test/test_repository_bare.py @@ -33,8 +33,8 @@ import pygit2 from pygit2.enums import FileMode, ObjectType -from . import utils +from . import utils HEAD_SHA = '784855caf26449a1914d2cf62d12b9374d76ae78' PARENT_SHA = 'f5e5aa4e36ab0fe62ee1ccc6eb8f79b866863b87' # HEAD^ diff --git a/test/test_repository_custom.py b/test/test_repository_custom.py index 5c365e09e..bc4f8b9de 100644 --- a/test/test_repository_custom.py +++ b/test/test_repository_custom.py @@ -24,6 +24,7 @@ # Boston, MA 02110-1301, USA. from pathlib import Path + import pytest import pygit2 diff --git a/test/test_revparse.py b/test/test_revparse.py index 10effc49e..a83f77db3 100644 --- a/test/test_revparse.py +++ b/test/test_revparse.py @@ -25,9 +25,10 @@ """Tests for revision parsing.""" +from pytest import raises + from pygit2 import InvalidSpecError from pygit2.enums import RevSpecFlag -from pytest import raises HEAD_SHA = '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98' PARENT_SHA = '5ebeeebb320790caf276b9fc8b24546d63316533' # HEAD^ diff --git a/test/test_revwalk.py b/test/test_revwalk.py index 483984c02..fa97456d6 100644 --- a/test/test_revwalk.py +++ b/test/test_revwalk.py @@ -27,7 +27,6 @@ from pygit2.enums import SortMode - # In the order given by git log log = [ '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98', diff --git a/test/test_submodule.py b/test/test_submodule.py index dcfc58291..52c55b2be 100644 --- a/test/test_submodule.py +++ b/test/test_submodule.py @@ -28,13 +28,14 @@ from pathlib import Path from typing import Generator +import pytest + import pygit2 from pygit2 import Repository, Submodule -import pytest +from pygit2.enums import SubmoduleIgnore as SI +from pygit2.enums import SubmoduleStatus as SS from . import utils -from pygit2.enums import SubmoduleIgnore as SI, SubmoduleStatus as SS - SUBM_NAME = 'TestGitRepository' SUBM_PATH = 'TestGitRepository' diff --git a/test/test_tag.py b/test/test_tag.py index e0e733227..e641dded3 100644 --- a/test/test_tag.py +++ b/test/test_tag.py @@ -30,7 +30,6 @@ import pygit2 from pygit2.enums import ObjectType - TAG_SHA = '3d2962987c695a29f1f80b6c3aa4ec046ef44369' diff --git a/test/test_tree.py b/test/test_tree.py index c50000830..85a225918 100644 --- a/test/test_tree.py +++ b/test/test_tree.py @@ -24,6 +24,7 @@ # Boston, MA 02110-1301, USA. import operator + import pytest import pygit2 @@ -31,7 +32,6 @@ from . import utils - TREE_SHA = '967fce8df97cc71722d3c2a5930ef3e6f1d27b12' SUBTREE_SHA = '614fd9a3094bf618ea938fffc00e7d1a54f89ad0' diff --git a/test/utils.py b/test/utils.py index 645a95bb1..ba98f5cef 100644 --- a/test/utils.py +++ b/test/utils.py @@ -25,14 +25,14 @@ # Standard library import hashlib -from pathlib import Path import shutil import socket import stat import sys import zipfile -from typing import Optional +from pathlib import Path from types import TracebackType +from typing import Optional # Requirements import pytest @@ -40,7 +40,6 @@ # Pygit2 import pygit2 - requires_future_libgit2 = pytest.mark.xfail( pygit2.LIBGIT2_VER < (2, 0, 0), reason='This test may work with a future version of libgit2', From 41a76dd0bad71da57ade31b651de9909ffe17faa Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Sat, 26 Jul 2025 13:44:26 +0200 Subject: [PATCH 018/225] make sure imports stay sorted also fix ruff related stuff --- .github/workflows/lint.yml | 5 ++++- pygit2/_pygit2.pyi | 2 +- pygit2/ffi.py | 2 +- pyproject.toml | 3 +++ 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 5c4f145fb..57db3c455 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -22,8 +22,11 @@ jobs: - name: Install ruff run: pip install ruff - - name: Check code style with ruff + - name: Format code with ruff run: ruff format --diff + - name: Check code style with ruff + run: ruff check + - name: Check typing with mypy run: LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.1 /bin/sh build.sh mypy diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index 29c99084b..88a10d7d6 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -50,7 +50,7 @@ from .enums import ( ) from .remotes import Remote from .repository import BaseRepository -from .submodules import Submodule, SubmoduleCollection +from .submodules import SubmoduleCollection GIT_OBJ_BLOB = Literal[3] GIT_OBJ_COMMIT = Literal[1] diff --git a/pygit2/ffi.py b/pygit2/ffi.py index 6fe791a51..41ebec4c5 100644 --- a/pygit2/ffi.py +++ b/pygit2/ffi.py @@ -25,4 +25,4 @@ # Import from pygit2 from ._libgit2 import ffi # type: ignore # noqa: F401 -from ._libgit2 import lib as C +from ._libgit2 import lib as C # type: ignore # noqa: F401 diff --git a/pyproject.toml b/pyproject.toml index c625df473..219efcac9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,9 @@ repair-wheel-command = "DYLD_LIBRARY_PATH=/Users/runner/work/pygit2/pygit2/ci/li extend-exclude = [ ".cache", ".coverage", "build", "site-packages", "venv*"] target-version = "py310" # oldest supported Python version +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F", "I"] + [tool.ruff.format] quote-style = "single" From dc1c22cc48ea639fff26a6c86b2c59300791b568 Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Sat, 26 Jul 2025 19:01:19 +0200 Subject: [PATCH 019/225] fix create branch example in docs fixes #1230 --- docs/branches.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/branches.rst b/docs/branches.rst index f98d9baaa..5afe6673e 100644 --- a/docs/branches.rst +++ b/docs/branches.rst @@ -26,14 +26,14 @@ Example:: >>> remote_branches = list(repo.branches.remote) >>> # Get a branch - >>> branch = repo.branches['master'] + >>> master_branch = repo.branches['master'] >>> other_branch = repo.branches['does-not-exist'] # Will raise a KeyError >>> other_branch = repo.branches.get('does-not-exist') # Returns None >>> remote_branch = repo.branches.remote['upstream/feature'] - >>> # Create a local branch - >>> new_branch = repo.branches.local.create('new-branch') + >>> # Create a local branch, branching from master + >>> new_branch = repo.branches.local.create('new-branch', repo[master_branch.target]) >>> And delete it >>> repo.branches.delete('new-branch') From b9a10be983490683ee9c59b50cde6ef53131a441 Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Sat, 26 Jul 2025 19:29:55 +0200 Subject: [PATCH 020/225] improve types according to test/test_blob.py --- pygit2/_pygit2.pyi | 11 +++++---- src/blob.c | 2 +- test/test_blob.py | 58 ++++++++++++++++++++++++++++------------------ 3 files changed, 44 insertions(+), 27 deletions(-) diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index 88a10d7d6..69b491629 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -386,20 +386,23 @@ class Blob(Object): ) -> Patch: ... def diff_to_buffer( self, - buffer: Optional[bytes] = None, + buffer: Optional[bytes | str] = None, flag: DiffOption = DiffOption.NORMAL, old_as_path: str = ..., buffer_as_path: str = ..., ) -> Patch: ... def _write_to_queue( self, - queue: Queue, - closed: Event, + queue: Queue[bytes], + ready: Event, + done: Event, chunk_size: int = DEFAULT_BUFFER_SIZE, as_path: Optional[str] = None, flags: BlobFilter = BlobFilter.CHECK_FOR_BINARY, commit_id: Optional[Oid] = None, ) -> None: ... + def __buffer__(self, flags: int) -> memoryview: ... + def __release_buffer__(self, buffer: memoryview) -> None: ... class Branch(Reference): branch_name: str @@ -769,7 +772,7 @@ class Repository: def create_blob(self, data: bytes) -> Oid: ... def create_blob_fromdisk(self, path: str) -> Oid: ... def create_blob_fromiobase(self, iobase: IOBase) -> Oid: ... - def create_blob_fromworkdir(self, path: str) -> Oid: ... + def create_blob_fromworkdir(self, path: str | Path) -> Oid: ... def create_branch(self, name: str, commit: Commit, force=False) -> Branch: ... def create_commit( self, diff --git a/src/blob.c b/src/blob.c index dac991bf8..93e7dbe47 100644 --- a/src/blob.c +++ b/src/blob.c @@ -235,7 +235,7 @@ static void blob_filter_stream_free(git_writestream *s) PyDoc_STRVAR(Blob__write_to_queue__doc__, - "_write_to_queue(queue: queue.Queue, closed: threading.Event, chunk_size: int = io.DEFAULT_BUFFER_SIZE, [as_path: str = None, flags: enums.BlobFilter = enums.BlobFilter.CHECK_FOR_BINARY, commit_id: oid = None]) -> None\n" + "_write_to_queue(queue: queue.Queue, ready: threading.Event, done: threading.Event, chunk_size: int = io.DEFAULT_BUFFER_SIZE, [as_path: str = None, flags: enums.BlobFilter = enums.BlobFilter.CHECK_FOR_BINARY, commit_id: oid = None]) -> None\n" "\n" "Write the contents of the blob in chunks to `queue`.\n" "If `as_path` is None, the raw contents of blob will be written to the queue,\n" diff --git a/test/test_blob.py b/test/test_blob.py index 63e97c00d..47ced2609 100644 --- a/test/test_blob.py +++ b/test/test_blob.py @@ -33,6 +33,7 @@ import pytest import pygit2 +from pygit2 import Repository from pygit2.enums import ObjectType from . import utils @@ -80,7 +81,7 @@ """ -def test_read_blob(testrepo): +def test_read_blob(testrepo: Repository) -> None: blob = testrepo[BLOB_SHA] assert blob.id == BLOB_SHA assert blob.id == BLOB_SHA @@ -92,7 +93,7 @@ def test_read_blob(testrepo): assert BLOB_CONTENT == blob.read_raw() -def test_create_blob(testrepo): +def test_create_blob(testrepo: Repository) -> None: blob_oid = testrepo.create_blob(BLOB_NEW_CONTENT) blob = testrepo[blob_oid] @@ -116,7 +117,7 @@ def set_content(): set_content() -def test_create_blob_fromworkdir(testrepo): +def test_create_blob_fromworkdir(testrepo: Repository) -> None: blob_oid = testrepo.create_blob_fromworkdir('bye.txt') blob = testrepo[blob_oid] @@ -131,19 +132,19 @@ def test_create_blob_fromworkdir(testrepo): assert BLOB_FILE_CONTENT == blob.read_raw() -def test_create_blob_fromworkdir_aspath(testrepo): +def test_create_blob_fromworkdir_aspath(testrepo: Repository) -> None: blob_oid = testrepo.create_blob_fromworkdir(Path('bye.txt')) blob = testrepo[blob_oid] assert isinstance(blob, pygit2.Blob) -def test_create_blob_outside_workdir(testrepo): +def test_create_blob_outside_workdir(testrepo: Repository) -> None: with pytest.raises(KeyError): testrepo.create_blob_fromworkdir(__file__) -def test_create_blob_fromdisk(testrepo): +def test_create_blob_fromdisk(testrepo: Repository) -> None: blob_oid = testrepo.create_blob_fromdisk(__file__) blob = testrepo[blob_oid] @@ -151,9 +152,9 @@ def test_create_blob_fromdisk(testrepo): assert ObjectType.BLOB == blob.type -def test_create_blob_fromiobase(testrepo): +def test_create_blob_fromiobase(testrepo: Repository) -> None: with pytest.raises(TypeError): - testrepo.create_blob_fromiobase('bad type') + testrepo.create_blob_fromiobase('bad type') # type: ignore f = io.BytesIO(BLOB_CONTENT) blob_oid = testrepo.create_blob_fromiobase(f) @@ -166,54 +167,64 @@ def test_create_blob_fromiobase(testrepo): assert BLOB_SHA == blob_oid -def test_diff_blob(testrepo): +def test_diff_blob(testrepo: Repository) -> None: blob = testrepo[BLOB_SHA] + assert isinstance(blob, pygit2.Blob) old_blob = testrepo['3b18e512dba79e4c8300dd08aeb37f8e728b8dad'] + assert isinstance(old_blob, pygit2.Blob) patch = blob.diff(old_blob, old_as_path='hello.txt') assert len(patch.hunks) == 1 -def test_diff_blob_to_buffer(testrepo): +def test_diff_blob_to_buffer(testrepo: Repository) -> None: blob = testrepo[BLOB_SHA] + assert isinstance(blob, pygit2.Blob) patch = blob.diff_to_buffer('hello world') assert len(patch.hunks) == 1 -def test_diff_blob_to_buffer_patch_patch(testrepo): +def test_diff_blob_to_buffer_patch_patch(testrepo: Repository) -> None: blob = testrepo[BLOB_SHA] + assert isinstance(blob, pygit2.Blob) patch = blob.diff_to_buffer('hello world') assert patch.text == BLOB_PATCH -def test_diff_blob_to_buffer_delete(testrepo): +def test_diff_blob_to_buffer_delete(testrepo: Repository) -> None: blob = testrepo[BLOB_SHA] + assert isinstance(blob, pygit2.Blob) patch = blob.diff_to_buffer(None) assert patch.text == BLOB_PATCH_DELETED -def test_diff_blob_create(testrepo): +def test_diff_blob_create(testrepo: Repository) -> None: old = testrepo[testrepo.create_blob(BLOB_CONTENT)] new = testrepo[testrepo.create_blob(BLOB_NEW_CONTENT)] + assert isinstance(old, pygit2.Blob) + assert isinstance(new, pygit2.Blob) patch = old.diff(new) assert patch.text == BLOB_PATCH_2 -def test_blob_from_repo(testrepo): +def test_blob_from_repo(testrepo: Repository) -> None: blob = testrepo[BLOB_SHA] + assert isinstance(blob, pygit2.Blob) patch_one = blob.diff_to_buffer(None) blob = testrepo[BLOB_SHA] + assert isinstance(blob, pygit2.Blob) patch_two = blob.diff_to_buffer(None) assert patch_one.text == patch_two.text -def test_blob_write_to_queue(testrepo): - queue = Queue() +def test_blob_write_to_queue(testrepo: Repository) -> None: + queue: Queue[bytes] = Queue() ready = Event() done = Event() blob = testrepo[BLOB_SHA] + assert isinstance(blob, pygit2.Blob) blob._write_to_queue(queue, ready, done) assert ready.wait() assert done.wait() @@ -223,12 +234,13 @@ def test_blob_write_to_queue(testrepo): assert BLOB_CONTENT == b''.join(chunks) -def test_blob_write_to_queue_filtered(testrepo): - queue = Queue() +def test_blob_write_to_queue_filtered(testrepo: Repository) -> None: + queue: Queue[bytes] = Queue() ready = Event() done = Event() blob_oid = testrepo.create_blob_fromworkdir('bye.txt') blob = testrepo[blob_oid] + assert isinstance(blob, pygit2.Blob) blob._write_to_queue(queue, ready, done, as_path='bye.txt') assert ready.wait() assert done.wait() @@ -238,17 +250,19 @@ def test_blob_write_to_queue_filtered(testrepo): assert b'bye world\n' == b''.join(chunks) -def test_blobio(testrepo): +def test_blobio(testrepo: Repository) -> None: blob_oid = testrepo.create_blob_fromworkdir('bye.txt') blob = testrepo[blob_oid] + assert isinstance(blob, pygit2.Blob) with pygit2.BlobIO(blob) as reader: assert b'bye world\n' == reader.read() - assert not reader.raw._thread.is_alive() + assert not reader.raw._thread.is_alive() # type: ignore[attr-defined] -def test_blobio_filtered(testrepo): +def test_blobio_filtered(testrepo: Repository) -> None: blob_oid = testrepo.create_blob_fromworkdir('bye.txt') blob = testrepo[blob_oid] + assert isinstance(blob, pygit2.Blob) with pygit2.BlobIO(blob, as_path='bye.txt') as reader: assert b'bye world\n' == reader.read() - assert not reader.raw._thread.is_alive() + assert not reader.raw._thread.is_alive() # type: ignore[attr-defined] From 992c6e102266afb87053fd5f56794f8e93a81bc8 Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Sat, 26 Jul 2025 19:35:15 +0200 Subject: [PATCH 021/225] fix typing in test_branch_empty.py --- test/test_branch_empty.py | 37 ++++++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/test/test_branch_empty.py b/test/test_branch_empty.py index 56198535b..c3a736df1 100644 --- a/test/test_branch_empty.py +++ b/test/test_branch_empty.py @@ -23,37 +23,40 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. +from typing import Generator + import pytest +from pygit2 import Commit, Repository from pygit2.enums import BranchType ORIGIN_MASTER_COMMIT = '784855caf26449a1914d2cf62d12b9374d76ae78' @pytest.fixture -def repo(emptyrepo): +def repo(emptyrepo: Repository) -> Generator[Repository, None, None]: remote = emptyrepo.remotes[0] remote.fetch() yield emptyrepo -def test_branches_remote_get(repo): +def test_branches_remote_get(repo: Repository) -> None: branch = repo.branches.remote.get('origin/master') assert branch.target == ORIGIN_MASTER_COMMIT assert repo.branches.remote.get('origin/not-exists') is None -def test_branches_remote(repo): +def test_branches_remote(repo: Repository) -> None: branches = sorted(repo.branches.remote) assert branches == ['origin/master'] -def test_branches_remote_getitem(repo): +def test_branches_remote_getitem(repo: Repository) -> None: branch = repo.branches.remote['origin/master'] assert branch.remote_name == 'origin' -def test_branches_upstream(repo): +def test_branches_upstream(repo: Repository) -> None: remote_master = repo.branches.remote['origin/master'] master = repo.branches.create('master', repo[remote_master.target]) @@ -71,7 +74,7 @@ def set_bad_upstream(): assert master.upstream is None -def test_branches_upstream_name(repo): +def test_branches_upstream_name(repo: Repository) -> None: remote_master = repo.branches.remote['origin/master'] master = repo.branches.create('master', repo[remote_master.target]) @@ -84,28 +87,30 @@ def test_branches_upstream_name(repo): # -def test_lookup_branch_remote(repo): +def test_lookup_branch_remote(repo: Repository) -> None: branch = repo.lookup_branch('origin/master', BranchType.REMOTE) assert branch.target == ORIGIN_MASTER_COMMIT assert repo.lookup_branch('origin/not-exists', BranchType.REMOTE) is None -def test_listall_branches(repo): +def test_listall_branches(repo: Repository) -> None: branches = sorted(repo.listall_branches(BranchType.REMOTE)) assert branches == ['origin/master'] - branches = sorted(repo.raw_listall_branches(BranchType.REMOTE)) - assert branches == [b'origin/master'] + branches_raw = sorted(repo.raw_listall_branches(BranchType.REMOTE)) + assert branches_raw == [b'origin/master'] -def test_branch_remote_name(repo): +def test_branch_remote_name(repo: Repository) -> None: branch = repo.lookup_branch('origin/master', BranchType.REMOTE) assert branch.remote_name == 'origin' -def test_branch_upstream(repo): +def test_branch_upstream(repo: Repository) -> None: remote_master = repo.lookup_branch('origin/master', BranchType.REMOTE) - master = repo.create_branch('master', repo[remote_master.target]) + commit = repo[remote_master.target] + assert isinstance(commit, Commit) + master = repo.create_branch('master', commit) assert master.upstream is None master.upstream = remote_master @@ -121,9 +126,11 @@ def set_bad_upstream(): assert master.upstream is None -def test_branch_upstream_name(repo): +def test_branch_upstream_name(repo: Repository) -> None: remote_master = repo.lookup_branch('origin/master', BranchType.REMOTE) - master = repo.create_branch('master', repo[remote_master.target]) + commit = repo[remote_master.target] + assert isinstance(commit, Commit) + master = repo.create_branch('master', commit) master.upstream = remote_master assert master.upstream_name == 'refs/remotes/origin/master' From b84bc40b6bdd0d3cf931924fcb86cc7d2115f22b Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Sat, 26 Jul 2025 19:37:44 +0200 Subject: [PATCH 022/225] improve types according to test/test_branch.py --- pygit2/_pygit2.pyi | 2 +- test/test_branch.py | 43 ++++++++++++++++++++++++++----------------- 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index 69b491629..7f1f84e24 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -843,7 +843,7 @@ class Repository: def listall_stashes(self) -> list[Stash]: ... def listall_submodules(self) -> list[str]: ... def lookup_branch( - self, branch_name: str, branch_type: BranchType = BranchType.LOCAL + self, branch_name: str | bytes, branch_type: BranchType = BranchType.LOCAL ) -> Branch: ... def lookup_note( self, annotated_id: str, ref: str = 'refs/notes/commits' diff --git a/test/test_branch.py b/test/test_branch.py index 3cb3316da..f5bff9d3d 100644 --- a/test/test_branch.py +++ b/test/test_branch.py @@ -30,7 +30,7 @@ import pytest import pygit2 -from pygit2 import Repository +from pygit2 import Commit, Repository from pygit2.enums import BranchType LAST_COMMIT = '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98' @@ -154,7 +154,7 @@ def test_branches_with_commit(testrepo: Repository) -> None: # -def test_lookup_branch_local(testrepo): +def test_lookup_branch_local(testrepo: Repository) -> None: assert testrepo.lookup_branch('master').target == LAST_COMMIT assert testrepo.lookup_branch(b'master').target == LAST_COMMIT @@ -167,16 +167,17 @@ def test_lookup_branch_local(testrepo): assert testrepo.lookup_branch(b'\xb1') is None -def test_listall_branches(testrepo): +def test_listall_branches(testrepo: Repository) -> None: branches = sorted(testrepo.listall_branches()) assert branches == ['i18n', 'master'] - branches = sorted(testrepo.raw_listall_branches()) - assert branches == [b'i18n', b'master'] + branches_raw = sorted(testrepo.raw_listall_branches()) + assert branches_raw == [b'i18n', b'master'] -def test_create_branch(testrepo): +def test_create_branch(testrepo: Repository) -> None: commit = testrepo[LAST_COMMIT] + assert isinstance(commit, Commit) testrepo.create_branch('version1', commit) refs = testrepo.listall_branches() assert 'version1' in refs @@ -191,64 +192,72 @@ def test_create_branch(testrepo): assert testrepo.create_branch('version1', commit, True).target == LAST_COMMIT -def test_delete(testrepo): +def test_delete(testrepo: Repository) -> None: branch = testrepo.lookup_branch('i18n') branch.delete() assert testrepo.lookup_branch('i18n') is None -def test_cant_delete_master(testrepo): +def test_cant_delete_master(testrepo: Repository) -> None: branch = testrepo.lookup_branch('master') with pytest.raises(pygit2.GitError): branch.delete() -def test_branch_is_head_returns_true_if_branch_is_head(testrepo): +def test_branch_is_head_returns_true_if_branch_is_head(testrepo: Repository) -> None: branch = testrepo.lookup_branch('master') assert branch.is_head() -def test_branch_is_head_returns_false_if_branch_is_not_head(testrepo): +def test_branch_is_head_returns_false_if_branch_is_not_head( + testrepo: Repository, +) -> None: branch = testrepo.lookup_branch('i18n') assert not branch.is_head() -def test_branch_is_checked_out_returns_true_if_branch_is_checked_out(testrepo): +def test_branch_is_checked_out_returns_true_if_branch_is_checked_out( + testrepo: Repository, +) -> None: branch = testrepo.lookup_branch('master') assert branch.is_checked_out() -def test_branch_is_checked_out_returns_false_if_branch_is_not_checked_out(testrepo): +def test_branch_is_checked_out_returns_false_if_branch_is_not_checked_out( + testrepo: Repository, +) -> None: branch = testrepo.lookup_branch('i18n') assert not branch.is_checked_out() -def test_branch_rename_succeeds(testrepo): +def test_branch_rename_succeeds(testrepo: Repository) -> None: branch = testrepo.lookup_branch('i18n') assert branch.rename('new-branch').target == I18N_LAST_COMMIT assert testrepo.lookup_branch('new-branch').target == I18N_LAST_COMMIT -def test_branch_rename_fails_if_destination_already_exists(testrepo): +def test_branch_rename_fails_if_destination_already_exists( + testrepo: Repository, +) -> None: original_branch = testrepo.lookup_branch('i18n') with pytest.raises(ValueError): original_branch.rename('master') -def test_branch_rename_not_fails_if_force_is_true(testrepo): +def test_branch_rename_not_fails_if_force_is_true(testrepo: Repository) -> None: branch = testrepo.lookup_branch('master') assert branch.rename('i18n', True).target == LAST_COMMIT -def test_branch_rename_fails_with_invalid_names(testrepo): +def test_branch_rename_fails_with_invalid_names(testrepo: Repository) -> None: original_branch = testrepo.lookup_branch('i18n') with pytest.raises(ValueError): original_branch.rename('abc@{123') -def test_branch_name(testrepo): +def test_branch_name(testrepo: Repository) -> None: branch = testrepo.lookup_branch('master') assert branch.branch_name == 'master' assert branch.name == 'refs/heads/master' From 4dcfc8840bf145083e668014e13230f72fda2ac6 Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Sat, 26 Jul 2025 19:47:36 +0200 Subject: [PATCH 023/225] improve types according to test/test_cherrypick.py --- pygit2/_pygit2.pyi | 6 ++++++ test/test_cherrypick.py | 15 +++++++++------ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index 7f1f84e24..63401cb45 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -45,6 +45,7 @@ from .enums import ( Option, ReferenceFilter, ReferenceType, + RepositoryState, ResetMode, SortMode, ) @@ -829,6 +830,7 @@ class Repository: def descendant_of(self, oid1: _OidArg, oid2: _OidArg) -> bool: ... def expand_id(self, hex: str) -> Oid: ... def free(self) -> None: ... + def get(self, key: str, default: Optional[Commit] = None) -> None | Object: ... def get_attr( self, path: str | bytes | Path, @@ -857,6 +859,8 @@ class Repository: def merge_base(self, oid1: _OidArg, oid2: _OidArg) -> Oid: ... def merge_base_many(self, oids: list[_OidArg]) -> Oid: ... def merge_base_octopus(self, oids: list[_OidArg]) -> Oid: ... + @property + def message(self) -> str: ... def notes(self) -> Iterator[Note]: ... def path_is_ignored(self, path: str) -> bool: ... def raw_listall_branches( @@ -880,6 +884,8 @@ class Repository: self, untracked_files: str = 'all', ignored: bool = False ) -> dict[str, int]: ... def status_file(self, path: str) -> int: ... + def state(self) -> RepositoryState: ... + def state_cleanup(self) -> None: ... def walk( self, oid: _OidArg | None, sort_mode: SortMode = SortMode.NONE ) -> Walker: ... diff --git a/test/test_cherrypick.py b/test/test_cherrypick.py index d1aebbe18..6c003223b 100644 --- a/test/test_cherrypick.py +++ b/test/test_cherrypick.py @@ -30,23 +30,26 @@ import pytest import pygit2 +from pygit2 import Repository from pygit2.enums import RepositoryState -def test_cherrypick_none(mergerepo): +def test_cherrypick_none(mergerepo: Repository) -> None: with pytest.raises(TypeError): - mergerepo.cherrypick(None) + mergerepo.cherrypick(None) # type: ignore -def test_cherrypick_invalid_hex(mergerepo): +def test_cherrypick_invalid_hex(mergerepo: Repository) -> None: branch_head_hex = '12345678' with pytest.raises(KeyError): mergerepo.cherrypick(branch_head_hex) -def test_cherrypick_already_something_in_index(mergerepo): +def test_cherrypick_already_something_in_index(mergerepo: Repository) -> None: branch_head_hex = '03490f16b15a09913edb3a067a3dc67fbb8d41f1' - branch_oid = mergerepo.get(branch_head_hex).id + branch_object = mergerepo.get(branch_head_hex) + assert branch_object is not None + branch_oid = branch_object.id with (Path(mergerepo.workdir) / 'inindex.txt').open('w') as f: f.write('new content') mergerepo.index.add('inindex.txt') @@ -54,7 +57,7 @@ def test_cherrypick_already_something_in_index(mergerepo): mergerepo.cherrypick(branch_oid) -def test_cherrypick_remove_conflicts(mergerepo): +def test_cherrypick_remove_conflicts(mergerepo: Repository) -> None: assert mergerepo.state() == RepositoryState.NONE assert not mergerepo.message From 5ad248773d8b17cf40666768ed6b4412eb30bebc Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Sat, 26 Jul 2025 20:07:15 +0200 Subject: [PATCH 024/225] improve types according to test/test_commit.py --- pygit2/_pygit2.pyi | 13 +++++++++- test/test_commit.py | 60 +++++++++++++++++++++++++++------------------ 2 files changed, 48 insertions(+), 25 deletions(-) diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index 63401cb45..fd3a07c13 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -9,6 +9,7 @@ from typing import ( Iterator, Literal, Optional, + Sequence, Type, TypedDict, TypeVar, @@ -740,6 +741,16 @@ class Repository: def _from_c(cls, ptr: 'GitRepositoryC', owned: bool) -> 'Repository': ... def __getitem__(self, key: str | Oid) -> Object: ... def add_worktree(self, name: str, path: str, ref: Reference = ...) -> Worktree: ... + def amend_commit( + self, + commit: Commit | Oid | str, + refname: Reference | str | None, + author: Signature | None = None, + committer: Signature | None = None, + message: str | None = None, + tree: Tree | Oid | str | None = None, + encoding: str = 'UTF-8', + ) -> Oid: ... def applies( self, diff: Diff, @@ -782,7 +793,7 @@ class Repository: committer: Signature, message: str | bytes, tree: _OidArg, - parents: list[_OidArg], + parents: Sequence[_OidArg], encoding: str = ..., ) -> Oid: ... def create_commit_string( diff --git a/test/test_commit.py b/test/test_commit.py index a38559dac..214340262 100644 --- a/test/test_commit.py +++ b/test/test_commit.py @@ -29,7 +29,7 @@ import pytest -from pygit2 import GitError, Oid, Signature +from pygit2 import Commit, GitError, Oid, Repository, Signature, Tree from pygit2.enums import ObjectType from . import utils @@ -41,7 +41,7 @@ @utils.requires_refcount -def test_commit_refcount(barerepo): +def test_commit_refcount(barerepo: Repository) -> None: commit = barerepo[COMMIT_SHA] start = sys.getrefcount(commit) tree = commit.tree @@ -50,8 +50,9 @@ def test_commit_refcount(barerepo): assert start == end -def test_read_commit(barerepo): +def test_read_commit(barerepo: Repository) -> None: commit = barerepo[COMMIT_SHA] + assert isinstance(commit, Commit) assert COMMIT_SHA == commit.id parents = commit.parents assert 1 == len(parents) @@ -71,7 +72,7 @@ def test_read_commit(barerepo): assert '967fce8df97cc71722d3c2a5930ef3e6f1d27b12' == commit.tree.id -def test_new_commit(barerepo): +def test_new_commit(barerepo: Repository) -> None: repo = barerepo message = 'New commit.\n\nMessage with non-ascii chars: ééé.\n' committer = Signature('John Doe', 'jdoe@example.com', 12346, 0) @@ -88,8 +89,9 @@ def test_new_commit(barerepo): sha = repo.create_commit(None, author, committer, message, tree_prefix, parents) commit = repo[sha] + assert isinstance(commit, Commit) - assert ObjectType.COMMIT == commit.type + assert ObjectType.COMMIT.value == commit.type assert '98286caaab3f1fde5bf52c8369b2b0423bad743b' == commit.id assert commit.message_encoding is None assert message == commit.message @@ -103,7 +105,7 @@ def test_new_commit(barerepo): assert Oid(hex=COMMIT_SHA) == commit.parent_ids[0] -def test_new_commit_encoding(barerepo): +def test_new_commit_encoding(barerepo: Repository) -> None: repo = barerepo encoding = 'iso-8859-1' message = 'New commit.\n\nMessage with non-ascii chars: ééé.\n' @@ -117,8 +119,9 @@ def test_new_commit_encoding(barerepo): None, author, committer, message, tree_prefix, parents, encoding ) commit = repo[sha] + assert isinstance(commit, Commit) - assert ObjectType.COMMIT == commit.type + assert ObjectType.COMMIT.value == commit.type assert 'iso-8859-1' == commit.message_encoding assert message.encode(encoding) == commit.raw_message assert 12346 == commit.commit_time @@ -131,7 +134,7 @@ def test_new_commit_encoding(barerepo): assert Oid(hex=COMMIT_SHA) == commit.parent_ids[0] -def test_modify_commit(barerepo): +def test_modify_commit(barerepo: Repository) -> None: message = 'New commit.\n\nMessage.\n' committer = ('John Doe', 'jdoe@example.com', 12346) author = ('Jane Doe', 'jdoe2@example.com', 12345) @@ -150,9 +153,10 @@ def test_modify_commit(barerepo): setattr(commit, 'parents', None) -def test_amend_commit_metadata(barerepo): +def test_amend_commit_metadata(barerepo: Repository) -> None: repo = barerepo commit = repo[COMMIT_SHA_TO_AMEND] + assert isinstance(commit, Commit) assert commit.id == repo.head.target encoding = 'iso-8859-1' @@ -173,9 +177,10 @@ def test_amend_commit_metadata(barerepo): encoding=encoding, ) amended_commit = repo[amended_oid] + assert isinstance(amended_commit, Commit) assert repo.head.target == amended_oid - assert ObjectType.COMMIT == amended_commit.type + assert ObjectType.COMMIT.value == amended_commit.type assert amended_committer == amended_commit.committer assert amended_author == amended_commit.author assert amended_message.encode(encoding) == amended_commit.raw_message @@ -184,9 +189,10 @@ def test_amend_commit_metadata(barerepo): assert commit.tree == amended_commit.tree # we didn't touch the tree -def test_amend_commit_tree(barerepo): +def test_amend_commit_tree(barerepo: Repository) -> None: repo = barerepo commit = repo[COMMIT_SHA_TO_AMEND] + assert isinstance(commit, Commit) assert commit.id == repo.head.target tree = '967fce8df97cc71722d3c2a5930ef3e6f1d27b12' @@ -194,9 +200,11 @@ def test_amend_commit_tree(barerepo): amended_oid = repo.amend_commit(commit, 'HEAD', tree=tree_prefix) amended_commit = repo[amended_oid] + assert isinstance(amended_commit, Commit) + assert isinstance(commit, Commit) assert repo.head.target == amended_oid - assert ObjectType.COMMIT == amended_commit.type + assert ObjectType.COMMIT.value == amended_commit.type assert commit.message == amended_commit.message assert commit.author == amended_commit.author assert commit.committer == amended_commit.committer @@ -204,11 +212,12 @@ def test_amend_commit_tree(barerepo): assert Oid(hex=tree) == amended_commit.tree_id -def test_amend_commit_not_tip_of_branch(barerepo): +def test_amend_commit_not_tip_of_branch(barerepo: Repository) -> None: repo = barerepo # This commit isn't at the tip of the branch. commit = repo['5fe808e8953c12735680c257f56600cb0de44b10'] + assert isinstance(commit, Commit) assert commit.id != repo.head.target # Can't update HEAD to the rewritten commit because it's not the tip of the branch. @@ -219,16 +228,17 @@ def test_amend_commit_not_tip_of_branch(barerepo): repo.amend_commit(commit, None, message='this will work') -def test_amend_commit_no_op(barerepo): +def test_amend_commit_no_op(barerepo: Repository) -> None: repo = barerepo commit = repo[COMMIT_SHA_TO_AMEND] + assert isinstance(commit, Commit) assert commit.id == repo.head.target amended_oid = repo.amend_commit(commit, None) assert amended_oid == commit.id -def test_amend_commit_argument_types(barerepo): +def test_amend_commit_argument_types(barerepo: Repository) -> None: repo = barerepo some_tree = repo['967fce8df97cc71722d3c2a5930ef3e6f1d27b12'] @@ -236,33 +246,34 @@ def test_amend_commit_argument_types(barerepo): alt_commit1 = Oid(hex=COMMIT_SHA_TO_AMEND) alt_commit2 = COMMIT_SHA_TO_AMEND alt_tree = some_tree + assert isinstance(alt_tree, Tree) alt_refname = ( repo.head ) # try this one last, because it'll change the commit at the tip # Pass bad values/types for the commit with pytest.raises(ValueError): - repo.amend_commit(None, None) + repo.amend_commit(None, None) # type: ignore with pytest.raises(TypeError): - repo.amend_commit(some_tree, None) + repo.amend_commit(some_tree, None) # type: ignore # Pass bad types for signatures with pytest.raises(TypeError): - repo.amend_commit(commit, None, author='Toto') + repo.amend_commit(commit, None, author='Toto') # type: ignore with pytest.raises(TypeError): - repo.amend_commit(commit, None, committer='Toto') + repo.amend_commit(commit, None, committer='Toto') # type: ignore # Pass bad refnames with pytest.raises(ValueError): - repo.amend_commit(commit, 'this-ref-doesnt-exist') + repo.amend_commit(commit, 'this-ref-doesnt-exist') # type: ignore with pytest.raises(TypeError): - repo.amend_commit(commit, repo) + repo.amend_commit(commit, repo) # type: ignore # Pass bad trees with pytest.raises(ValueError): - repo.amend_commit(commit, None, tree="can't parse this") + repo.amend_commit(commit, None, tree="can't parse this") # type: ignore with pytest.raises(KeyError): - repo.amend_commit(commit, None, tree='baaaaad') + repo.amend_commit(commit, None, tree='baaaaad') # type: ignore # Pass an Oid for the commit amended_oid = repo.amend_commit(alt_commit1, None, message='Hello') @@ -273,7 +284,8 @@ def test_amend_commit_argument_types(barerepo): # Pass a str for the commit amended_oid = repo.amend_commit(alt_commit2, None, message='Hello', tree=alt_tree) amended_commit = repo[amended_oid] - assert ObjectType.COMMIT == amended_commit.type + assert isinstance(amended_commit, Commit) + assert ObjectType.COMMIT.value == amended_commit.type assert amended_oid != COMMIT_SHA_TO_AMEND assert repo[COMMIT_SHA_TO_AMEND].tree != amended_commit.tree assert alt_tree.id == amended_commit.tree_id From efc112a7dfc0f8f694cde1cc5d92e3a7415a3de8 Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Sat, 26 Jul 2025 20:11:54 +0200 Subject: [PATCH 025/225] improve types according to test/test_commit_gpg.py --- pygit2/_pygit2.pyi | 2 +- pygit2/repository.py | 2 +- test/test_commit_gpg.py | 8 +++++--- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index fd3a07c13..0f529cb27 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -841,7 +841,7 @@ class Repository: def descendant_of(self, oid1: _OidArg, oid2: _OidArg) -> bool: ... def expand_id(self, hex: str) -> Oid: ... def free(self) -> None: ... - def get(self, key: str, default: Optional[Commit] = None) -> None | Object: ... + def get(self, key: _OidArg, default: Optional[Commit] = None) -> None | Object: ... def get_attr( self, path: str | bytes | Path, diff --git a/pygit2/repository.py b/pygit2/repository.py index 0c2db04e3..7e0b63622 100644 --- a/pygit2/repository.py +++ b/pygit2/repository.py @@ -202,7 +202,7 @@ def __iter__(self): # # Mapping interface # - def get(self, key: str, default: Optional[Commit] = None) -> Object: + def get(self, key: str | Oid, default: Optional[Commit] = None) -> Object: value = self.git_object_lookup_prefix(key) return value if (value is not None) else default diff --git a/test/test_commit_gpg.py b/test/test_commit_gpg.py index 88450b6cb..d20f584ef 100644 --- a/test/test_commit_gpg.py +++ b/test/test_commit_gpg.py @@ -23,7 +23,7 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. -from pygit2 import Oid, Signature +from pygit2 import Commit, Oid, Repository, Signature from pygit2.enums import ObjectType content = """\ @@ -84,7 +84,7 @@ # XXX: seems macos wants the space while linux does not -def test_commit_signing(gpgsigned): +def test_commit_signing(gpgsigned: Repository) -> None: repo = gpgsigned message = 'a simple commit which works' author = Signature( @@ -111,6 +111,7 @@ def test_commit_signing(gpgsigned): # create/retrieve signed commit oid = repo.create_commit_with_signature(content, gpgsig) commit = repo.get(oid) + assert isinstance(commit, Commit) signature, payload = commit.gpg_signature # validate signed commit @@ -133,11 +134,12 @@ def test_commit_signing(gpgsigned): assert Oid(hex=parent) == commit.parent_ids[0] -def test_get_gpg_signature_when_unsigned(gpgsigned): +def test_get_gpg_signature_when_unsigned(gpgsigned: Repository) -> None: unhash = '5b5b025afb0b4c913b4c338a42934a3863bf3644' repo = gpgsigned commit = repo.get(unhash) + assert isinstance(commit, Commit) signature, payload = commit.gpg_signature assert signature is None From 8ec1937c5780957183cef7e78bd3e838994afd53 Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Sat, 26 Jul 2025 20:14:42 +0200 Subject: [PATCH 026/225] fix typing in test/test_commit_trailer.py --- test/test_commit_trailer.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/test/test_commit_trailer.py b/test/test_commit_trailer.py index 055047599..51043ff80 100644 --- a/test/test_commit_trailer.py +++ b/test/test_commit_trailer.py @@ -23,26 +23,31 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. +from pathlib import Path +from typing import Generator + import pytest import pygit2 +from pygit2 import Commit, Repository from . import utils @pytest.fixture -def repo(tmp_path): +def repo(tmp_path: Path) -> Generator[Repository, None, None]: with utils.TemporaryRepository('trailerrepo.zip', tmp_path) as path: yield pygit2.Repository(path) -def test_get_trailers_array(repo): +def test_get_trailers_array(repo: Repository) -> None: commit_hash = '010231b2fdaee6b21da4f06058cf6c6a3392dd12' expected_trailers = { 'Bug': '1234', 'Signed-off-by': 'Tyler Cipriani ', } commit = repo.get(commit_hash) + assert isinstance(commit, Commit) trailers = commit.message_trailers assert trailers['Bug'] == expected_trailers['Bug'] From 25c98a80b6d4f8eb0228b9c2d302618408ea877c Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Sat, 26 Jul 2025 20:44:49 +0200 Subject: [PATCH 027/225] improve types according to test/test_config.py --- pygit2/_libgit2/ffi.pyi | 10 ++++++++++ pygit2/_pygit2.pyi | 3 +++ pygit2/config.py | 44 +++++++++++++++++++++++++++-------------- pygit2/utils.py | 14 ++++++++++++- test/test_config.py | 5 +++-- 5 files changed, 58 insertions(+), 18 deletions(-) diff --git a/pygit2/_libgit2/ffi.pyi b/pygit2/_libgit2/ffi.pyi index 39b36b84d..b2f6a8f17 100644 --- a/pygit2/_libgit2/ffi.pyi +++ b/pygit2/_libgit2/ffi.pyi @@ -120,8 +120,14 @@ class GitCommitC: pass class GitConfigC: + # incomplete pass +class GitConfigEntryC: + # incomplete + name: char_pointer + level: int + class GitDescribeFormatOptionsC: version: int abbreviated_size: int @@ -229,6 +235,10 @@ def new(a: Literal['git_commit **']) -> _Pointer[GitCommitC]: ... @overload def new(a: Literal['git_config *']) -> GitConfigC: ... @overload +def new(a: Literal['git_config **']) -> _Pointer[GitConfigC]: ... +@overload +def new(a: Literal['git_config_entry **']) -> _Pointer[GitConfigEntryC]: ... +@overload def new(a: Literal['git_describe_format_options *']) -> GitDescribeFormatOptionsC: ... @overload def new(a: Literal['git_describe_options *']) -> GitDescribeOptionsC: ... diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index 0f529cb27..6fa853f03 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -27,6 +27,7 @@ from ._libgit2.ffi import ( ) from .blame import Blame from .callbacks import CheckoutCallbacks +from .config import Config from .enums import ( ApplyLocation, AttrCheck, @@ -781,6 +782,8 @@ class Repository: ) -> None: ... def cherrypick(self, id: _OidArg) -> None: ... def compress_references(self) -> None: ... + @property + def config(self) -> Config: ... def create_blob(self, data: bytes) -> Oid: ... def create_blob_fromdisk(self, path: str) -> Oid: ... def create_blob_fromiobase(self, iobase: IOBase) -> Oid: ... diff --git a/pygit2/config.py b/pygit2/config.py index e8619eda0..0f18d6cfd 100644 --- a/pygit2/config.py +++ b/pygit2/config.py @@ -23,6 +23,9 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. +from os import PathLike +from typing import TYPE_CHECKING + try: from functools import cached_property except ImportError: @@ -33,8 +36,11 @@ from .ffi import C, ffi from .utils import to_bytes +if TYPE_CHECKING: + from ._libgit2.ffi import GitConfigC, GitConfigEntryC, GitRepositoryC + -def str_to_bytes(value, name): +def str_to_bytes(value: str | PathLike[str] | bytes, name: str) -> bytes: if not isinstance(value, str): raise TypeError(f'{name} must be a string') @@ -72,33 +78,36 @@ def __next__(self): class Config: """Git configuration management.""" - def __init__(self, path=None): + _repo: 'GitRepositoryC' + _config: 'GitConfigC' + + def __init__(self, path: str | None = None) -> None: cconfig = ffi.new('git_config **') if not path: err = C.git_config_new(cconfig) else: - path = str_to_bytes(path, 'path') - err = C.git_config_open_ondisk(cconfig, path) + path_bytes = str_to_bytes(path, 'path') + err = C.git_config_open_ondisk(cconfig, path_bytes) check_error(err, io=True) self._config = cconfig[0] @classmethod - def from_c(cls, repo, ptr): + def from_c(cls, repo: 'GitRepositoryC', ptr: 'GitConfigC') -> 'Config': config = cls.__new__(cls) config._repo = repo config._config = ptr return config - def __del__(self): + def __del__(self) -> None: try: C.git_config_free(self._config) except AttributeError: pass - def _get(self, key): + def _get(self, key: str | bytes) -> tuple[object, 'ConfigEntry']: key = str_to_bytes(key, 'key') entry = ffi.new('git_config_entry **') @@ -106,7 +115,7 @@ def _get(self, key): return err, ConfigEntry._from_c(entry[0]) - def _get_entry(self, key): + def _get_entry(self, key: str | bytes) -> 'ConfigEntry': err, entry = self._get(key) if err == C.GIT_ENOTFOUND: @@ -306,8 +315,13 @@ def get_xdg_config(): class ConfigEntry: """An entry in a configuration object.""" + _entry: 'GitConfigEntryC' + iterator: ConfigIterator | None + @classmethod - def _from_c(cls, ptr, iterator=None): + def _from_c( + cls, ptr: 'GitConfigEntryC', iterator: ConfigIterator | None = None + ) -> 'ConfigEntry': """Builds the entry from a ``git_config_entry`` pointer. ``iterator`` must be a ``ConfigIterator`` instance if the entry was @@ -330,7 +344,7 @@ def _from_c(cls, ptr, iterator=None): return entry - def __del__(self): + def __del__(self) -> None: if self.iterator is None: C.git_config_entry_free(self._entry) @@ -340,24 +354,24 @@ def c_value(self): return self._entry.value @cached_property - def raw_name(self): + def raw_name(self) -> bytes: return ffi.string(self._entry.name) @cached_property - def raw_value(self): + def raw_value(self) -> bytes: return ffi.string(self.c_value) @cached_property - def level(self): + def level(self) -> int: """The entry's ``git_config_level_t`` value.""" return self._entry.level @property - def name(self): + def name(self) -> str: """The entry's name.""" return self.raw_name.decode('utf-8') @property - def value(self): + def value(self) -> str: """The entry's value as a string.""" return self.raw_value.decode('utf-8') diff --git a/pygit2/utils.py b/pygit2/utils.py index 427986842..e2d4b4c41 100644 --- a/pygit2/utils.py +++ b/pygit2/utils.py @@ -25,7 +25,7 @@ import contextlib import os -from typing import Generic, Iterator, Protocol, TypeVar, Union +from typing import Generic, Iterator, Protocol, TypeVar, Union, overload # Import from pygit2 from .ffi import C, ffi @@ -38,6 +38,18 @@ def maybe_string(ptr): return ffi.string(ptr).decode('utf8', errors='surrogateescape') +@overload +def to_bytes( + s: Union[str, bytes, os.PathLike[str]], + encoding: str = 'utf-8', + errors: str = 'strict', +) -> bytes: ... +@overload +def to_bytes( + s: Union['ffi.NULL_TYPE', None], + encoding: str = 'utf-8', + errors: str = 'strict', +) -> Union['ffi.NULL_TYPE']: ... def to_bytes( s: Union[str, bytes, 'ffi.NULL_TYPE', os.PathLike[str], None], encoding: str = 'utf-8', diff --git a/test/test_config.py b/test/test_config.py index 71b2b5345..5d4e5a09d 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -24,10 +24,11 @@ # Boston, MA 02110-1301, USA. from pathlib import Path +from typing import Generator import pytest -from pygit2 import Config +from pygit2 import Config, Repository from . import utils @@ -35,7 +36,7 @@ @pytest.fixture -def config(testrepo): +def config(testrepo: Repository) -> Generator[object, None, None]: yield testrepo.config try: Path(CONFIG_FILENAME).unlink() From 99b1c6840bc55028e59ec0021e37d7fd16fda668 Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Sat, 26 Jul 2025 20:49:31 +0200 Subject: [PATCH 028/225] add types to test/test_credentials.py --- test/test_credentials.py | 37 +++++++++++++++++++++++-------------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/test/test_credentials.py b/test/test_credentials.py index 2cfe48220..1a5752579 100644 --- a/test/test_credentials.py +++ b/test/test_credentials.py @@ -29,7 +29,14 @@ import pytest import pygit2 -from pygit2 import Keypair, KeypairFromAgent, KeypairFromMemory, Username, UserPass +from pygit2 import ( + Keypair, + KeypairFromAgent, + KeypairFromMemory, + Repository, + Username, + UserPass, +) from pygit2.enums import CredentialType from . import utils @@ -44,13 +51,13 @@ ORIGIN_REFSPEC = '+refs/heads/*:refs/remotes/origin/*' -def test_username(): +def test_username() -> None: username = 'git' cred = Username(username) assert (username,) == cred.credential_tuple -def test_userpass(): +def test_userpass() -> None: username = 'git' password = 'sekkrit' @@ -58,7 +65,7 @@ def test_userpass(): assert (username, password) == cred.credential_tuple -def test_ssh_key(): +def test_ssh_key() -> None: username = 'git' pubkey = 'id_rsa.pub' privkey = 'id_rsa' @@ -68,7 +75,7 @@ def test_ssh_key(): assert (username, pubkey, privkey, passphrase) == cred.credential_tuple -def test_ssh_key_aspath(): +def test_ssh_key_aspath() -> None: username = 'git' pubkey = Path('id_rsa.pub') privkey = Path('id_rsa') @@ -78,14 +85,14 @@ def test_ssh_key_aspath(): assert (username, pubkey, privkey, passphrase) == cred.credential_tuple -def test_ssh_agent(): +def test_ssh_agent() -> None: username = 'git' cred = KeypairFromAgent(username) assert (username, None, None, None) == cred.credential_tuple -def test_ssh_from_memory(): +def test_ssh_from_memory() -> None: username = 'git' pubkey = 'public key data' privkey = 'private key data' @@ -97,7 +104,7 @@ def test_ssh_from_memory(): @utils.requires_network @utils.requires_ssh -def test_keypair(tmp_path, pygit2_empty_key): +def test_keypair(tmp_path: Path, pygit2_empty_key: tuple[Path, str, str]) -> None: url = 'ssh://git@github.com/pygit2/empty' with pytest.raises(pygit2.GitError): pygit2.clone_repository(url, tmp_path) @@ -111,7 +118,9 @@ def test_keypair(tmp_path, pygit2_empty_key): @utils.requires_network @utils.requires_ssh -def test_keypair_from_memory(tmp_path, pygit2_empty_key): +def test_keypair_from_memory( + tmp_path: Path, pygit2_empty_key: tuple[Path, str, str] +) -> None: url = 'ssh://git@github.com/pygit2/empty' with pytest.raises(pygit2.GitError): pygit2.clone_repository(url, tmp_path) @@ -128,7 +137,7 @@ def test_keypair_from_memory(tmp_path, pygit2_empty_key): pygit2.clone_repository(url, tmp_path, callbacks=callbacks) -def test_callback(testrepo): +def test_callback(testrepo: Repository): class MyCallbacks(pygit2.RemoteCallbacks): def credentials(testrepo, url, username, allowed): assert allowed & CredentialType.USERPASS_PLAINTEXT @@ -141,7 +150,7 @@ def credentials(testrepo, url, username, allowed): @utils.requires_network -def test_bad_cred_type(testrepo): +def test_bad_cred_type(testrepo: Repository): class MyCallbacks(pygit2.RemoteCallbacks): def credentials(testrepo, url, username, allowed): assert allowed & CredentialType.USERPASS_PLAINTEXT @@ -154,7 +163,7 @@ def credentials(testrepo, url, username, allowed): @utils.requires_network -def test_fetch_certificate_check(testrepo): +def test_fetch_certificate_check(testrepo: Repository): class MyCallbacks(pygit2.RemoteCallbacks): def certificate_check(testrepo, certificate, valid, host): assert certificate is None @@ -179,7 +188,7 @@ def certificate_check(testrepo, certificate, valid, host): @utils.requires_network -def test_user_pass(testrepo): +def test_user_pass(testrepo: Repository): credentials = UserPass('libgit2', 'libgit2') callbacks = pygit2.RemoteCallbacks(credentials=credentials) @@ -191,7 +200,7 @@ def test_user_pass(testrepo): @utils.requires_proxy @utils.requires_network @utils.requires_future_libgit2 -def test_proxy(testrepo): +def test_proxy(testrepo: Repository): credentials = UserPass('libgit2', 'libgit2') callbacks = pygit2.RemoteCallbacks(credentials=credentials) From dce6c0ded69e42654debbf6e21dcc0e4a2e5609f Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Sun, 27 Jul 2025 21:57:57 +0200 Subject: [PATCH 029/225] improve types according to test/test_describe.py --- pygit2/_pygit2.pyi | 13 +++++++++++++ test/test_describe.py | 25 +++++++++++++------------ 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index 6fa853f03..1fb896d68 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -36,6 +36,7 @@ from .enums import ( BranchType, CheckoutStrategy, DeltaStatus, + DescribeStrategy, DiffFind, DiffFlag, DiffOption, @@ -842,6 +843,18 @@ class Repository: interhunk_lines: int = 0, ) -> Diff: ... def descendant_of(self, oid1: _OidArg, oid2: _OidArg) -> bool: ... + def describe( + self, + committish: str | Reference | Commit | None = None, + max_candidates_tags: int | None = None, + describe_strategy: DescribeStrategy = DescribeStrategy.DEFAULT, + pattern: str | None = None, + only_follow_first_parent: bool | None = None, + show_commit_oid_as_fallback: bool | None = None, + abbreviated_size: object | None = None, + always_use_long_format: bool | None = None, + dirty_suffix: str | None = None, + ) -> str: ... def expand_id(self, hex: str) -> Oid: ... def free(self) -> None: ... def get(self, key: _OidArg, default: Optional[Commit] = None) -> None | Object: ... diff --git a/test/test_describe.py b/test/test_describe.py index f24c60a9b..963649b4a 100644 --- a/test/test_describe.py +++ b/test/test_describe.py @@ -28,10 +28,11 @@ import pytest import pygit2 +from pygit2 import Oid, Repository from pygit2.enums import DescribeStrategy, ObjectType -def add_tag(repo, name, target): +def add_tag(repo: Repository, name: str, target: str) -> Oid: message = 'Example tag.\n' tagger = pygit2.Signature('John Doe', 'jdoe@example.com', 12347, 0) @@ -39,21 +40,21 @@ def add_tag(repo, name, target): return sha -def test_describe(testrepo): +def test_describe(testrepo: Repository) -> None: add_tag(testrepo, 'thetag', '4ec4389a8068641da2d6578db0419484972284c8') assert 'thetag-2-g2be5719' == testrepo.describe() -def test_describe_without_ref(testrepo): +def test_describe_without_ref(testrepo: Repository) -> None: with pytest.raises(pygit2.GitError): testrepo.describe() -def test_describe_default_oid(testrepo): +def test_describe_default_oid(testrepo: Repository) -> None: assert '2be5719' == testrepo.describe(show_commit_oid_as_fallback=True) -def test_describe_strategies(testrepo): +def test_describe_strategies(testrepo: Repository) -> None: assert 'heads/master' == testrepo.describe(describe_strategy=DescribeStrategy.ALL) testrepo.create_reference( @@ -66,14 +67,14 @@ def test_describe_strategies(testrepo): ) -def test_describe_pattern(testrepo): +def test_describe_pattern(testrepo: Repository) -> None: add_tag(testrepo, 'private/tag1', '5ebeeebb320790caf276b9fc8b24546d63316533') add_tag(testrepo, 'public/tag2', '4ec4389a8068641da2d6578db0419484972284c8') assert 'public/tag2-2-g2be5719' == testrepo.describe(pattern='public/*') -def test_describe_committish(testrepo): +def test_describe_committish(testrepo: Repository) -> None: add_tag(testrepo, 'thetag', 'acecd5ea2924a4b900e7e149496e1f4b57976e51') assert 'thetag-4-g2be5719' == testrepo.describe(committish='HEAD') assert 'thetag-1-g5ebeeeb' == testrepo.describe(committish='HEAD^') @@ -86,28 +87,28 @@ def test_describe_committish(testrepo): assert 'thetag-1-g6aaa262' == testrepo.describe(committish='6aaa262') -def test_describe_follows_first_branch_only(testrepo): +def test_describe_follows_first_branch_only(testrepo: Repository) -> None: add_tag(testrepo, 'thetag', '4ec4389a8068641da2d6578db0419484972284c8') with pytest.raises(KeyError): testrepo.describe(only_follow_first_parent=True) -def test_describe_abbreviated_size(testrepo): +def test_describe_abbreviated_size(testrepo: Repository) -> None: add_tag(testrepo, 'thetag', '4ec4389a8068641da2d6578db0419484972284c8') assert 'thetag-2-g2be5719152d4f82c' == testrepo.describe(abbreviated_size=16) assert 'thetag' == testrepo.describe(abbreviated_size=0) -def test_describe_long_format(testrepo): +def test_describe_long_format(testrepo: Repository) -> None: add_tag(testrepo, 'thetag', '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98') assert 'thetag-0-g2be5719' == testrepo.describe(always_use_long_format=True) -def test_describe_dirty(dirtyrepo): +def test_describe_dirty(dirtyrepo: Repository) -> None: add_tag(dirtyrepo, 'thetag', 'a763aa560953e7cfb87ccbc2f536d665aa4dff22') assert 'thetag' == dirtyrepo.describe() -def test_describe_dirty_with_suffix(dirtyrepo): +def test_describe_dirty_with_suffix(dirtyrepo: Repository) -> None: add_tag(dirtyrepo, 'thetag', 'a763aa560953e7cfb87ccbc2f536d665aa4dff22') assert 'thetag-dirty' == dirtyrepo.describe(dirty_suffix='-dirty') From b30c1a708b9aa2684ab11f36b4bd257ce28c0f96 Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Sun, 27 Jul 2025 22:00:36 +0200 Subject: [PATCH 030/225] improve types according to test/test_describe.py --- pygit2/_pygit2.pyi | 4 ++-- test/test_diff_binary.py | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index 1fb896d68..28e6ea2e8 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -835,8 +835,8 @@ class Repository: ) -> Oid: ... def diff( self, - a: None | str | Reference = None, - b: None | str | Reference = None, + a: None | str | bytes | Reference = None, + b: None | str | bytes | Reference = None, cached: bool = False, flags: DiffOption = DiffOption.NORMAL, context_lines: int = 3, diff --git a/test/test_diff_binary.py b/test/test_diff_binary.py index e23583ada..9eb3a38c9 100644 --- a/test/test_diff_binary.py +++ b/test/test_diff_binary.py @@ -23,16 +23,20 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. +from pathlib import Path +from typing import Generator + import pytest import pygit2 +from pygit2 import Repository from pygit2.enums import DiffOption from . import utils @pytest.fixture -def repo(tmp_path): +def repo(tmp_path: Path) -> Generator[Repository, None, None]: with utils.TemporaryRepository('binaryfilerepo.zip', tmp_path) as path: yield pygit2.Repository(path) @@ -54,7 +58,7 @@ def repo(tmp_path): """ -def test_binary_diff(repo): +def test_binary_diff(repo: Repository) -> None: diff = repo.diff('HEAD', 'HEAD^') assert PATCH_BINARY == diff.patch diff = repo.diff('HEAD', 'HEAD^', flags=DiffOption.SHOW_BINARY) From 79283f544962a1826fe6643f1f4992dde5336d4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Mon, 28 Jul 2025 09:56:43 +0200 Subject: [PATCH 031/225] Update cibuildwheel --- .github/workflows/wheels.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 914312150..bd61ae63e 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -30,7 +30,7 @@ jobs: python-version: '3.13' - name: Install cibuildwheel - run: python -m pip install cibuildwheel==3.0.0 + run: python -m pip install cibuildwheel==3.1.1 - name: Build wheels run: python -m cibuildwheel --output-dir wheelhouse @@ -56,7 +56,7 @@ jobs: platforms: linux/ppc64le - name: Install cibuildwheel - run: python -m pip install cibuildwheel==3.0.0 + run: python -m pip install cibuildwheel==3.1.1 - name: Build wheels run: python -m cibuildwheel --output-dir wheelhouse From aec06dd04c8895b41d8244e298a162343ef0a438 Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Sun, 27 Jul 2025 22:10:39 +0200 Subject: [PATCH 032/225] improve types according to test/test_diff.py --- pygit2/_pygit2.pyi | 5 +++-- test/test_diff.py | 52 ++++++++++++++++++++++++---------------------- 2 files changed, 30 insertions(+), 27 deletions(-) diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index 28e6ea2e8..6a6734122 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -458,6 +458,7 @@ class Diff: patch: str | None patchid: Oid stats: DiffStats + text: str def find_similar( self, flags: DiffFind = DiffFind.FIND_BY_CONFIG, @@ -835,8 +836,8 @@ class Repository: ) -> Oid: ... def diff( self, - a: None | str | bytes | Reference = None, - b: None | str | bytes | Reference = None, + a: None | str | bytes | Oid | Reference = None, + b: None | str | bytes | Oid | Reference = None, cached: bool = False, flags: DiffOption = DiffOption.NORMAL, context_lines: int = 3, diff --git a/test/test_diff.py b/test/test_diff.py index 5deb3481a..dea3a92e2 100644 --- a/test/test_diff.py +++ b/test/test_diff.py @@ -27,10 +27,12 @@ import textwrap from itertools import chain +from typing import Iterator import pytest import pygit2 +from pygit2 import Diff, Repository from pygit2.enums import DeltaStatus, DiffFlag, DiffOption, DiffStatsFormat, FileMode COMMIT_SHA1_1 = '5fe808e8953c12735680c257f56600cb0de44b10' @@ -169,7 +171,7 @@ """ -def test_diff_empty_index(dirtyrepo): +def test_diff_empty_index(dirtyrepo: Repository) -> None: repo = dirtyrepo head = repo[repo.lookup_reference('HEAD').resolve().target] @@ -182,7 +184,7 @@ def test_diff_empty_index(dirtyrepo): assert DIFF_HEAD_TO_INDEX_EXPECTED == files -def test_workdir_to_tree(dirtyrepo): +def test_workdir_to_tree(dirtyrepo: Repository) -> None: repo = dirtyrepo head = repo[repo.lookup_reference('HEAD').resolve().target] @@ -195,22 +197,22 @@ def test_workdir_to_tree(dirtyrepo): assert DIFF_HEAD_TO_WORKDIR_EXPECTED == files -def test_index_to_workdir(dirtyrepo): +def test_index_to_workdir(dirtyrepo: Repository) -> None: diff = dirtyrepo.diff() files = [patch.delta.new_file.path for patch in diff] assert DIFF_INDEX_TO_WORK_EXPECTED == files -def test_diff_invalid(barerepo): +def test_diff_invalid(barerepo: Repository) -> None: commit_a = barerepo[COMMIT_SHA1_1] commit_b = barerepo[COMMIT_SHA1_2] with pytest.raises(TypeError): - commit_a.tree.diff_to_tree(commit_b) + commit_a.tree.diff_to_tree(commit_b) # type: ignore with pytest.raises(TypeError): - commit_a.tree.diff_to_index(commit_b) + commit_a.tree.diff_to_index(commit_b) # type: ignore -def test_diff_empty_index_bare(barerepo): +def test_diff_empty_index_bare(barerepo: Repository) -> None: repo = barerepo head = repo[repo.lookup_reference('HEAD').resolve().target] @@ -227,11 +229,11 @@ def test_diff_empty_index_bare(barerepo): assert [x.name for x in head.tree] == files -def test_diff_tree(barerepo): +def test_diff_tree(barerepo: Repository) -> None: commit_a = barerepo[COMMIT_SHA1_1] commit_b = barerepo[COMMIT_SHA1_2] - def _test(diff): + def _test(diff: Diff) -> None: assert diff is not None assert 2 == sum(map(lambda x: len(x.hunks), diff)) @@ -260,11 +262,11 @@ def _test(diff): _test(barerepo.diff(COMMIT_SHA1_1, COMMIT_SHA1_2)) -def test_diff_empty_tree(barerepo): +def test_diff_empty_tree(barerepo: Repository) -> None: commit_a = barerepo[COMMIT_SHA1_1] diff = commit_a.tree.diff_to_tree() - def get_context_for_lines(diff): + def get_context_for_lines(diff: Diff) -> Iterator[str]: hunks = chain.from_iterable(map(lambda x: x.hunks, diff)) lines = chain.from_iterable(map(lambda x: x.lines, hunks)) return map(lambda x: x.origin, lines) @@ -279,12 +281,12 @@ def get_context_for_lines(diff): assert all('+' == x for x in get_context_for_lines(diff_swaped)) -def test_diff_revparse(barerepo): +def test_diff_revparse(barerepo: Repository) -> None: diff = barerepo.diff('HEAD', 'HEAD~6') assert type(diff) is pygit2.Diff -def test_diff_tree_opts(barerepo): +def test_diff_tree_opts(barerepo: Repository) -> None: commit_c = barerepo[COMMIT_SHA1_3] commit_d = barerepo[COMMIT_SHA1_4] @@ -298,7 +300,7 @@ def test_diff_tree_opts(barerepo): assert 1 == len(diff[0].hunks) -def test_diff_merge(barerepo): +def test_diff_merge(barerepo: Repository) -> None: commit_a = barerepo[COMMIT_SHA1_1] commit_b = barerepo[COMMIT_SHA1_2] commit_c = barerepo[COMMIT_SHA1_3] @@ -325,7 +327,7 @@ def test_diff_merge(barerepo): assert patch.delta.new_file.path == 'a' -def test_diff_patch(barerepo): +def test_diff_patch(barerepo: Repository) -> None: commit_a = barerepo[COMMIT_SHA1_1] commit_b = barerepo[COMMIT_SHA1_2] @@ -334,7 +336,7 @@ def test_diff_patch(barerepo): assert len(diff) == len([patch for patch in diff]) -def test_diff_ids(barerepo): +def test_diff_ids(barerepo: Repository) -> None: commit_a = barerepo[COMMIT_SHA1_1] commit_b = barerepo[COMMIT_SHA1_2] patch = commit_a.tree.diff_to_tree(commit_b.tree)[0] @@ -343,7 +345,7 @@ def test_diff_ids(barerepo): assert delta.new_file.id == 'af431f20fc541ed6d5afede3e2dc7160f6f01f16' -def test_diff_patchid(barerepo): +def test_diff_patchid(barerepo: Repository) -> None: commit_a = barerepo[COMMIT_SHA1_1] commit_b = barerepo[COMMIT_SHA1_2] diff = commit_a.tree.diff_to_tree(commit_b.tree) @@ -351,7 +353,7 @@ def test_diff_patchid(barerepo): assert diff.patchid == PATCHID -def test_hunk_content(barerepo): +def test_hunk_content(barerepo: Repository) -> None: commit_a = barerepo[COMMIT_SHA1_1] commit_b = barerepo[COMMIT_SHA1_2] patch = commit_a.tree.diff_to_tree(commit_b.tree)[0] @@ -362,7 +364,7 @@ def test_hunk_content(barerepo): assert line.content == line.raw_content.decode() -def test_find_similar(barerepo): +def test_find_similar(barerepo: Repository) -> None: commit_a = barerepo[COMMIT_SHA1_6] commit_b = barerepo[COMMIT_SHA1_7] @@ -376,7 +378,7 @@ def test_find_similar(barerepo): assert any(x.delta.status_char() == 'R' for x in diff) -def test_diff_stats(barerepo): +def test_diff_stats(barerepo: Repository) -> None: commit_a = barerepo[COMMIT_SHA1_1] commit_b = barerepo[COMMIT_SHA1_2] @@ -391,7 +393,7 @@ def test_diff_stats(barerepo): assert STATS_EXPECTED == formatted -def test_deltas(barerepo): +def test_deltas(barerepo: Repository) -> None: commit_a = barerepo[COMMIT_SHA1_1] commit_b = barerepo[COMMIT_SHA1_2] diff = commit_a.tree.diff_to_tree(commit_b.tree) @@ -414,7 +416,7 @@ def test_deltas(barerepo): # assert delta.flags == patch_delta.flags -def test_diff_parse(barerepo): +def test_diff_parse(barerepo: Repository) -> None: diff = pygit2.Diff.parse_diff(PATCH) stats = diff.stats @@ -426,9 +428,9 @@ def test_diff_parse(barerepo): assert 2 == len(deltas) -def test_parse_diff_null(): +def test_parse_diff_null() -> None: with pytest.raises(TypeError): - pygit2.Diff.parse_diff(None) + pygit2.Diff.parse_diff(None) # type: ignore def test_parse_diff_bad(): @@ -445,7 +447,7 @@ def test_parse_diff_bad(): pygit2.Diff.parse_diff(diff) -def test_diff_blobs(emptyrepo): +def test_diff_blobs(emptyrepo: Repository) -> None: repo = emptyrepo blob1 = repo.create_blob(TEXT_BLOB1.encode()) blob2 = repo.create_blob(TEXT_BLOB2.encode()) From dac8cfb49b385a72616ac5b41facc9f54ba47957 Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Sun, 27 Jul 2025 22:50:43 +0200 Subject: [PATCH 033/225] improve types according to test/test_filter.py --- pygit2/_pygit2.pyi | 4 ++++ pygit2/filter.py | 6 +++--- test/test_filter.py | 46 ++++++++++++++++++++++++++++++--------------- 3 files changed, 38 insertions(+), 18 deletions(-) diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index 6a6734122..bd35b6b02 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -52,6 +52,7 @@ from .enums import ( ResetMode, SortMode, ) +from .filter import Filter from .remotes import Remote from .repository import BaseRepository from .submodules import SubmoduleCollection @@ -522,6 +523,7 @@ class DiffStats: class FilterSource: # probably incomplete + repo: object pass class GitError(Exception): ... @@ -1036,5 +1038,7 @@ def option(opt: Option, *args) -> None: ... def reference_is_valid_name(refname: str) -> bool: ... def tree_entry_cmp(a: Object, b: Object) -> int: ... def _cache_enums() -> None: ... +def filter_register(name: str, filter: type[Filter]) -> None: ... +def filter_unregister(name: str) -> None: ... _OidArg = str | Oid diff --git a/pygit2/filter.py b/pygit2/filter.py index 00c651849..abf3eb351 100644 --- a/pygit2/filter.py +++ b/pygit2/filter.py @@ -58,7 +58,7 @@ class Filter: def nattrs(cls) -> int: return len(cls.attributes.split()) - def check(self, src: FilterSource, attr_values: List[Optional[str]]): + def check(self, src: FilterSource, attr_values: List[Optional[str]]) -> None: """ Check whether this filter should be applied to the given source. @@ -77,7 +77,7 @@ def check(self, src: FilterSource, attr_values: List[Optional[str]]): def write( self, data: bytes, src: FilterSource, write_next: Callable[[bytes], None] - ): + ) -> None: """ Write input `data` to this filter. @@ -95,7 +95,7 @@ def write( """ write_next(data) - def close(self, write_next: Callable[[bytes], None]): + def close(self, write_next: Callable[[bytes], None]) -> None: """ Close this filter. diff --git a/test/test_filter.py b/test/test_filter.py index 5fedb22bf..d29b8b7ef 100644 --- a/test/test_filter.py +++ b/test/test_filter.py @@ -1,40 +1,52 @@ import codecs from io import BytesIO +from typing import Callable, Generator import pytest import pygit2 +from pygit2 import Blob, Filter, FilterSource, Repository from pygit2.enums import BlobFilter from pygit2.errors import Passthrough -def _rot13(data): +def _rot13(data: bytes) -> bytes: return codecs.encode(data.decode('utf-8'), 'rot_13').encode('utf-8') class _Rot13Filter(pygit2.Filter): attributes = 'text' - def write(self, data, src, write_next): + def write( + self, + data: bytes, + src: FilterSource, + write_next: Callable[[bytes], None], + ) -> None: return super().write(_rot13(data), src, write_next) class _BufferedFilter(pygit2.Filter): attributes = 'text' - def __init__(self): + def __init__(self) -> None: super().__init__() self.buf = BytesIO() - def write(self, data, src, write_next): + def write( + self, + data: bytes, + src: FilterSource, + write_next: Callable[[bytes], None], + ) -> None: self.buf.write(data) - def close(self, write_next): + def close(self, write_next: Callable[[bytes], None]) -> None: write_next(_rot13(self.buf.getvalue())) class _PassthroughFilter(_Rot13Filter): - def check(self, src, attr_values): + def check(self, src: FilterSource, attr_values: list[str | None]) -> None: assert attr_values == [None] assert src.repo raise Passthrough @@ -45,36 +57,37 @@ class _UnmatchedFilter(_Rot13Filter): @pytest.fixture -def rot13_filter(): +def rot13_filter() -> Generator[None, None, None]: pygit2.filter_register('rot13', _Rot13Filter) yield pygit2.filter_unregister('rot13') @pytest.fixture -def passthrough_filter(): +def passthrough_filter() -> Generator[None, None, None]: pygit2.filter_register('passthrough-rot13', _PassthroughFilter) yield pygit2.filter_unregister('passthrough-rot13') @pytest.fixture -def buffered_filter(): +def buffered_filter() -> Generator[None, None, None]: pygit2.filter_register('buffered-rot13', _BufferedFilter) yield pygit2.filter_unregister('buffered-rot13') @pytest.fixture -def unmatched_filter(): +def unmatched_filter() -> Generator[None, None, None]: pygit2.filter_register('unmatched-rot13', _UnmatchedFilter) yield pygit2.filter_unregister('unmatched-rot13') -def test_filter(testrepo, rot13_filter): +def test_filter(testrepo: Repository, rot13_filter: Filter) -> None: blob_oid = testrepo.create_blob_fromworkdir('bye.txt') blob = testrepo[blob_oid] + assert isinstance(blob, Blob) flags = BlobFilter.CHECK_FOR_BINARY | BlobFilter.ATTRIBUTES_FROM_HEAD assert b'olr jbeyq\n' == blob.data with pygit2.BlobIO(blob) as reader: @@ -83,9 +96,10 @@ def test_filter(testrepo, rot13_filter): assert b'bye world\n' == reader.read() -def test_filter_buffered(testrepo, buffered_filter): +def test_filter_buffered(testrepo: Repository, buffered_filter: Filter) -> None: blob_oid = testrepo.create_blob_fromworkdir('bye.txt') blob = testrepo[blob_oid] + assert isinstance(blob, Blob) flags = BlobFilter.CHECK_FOR_BINARY | BlobFilter.ATTRIBUTES_FROM_HEAD assert b'olr jbeyq\n' == blob.data with pygit2.BlobIO(blob) as reader: @@ -94,9 +108,10 @@ def test_filter_buffered(testrepo, buffered_filter): assert b'bye world\n' == reader.read() -def test_filter_passthrough(testrepo, passthrough_filter): +def test_filter_passthrough(testrepo: Repository, passthrough_filter: Filter) -> None: blob_oid = testrepo.create_blob_fromworkdir('bye.txt') blob = testrepo[blob_oid] + assert isinstance(blob, Blob) flags = BlobFilter.CHECK_FOR_BINARY | BlobFilter.ATTRIBUTES_FROM_HEAD assert b'bye world\n' == blob.data with pygit2.BlobIO(blob) as reader: @@ -105,9 +120,10 @@ def test_filter_passthrough(testrepo, passthrough_filter): assert b'bye world\n' == reader.read() -def test_filter_unmatched(testrepo, unmatched_filter): +def test_filter_unmatched(testrepo: Repository, unmatched_filter: Filter) -> None: blob_oid = testrepo.create_blob_fromworkdir('bye.txt') blob = testrepo[blob_oid] + assert isinstance(blob, Blob) flags = BlobFilter.CHECK_FOR_BINARY | BlobFilter.ATTRIBUTES_FROM_HEAD assert b'bye world\n' == blob.data with pygit2.BlobIO(blob) as reader: @@ -116,7 +132,7 @@ def test_filter_unmatched(testrepo, unmatched_filter): assert b'bye world\n' == reader.read() -def test_filter_cleanup(dirtyrepo, rot13_filter): +def test_filter_cleanup(dirtyrepo: Repository, rot13_filter: Filter) -> None: # Indirectly test that pygit2_filter_cleanup has the GIL # before calling pygit2_filter_payload_free. dirtyrepo.diff() From 548f6a7d3ccb6c54af933db7ae8ec765174fbbe9 Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Mon, 28 Jul 2025 22:22:23 +0200 Subject: [PATCH 034/225] improve types according to test/test_index.py --- pygit2/_libgit2/ffi.pyi | 40 +++++++++++++++++++------- pygit2/_pygit2.pyi | 3 +- pygit2/index.py | 45 +++++++++++++++++------------ pygit2/repository.py | 2 +- pygit2/utils.py | 41 ++++++++++++++++++++------ test/test_index.py | 64 +++++++++++++++++++++-------------------- test/utils.py | 13 +++++++-- 7 files changed, 136 insertions(+), 72 deletions(-) diff --git a/pygit2/_libgit2/ffi.pyi b/pygit2/_libgit2/ffi.pyi index b2f6a8f17..a911985ff 100644 --- a/pygit2/_libgit2/ffi.pyi +++ b/pygit2/_libgit2/ffi.pyi @@ -42,6 +42,11 @@ class _Pointer(Generic[T]): @overload def __getitem__(self, item: slice[None, None, None]) -> bytes: ... +class ArrayC(Generic[T]): + # incomplete! + # def _len(self, ?) -> ?: ... + pass + class GitTimeC: # incomplete time: int @@ -132,13 +137,13 @@ class GitDescribeFormatOptionsC: version: int abbreviated_size: int always_use_long_format: int - dirty_suffix: char_pointer + dirty_suffix: ArrayC[char] class GitDescribeOptionsC: version: int max_candidates_tags: int describe_strategy: int - pattern: char_pointer + pattern: ArrayC[char] only_follow_first_parent: int show_commit_oid_as_fallback: int @@ -148,6 +153,11 @@ class GitDescribeResultC: class GitIndexC: pass +class GitIndexEntryC: + # incomplete? + mode: int + path: ArrayC[char] + class GitMergeFileResultC: pass @@ -158,11 +168,13 @@ class GitStashSaveOptionsC: version: int flags: int stasher: GitSignatureC - message: char_pointer + message: ArrayC[char] paths: GitStrrayC class GitStrrayC: - pass + # incomplete? + strings: NULL_TYPE | ArrayC[char] + count: int class GitTreeC: pass @@ -171,11 +183,11 @@ class GitRepositoryInitOptionsC: version: int flags: int mode: int - workdir_path: char_pointer - description: char_pointer - template_path: char_pointer - initial_head: char_pointer - origin_url: char_pointer + workdir_path: ArrayC[char] + description: ArrayC[char] + template_path: ArrayC[char] + initial_head: ArrayC[char] + origin_url: ArrayC[char] class GitCloneOptionsC: pass @@ -229,6 +241,10 @@ def new(a: Literal['git_attr_options *']) -> GitAttrOptionsC: ... @overload def new(a: Literal['git_buf *']) -> GitBufC: ... @overload +def new(a: Literal['char *'], b: bytes) -> char_pointer: ... +@overload +def new(a: Literal['char *[]'], b: list[char_pointer]) -> ArrayC[char_pointer]: ... +@overload def new(a: Literal['git_checkout_options *']) -> GitCheckoutOptionsC: ... @overload def new(a: Literal['git_commit **']) -> _Pointer[GitCommitC]: ... @@ -251,6 +267,8 @@ def new(a: Literal['struct git_reference **']) -> _Pointer[GitReferenceC]: ... @overload def new(a: Literal['git_index **']) -> _Pointer[GitIndexC]: ... @overload +def new(a: Literal['git_index_entry *']) -> GitIndexEntryC: ... +@overload def new(a: Literal['git_merge_file_result *']) -> GitMergeFileResultC: ... @overload def new(a: Literal['git_object *']) -> GitObjectC: ... @@ -263,13 +281,15 @@ def new(a: Literal['git_signature **']) -> _Pointer[GitSignatureC]: ... @overload def new(a: Literal['git_stash_save_options *']) -> GitStashSaveOptionsC: ... @overload +def new(a: Literal['git_strarray *']) -> GitStrrayC: ... +@overload def new(a: Literal['git_tree **']) -> _Pointer[GitTreeC]: ... @overload def new(a: Literal['git_buf *'], b: tuple[NULL_TYPE, Literal[0]]) -> GitBufC: ... @overload def new(a: Literal['char **']) -> _Pointer[char_pointer]: ... @overload -def new(a: Literal['char[]', 'char []'], b: bytes | NULL_TYPE) -> char_pointer: ... +def new(a: Literal['char[]', 'char []'], b: bytes | NULL_TYPE) -> ArrayC[char]: ... def addressof(a: object, attribute: str) -> _Pointer[object]: ... class buffer(bytes): diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index bd35b6b02..ec9a96f30 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -723,6 +723,7 @@ class Branches: class Repository: _pointer: GitRepositoryC + _repo: GitRepositoryC default_signature: Signature head: Reference head_is_detached: bool @@ -788,7 +789,7 @@ class Repository: def compress_references(self) -> None: ... @property def config(self) -> Config: ... - def create_blob(self, data: bytes) -> Oid: ... + def create_blob(self, data: str | bytes) -> Oid: ... def create_blob_fromdisk(self, path: str) -> Oid: ... def create_blob_fromiobase(self, iobase: IOBase) -> Oid: ... def create_blob_fromworkdir(self, path: str | Path) -> Oid: ... diff --git a/pygit2/index.py b/pygit2/index.py index e6ae65934..1b52b7976 100644 --- a/pygit2/index.py +++ b/pygit2/index.py @@ -26,9 +26,10 @@ import typing import warnings from dataclasses import dataclass +from os import PathLike # Import from pygit2 -from ._pygit2 import Diff, Oid, Tree +from ._pygit2 import Diff, Oid, Repository, Tree from .enums import DiffOption, FileMode from .errors import check_error from .ffi import C, ffi @@ -41,7 +42,7 @@ class Index: # a proper implementation in some places: e.g. checking the index type # from C code (see Tree_diff_to_index) - def __init__(self, path: str | None = None) -> None: + def __init__(self, path: str | PathLike[str] | None = None) -> None: """Create a new Index If path is supplied, the read and write methods will use that path @@ -116,16 +117,16 @@ def read(self, force=True): err = C.git_index_read(self._index, force) check_error(err, io=True) - def write(self): + def write(self) -> None: """Write the contents of the Index to disk.""" err = C.git_index_write(self._index) check_error(err, io=True) - def clear(self): + def clear(self) -> None: err = C.git_index_clear(self._index) check_error(err) - def read_tree(self, tree): + def read_tree(self, tree: Oid | Tree | str) -> None: """Replace the contents of the Index with those of the given tree, expressed either as a object or as an oid (string or ). @@ -134,6 +135,8 @@ def read_tree(self, tree): """ repo = self._repo if isinstance(tree, str): + if repo is None: + raise TypeError('id given but no associated repository') tree = repo[tree] if isinstance(tree, Oid): @@ -142,14 +145,14 @@ def read_tree(self, tree): tree = repo[tree] elif not isinstance(tree, Tree): - raise TypeError('argument must be Oid or Tree') + raise TypeError('argument must be Oid, Tree or str') tree_cptr = ffi.new('git_tree **') ffi.buffer(tree_cptr)[:] = tree._pointer[:] err = C.git_index_read_tree(self._index, tree_cptr[0]) check_error(err) - def write_tree(self, repo=None): + def write_tree(self, repo: Repository | None = None) -> Oid: """Create a tree out of the Index. Return the object of the written tree. @@ -172,23 +175,23 @@ def write_tree(self, repo=None): check_error(err) return Oid(raw=bytes(ffi.buffer(coid)[:])) - def remove(self, path, level=0): + def remove(self, path: PathLike[str] | str, level: int = 0) -> None: """Remove an entry from the Index.""" err = C.git_index_remove(self._index, to_bytes(path), level) check_error(err, io=True) - def remove_directory(self, path, level=0): + def remove_directory(self, path: PathLike[str] | str, level: int = 0) -> None: """Remove a directory from the Index.""" err = C.git_index_remove_directory(self._index, to_bytes(path), level) check_error(err, io=True) - def remove_all(self, pathspecs): + def remove_all(self, pathspecs: typing.Sequence[str | PathLike[str]]) -> None: """Remove all index entries matching pathspecs.""" with StrArray(pathspecs) as arr: err = C.git_index_remove_all(self._index, arr.ptr, ffi.NULL, ffi.NULL) check_error(err, io=True) - def add_all(self, pathspecs=None): + def add_all(self, pathspecs: None | list[str | PathLike[str]] = None) -> None: """Add or update index entries matching files in the working directory. If pathspecs are specified, only files matching those pathspecs will @@ -199,7 +202,7 @@ def add_all(self, pathspecs=None): err = C.git_index_add_all(self._index, arr.ptr, 0, ffi.NULL, ffi.NULL) check_error(err, io=True) - def add(self, path_or_entry): + def add(self, path_or_entry: 'IndexEntry | str | PathLike[str]') -> None: """Add or update an entry in the Index. If a path is given, that file will be added. The path must be relative @@ -217,11 +220,13 @@ def add(self, path_or_entry): path = path_or_entry err = C.git_index_add_bypath(self._index, to_bytes(path)) else: - raise TypeError('argument must be string or IndexEntry') + raise TypeError('argument must be string, Path or IndexEntry') check_error(err, io=True) - def add_conflict(self, ancestor, ours, theirs): + def add_conflict( + self, ancestor: 'IndexEntry', ours: 'IndexEntry', theirs: 'IndexEntry | None' + ) -> None: """ Add or update index entries to represent a conflict. Any staged entries that exist at the given paths will be removed. @@ -243,7 +248,9 @@ def add_conflict(self, ancestor, ours, theirs): if theirs and not isinstance(theirs, IndexEntry): raise TypeError('theirs has to be an instance of IndexEntry or None') - centry_ancestor = centry_ours = centry_theirs = ffi.NULL + centry_ancestor: ffi.NULL_TYPE | ffi.GitIndexEntryC = ffi.NULL + centry_ours: ffi.NULL_TYPE | ffi.GitIndexEntryC = ffi.NULL + centry_theirs: ffi.NULL_TYPE | ffi.GitIndexEntryC = ffi.NULL if ancestor is not None: centry_ancestor, _ = ancestor._to_c() if ours is not None: @@ -418,7 +425,7 @@ def _from_c(cls, centry): class IndexEntry: - path: str + path: str | PathLike[str] 'The path of this entry' id: Oid @@ -427,7 +434,9 @@ class IndexEntry: mode: FileMode 'The mode of this entry, a FileMode value' - def __init__(self, path, object_id: Oid, mode: FileMode): + def __init__( + self, path: str | PathLike[str], object_id: Oid, mode: FileMode + ) -> None: self.path = path self.id = object_id self.mode = mode @@ -459,7 +468,7 @@ def __eq__(self, other): self.path == other.path and self.id == other.id and self.mode == other.mode ) - def _to_c(self): + def _to_c(self) -> tuple['ffi.GitIndexEntryC', 'ffi.ArrayC[ffi.char]']: """Convert this entry into the C structure The first returned arg is the pointer, the second is the reference to diff --git a/pygit2/repository.py b/pygit2/repository.py index 7e0b63622..5d2d00722 100644 --- a/pygit2/repository.py +++ b/pygit2/repository.py @@ -1183,7 +1183,7 @@ def stash( if paths: arr = StrArray(paths) - opts.paths = arr.ptr[0] + opts.paths = arr.ptr[0] # type: ignore[index] coid = ffi.new('git_oid *') err = C.git_stash_save_with_opts(coid, self._repo, opts) diff --git a/pygit2/utils.py b/pygit2/utils.py index e2d4b4c41..8b01d3a16 100644 --- a/pygit2/utils.py +++ b/pygit2/utils.py @@ -25,11 +25,25 @@ import contextlib import os -from typing import Generic, Iterator, Protocol, TypeVar, Union, overload +from types import TracebackType +from typing import ( + TYPE_CHECKING, + Generic, + Iterator, + Optional, + Protocol, + Sequence, + TypeVar, + Union, + overload, +) # Import from pygit2 from .ffi import C, ffi +if TYPE_CHECKING: + from ._libgit2.ffi import ArrayC, GitStrrayC, char + def maybe_string(ptr): if not ptr: @@ -130,7 +144,11 @@ class StrArray: contents of 'struct' only remain valid within the StrArray context. """ - def __init__(self, lst): + __array: 'GitStrrayC | ffi.NULL_TYPE' + __strings: list['None | ArrayC[char]'] + __arr: 'ArrayC[char]' + + def __init__(self, lst: None | Sequence[str | os.PathLike[str]]): # Allow passing in None as lg2 typically considers them the same as empty if lst is None: self.__array = ffi.NULL @@ -139,7 +157,7 @@ def __init__(self, lst): if not isinstance(lst, (list, tuple)): raise TypeError('Value must be a list') - strings = [None] * len(lst) + strings: list[None | 'ArrayC[char]'] = [None] * len(lst) for i in range(len(lst)): li = lst[i] if not isinstance(li, str) and not hasattr(li, '__fspath__'): @@ -147,21 +165,26 @@ def __init__(self, lst): strings[i] = ffi.new('char []', to_bytes(li)) - self.__arr = ffi.new('char *[]', strings) + self.__arr = ffi.new('char *[]', strings) # type: ignore[call-overload] self.__strings = strings - self.__array = ffi.new('git_strarray *', [self.__arr, len(strings)]) + self.__array = ffi.new('git_strarray *', [self.__arr, len(strings)]) # type: ignore[call-overload] - def __enter__(self): + def __enter__(self) -> 'StrArray': return self - def __exit__(self, type, value, traceback): + def __exit__( + self, + exc_type: Optional[type[BaseException]], + exc_value: Optional[BaseException], + traceback: Optional[TracebackType], + ) -> None: pass @property - def ptr(self): + def ptr(self) -> 'GitStrrayC | ffi.NULL_TYPE': return self.__array - def assign_to(self, git_strarray): + def assign_to(self, git_strarray: 'GitStrrayC') -> None: if self.__array == ffi.NULL: git_strarray.strings = ffi.NULL git_strarray.count = 0 diff --git a/test/test_index.py b/test/test_index.py index b1fccba90..086863b74 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -30,21 +30,21 @@ import pytest import pygit2 -from pygit2 import Index, IndexEntry, Oid, Repository +from pygit2 import Index, IndexEntry, Oid, Repository, Tree from pygit2.enums import FileMode from . import utils -def test_bare(barerepo): +def test_bare(barerepo: Repository) -> None: assert len(barerepo.index) == 0 -def test_index(testrepo): +def test_index(testrepo: Repository) -> None: assert testrepo.index is not None -def test_read(testrepo): +def test_read(testrepo: Repository) -> None: index = testrepo.index assert len(index) == 2 @@ -60,7 +60,7 @@ def test_read(testrepo): assert index[1].id == sha -def test_add(testrepo): +def test_add(testrepo: Repository) -> None: index = testrepo.index sha = '0907563af06c7464d62a70cdd135a6ba7d2b41d8' @@ -71,7 +71,7 @@ def test_add(testrepo): assert index['bye.txt'].id == sha -def test_add_aspath(testrepo): +def test_add_aspath(testrepo: Repository) -> None: index = testrepo.index assert 'bye.txt' not in index @@ -79,7 +79,7 @@ def test_add_aspath(testrepo): assert 'bye.txt' in index -def test_add_all(testrepo): +def test_add_all(testrepo: Repository) -> None: clear(testrepo) sha_bye = '0907563af06c7464d62a70cdd135a6ba7d2b41d8' @@ -113,7 +113,7 @@ def test_add_all(testrepo): assert index['hello.txt'].id == sha_hello -def test_add_all_aspath(testrepo): +def test_add_all_aspath(testrepo: Repository) -> None: clear(testrepo) index = testrepo.index @@ -122,14 +122,14 @@ def test_add_all_aspath(testrepo): assert 'hello.txt' in index -def clear(repo): +def clear(repo: Repository) -> None: index = repo.index assert len(index) == 2 index.clear() assert len(index) == 0 -def test_write(testrepo): +def test_write(testrepo: Repository) -> None: index = testrepo.index index.add('bye.txt') index.write() @@ -140,7 +140,7 @@ def test_write(testrepo): assert 'bye.txt' in index -def test_read_tree(testrepo): +def test_read_tree(testrepo: Repository) -> None: tree_oid = '68aba62e560c0ebc3396e8ae9335232cd93a3f60' # Test reading first tree index = testrepo.index @@ -154,11 +154,11 @@ def test_read_tree(testrepo): assert len(index) == 2 -def test_write_tree(testrepo): +def test_write_tree(testrepo: Repository) -> None: assert testrepo.index.write_tree() == 'fd937514cb799514d4b81bb24c5fcfeb6472b245' -def test_iter(testrepo): +def test_iter(testrepo: Repository) -> None: index = testrepo.index n = len(index) assert len(list(index)) == n @@ -168,7 +168,7 @@ def test_iter(testrepo): assert list(x.id for x in index) == entries -def test_mode(testrepo): +def test_mode(testrepo: Repository) -> None: """ Testing that we can access an index entry mode. """ @@ -178,7 +178,7 @@ def test_mode(testrepo): assert hello_mode == 33188 -def test_bare_index(testrepo): +def test_bare_index(testrepo: Repository) -> None: index = pygit2.Index(Path(testrepo.path) / 'index') assert [x.id for x in index] == [x.id for x in testrepo.index] @@ -186,21 +186,21 @@ def test_bare_index(testrepo): index.add('bye.txt') -def test_remove(testrepo): +def test_remove(testrepo: Repository) -> None: index = testrepo.index assert 'hello.txt' in index index.remove('hello.txt') assert 'hello.txt' not in index -def test_remove_directory(dirtyrepo): +def test_remove_directory(dirtyrepo: Repository) -> None: index = dirtyrepo.index assert 'subdir/current_file' in index index.remove_directory('subdir') assert 'subdir/current_file' not in index -def test_remove_all(testrepo): +def test_remove_all(testrepo: Repository) -> None: index = testrepo.index assert 'hello.txt' in index index.remove_all(['*.txt']) @@ -209,28 +209,28 @@ def test_remove_all(testrepo): index.remove_all(['not-existing']) # this doesn't error -def test_remove_aspath(testrepo): +def test_remove_aspath(testrepo: Repository) -> None: index = testrepo.index assert 'hello.txt' in index index.remove(Path('hello.txt')) assert 'hello.txt' not in index -def test_remove_directory_aspath(dirtyrepo): +def test_remove_directory_aspath(dirtyrepo: Repository) -> None: index = dirtyrepo.index assert 'subdir/current_file' in index index.remove_directory(Path('subdir')) assert 'subdir/current_file' not in index -def test_remove_all_aspath(testrepo): +def test_remove_all_aspath(testrepo: Repository) -> None: index = testrepo.index assert 'hello.txt' in index index.remove_all([Path('hello.txt')]) assert 'hello.txt' not in index -def test_change_attributes(testrepo): +def test_change_attributes(testrepo: Repository) -> None: index = testrepo.index entry = index['hello.txt'] ign_entry = index['.gitignore'] @@ -244,7 +244,7 @@ def test_change_attributes(testrepo): assert FileMode.BLOB_EXECUTABLE == entry.mode -def test_write_tree_to(testrepo, tmp_path): +def test_write_tree_to(testrepo: Repository, tmp_path: Path) -> None: pygit2.option(pygit2.enums.Option.ENABLE_STRICT_OBJECT_CREATION, False) with utils.TemporaryRepository('emptyrepo.zip', tmp_path) as path: nrepo = Repository(path) @@ -252,7 +252,7 @@ def test_write_tree_to(testrepo, tmp_path): assert nrepo[id] is not None -def test_create_entry(testrepo): +def test_create_entry(testrepo: Repository) -> None: index = testrepo.index hello_entry = index['hello.txt'] entry = pygit2.IndexEntry('README.md', hello_entry.id, hello_entry.mode) @@ -260,7 +260,7 @@ def test_create_entry(testrepo): assert '60e769e57ae1d6a2ab75d8d253139e6260e1f912' == index.write_tree() -def test_create_entry_aspath(testrepo): +def test_create_entry_aspath(testrepo: Repository) -> None: index = testrepo.index hello_entry = index[Path('hello.txt')] entry = pygit2.IndexEntry(Path('README.md'), hello_entry.id, hello_entry.mode) @@ -268,7 +268,7 @@ def test_create_entry_aspath(testrepo): index.write_tree() -def test_entry_eq(testrepo): +def test_entry_eq(testrepo: Repository) -> None: index = testrepo.index hello_entry = index['hello.txt'] entry = pygit2.IndexEntry(hello_entry.path, hello_entry.id, hello_entry.mode) @@ -285,7 +285,7 @@ def test_entry_eq(testrepo): assert hello_entry != entry -def test_entry_repr(testrepo): +def test_entry_repr(testrepo: Repository) -> None: index = testrepo.index hello_entry = index['hello.txt'] assert ( @@ -306,16 +306,18 @@ def test_create_empty_read_tree_as_string(): index = Index() # no repo associated, so we don't know where to read from with pytest.raises(TypeError): - index('read_tree', 'fd937514cb799514d4b81bb24c5fcfeb6472b245') + index('read_tree', 'fd937514cb799514d4b81bb24c5fcfeb6472b245') # type: ignore -def test_create_empty_read_tree(testrepo): +def test_create_empty_read_tree(testrepo: Repository) -> None: index = Index() - index.read_tree(testrepo['fd937514cb799514d4b81bb24c5fcfeb6472b245']) + tree = testrepo['fd937514cb799514d4b81bb24c5fcfeb6472b245'] + assert isinstance(tree, Tree) + index.read_tree(tree) @utils.fails_in_macos -def test_add_conflict(testrepo): +def test_add_conflict(testrepo: Repository) -> None: ancestor_blob_id = testrepo.create_blob('ancestor') ancestor = IndexEntry('conflict.txt', ancestor_blob_id, FileMode.BLOB_EXECUTABLE) diff --git a/test/utils.py b/test/utils.py index ba98f5cef..f4cadbc85 100644 --- a/test/utils.py +++ b/test/utils.py @@ -32,7 +32,7 @@ import zipfile from pathlib import Path from types import TracebackType -from typing import Optional +from typing import Callable, Optional, ParamSpec, TypeVar # Requirements import pytest @@ -40,6 +40,9 @@ # Pygit2 import pygit2 +T = TypeVar('T') +P = ParamSpec('P') + requires_future_libgit2 = pytest.mark.xfail( pygit2.LIBGIT2_VER < (2, 0, 0), reason='This test may work with a future version of libgit2', @@ -121,7 +124,13 @@ def __exit__( pass -def assertRaisesWithArg(exc_class, arg, func, *args, **kwargs): +def assertRaisesWithArg( + exc_class: type[Exception], + arg: object, + func: Callable[P, T], + *args: P.args, + **kwargs: P.kwargs, +) -> None: with pytest.raises(exc_class) as excinfo: func(*args, **kwargs) assert excinfo.value.args == (arg,) From 1cb902ac4502e789c798ee1e7cc70f71a9e2f6eb Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Tue, 29 Jul 2025 17:16:17 +0200 Subject: [PATCH 035/225] improve types according to test/test_mailmap.py --- pygit2/_pygit2.pyi | 6 +++--- test/test_mailmap.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index ec9a96f30..d2b2c95d1 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -533,9 +533,9 @@ class Mailmap: def __init__(self, *args) -> None: ... def add_entry( self, - real_name: str = ..., - real_email: str = ..., - replace_name: str = ..., + real_name: str | None = ..., + real_email: str | None = ..., + replace_name: str | None = ..., replace_email: str = ..., ) -> None: ... @staticmethod diff --git a/test/test_mailmap.py b/test/test_mailmap.py index e5a3b90e6..44da270f4 100644 --- a/test/test_mailmap.py +++ b/test/test_mailmap.py @@ -62,14 +62,14 @@ ] -def test_empty(): +def test_empty() -> None: mailmap = Mailmap() for _, _, name, email in TEST_RESOLVE: assert mailmap.resolve(name, email) == (name, email) -def test_new(): +def test_new() -> None: mailmap = Mailmap() # Add entries to the mailmap @@ -80,7 +80,7 @@ def test_new(): assert mailmap.resolve(name, email) == (real_name, real_email) -def test_parsed(): +def test_parsed() -> None: mailmap = Mailmap.from_buffer(TEST_MAILMAP) for real_name, real_email, name, email in TEST_RESOLVE: From 7e0f891deef0909dbd5e896b3dd873d1082c2b2b Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Tue, 29 Jul 2025 17:37:20 +0200 Subject: [PATCH 036/225] improve types according to test/test_merge.py --- pygit2/_pygit2.pyi | 35 ++++++++++++++++++++ pygit2/repository.py | 4 ++- test/test_merge.py | 78 ++++++++++++++++++++++++-------------------- 3 files changed, 80 insertions(+), 37 deletions(-) diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index d2b2c95d1..acee49842 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -19,6 +19,7 @@ from typing import ( from . import Index from ._libgit2.ffi import ( GitCommitC, + GitMergeOptionsC, GitObjectC, GitProxyOptionsC, GitRepositoryC, @@ -43,6 +44,9 @@ from .enums import ( DiffStatsFormat, FileMode, MergeAnalysis, + MergeFavor, + MergeFileFlag, + MergeFlag, MergePreference, ObjectType, Option, @@ -884,12 +888,40 @@ class Repository: def lookup_reference(self, name: str) -> Reference: ... def lookup_reference_dwim(self, name: str) -> Reference: ... def lookup_worktree(self, name: str) -> Worktree: ... + def merge( + self, + source: Reference | Commit | Oid | str, + favor: MergeFavor = MergeFavor.NORMAL, + flags: MergeFlag = MergeFlag.FIND_RENAMES, + file_flags: MergeFileFlag = MergeFileFlag.DEFAULT, + ) -> None: ... def merge_analysis( self, their_head: _OidArg, our_ref: str = 'HEAD' ) -> tuple[MergeAnalysis, MergePreference]: ... def merge_base(self, oid1: _OidArg, oid2: _OidArg) -> Oid: ... def merge_base_many(self, oids: list[_OidArg]) -> Oid: ... def merge_base_octopus(self, oids: list[_OidArg]) -> Oid: ... + def merge_commits( + self, + ours: str | Oid | Commit, + theirs: str | Oid | Commit, + favor: MergeFavor = MergeFavor.NORMAL, + flags: MergeFlag = MergeFlag.FIND_RENAMES, + file_flags: MergeFileFlag = MergeFileFlag.DEFAULT, + ) -> Index: ... + @staticmethod + def _merge_options( + favor: int | MergeFavor, flags: int | MergeFlag, file_flags: int | MergeFileFlag + ) -> GitMergeOptionsC: ... + def merge_trees( + self, + ancestor: str | Oid | Tree, + ours: str | Oid | Tree, + theirs: str | Oid | Tree, + favor: MergeFavor = MergeFavor.NORMAL, + flags: MergeFlag = MergeFlag.FIND_RENAMES, + file_flags: MergeFileFlag = MergeFileFlag.DEFAULT, + ) -> Index: ... @property def message(self) -> str: ... def notes(self) -> Iterator[Note]: ... @@ -898,6 +930,9 @@ class Repository: self, flag: BranchType = BranchType.LOCAL ) -> list[bytes]: ... def raw_listall_references(self) -> list[bytes]: ... + @property + def raw_message(self) -> bytes: ... + def remove_message(self) -> None: ... def references_iterator_init(self) -> Iterator[Reference]: ... def references_iterator_next( self, diff --git a/pygit2/repository.py b/pygit2/repository.py index 5d2d00722..df54065f5 100644 --- a/pygit2/repository.py +++ b/pygit2/repository.py @@ -670,7 +670,9 @@ def index(self): # Merging # @staticmethod - def _merge_options(favor: MergeFavor, flags: MergeFlag, file_flags: MergeFileFlag): + def _merge_options( + favor: int | MergeFavor, flags: int | MergeFlag, file_flags: int | MergeFileFlag + ): """Return a 'git_merge_opts *'""" # Check arguments type diff --git a/test/test_merge.py b/test/test_merge.py index 538070f06..492c00346 100644 --- a/test/test_merge.py +++ b/test/test_merge.py @@ -30,28 +30,29 @@ import pytest import pygit2 +from pygit2 import Repository from pygit2.enums import FileStatus, MergeAnalysis, MergeFavor, MergeFileFlag, MergeFlag @pytest.mark.parametrize('id', [None, 42]) -def test_merge_invalid_type(mergerepo, id): +def test_merge_invalid_type(mergerepo: Repository, id: None | int) -> None: with pytest.raises(TypeError): - mergerepo.merge(id) + mergerepo.merge(id) # type:ignore # TODO: Once Repository.merge drops support for str arguments, # add an extra parameter to test_merge_invalid_type above # to make sure we cover legacy code. -def test_merge_string_argument_deprecated(mergerepo): +def test_merge_string_argument_deprecated(mergerepo: Repository) -> None: branch_head_hex = '5ebeeebb320790caf276b9fc8b24546d63316533' with pytest.warns(DeprecationWarning, match=r'Pass Commit.+instead'): mergerepo.merge(branch_head_hex) -def test_merge_analysis_uptodate(mergerepo): +def test_merge_analysis_uptodate(mergerepo: Repository) -> None: branch_head_hex = '5ebeeebb320790caf276b9fc8b24546d63316533' - branch_id = mergerepo.get(branch_head_hex).id + branch_id = mergerepo[branch_head_hex].id analysis, preference = mergerepo.merge_analysis(branch_id) assert analysis & MergeAnalysis.UP_TO_DATE @@ -64,9 +65,9 @@ def test_merge_analysis_uptodate(mergerepo): assert {} == mergerepo.status() -def test_merge_analysis_fastforward(mergerepo): +def test_merge_analysis_fastforward(mergerepo: Repository) -> None: branch_head_hex = 'e97b4cfd5db0fb4ebabf4f203979ca4e5d1c7c87' - branch_id = mergerepo.get(branch_head_hex).id + branch_id = mergerepo[branch_head_hex].id analysis, preference = mergerepo.merge_analysis(branch_id) assert not analysis & MergeAnalysis.UP_TO_DATE @@ -79,9 +80,9 @@ def test_merge_analysis_fastforward(mergerepo): assert {} == mergerepo.status() -def test_merge_no_fastforward_no_conflicts(mergerepo): +def test_merge_no_fastforward_no_conflicts(mergerepo: Repository) -> None: branch_head_hex = '03490f16b15a09913edb3a067a3dc67fbb8d41f1' - branch_id = mergerepo.get(branch_head_hex).id + branch_id = mergerepo[branch_head_hex].id analysis, preference = mergerepo.merge_analysis(branch_id) assert not analysis & MergeAnalysis.UP_TO_DATE assert not analysis & MergeAnalysis.FASTFORWARD @@ -90,7 +91,7 @@ def test_merge_no_fastforward_no_conflicts(mergerepo): assert {} == mergerepo.status() -def test_merge_invalid_hex(mergerepo): +def test_merge_invalid_hex(mergerepo: Repository) -> None: branch_head_hex = '12345678' with ( pytest.raises(KeyError), @@ -99,9 +100,9 @@ def test_merge_invalid_hex(mergerepo): mergerepo.merge(branch_head_hex) -def test_merge_already_something_in_index(mergerepo): +def test_merge_already_something_in_index(mergerepo: Repository) -> None: branch_head_hex = '03490f16b15a09913edb3a067a3dc67fbb8d41f1' - branch_oid = mergerepo.get(branch_head_hex).id + branch_oid = mergerepo[branch_head_hex].id with (Path(mergerepo.workdir) / 'inindex.txt').open('w') as f: f.write('new content') mergerepo.index.add('inindex.txt') @@ -109,9 +110,9 @@ def test_merge_already_something_in_index(mergerepo): mergerepo.merge(branch_oid) -def test_merge_no_fastforward_conflicts(mergerepo): +def test_merge_no_fastforward_conflicts(mergerepo: Repository) -> None: branch_head_hex = '1b2bae55ac95a4be3f8983b86cd579226d0eb247' - branch_id = mergerepo.get(branch_head_hex).id + branch_id = mergerepo[branch_head_hex].id analysis, preference = mergerepo.merge_analysis(branch_id) assert not analysis & MergeAnalysis.UP_TO_DATE @@ -144,7 +145,7 @@ def test_merge_no_fastforward_conflicts(mergerepo): assert {'.gitignore': FileStatus.INDEX_MODIFIED} == mergerepo.status() -def test_merge_remove_conflicts(mergerepo): +def test_merge_remove_conflicts(mergerepo: Repository) -> None: other_branch_tip = pygit2.Oid(hex='1b2bae55ac95a4be3f8983b86cd579226d0eb247') mergerepo.merge(other_branch_tip) idx = mergerepo.index @@ -154,7 +155,7 @@ def test_merge_remove_conflicts(mergerepo): try: conflicts['.gitignore'] except KeyError: - mergerepo.fail("conflicts['.gitignore'] raised KeyError unexpectedly") + mergerepo.fail("conflicts['.gitignore'] raised KeyError unexpectedly") # type: ignore del idx.conflicts['.gitignore'] with pytest.raises(KeyError): conflicts.__getitem__('.gitignore') @@ -170,14 +171,14 @@ def test_merge_remove_conflicts(mergerepo): MergeFavor.UNION, ], ) -def test_merge_favor(mergerepo, favor): +def test_merge_favor(mergerepo: Repository, favor: MergeFavor) -> None: branch_head = pygit2.Oid(hex='1b2bae55ac95a4be3f8983b86cd579226d0eb247') mergerepo.merge(branch_head, favor=favor) assert mergerepo.index.conflicts is None -def test_merge_fail_on_conflict(mergerepo): +def test_merge_fail_on_conflict(mergerepo: Repository) -> None: branch_head = pygit2.Oid(hex='1b2bae55ac95a4be3f8983b86cd579226d0eb247') with pytest.raises(pygit2.GitError, match=r'merge conflicts exist'): @@ -186,7 +187,7 @@ def test_merge_fail_on_conflict(mergerepo): ) -def test_merge_commits(mergerepo): +def test_merge_commits(mergerepo: Repository) -> None: branch_head = pygit2.Oid(hex='03490f16b15a09913edb3a067a3dc67fbb8d41f1') merge_index = mergerepo.merge_commits(mergerepo.head.target, branch_head) @@ -201,7 +202,7 @@ def test_merge_commits(mergerepo): assert merge_tree == merge_commits_tree -def test_merge_commits_favor(mergerepo): +def test_merge_commits_favor(mergerepo: Repository) -> None: branch_head = pygit2.Oid(hex='1b2bae55ac95a4be3f8983b86cd579226d0eb247') merge_index = mergerepo.merge_commits( @@ -211,10 +212,10 @@ def test_merge_commits_favor(mergerepo): # Incorrect favor value with pytest.raises(TypeError, match=r'favor argument must be MergeFavor'): - mergerepo.merge_commits(mergerepo.head.target, branch_head, favor='foo') + mergerepo.merge_commits(mergerepo.head.target, branch_head, favor='foo') # type: ignore -def test_merge_trees(mergerepo): +def test_merge_trees(mergerepo: Repository) -> None: branch_id = pygit2.Oid(hex='03490f16b15a09913edb3a067a3dc67fbb8d41f1') ancestor_id = mergerepo.merge_base(mergerepo.head.target, branch_id) @@ -230,7 +231,7 @@ def test_merge_trees(mergerepo): assert merge_tree == merge_commits_tree -def test_merge_trees_favor(mergerepo): +def test_merge_trees_favor(mergerepo: Repository) -> None: branch_head_hex = '1b2bae55ac95a4be3f8983b86cd579226d0eb247' ancestor_id = mergerepo.merge_base(mergerepo.head.target, branch_head_hex) merge_index = mergerepo.merge_trees( @@ -240,14 +241,19 @@ def test_merge_trees_favor(mergerepo): with pytest.raises(TypeError): mergerepo.merge_trees( - ancestor_id, mergerepo.head.target, branch_head_hex, favor='foo' + ancestor_id, + mergerepo.head.target, + branch_head_hex, + favor='foo', # type: ignore ) -def test_merge_options(): +def test_merge_options() -> None: favor = MergeFavor.OURS - flags = MergeFlag.FIND_RENAMES | MergeFlag.FAIL_ON_CONFLICT - file_flags = MergeFileFlag.IGNORE_WHITESPACE | MergeFileFlag.DIFF_PATIENCE + flags: int | MergeFlag = MergeFlag.FIND_RENAMES | MergeFlag.FAIL_ON_CONFLICT + file_flags: int | MergeFileFlag = ( + MergeFileFlag.IGNORE_WHITESPACE | MergeFileFlag.DIFF_PATIENCE + ) o1 = pygit2.Repository._merge_options( favor=favor, flags=flags, file_flags=file_flags ) @@ -280,9 +286,9 @@ def test_merge_options(): assert file_flags == o1.file_flags -def test_merge_many(mergerepo): +def test_merge_many(mergerepo: Repository) -> None: branch_head_hex = '03490f16b15a09913edb3a067a3dc67fbb8d41f1' - branch_id = mergerepo.get(branch_head_hex).id + branch_id = mergerepo[branch_head_hex].id ancestor_id = mergerepo.merge_base_many([mergerepo.head.target, branch_id]) merge_index = mergerepo.merge_trees( @@ -299,9 +305,9 @@ def test_merge_many(mergerepo): assert merge_tree == merge_commits_tree -def test_merge_octopus(mergerepo): +def test_merge_octopus(mergerepo: Repository) -> None: branch_head_hex = '03490f16b15a09913edb3a067a3dc67fbb8d41f1' - branch_id = mergerepo.get(branch_head_hex).id + branch_id = mergerepo[branch_head_hex].id ancestor_id = mergerepo.merge_base_octopus([mergerepo.head.target, branch_id]) merge_index = mergerepo.merge_trees( @@ -318,7 +324,7 @@ def test_merge_octopus(mergerepo): assert merge_tree == merge_commits_tree -def test_merge_mergeheads(mergerepo): +def test_merge_mergeheads(mergerepo: Repository) -> None: assert mergerepo.listall_mergeheads() == [] branch_head = pygit2.Oid(hex='1b2bae55ac95a4be3f8983b86cd579226d0eb247') @@ -332,7 +338,7 @@ def test_merge_mergeheads(mergerepo): ) -def test_merge_message(mergerepo): +def test_merge_message(mergerepo: Repository) -> None: assert not mergerepo.message assert not mergerepo.raw_message @@ -346,7 +352,7 @@ def test_merge_message(mergerepo): assert not mergerepo.message -def test_merge_remove_message(mergerepo): +def test_merge_remove_message(mergerepo: Repository) -> None: branch_head = pygit2.Oid(hex='1b2bae55ac95a4be3f8983b86cd579226d0eb247') mergerepo.merge(branch_head) @@ -355,7 +361,7 @@ def test_merge_remove_message(mergerepo): assert not mergerepo.message -def test_merge_commit(mergerepo): +def test_merge_commit(mergerepo: Repository) -> None: commit = mergerepo['1b2bae55ac95a4be3f8983b86cd579226d0eb247'] assert isinstance(commit, pygit2.Commit) mergerepo.merge(commit) @@ -364,7 +370,7 @@ def test_merge_commit(mergerepo): assert mergerepo.listall_mergeheads() == [commit.id] -def test_merge_reference(mergerepo): +def test_merge_reference(mergerepo: Repository) -> None: branch = mergerepo.branches.local['branch-conflicts'] branch_head_hex = '1b2bae55ac95a4be3f8983b86cd579226d0eb247' mergerepo.merge(branch) From 51d0cacf42fd04f210a279ad4503f760de23c76d Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Tue, 29 Jul 2025 21:48:32 +0200 Subject: [PATCH 037/225] improve types according to test/test_nonunicode.py --- pygit2/_pygit2.pyi | 1 + test/test_nonunicode.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index acee49842..8ef2d72a8 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -877,6 +877,7 @@ class Repository: def list_worktrees(self) -> list[str]: ... def listall_branches(self, flag: BranchType = BranchType.LOCAL) -> list[str]: ... def listall_mergeheads(self) -> list[Oid]: ... + def listall_references(self) -> list[str]: ... def listall_stashes(self) -> list[Stash]: ... def listall_submodules(self) -> list[str]: ... def lookup_branch( diff --git a/test/test_nonunicode.py b/test/test_nonunicode.py index 3daef1a26..cdf1ce167 100644 --- a/test/test_nonunicode.py +++ b/test/test_nonunicode.py @@ -32,6 +32,7 @@ import pytest import pygit2 +from pygit2 import Repository from . import utils @@ -44,7 +45,7 @@ @utils.requires_network @works_in_linux -def test_nonunicode_branchname(testrepo): +def test_nonunicode_branchname(testrepo: Repository) -> None: folderpath = 'temp_repo_nonutf' if os.path.exists(folderpath): shutil.rmtree(folderpath) From 5c57a7f344f78d9cd11953e0286daf50a3d01dc4 Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Tue, 29 Jul 2025 21:56:00 +0200 Subject: [PATCH 038/225] improve types according to test/test_note.py --- pygit2/_pygit2.pyi | 1 + test/test_note.py | 23 ++++++++++++++--------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index 8ef2d72a8..b3340771b 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -553,6 +553,7 @@ class Note: annotated_id: Oid id: Oid message: str + data: bytes def remove( self, author: Signature, committer: Signature, ref: str = 'refs/notes/commits' ) -> None: ... diff --git a/test/test_note.py b/test/test_note.py index 1f95e2a43..6a1846c07 100644 --- a/test/test_note.py +++ b/test/test_note.py @@ -27,7 +27,7 @@ import pytest -from pygit2 import Signature +from pygit2 import Blob, Repository, Signature NOTE = ('6c8980ba963cad8b25a9bcaf68d4023ee57370d8', 'note message') @@ -45,24 +45,26 @@ ] -def test_create_note(barerepo): +def test_create_note(barerepo: Repository) -> None: annotated_id = barerepo.revparse_single('HEAD~3').id author = committer = Signature('Foo bar', 'foo@bar.com', 12346, 0) note_id = barerepo.create_note(NOTE[1], author, committer, str(annotated_id)) assert NOTE[0] == note_id + note = barerepo[note_id] + assert isinstance(note, Blob) # check the note blob - assert NOTE[1].encode() == barerepo[note_id].data + assert NOTE[1].encode() == note.data -def test_lookup_note(barerepo): +def test_lookup_note(barerepo: Repository) -> None: annotated_id = str(barerepo.head.target) note = barerepo.lookup_note(annotated_id) assert NOTES[0][0] == note.id assert NOTES[0][1] == note.message -def test_remove_note(barerepo): +def test_remove_note(barerepo: Repository) -> None: head = barerepo.head note = barerepo.lookup_note(str(head.target)) author = committer = Signature('Foo bar', 'foo@bar.com', 12346, 0) @@ -71,11 +73,14 @@ def test_remove_note(barerepo): barerepo.lookup_note(str(head.target)) -def test_iterate_notes(barerepo): +def test_iterate_notes(barerepo: Repository) -> None: for i, note in enumerate(barerepo.notes()): - assert NOTES[i] == (note.id, note.message, note.annotated_id) + note_id, message, annotated_id = NOTES[i] + assert note_id == note.id + assert message == note.message + assert annotated_id == note.annotated_id -def test_iterate_non_existing_ref(barerepo): +def test_iterate_non_existing_ref(barerepo: Repository) -> None: with pytest.raises(KeyError): - barerepo.notes('refs/notes/bad_ref') + barerepo.notes('refs/notes/bad_ref') # type: ignore From 285b82a6db250caa2b4fd954dd78629c67ded8e9 Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Tue, 29 Jul 2025 22:04:11 +0200 Subject: [PATCH 039/225] improve types according to test/test_object.py --- pygit2/_pygit2.pyi | 14 ++++++++++---- test/test_object.py | 22 +++++++++++----------- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index b3340771b..3018204bd 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -327,14 +327,20 @@ class _ObjectBase(Generic[T]): tree: Tree @overload def peel( - self, target_type: 'Literal[GIT_OBJ_COMMIT] | Type[Commit]' + self, target_type: 'Literal[GIT_OBJ_COMMIT, ObjectType.COMMIT] | Type[Commit]' ) -> 'Commit': ... @overload - def peel(self, target_type: 'Literal[GIT_OBJ_TREE] | Type[Tree]') -> 'Tree': ... + def peel( + self, target_type: 'Literal[GIT_OBJ_TREE, ObjectType.TREE] | Type[Tree]' + ) -> 'Tree': ... @overload - def peel(self, target_type: 'Literal[GIT_OBJ_TAG] | Type[Tag]') -> 'Tag': ... + def peel( + self, target_type: 'Literal[GIT_OBJ_TAG, ObjectType.TAG] | Type[Tag]' + ) -> 'Tag': ... @overload - def peel(self, target_type: 'Literal[GIT_OBJ_BLOB] | Type[Blob]') -> 'Blob': ... + def peel( + self, target_type: 'Literal[GIT_OBJ_BLOB, ObjectType.BLOB] | Type[Blob]' + ) -> 'Blob': ... @overload def peel(self, target_type: 'None') -> 'Commit|Tree|Tag|Blob': ... def read_raw(self) -> bytes: ... diff --git a/test/test_object.py b/test/test_object.py index 95b534582..fa1a83119 100644 --- a/test/test_object.py +++ b/test/test_object.py @@ -27,7 +27,7 @@ import pytest -from pygit2 import Tag, Tree +from pygit2 import Commit, Object, Oid, Repository, Tag, Tree from pygit2.enums import ObjectType BLOB_SHA = 'a520c24d85fbfc815d385957eed41406ca5a860b' @@ -39,7 +39,7 @@ BLOB_FILE_CONTENT = b'bye world\n' -def test_equality(testrepo): +def test_equality(testrepo: Repository) -> None: # get a commit object twice and see if it equals ittestrepo commit_id = testrepo.lookup_reference('refs/heads/master').target commit_a = testrepo[commit_id] @@ -50,7 +50,7 @@ def test_equality(testrepo): assert not (commit_a != commit_b) -def test_hashing(testrepo): +def test_hashing(testrepo: Repository) -> None: # get a commit object twice and compare hashes commit_id = testrepo.lookup_reference('refs/heads/master').target commit_a = testrepo[commit_id] @@ -80,7 +80,7 @@ def test_hashing(testrepo): assert commit_b == commit_a -def test_peel_commit(testrepo): +def test_peel_commit(testrepo: Repository) -> None: # start by looking up the commit commit_id = testrepo.lookup_reference('refs/heads/master').target commit = testrepo[commit_id] @@ -91,7 +91,7 @@ def test_peel_commit(testrepo): assert tree.id == 'fd937514cb799514d4b81bb24c5fcfeb6472b245' -def test_peel_commit_type(testrepo): +def test_peel_commit_type(testrepo: Repository) -> None: commit_id = testrepo.lookup_reference('refs/heads/master').target commit = testrepo[commit_id] tree = commit.peel(Tree) @@ -100,7 +100,7 @@ def test_peel_commit_type(testrepo): assert tree.id == 'fd937514cb799514d4b81bb24c5fcfeb6472b245' -def test_invalid(testrepo): +def test_invalid(testrepo: Repository) -> None: commit_id = testrepo.lookup_reference('refs/heads/master').target commit = testrepo[commit_id] @@ -108,7 +108,7 @@ def test_invalid(testrepo): commit.peel(ObjectType.TAG) -def test_invalid_type(testrepo): +def test_invalid_type(testrepo: Repository) -> None: commit_id = testrepo.lookup_reference('refs/heads/master').target commit = testrepo[commit_id] @@ -116,10 +116,10 @@ def test_invalid_type(testrepo): commit.peel(Tag) -def test_short_id(testrepo): - seen = {} # from short_id to full hex id +def test_short_id(testrepo: Repository) -> None: + seen: dict[str, Oid] = {} # from short_id to full hex id - def test_obj(obj, msg): + def test_obj(obj: Object | Commit, msg: str) -> None: short_id = obj.short_id msg = msg + f' short_id={short_id}' already = seen.get(short_id) @@ -138,7 +138,7 @@ def test_obj(obj, msg): test_obj(testrepo[entry.id], f'entry={entry.name}#{entry.id}') -def test_repr(testrepo): +def test_repr(testrepo: Repository) -> None: commit_id = testrepo.lookup_reference('refs/heads/master').target commit_a = testrepo[commit_id] assert repr(commit_a) == '' % commit_id From 0b93aab85984d3aef1903c1e196e913423bc8738 Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Tue, 29 Jul 2025 22:08:06 +0200 Subject: [PATCH 040/225] improve types according to test/test_odb.py --- pygit2/_pygit2.pyi | 4 ++-- test/test_odb.py | 17 +++++++++-------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index 3018204bd..4be51b3ea 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -568,9 +568,9 @@ class Odb: backends: Iterator[OdbBackend] def __init__(self, *args, **kwargs) -> None: ... def add_backend(self, backend: OdbBackend, priority: int) -> None: ... - def add_disk_alternate(self, path: str) -> None: ... + def add_disk_alternate(self, path: str | Path) -> None: ... def exists(self, oid: _OidArg) -> bool: ... - def read(self, oid: _OidArg) -> tuple[int, int, bytes]: ... + def read(self, oid: _OidArg) -> tuple[int, bytes]: ... def write(self, type: int, data: bytes) -> Oid: ... def __contains__(self, other: _OidArg) -> bool: ... def __iter__(self) -> Iterator[Oid]: ... # Odb_as_iter diff --git a/test/test_odb.py b/test/test_odb.py index ce07c937d..a9b33e188 100644 --- a/test/test_odb.py +++ b/test/test_odb.py @@ -28,11 +28,12 @@ # Standard Library import binascii from pathlib import Path +from typing import Generator import pytest # pygit2 -from pygit2 import Odb, Oid +from pygit2 import Odb, Oid, Repository from pygit2.enums import ObjectType from . import utils @@ -42,7 +43,7 @@ BLOB_OID = Oid(raw=BLOB_RAW) -def test_emptyodb(barerepo): +def test_emptyodb(barerepo: Repository) -> None: odb = Odb() assert len(list(odb)) == 0 @@ -53,22 +54,22 @@ def test_emptyodb(barerepo): @pytest.fixture -def odb(barerepo): +def odb(barerepo: Repository) -> Generator[Odb, None, None]: odb = barerepo.odb yield odb -def test_iterable(odb): +def test_iterable(odb: Odb) -> None: assert BLOB_HEX in odb -def test_contains(odb): +def test_contains(odb: Odb) -> None: assert BLOB_HEX in odb -def test_read(odb): +def test_read(odb: Odb) -> None: with pytest.raises(TypeError): - odb.read(123) + odb.read(123) # type: ignore utils.assertRaisesWithArg(KeyError, '1' * 40, odb.read, '1' * 40) ab = odb.read(BLOB_OID) @@ -84,7 +85,7 @@ def test_read(odb): assert (ObjectType.BLOB, b'a contents\n') == a3 -def test_write(odb): +def test_write(odb: Odb) -> None: data = b'hello world' # invalid object type with pytest.raises(ValueError): From 5424d2c4928a883845614017d1a364ec0b9c74fc Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Tue, 29 Jul 2025 22:22:13 +0200 Subject: [PATCH 041/225] add types to test/test_odb_backend.py --- test/test_odb_backend.py | 54 +++++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/test/test_odb_backend.py b/test/test_odb_backend.py index c1995f268..ab1ea5888 100644 --- a/test/test_odb_backend.py +++ b/test/test_odb_backend.py @@ -28,11 +28,13 @@ # Standard Library import binascii from pathlib import Path +from typing import Generator, Iterator import pytest # pygit2 import pygit2 +from pygit2 import Odb, Oid, Repository from pygit2.enums import ObjectType from . import utils @@ -43,12 +45,12 @@ @pytest.fixture -def odb(barerepo): +def odb_path(barerepo: Repository) -> Generator[tuple[Odb, Path], None, None]: yield barerepo.odb, Path(barerepo.path) / 'objects' -def test_pack(odb): - odb, path = odb +def test_pack(odb_path: tuple[Odb, Path]) -> None: + odb, path = odb_path pack = pygit2.OdbBackendPack(path) assert len(list(pack)) > 0 @@ -56,8 +58,8 @@ def test_pack(odb): assert obj in odb -def test_loose(odb): - odb, path = odb +def test_loose(odb_path: tuple[Odb, Path]) -> None: + odb, path = odb_path pack = pygit2.OdbBackendLoose(path, 5, False) assert len(list(pack)) > 0 @@ -66,30 +68,30 @@ def test_loose(odb): class ProxyBackend(pygit2.OdbBackend): - def __init__(self, source): + def __init__(self, source: pygit2.OdbBackend | pygit2.OdbBackendPack) -> None: super().__init__() self.source = source - def read_cb(self, oid): + def read_cb(self, oid: Oid | str) -> tuple[int, bytes]: return self.source.read(oid) - def read_prefix_cb(self, oid): + def read_prefix_cb(self, oid: Oid | str) -> tuple[int, bytes, Oid]: return self.source.read_prefix(oid) - def read_header_cb(self, oid): + def read_header_cb(self, oid: Oid | str) -> tuple[int, int]: typ, data = self.source.read(oid) return typ, len(data) - def exists_cb(self, oid): + def exists_cb(self, oid: Oid | str) -> bool: return self.source.exists(oid) - def exists_prefix_cb(self, oid): + def exists_prefix_cb(self, oid: Oid | str) -> Oid: return self.source.exists_prefix(oid) - def refresh_cb(self): + def refresh_cb(self) -> None: self.source.refresh() - def __iter__(self): + def __iter__(self) -> Iterator[Oid]: return iter(self.source) @@ -100,18 +102,18 @@ def __iter__(self): @pytest.fixture -def proxy(barerepo): +def proxy(barerepo: Repository) -> Generator[ProxyBackend, None, None]: path = Path(barerepo.path) / 'objects' yield ProxyBackend(pygit2.OdbBackendPack(path)) -def test_iterable(proxy): +def test_iterable(proxy: ProxyBackend) -> None: assert BLOB_HEX in [o for o in proxy] -def test_read(proxy): +def test_read(proxy: ProxyBackend) -> None: with pytest.raises(TypeError): - proxy.read(123) + proxy.read(123) # type: ignore utils.assertRaisesWithArg(KeyError, '1' * 40, proxy.read, '1' * 40) ab = proxy.read(BLOB_OID) @@ -120,21 +122,21 @@ def test_read(proxy): assert (ObjectType.BLOB, b'a contents\n') == a -def test_read_prefix(proxy): +def test_read_prefix(proxy: ProxyBackend) -> None: a_hex_prefix = BLOB_HEX[:4] a3 = proxy.read_prefix(a_hex_prefix) assert (ObjectType.BLOB, b'a contents\n', BLOB_OID) == a3 -def test_exists(proxy): +def test_exists(proxy: ProxyBackend) -> None: with pytest.raises(TypeError): - proxy.exists(123) + proxy.exists(123) # type: ignore assert not proxy.exists('1' * 40) assert proxy.exists(BLOB_HEX) -def test_exists_prefix(proxy): +def test_exists_prefix(proxy: ProxyBackend) -> None: a_hex_prefix = BLOB_HEX[:4] assert BLOB_HEX == proxy.exists_prefix(a_hex_prefix) @@ -145,12 +147,12 @@ def test_exists_prefix(proxy): @pytest.fixture -def repo(barerepo): +def repo(barerepo: Repository) -> Generator[Repository, None, None]: odb = pygit2.Odb() path = Path(barerepo.path) / 'objects' - backend = pygit2.OdbBackendPack(path) - backend = ProxyBackend(backend) + backend_org = pygit2.OdbBackendPack(path) + backend = ProxyBackend(backend_org) odb.add_backend(backend, 1) repo = pygit2.Repository() @@ -158,9 +160,9 @@ def repo(barerepo): yield repo -def test_repo_read(repo): +def test_repo_read(repo: Repository) -> None: with pytest.raises(TypeError): - repo[123] + repo[123] # type: ignore utils.assertRaisesWithArg(KeyError, '1' * 40, repo.__getitem__, '1' * 40) From 197cf81c643a2280ca3880583399f1693ce7f82e Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Wed, 30 Jul 2025 20:20:25 +0200 Subject: [PATCH 042/225] add types to test/test_oid.py --- test/test_oid.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/test/test_oid.py b/test/test_oid.py index 41170226e..d57432bff 100644 --- a/test/test_oid.py +++ b/test/test_oid.py @@ -36,42 +36,42 @@ RAW = unhexlify(HEX.encode('ascii')) -def test_raw(): +def test_raw() -> None: oid = Oid(raw=RAW) assert oid.raw == RAW assert oid == HEX -def test_hex(): +def test_hex() -> None: oid = Oid(hex=HEX) assert oid.raw == RAW assert oid == HEX -def test_hex_bytes(): +def test_hex_bytes() -> None: hex = bytes(HEX, 'ascii') with pytest.raises(TypeError): - Oid(hex=hex) + Oid(hex=hex) # type: ignore -def test_none(): +def test_none() -> None: with pytest.raises(ValueError): Oid() -def test_both(): +def test_both() -> None: with pytest.raises(ValueError): Oid(raw=RAW, hex=HEX) -def test_long(): +def test_long() -> None: with pytest.raises(ValueError): Oid(raw=RAW + b'a') with pytest.raises(ValueError): Oid(hex=HEX + 'a') -def test_cmp(): +def test_cmp() -> None: oid1 = Oid(raw=RAW) # Equal @@ -90,7 +90,7 @@ def test_cmp(): assert not oid1 >= oid2 -def test_hash(): +def test_hash() -> None: s = set() s.add(Oid(raw=RAW)) s.add(Oid(hex=HEX)) @@ -101,7 +101,7 @@ def test_hash(): assert len(s) == 3 -def test_bool(): +def test_bool() -> None: assert Oid(raw=RAW) assert Oid(hex=HEX) assert not Oid(raw=b'') From 551af8d33cde4fab7ba6f30250079f3ef2c47e27 Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Wed, 30 Jul 2025 22:01:16 +0200 Subject: [PATCH 043/225] improve types according to test/test_options.py --- pygit2/_pygit2.pyi | 75 +++++++++++++++++++++++++++++++++++++++++++- pygit2/settings.py | 68 ++++++++++++++++++++++++--------------- test/test_options.py | 40 +++++++++++------------ 3 files changed, 136 insertions(+), 47 deletions(-) diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index 4be51b3ea..82819edd5 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -36,6 +36,7 @@ from .enums import ( BlobFilter, BranchType, CheckoutStrategy, + ConfigLevel, DeltaStatus, DescribeStrategy, DiffFind, @@ -1078,7 +1079,79 @@ def discover_repository( def hash(data: bytes) -> Oid: ... def hashfile(path: str) -> Oid: ... def init_file_backend(path: str, flags: int = 0) -> object: ... -def option(opt: Option, *args) -> None: ... +@overload +def option( + opt: Literal[ + Option.GET_MWINDOW_FILE_LIMIT, + Option.GET_MWINDOW_MAPPED_LIMIT, + Option.GET_MWINDOW_SIZE, + ], +) -> int: ... +@overload +def option( + opt: Literal[ + Option.SET_MWINDOW_FILE_LIMIT, + Option.SET_MWINDOW_MAPPED_LIMIT, + Option.SET_MWINDOW_SIZE, + ], + value: int, +) -> None: ... +@overload +def option(opt: Literal[Option.GET_SEARCH_PATH], level: ConfigLevel) -> str: ... +@overload +def option( + opt: Literal[Option.SET_SEARCH_PATH], level: ConfigLevel, value: str +) -> None: ... +@overload +def option( + opt: Literal[Option.SET_CACHE_OBJECT_LIMIT], object_type: ObjectType, limit: int +) -> None: ... +@overload +def option(opt: Literal[Option.SET_CACHE_MAX_SIZE], max_size: int) -> None: ... +@overload +def option(opt: Literal[Option.GET_CACHED_MEMORY]) -> tuple[int, int]: ... + +# not implemented: +# Option.GET_TEMPLATE_PATH +# Option.SET_TEMPLATE_PATH + +@overload +def option( + opt: Literal[Option.SET_SSL_CERT_LOCATIONS], + file: str | bytes | None, + dir: str | bytes | None, +) -> None: ... + +# not implemented: +# Option.SET_USER_AGENT + +@overload +def option( + opt: Literal[ + Option.ENABLE_CACHING, + Option.ENABLE_STRICT_OBJECT_CREATION, + Option.ENABLE_STRICT_SYMBOLIC_REF_CREATION, + Option.ENABLE_OFS_DELTA, + Option.ENABLE_FSYNC_GITDIR, + Option.ENABLE_STRICT_HASH_VERIFICATION, + Option.ENABLE_UNSAVED_INDEX_SAFETY, + Option.DISABLE_PACK_KEEP_FILE_CHECKS, + Option.SET_OWNER_VALIDATION, + ], + value: bool, +) -> None: ... +@overload +def option(opt: Literal[Option.GET_OWNER_VALIDATION]) -> int: ... + +# not implemented: +# Option.SET_SSL_CIPHERS +# Option.GET_USER_AGENT +# Option.GET_WINDOWS_SHAREMODE +# Option.SET_WINDOWS_SHAREMODE +# Option.SET_ALLOCATOR +# Option.GET_PACK_MAX_OBJECTS +# Option.SET_PACK_MAX_OBJECTS + def reference_is_valid_name(refname: str) -> bool: ... def tree_entry_cmp(a: Object, b: Object) -> int: ... def _cache_enums() -> None: ... diff --git a/pygit2/settings.py b/pygit2/settings.py index 52eca6d94..da63d9798 100644 --- a/pygit2/settings.py +++ b/pygit2/settings.py @@ -27,20 +27,21 @@ Settings mapping. """ -from ssl import get_default_verify_paths +from ssl import DefaultVerifyPaths, get_default_verify_paths +from typing import overload import pygit2.enums from ._pygit2 import option -from .enums import Option +from .enums import ConfigLevel, Option from .errors import GitError class SearchPathList: - def __getitem__(self, key): + def __getitem__(self, key: ConfigLevel) -> str: return option(Option.GET_SEARCH_PATH, key) - def __setitem__(self, key, value): + def __setitem__(self, key: ConfigLevel, value: str) -> None: option(Option.SET_SEARCH_PATH, key, value) @@ -50,12 +51,15 @@ class Settings: __slots__ = '_default_tls_verify_paths', '_ssl_cert_dir', '_ssl_cert_file' _search_path = SearchPathList() + _default_tls_verify_paths: DefaultVerifyPaths | None + _ssl_cert_file: str | bytes | None + _ssl_cert_dir: str | bytes | None - def __init__(self): + def __init__(self) -> None: """Initialize global pygit2 and libgit2 settings.""" self._initialize_tls_certificate_locations() - def _initialize_tls_certificate_locations(self): + def _initialize_tls_certificate_locations(self) -> None: """Set up initial TLS file and directory lookup locations.""" self._default_tls_verify_paths = get_default_verify_paths() try: @@ -72,7 +76,7 @@ def _initialize_tls_certificate_locations(self): self._ssl_cert_dir = None @property - def search_path(self): + def search_path(self) -> SearchPathList: """Configuration file search path. This behaves like an array whose indices correspond to ConfigLevel values. @@ -81,16 +85,16 @@ def search_path(self): return self._search_path @property - def mwindow_size(self): + def mwindow_size(self) -> int: """Get or set the maximum mmap window size""" return option(Option.GET_MWINDOW_SIZE) @mwindow_size.setter - def mwindow_size(self, value): + def mwindow_size(self, value: int) -> None: option(Option.SET_MWINDOW_SIZE, value) @property - def mwindow_mapped_limit(self): + def mwindow_mapped_limit(self) -> int: """ Get or set the maximum memory that will be mapped in total by the library @@ -98,18 +102,18 @@ def mwindow_mapped_limit(self): return option(Option.GET_MWINDOW_MAPPED_LIMIT) @mwindow_mapped_limit.setter - def mwindow_mapped_limit(self, value): + def mwindow_mapped_limit(self, value: int) -> None: option(Option.SET_MWINDOW_MAPPED_LIMIT, value) @property - def cached_memory(self): + def cached_memory(self) -> tuple[int, int]: """ Get the current bytes in cache and the maximum that would be allowed in the cache. """ return option(Option.GET_CACHED_MEMORY) - def enable_caching(self, value=True): + def enable_caching(self, value: bool = True) -> None: """ Enable or disable caching completely. @@ -119,7 +123,7 @@ def enable_caching(self, value=True): """ return option(Option.ENABLE_CACHING, value) - def disable_pack_keep_file_checks(self, value=True): + def disable_pack_keep_file_checks(self, value: bool = True) -> None: """ This will cause .keep file existence checks to be skipped when accessing packfiles, which can help performance with remote @@ -127,7 +131,7 @@ def disable_pack_keep_file_checks(self, value=True): """ return option(Option.DISABLE_PACK_KEEP_FILE_CHECKS, value) - def cache_max_size(self, value): + def cache_max_size(self, value: int) -> None: """ Set the maximum total data size that will be cached in memory across all repositories before libgit2 starts evicting objects @@ -137,7 +141,9 @@ def cache_max_size(self, value): """ return option(Option.SET_CACHE_MAX_SIZE, value) - def cache_object_limit(self, object_type: pygit2.enums.ObjectType, value): + def cache_object_limit( + self, object_type: pygit2.enums.ObjectType, value: int + ) -> None: """ Set the maximum data size for the given type of object to be considered eligible for caching in memory. Setting to value to @@ -148,36 +154,46 @@ def cache_object_limit(self, object_type: pygit2.enums.ObjectType, value): return option(Option.SET_CACHE_OBJECT_LIMIT, object_type, value) @property - def ssl_cert_file(self): + def ssl_cert_file(self) -> str | bytes | None: """TLS certificate file path.""" return self._ssl_cert_file @ssl_cert_file.setter - def ssl_cert_file(self, value): + def ssl_cert_file(self, value: str | bytes) -> None: """Set the TLS cert file path.""" self.set_ssl_cert_locations(value, self._ssl_cert_dir) @ssl_cert_file.deleter - def ssl_cert_file(self): + def ssl_cert_file(self) -> None: """Reset the TLS cert file path.""" - self.ssl_cert_file = self._default_tls_verify_paths.cafile + self.ssl_cert_file = self._default_tls_verify_paths.cafile # type: ignore[union-attr] @property - def ssl_cert_dir(self): + def ssl_cert_dir(self) -> str | bytes | None: """TLS certificates lookup directory path.""" return self._ssl_cert_dir @ssl_cert_dir.setter - def ssl_cert_dir(self, value): + def ssl_cert_dir(self, value: str | bytes) -> None: """Set the TLS certificate lookup folder.""" self.set_ssl_cert_locations(self._ssl_cert_file, value) @ssl_cert_dir.deleter - def ssl_cert_dir(self): + def ssl_cert_dir(self) -> None: """Reset the TLS certificate lookup folder.""" - self.ssl_cert_dir = self._default_tls_verify_paths.capath - - def set_ssl_cert_locations(self, cert_file, cert_dir): + self.ssl_cert_dir = self._default_tls_verify_paths.capath # type: ignore[union-attr] + + @overload + def set_ssl_cert_locations( + self, cert_file: str | bytes | None, cert_dir: str | bytes + ) -> None: ... + @overload + def set_ssl_cert_locations( + self, cert_file: str | bytes, cert_dir: str | bytes | None + ) -> None: ... + def set_ssl_cert_locations( + self, cert_file: str | bytes | None, cert_dir: str | bytes | None + ) -> None: """ Set the SSL certificate-authority locations. diff --git a/test/test_options.py b/test/test_options.py index 5510d7141..790f459f4 100644 --- a/test/test_options.py +++ b/test/test_options.py @@ -28,15 +28,15 @@ from pygit2.enums import ConfigLevel, ObjectType, Option -def __option(getter, setter, value): - old_value = option(getter) - option(setter, value) - assert value == option(getter) +def __option(getter: Option, setter: Option, value: object) -> None: + old_value = option(getter) # type: ignore[call-overload] + option(setter, value) # type: ignore[call-overload] + assert value == option(getter) # type: ignore[call-overload] # Reset to avoid side effects in later tests - option(setter, old_value) + option(setter, old_value) # type: ignore[call-overload] -def __proxy(name, value): +def __proxy(name: str, value: object) -> None: old_value = getattr(pygit2.settings, name) setattr(pygit2.settings, name, value) assert value == getattr(pygit2.settings, name) @@ -44,44 +44,44 @@ def __proxy(name, value): setattr(pygit2.settings, name, old_value) -def test_mwindow_size(): +def test_mwindow_size() -> None: __option(Option.GET_MWINDOW_SIZE, Option.SET_MWINDOW_SIZE, 200 * 1024) -def test_mwindow_size_proxy(): +def test_mwindow_size_proxy() -> None: __proxy('mwindow_size', 300 * 1024) -def test_mwindow_mapped_limit_200(): +def test_mwindow_mapped_limit_200() -> None: __option( Option.GET_MWINDOW_MAPPED_LIMIT, Option.SET_MWINDOW_MAPPED_LIMIT, 200 * 1024 ) -def test_mwindow_mapped_limit_300(): +def test_mwindow_mapped_limit_300() -> None: __proxy('mwindow_mapped_limit', 300 * 1024) -def test_cache_object_limit(): +def test_cache_object_limit() -> None: new_limit = 2 * 1024 option(Option.SET_CACHE_OBJECT_LIMIT, ObjectType.BLOB, new_limit) -def test_cache_object_limit_proxy(): +def test_cache_object_limit_proxy() -> None: new_limit = 4 * 1024 pygit2.settings.cache_object_limit(ObjectType.BLOB, new_limit) -def test_cached_memory(): +def test_cached_memory() -> None: value = option(Option.GET_CACHED_MEMORY) assert value[1] == 256 * 1024**2 -def test_cached_memory_proxy(): +def test_cached_memory_proxy() -> None: assert pygit2.settings.cached_memory[1] == 256 * 1024**2 -def test_enable_caching(): +def test_enable_caching() -> None: pygit2.settings.enable_caching(False) pygit2.settings.enable_caching(True) # Lower level API @@ -89,7 +89,7 @@ def test_enable_caching(): option(Option.ENABLE_CACHING, True) -def test_disable_pack_keep_file_checks(): +def test_disable_pack_keep_file_checks() -> None: pygit2.settings.disable_pack_keep_file_checks(False) pygit2.settings.disable_pack_keep_file_checks(True) # Lower level API @@ -97,14 +97,14 @@ def test_disable_pack_keep_file_checks(): option(Option.DISABLE_PACK_KEEP_FILE_CHECKS, True) -def test_cache_max_size_proxy(): +def test_cache_max_size_proxy() -> None: pygit2.settings.cache_max_size(128 * 1024**2) assert pygit2.settings.cached_memory[1] == 128 * 1024**2 pygit2.settings.cache_max_size(256 * 1024**2) assert pygit2.settings.cached_memory[1] == 256 * 1024**2 -def test_search_path(): +def test_search_path() -> None: paths = [ (ConfigLevel.GLOBAL, '/tmp/global'), (ConfigLevel.XDG, '/tmp/xdg'), @@ -116,7 +116,7 @@ def test_search_path(): assert path == option(Option.GET_SEARCH_PATH, level) -def test_search_path_proxy(): +def test_search_path_proxy() -> None: paths = [ (ConfigLevel.GLOBAL, '/tmp2/global'), (ConfigLevel.XDG, '/tmp2/xdg'), @@ -128,5 +128,5 @@ def test_search_path_proxy(): assert path == pygit2.settings.search_path[level] -def test_owner_validation(): +def test_owner_validation() -> None: __option(Option.GET_OWNER_VALIDATION, Option.SET_OWNER_VALIDATION, 0) From 6a4e860b043d2695fa5924418dd61238127227b5 Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Wed, 30 Jul 2025 22:25:02 +0200 Subject: [PATCH 044/225] improve types according to test/test_packbuilder.py --- pygit2/_libgit2/ffi.pyi | 5 +++++ pygit2/_pygit2.pyi | 9 +++++++++ pygit2/packbuilder.py | 29 +++++++++++++++++------------ test/test_packbuilder.py | 24 +++++++++++++++--------- test/utils.py | 2 +- 5 files changed, 47 insertions(+), 22 deletions(-) diff --git a/pygit2/_libgit2/ffi.pyi b/pygit2/_libgit2/ffi.pyi index a911985ff..4bf2637bc 100644 --- a/pygit2/_libgit2/ffi.pyi +++ b/pygit2/_libgit2/ffi.pyi @@ -192,6 +192,9 @@ class GitRepositoryInitOptionsC: class GitCloneOptionsC: pass +class GitPackbuilderC: + pass + class GitProxyTC: pass @@ -275,6 +278,8 @@ def new(a: Literal['git_object *']) -> GitObjectC: ... @overload def new(a: Literal['git_object **']) -> _Pointer[GitObjectC]: ... @overload +def new(a: Literal['git_packbuilder **']) -> _Pointer[GitPackbuilderC]: ... +@overload def new(a: Literal['git_signature *']) -> GitSignatureC: ... @overload def new(a: Literal['git_signature **']) -> _Pointer[GitSignatureC]: ... diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index 82819edd5..1af401740 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -5,6 +5,7 @@ from pathlib import Path from queue import Queue from threading import Event from typing import ( + Callable, Generic, Iterator, Literal, @@ -58,6 +59,7 @@ from .enums import ( SortMode, ) from .filter import Filter +from .packbuilder import PackBuilder from .remotes import Remote from .repository import BaseRepository from .submodules import SubmoduleCollection @@ -757,6 +759,7 @@ class Repository: def _disown(self, *args, **kwargs) -> None: ... @classmethod def _from_c(cls, ptr: 'GitRepositoryC', owned: bool) -> 'Repository': ... + def __iter__(self) -> Iterator[Oid]: ... def __getitem__(self, key: str | Oid) -> Object: ... def add_worktree(self, name: str, path: str, ref: Reference = ...) -> Worktree: ... def amend_commit( @@ -934,6 +937,12 @@ class Repository: @property def message(self) -> str: ... def notes(self) -> Iterator[Note]: ... + def pack( + self, + path: str | Path | None = None, + pack_delegate: Callable[[PackBuilder], None] | None = None, + n_threads: int | None = None, + ) -> bool: ... def path_is_ignored(self, path: str) -> bool: ... def raw_listall_branches( self, flag: BranchType = BranchType.LOCAL diff --git a/pygit2/packbuilder.py b/pygit2/packbuilder.py index 5551e1fd6..b7325c043 100644 --- a/pygit2/packbuilder.py +++ b/pygit2/packbuilder.py @@ -23,15 +23,18 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. +from os import PathLike # Import from pygit2 +from pygit2 import Oid, Repository + from .errors import check_error from .ffi import C, ffi from .utils import to_bytes class PackBuilder: - def __init__(self, repo): + def __init__(self, repo: Repository) -> None: cpackbuilder = ffi.new('git_packbuilder **') err = C.git_packbuilder_new(cpackbuilder, repo._repo) check_error(err) @@ -41,39 +44,41 @@ def __init__(self, repo): self._cpackbuilder = cpackbuilder @property - def _pointer(self): + def _pointer(self) -> bytes: return bytes(ffi.buffer(self._packbuilder)[:]) - def __del__(self): + def __del__(self) -> None: C.git_packbuilder_free(self._packbuilder) - def __len__(self): + def __len__(self) -> int: return C.git_packbuilder_object_count(self._packbuilder) @staticmethod - def __convert_object_to_oid(oid): + def __convert_object_to_oid(oid: Oid) -> 'ffi.GitOidC': git_oid = ffi.new('git_oid *') ffi.buffer(git_oid)[:] = oid.raw[:] return git_oid - def add(self, oid): + def add(self, oid: Oid) -> None: git_oid = self.__convert_object_to_oid(oid) err = C.git_packbuilder_insert(self._packbuilder, git_oid, ffi.NULL) check_error(err) - def add_recur(self, oid): + def add_recur(self, oid: Oid) -> None: git_oid = self.__convert_object_to_oid(oid) err = C.git_packbuilder_insert_recur(self._packbuilder, git_oid, ffi.NULL) check_error(err) - def set_threads(self, n_threads): + def set_threads(self, n_threads: int) -> int: return C.git_packbuilder_set_threads(self._packbuilder, n_threads) - def write(self, path=None): - path = ffi.NULL if path is None else to_bytes(path) - err = C.git_packbuilder_write(self._packbuilder, path, 0, ffi.NULL, ffi.NULL) + def write(self, path: str | bytes | PathLike[str] | None = None) -> None: + path_bytes = ffi.NULL if path is None else to_bytes(path) + err = C.git_packbuilder_write( + self._packbuilder, path_bytes, 0, ffi.NULL, ffi.NULL + ) check_error(err) @property - def written_objects_count(self): + def written_objects_count(self) -> int: return C.git_packbuilder_written(self._packbuilder) diff --git a/test/test_packbuilder.py b/test/test_packbuilder.py index cea99b395..d6b6a8e14 100644 --- a/test/test_packbuilder.py +++ b/test/test_packbuilder.py @@ -26,20 +26,21 @@ """Tests for Index files.""" from pathlib import Path +from typing import Callable import pygit2 -from pygit2 import PackBuilder +from pygit2 import Oid, PackBuilder, Repository from . import utils -def test_create_packbuilder(testrepo): +def test_create_packbuilder(testrepo: Repository) -> None: # simple test of PackBuilder creation packbuilder = PackBuilder(testrepo) assert len(packbuilder) == 0 -def test_add(testrepo): +def test_add(testrepo: Repository) -> None: # Add a few objects and confirm that the count is correct packbuilder = PackBuilder(testrepo) objects_to_add = [obj for obj in testrepo] @@ -49,9 +50,10 @@ def test_add(testrepo): assert len(packbuilder) == 2 -def test_add_recursively(testrepo): +def test_add_recursively(testrepo: Repository) -> None: # Add the head object and referenced objects recursively and confirm that the count is correct packbuilder = PackBuilder(testrepo) + assert isinstance(testrepo.head.target, Oid) packbuilder.add_recur(testrepo.head.target) # expect a count of 4 made up of the following referenced objects: @@ -63,14 +65,14 @@ def test_add_recursively(testrepo): assert len(packbuilder) == 4 -def test_repo_pack(testrepo, tmp_path): +def test_repo_pack(testrepo: Repository, tmp_path: Path) -> None: # pack the repo with the default strategy confirm_same_repo_after_packing(testrepo, tmp_path, None) -def test_pack_with_delegate(testrepo, tmp_path): +def test_pack_with_delegate(testrepo: Repository, tmp_path: Path) -> None: # loop through all branches and add each commit to the packbuilder - def pack_delegate(pb): + def pack_delegate(pb: PackBuilder) -> None: for branch in pb._repo.branches: br = pb._repo.branches.get(branch) for commit in br.log(): @@ -79,7 +81,7 @@ def pack_delegate(pb): confirm_same_repo_after_packing(testrepo, tmp_path, pack_delegate) -def setup_second_repo(tmp_path): +def setup_second_repo(tmp_path: Path) -> Repository: # helper method to set up a second repo for comparison tmp_path_2 = tmp_path / 'test_repo2' with utils.TemporaryRepository('testrepo.zip', tmp_path_2) as path: @@ -87,7 +89,11 @@ def setup_second_repo(tmp_path): return testrepo -def confirm_same_repo_after_packing(testrepo, tmp_path, pack_delegate): +def confirm_same_repo_after_packing( + testrepo: Repository, + tmp_path: Path, + pack_delegate: Callable[[PackBuilder], None] | None, +) -> None: # Helper method to confirm the contents of two repos before and after packing pack_repo = setup_second_repo(tmp_path) pack_repo_path = Path(pack_repo.path) diff --git a/test/utils.py b/test/utils.py index f4cadbc85..f4490ab40 100644 --- a/test/utils.py +++ b/test/utils.py @@ -89,7 +89,7 @@ def force_rm_handle(remove_path, path, excinfo): remove_path(path) -def rmtree(path): +def rmtree(path: str | Path) -> None: """In Windows a read-only file cannot be removed, and shutil.rmtree fails. So we implement our own version of rmtree to address this issue. """ From e7f0d4b1eab35dd87d28822d0f8c162a8497bddb Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Wed, 30 Jul 2025 22:29:19 +0200 Subject: [PATCH 045/225] add types to test/test_patch_encoding.py --- test/test_patch_encoding.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/test/test_patch_encoding.py b/test/test_patch_encoding.py index 30072cf4d..4a151a70d 100644 --- a/test/test_patch_encoding.py +++ b/test/test_patch_encoding.py @@ -24,6 +24,7 @@ # Boston, MA 02110-1301, USA. import pygit2 +from pygit2 import Blob, Repository expected_diff = b"""diff --git a/iso-8859-1.txt b/iso-8859-1.txt index e84e339..201e0c9 100644 @@ -35,7 +36,7 @@ """ -def test_patch_from_non_utf8(): +def test_patch_from_non_utf8() -> None: # blobs encoded in ISO-8859-1 old_content = b'Kristian H\xf8gsberg\n' new_content = old_content + b'foo\n' @@ -54,10 +55,14 @@ def test_patch_from_non_utf8(): assert patch.text.encode('utf-8') != expected_diff -def test_patch_create_from_blobs(encodingrepo): +def test_patch_create_from_blobs(encodingrepo: Repository) -> None: + old_content = encodingrepo['e84e339ac7fcc823106efa65a6972d7a20016c85'] + new_content = encodingrepo['201e0c908e3d9f526659df3e556c3d06384ef0df'] + assert isinstance(old_content, Blob) + assert isinstance(new_content, Blob) patch = pygit2.Patch.create_from( - encodingrepo['e84e339ac7fcc823106efa65a6972d7a20016c85'], - encodingrepo['201e0c908e3d9f526659df3e556c3d06384ef0df'], + old_content, + new_content, old_as_path='iso-8859-1.txt', new_as_path='iso-8859-1.txt', ) From ab2a5fe47eeb2403b5998e280c99377173768a7a Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Wed, 30 Jul 2025 22:33:15 +0200 Subject: [PATCH 046/225] add types to test/test_patch.py --- test/test_patch.py | 51 +++++++++++++++++++++++++++++++--------------- 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/test/test_patch.py b/test/test_patch.py index b6b7c6308..1c27986e1 100644 --- a/test/test_patch.py +++ b/test/test_patch.py @@ -26,6 +26,7 @@ import pytest import pygit2 +from pygit2 import Blob, Repository BLOB_OLD_SHA = 'a520c24d85fbfc815d385957eed41406ca5a860b' BLOB_NEW_SHA = '3b18e512dba79e4c8300dd08aeb37f8e728b8dad' @@ -91,9 +92,11 @@ def test_patch_create_from_buffers(): assert patch.text == BLOB_PATCH -def test_patch_create_from_blobs(testrepo): +def test_patch_create_from_blobs(testrepo: Repository) -> None: old_blob = testrepo[BLOB_OLD_SHA] new_blob = testrepo[BLOB_NEW_SHA] + assert isinstance(old_blob, Blob) + assert isinstance(new_blob, Blob) patch = pygit2.Patch.create_from( old_blob, @@ -105,8 +108,9 @@ def test_patch_create_from_blobs(testrepo): assert patch.text == BLOB_PATCH2 -def test_patch_create_from_blob_buffer(testrepo): +def test_patch_create_from_blob_buffer(testrepo: Repository) -> None: old_blob = testrepo[BLOB_OLD_SHA] + assert isinstance(old_blob, Blob) patch = pygit2.Patch.create_from( old_blob, BLOB_NEW_CONTENT, @@ -117,7 +121,7 @@ def test_patch_create_from_blob_buffer(testrepo): assert patch.text == BLOB_PATCH -def test_patch_create_from_blob_buffer_add(testrepo): +def test_patch_create_from_blob_buffer_add(testrepo: Repository) -> None: patch = pygit2.Patch.create_from( None, BLOB_NEW_CONTENT, @@ -128,8 +132,9 @@ def test_patch_create_from_blob_buffer_add(testrepo): assert patch.text == BLOB_PATCH_ADDED -def test_patch_create_from_blob_buffer_delete(testrepo): +def test_patch_create_from_blob_buffer_delete(testrepo: Repository) -> None: old_blob = testrepo[BLOB_OLD_SHA] + assert isinstance(old_blob, Blob) patch = pygit2.Patch.create_from( old_blob, @@ -141,19 +146,21 @@ def test_patch_create_from_blob_buffer_delete(testrepo): assert patch.text == BLOB_PATCH_DELETED -def test_patch_create_from_bad_old_type_arg(testrepo): +def test_patch_create_from_bad_old_type_arg(testrepo: Repository) -> None: with pytest.raises(TypeError): - pygit2.Patch.create_from(testrepo, BLOB_NEW_CONTENT) + pygit2.Patch.create_from(testrepo, BLOB_NEW_CONTENT) # type: ignore -def test_patch_create_from_bad_new_type_arg(testrepo): +def test_patch_create_from_bad_new_type_arg(testrepo: Repository) -> None: with pytest.raises(TypeError): - pygit2.Patch.create_from(None, testrepo) + pygit2.Patch.create_from(None, testrepo) # type: ignore -def test_context_lines(testrepo): +def test_context_lines(testrepo: Repository) -> None: old_blob = testrepo[BLOB_OLD_SHA] new_blob = testrepo[BLOB_NEW_SHA] + assert isinstance(old_blob, Blob) + assert isinstance(new_blob, Blob) patch = pygit2.Patch.create_from( old_blob, @@ -162,6 +169,7 @@ def test_context_lines(testrepo): new_as_path=BLOB_NEW_PATH, ) + assert patch.text is not None context_count = len( [line for line in patch.text.splitlines() if line.startswith(' ')] ) @@ -169,9 +177,11 @@ def test_context_lines(testrepo): assert context_count != 0 -def test_no_context_lines(testrepo): +def test_no_context_lines(testrepo: Repository) -> None: old_blob = testrepo[BLOB_OLD_SHA] new_blob = testrepo[BLOB_NEW_SHA] + assert isinstance(old_blob, Blob) + assert isinstance(new_blob, Blob) patch = pygit2.Patch.create_from( old_blob, @@ -181,6 +191,7 @@ def test_no_context_lines(testrepo): context_lines=0, ) + assert patch.text is not None context_count = len( [line for line in patch.text.splitlines() if line.startswith(' ')] ) @@ -188,9 +199,11 @@ def test_no_context_lines(testrepo): assert context_count == 0 -def test_patch_create_blob_blobs(testrepo): +def test_patch_create_blob_blobs(testrepo: Repository) -> None: old_blob = testrepo[testrepo.create_blob(BLOB_OLD_CONTENT)] new_blob = testrepo[testrepo.create_blob(BLOB_NEW_CONTENT)] + assert isinstance(old_blob, Blob) + assert isinstance(new_blob, Blob) patch = pygit2.Patch.create_from( old_blob, @@ -202,8 +215,9 @@ def test_patch_create_blob_blobs(testrepo): assert patch.text == BLOB_PATCH -def test_patch_create_blob_buffer(testrepo): +def test_patch_create_blob_buffer(testrepo: Repository) -> None: blob = testrepo[testrepo.create_blob(BLOB_OLD_CONTENT)] + assert isinstance(blob, Blob) patch = pygit2.Patch.create_from( blob, BLOB_NEW_CONTENT, @@ -214,8 +228,9 @@ def test_patch_create_blob_buffer(testrepo): assert patch.text == BLOB_PATCH -def test_patch_create_blob_delete(testrepo): +def test_patch_create_blob_delete(testrepo: Repository) -> None: blob = testrepo[testrepo.create_blob(BLOB_OLD_CONTENT)] + assert isinstance(blob, Blob) patch = pygit2.Patch.create_from( blob, None, @@ -226,8 +241,9 @@ def test_patch_create_blob_delete(testrepo): assert patch.text == BLOB_PATCH_DELETED -def test_patch_create_blob_add(testrepo): +def test_patch_create_blob_add(testrepo: Repository) -> None: blob = testrepo[testrepo.create_blob(BLOB_NEW_CONTENT)] + assert isinstance(blob, Blob) patch = pygit2.Patch.create_from( None, blob, @@ -238,8 +254,9 @@ def test_patch_create_blob_add(testrepo): assert patch.text == BLOB_PATCH_ADDED -def test_patch_delete_blob(testrepo): +def test_patch_delete_blob(testrepo: Repository) -> None: blob = testrepo[BLOB_OLD_SHA] + assert isinstance(blob, Blob) patch = pygit2.Patch.create_from( blob, None, @@ -253,12 +270,14 @@ def test_patch_delete_blob(testrepo): assert patch.text == BLOB_PATCH_DELETED -def test_patch_multi_blob(testrepo): +def test_patch_multi_blob(testrepo: Repository) -> None: blob = testrepo[BLOB_OLD_SHA] + assert isinstance(blob, Blob) patch = pygit2.Patch.create_from(blob, None) patch_text = patch.text blob = testrepo[BLOB_OLD_SHA] + assert isinstance(blob, Blob) patch2 = pygit2.Patch.create_from(blob, None) patch_text2 = patch.text From f8d9a05bc8e2829ddec72cc0a246e37bffe94ac3 Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Thu, 31 Jul 2025 17:06:12 +0200 Subject: [PATCH 047/225] improve types according to test/test_refdb_backend.py also renamed testrepo to self in ProxyRefdbBackend class --- pygit2/_pygit2.pyi | 10 ++++-- test/test_refdb_backend.py | 73 ++++++++++++++++++++++---------------- 2 files changed, 50 insertions(+), 33 deletions(-) diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index 1af401740..28aebbb38 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -643,7 +643,9 @@ class Refdb: class RefdbBackend: def __init__(self, *args, **kwargs) -> None: ... def compress(self) -> None: ... - def delete(self, ref_name: str, old_id: _OidArg, old_target: str) -> None: ... + def delete( + self, ref_name: str, old_id: _OidArg, old_target: str | None + ) -> None: ... def ensure_log(self, ref_name: str) -> bool: ... def exists(self, refname: str) -> bool: ... def has_log(self, ref_name: str) -> bool: ... @@ -657,9 +659,10 @@ class RefdbBackend: force: bool, who: Signature, message: str, - old: _OidArg, - old_target: str, + old: None | _OidArg, + old_target: None | str, ) -> None: ... + def __iter__(self) -> Iterator[Reference]: ... class RefdbFsBackend(RefdbBackend): def __init__(self, *args, **kwargs) -> None: ... @@ -738,6 +741,7 @@ class Branches: class Repository: _pointer: GitRepositoryC _repo: GitRepositoryC + backend: RefdbBackend default_signature: Signature head: Reference head_is_detached: bool diff --git a/test/test_refdb_backend.py b/test/test_refdb_backend.py index cec430c28..903b93889 100644 --- a/test/test_refdb_backend.py +++ b/test/test_refdb_backend.py @@ -26,10 +26,12 @@ """Tests for Refdb objects.""" from pathlib import Path +from typing import Generator, Iterator import pytest import pygit2 +from pygit2 import Commit, Oid, Reference, Repository, Signature # Note: the refdb abstraction from libgit2 is meant to provide information @@ -38,77 +40,88 @@ # incomplete, to avoid hitting the semi-valid states that refdbs produce by # design. class ProxyRefdbBackend(pygit2.RefdbBackend): - def __init__(testrepo, source): - testrepo.source = source + def __init__(self, source: pygit2.RefdbBackend) -> None: + self.source = source - def exists(testrepo, ref): - return testrepo.source.exists(ref) + def exists(self, ref: str) -> bool: + return self.source.exists(ref) - def lookup(testrepo, ref): - return testrepo.source.lookup(ref) + def lookup(self, ref: str) -> Reference: + return self.source.lookup(ref) - def write(testrepo, ref, force, who, message, old, old_target): - return testrepo.source.write(ref, force, who, message, old, old_target) + def write( + self, + ref: Reference, + force: bool, + who: Signature, + message: str, + old: None | str | Oid, + old_target: None | str, + ) -> None: + return self.source.write(ref, force, who, message, old, old_target) - def rename(testrepo, old_name, new_name, force, who, message): - return testrepo.source.rename(old_name, new_name, force, who, message) + def rename( + self, old_name: str, new_name: str, force: bool, who: Signature, message: str + ) -> Reference: + return self.source.rename(old_name, new_name, force, who, message) - def delete(testrepo, ref_name, old_id, old_target): - return testrepo.source.delete(ref_name, old_id, old_target) + def delete(self, ref_name: str, old_id: Oid | str, old_target: str | None) -> None: + return self.source.delete(ref_name, old_id, old_target) - def compress(testrepo): - return testrepo.source.compress() + def compress(self) -> None: + return self.source.compress() - def has_log(testrepo, ref_name): - return testrepo.source.has_log(ref_name) + def has_log(self, ref_name: str) -> bool: + return self.source.has_log(ref_name) - def ensure_log(testrepo, ref_name): - return testrepo.source.ensure_log(ref_name) + def ensure_log(self, ref_name: str) -> bool: + return self.source.ensure_log(ref_name) - def __iter__(testrepo): - return iter(testrepo.source) + def __iter__(self) -> Iterator[Reference]: + return iter(self.source) @pytest.fixture -def repo(testrepo): +def repo(testrepo: Repository) -> Generator[Repository, None, None]: testrepo.backend = ProxyRefdbBackend(pygit2.RefdbFsBackend(testrepo)) yield testrepo -def test_exists(repo): +def test_exists(repo: Repository) -> None: assert not repo.backend.exists('refs/heads/does-not-exist') assert repo.backend.exists('refs/heads/master') -def test_lookup(repo): +def test_lookup(repo: Repository) -> None: assert repo.backend.lookup('refs/heads/does-not-exist') is None assert repo.backend.lookup('refs/heads/master').name == 'refs/heads/master' -def test_write(repo): +def test_write(repo: Repository) -> None: master = repo.backend.lookup('refs/heads/master') - commit = repo.get(master.target) + commit = repo[master.target] ref = pygit2.Reference('refs/heads/test-write', master.target, None) repo.backend.write(ref, False, commit.author, 'Create test-write', None, None) assert repo.backend.lookup('refs/heads/test-write').target == master.target -def test_rename(repo): +def test_rename(repo: Repository) -> None: old_ref = repo.backend.lookup('refs/heads/i18n') target = repo.get(old_ref.target) + assert isinstance(target, Commit) repo.backend.rename( 'refs/heads/i18n', 'refs/heads/intl', False, target.committer, target.message ) assert repo.backend.lookup('refs/heads/intl').target == target.id -def test_delete(repo): +def test_delete(repo: Repository) -> None: old = repo.backend.lookup('refs/heads/i18n') repo.backend.delete('refs/heads/i18n', old.target, None) assert not repo.backend.lookup('refs/heads/i18n') -def test_compress(repo): +def test_compress(repo: Repository) -> None: repo = repo packed_refs_file = Path(repo.path) / 'packed-refs' assert not packed_refs_file.exists() @@ -116,12 +129,12 @@ def test_compress(repo): assert packed_refs_file.exists() -def test_has_log(repo): +def test_has_log(repo: Repository) -> None: assert repo.backend.has_log('refs/heads/master') assert not repo.backend.has_log('refs/heads/does-not-exist') -def test_ensure_log(repo): +def test_ensure_log(repo: Repository) -> None: assert not repo.backend.has_log('refs/heads/new-log') repo.backend.ensure_log('refs/heads/new-log') assert repo.backend.has_log('refs/heads/new-log') From edcccfc9c0ec32748e11fbf93f5e12009ba680ef Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Thu, 31 Jul 2025 17:26:45 +0200 Subject: [PATCH 048/225] improve types according to test/test_refs.py --- pygit2/_pygit2.pyi | 8 ++++- test/test_refs.py | 82 +++++++++++++++++++++++++--------------------- 2 files changed, 51 insertions(+), 39 deletions(-) diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index 28aebbb38..43decedb3 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -845,7 +845,11 @@ class Repository: force: bool = False, ) -> Oid: ... def create_reference( - self, name: str, target: _OidArg, force: bool = False + self, + name: str, + target: _OidArg, + force: bool = False, + message: str | None = None, ) -> Reference: ... def create_reference_direct( self, name: str, target: _OidArg, force: bool, message: Optional[str] = None @@ -893,6 +897,7 @@ class Repository: def listall_branches(self, flag: BranchType = BranchType.LOCAL) -> list[str]: ... def listall_mergeheads(self) -> list[Oid]: ... def listall_references(self) -> list[str]: ... + def listall_reference_objects(self) -> list[Reference]: ... def listall_stashes(self) -> list[Stash]: ... def listall_submodules(self) -> list[str]: ... def lookup_branch( @@ -962,6 +967,7 @@ class Repository: references_return_type: ReferenceFilter = ReferenceFilter.ALL, ) -> Reference: ... def reset(self, oid: _OidArg, reset_type: ResetMode) -> None: ... + def resolve_refish(self, refresh: str) -> tuple[Commit, Reference]: ... def revparse(self, revspec: str) -> RevSpec: ... def revparse_ext(self, revision: str) -> tuple[Object, Reference]: ... def revparse_single(self, revision: str) -> Object: ... diff --git a/test/test_refs.py b/test/test_refs.py index 4ffdca8a8..e1eced7a8 100644 --- a/test/test_refs.py +++ b/test/test_refs.py @@ -34,17 +34,18 @@ Commit, GitError, InvalidSpecError, + Oid, Repository, Signature, Tree, reference_is_valid_name, ) -from pygit2.enums import ReferenceType +from pygit2.enums import ReferenceFilter, ReferenceType LAST_COMMIT = '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98' -def test_refs_list_objects(testrepo): +def test_refs_list_objects(testrepo: Repository) -> None: refs = [(ref.name, ref.target) for ref in testrepo.references.objects] assert sorted(refs) == [ ('refs/heads/i18n', '5470a671a80ac3789f1a6a8cefbcf43ce7af0563'), @@ -292,7 +293,7 @@ def test_refs_compress(testrepo: Repository) -> None: # -def test_list_all_reference_objects(testrepo): +def test_list_all_reference_objects(testrepo: Repository) -> None: repo = testrepo refs = [(ref.name, ref.target) for ref in repo.listall_reference_objects()] @@ -302,7 +303,7 @@ def test_list_all_reference_objects(testrepo): ] -def test_list_all_references(testrepo): +def test_list_all_references(testrepo: Repository) -> None: repo = testrepo # Without argument @@ -326,14 +327,14 @@ def test_list_all_references(testrepo): ] -def test_references_iterator_init(testrepo): +def test_references_iterator_init(testrepo: Repository) -> None: repo = testrepo iter = repo.references_iterator_init() assert iter.__class__.__name__ == 'RefsIterator' -def test_references_iterator_next(testrepo): +def test_references_iterator_next(testrepo: Repository) -> None: repo = testrepo repo.create_reference( 'refs/tags/version1', '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98' @@ -359,7 +360,9 @@ def test_references_iterator_next(testrepo): iter_branches = repo.references_iterator_init() all_branches = [] for _ in range(4): - curr_ref = repo.references_iterator_next(iter_branches, 1) + curr_ref = repo.references_iterator_next( + iter_branches, ReferenceFilter.BRANCHES + ) if curr_ref: all_branches.append((curr_ref.name, curr_ref.target)) @@ -371,7 +374,7 @@ def test_references_iterator_next(testrepo): iter_tags = repo.references_iterator_init() all_tags = [] for _ in range(4): - curr_ref = repo.references_iterator_next(iter_tags, 2) + curr_ref = repo.references_iterator_next(iter_tags, ReferenceFilter.TAGS) if curr_ref: all_tags.append((curr_ref.name, curr_ref.target)) @@ -381,7 +384,7 @@ def test_references_iterator_next(testrepo): ] -def test_references_iterator_next_python(testrepo): +def test_references_iterator_next_python(testrepo: Repository) -> None: repo = testrepo repo.create_reference( 'refs/tags/version1', '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98' @@ -398,41 +401,43 @@ def test_references_iterator_next_python(testrepo): ('refs/tags/version2', '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98'), ] - branches = [(x.name, x.target) for x in repo.references.iterator(1)] + branches = [ + (x.name, x.target) for x in repo.references.iterator(ReferenceFilter.BRANCHES) + ] assert sorted(branches) == [ ('refs/heads/i18n', '5470a671a80ac3789f1a6a8cefbcf43ce7af0563'), ('refs/heads/master', '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98'), ] - tags = [(x.name, x.target) for x in repo.references.iterator(2)] + tags = [(x.name, x.target) for x in repo.references.iterator(ReferenceFilter.TAGS)] assert sorted(tags) == [ ('refs/tags/version1', '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98'), ('refs/tags/version2', '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98'), ] -def test_references_iterator_invalid_filter(testrepo): +def test_references_iterator_invalid_filter(testrepo: Repository) -> None: repo = testrepo iter_all = repo.references_iterator_init() all_refs = [] for _ in range(4): - curr_ref = repo.references_iterator_next(iter_all, 5) + curr_ref = repo.references_iterator_next(iter_all, 5) # type: ignore if curr_ref: all_refs.append((curr_ref.name, curr_ref.target)) assert all_refs == [] -def test_references_iterator_invalid_filter_python(testrepo): +def test_references_iterator_invalid_filter_python(testrepo: Repository) -> None: repo = testrepo refs = [] with pytest.raises(ValueError): - for ref in repo.references.iterator(5): + for ref in repo.references.iterator(5): # type: ignore refs.append((ref.name, ref.target)) -def test_lookup_reference(testrepo): +def test_lookup_reference(testrepo: Repository) -> None: repo = testrepo # Raise KeyError ? @@ -444,7 +449,7 @@ def test_lookup_reference(testrepo): assert reference.name == 'refs/heads/master' -def test_lookup_reference_dwim(testrepo): +def test_lookup_reference_dwim(testrepo: Repository) -> None: repo = testrepo # remote ref @@ -474,7 +479,7 @@ def test_lookup_reference_dwim(testrepo): assert reference.name == 'refs/tags/version1' -def test_resolve_refish(testrepo): +def test_resolve_refish(testrepo: Repository) -> None: repo = testrepo # remote ref @@ -516,37 +521,37 @@ def test_resolve_refish(testrepo): assert commit.id == '5ebeeebb320790caf276b9fc8b24546d63316533' -def test_reference_get_sha(testrepo): +def test_reference_get_sha(testrepo: Repository) -> None: reference = testrepo.lookup_reference('refs/heads/master') assert reference.target == LAST_COMMIT -def test_reference_set_sha(testrepo): +def test_reference_set_sha(testrepo: Repository) -> None: NEW_COMMIT = '5ebeeebb320790caf276b9fc8b24546d63316533' reference = testrepo.lookup_reference('refs/heads/master') reference.set_target(NEW_COMMIT) assert reference.target == NEW_COMMIT -def test_reference_set_sha_prefix(testrepo): +def test_reference_set_sha_prefix(testrepo: Repository) -> None: NEW_COMMIT = '5ebeeebb320790caf276b9fc8b24546d63316533' reference = testrepo.lookup_reference('refs/heads/master') reference.set_target(NEW_COMMIT[0:6]) assert reference.target == NEW_COMMIT -def test_reference_get_type(testrepo): +def test_reference_get_type(testrepo: Repository) -> None: reference = testrepo.lookup_reference('refs/heads/master') assert reference.type == ReferenceType.DIRECT -def test_get_target(testrepo): +def test_get_target(testrepo: Repository) -> None: reference = testrepo.lookup_reference('HEAD') assert reference.target == 'refs/heads/master' assert reference.raw_target == b'refs/heads/master' -def test_set_target(testrepo): +def test_set_target(testrepo: Repository) -> None: reference = testrepo.lookup_reference('HEAD') assert reference.target == 'refs/heads/master' assert reference.raw_target == b'refs/heads/master' @@ -555,14 +560,14 @@ def test_set_target(testrepo): assert reference.raw_target == b'refs/heads/i18n' -def test_get_shorthand(testrepo): +def test_get_shorthand(testrepo: Repository) -> None: reference = testrepo.lookup_reference('refs/heads/master') assert reference.shorthand == 'master' reference = testrepo.create_reference('refs/remotes/origin/master', LAST_COMMIT) assert reference.shorthand == 'origin/master' -def test_set_target_with_message(testrepo): +def test_set_target_with_message(testrepo: Repository) -> None: reference = testrepo.lookup_reference('HEAD') assert reference.target == 'refs/heads/master' assert reference.raw_target == b'refs/heads/master' @@ -577,7 +582,7 @@ def test_set_target_with_message(testrepo): assert first.committer == sig -def test_delete(testrepo): +def test_delete(testrepo: Repository) -> None: repo = testrepo # We add a tag as a new reference that points to "origin/master" @@ -605,7 +610,7 @@ def test_delete(testrepo): reference.rename('refs/tags/version2') -def test_rename(testrepo): +def test_rename(testrepo: Repository) -> None: # We add a tag as a new reference that points to "origin/master" reference = testrepo.create_reference('refs/tags/version1', LAST_COMMIT) assert reference.name == 'refs/tags/version1' @@ -613,7 +618,7 @@ def test_rename(testrepo): assert reference.name == 'refs/tags/version2' -# def test_reload(testrepo): +# def test_reload(testrepo: Repository) -> None: # name = 'refs/tags/version1' # repo = testrepo @@ -625,7 +630,7 @@ def test_rename(testrepo): # with pytest.raises(GitError): getattr(ref2, 'name') -def test_reference_resolve(testrepo): +def test_reference_resolve(testrepo: Repository) -> None: reference = testrepo.lookup_reference('HEAD') assert reference.type == ReferenceType.SYMBOLIC reference = reference.resolve() @@ -633,13 +638,13 @@ def test_reference_resolve(testrepo): assert reference.target == LAST_COMMIT -def test_reference_resolve_identity(testrepo): +def test_reference_resolve_identity(testrepo: Repository) -> None: head = testrepo.lookup_reference('HEAD') ref = head.resolve() assert ref.resolve() is ref -def test_create_reference(testrepo): +def test_create_reference(testrepo: Repository) -> None: # We add a tag as a new reference that points to "origin/master" reference = testrepo.create_reference('refs/tags/version1', LAST_COMMIT) assert 'refs/tags/version1' in testrepo.listall_references() @@ -660,7 +665,7 @@ def test_create_reference(testrepo): assert reference.target == LAST_COMMIT -def test_create_reference_with_message(testrepo): +def test_create_reference_with_message(testrepo: Repository) -> None: sig = Signature('foo', 'bar') testrepo.set_ident('foo', 'bar') msg = 'Hello log' @@ -672,7 +677,7 @@ def test_create_reference_with_message(testrepo): assert first.committer == sig -def test_create_symbolic_reference(testrepo): +def test_create_symbolic_reference(testrepo: Repository) -> None: repo = testrepo # We add a tag as a new symbolic reference that always points to # "refs/heads/master" @@ -693,7 +698,7 @@ def test_create_symbolic_reference(testrepo): assert reference.raw_target == b'refs/heads/master' -def test_create_symbolic_reference_with_message(testrepo): +def test_create_symbolic_reference_with_message(testrepo: Repository) -> None: sig = Signature('foo', 'bar') testrepo.set_ident('foo', 'bar') msg = 'Hello log' @@ -705,7 +710,7 @@ def test_create_symbolic_reference_with_message(testrepo): assert first.committer == sig -def test_create_invalid_reference(testrepo): +def test_create_invalid_reference(testrepo: Repository) -> None: repo = testrepo # try to create a reference with an invalid name @@ -714,14 +719,15 @@ def test_create_invalid_reference(testrepo): assert isinstance(error.value, ValueError) -# def test_packall_references(testrepo): +# def test_packall_references(testrepo: Repository) -> None: # testrepo.packall_references() -def test_peel(testrepo): +def test_peel(testrepo: Repository) -> None: repo = testrepo ref = repo.lookup_reference('refs/heads/master') assert repo[ref.target].id == ref.peel().id + assert isinstance(ref.raw_target, Oid) assert repo[ref.raw_target].id == ref.peel().id commit = ref.peel(Commit) From fb0bfd76187392dc323974981e7fd10687766247 Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Thu, 31 Jul 2025 20:19:54 +0200 Subject: [PATCH 049/225] improve types according to test/test_remote.py --- pygit2/_libgit2/ffi.pyi | 19 +++++++++++++++++ pygit2/remotes.py | 46 ++++++++++++++++++++++++++++++----------- test/test_remote.py | 8 ++++--- 3 files changed, 58 insertions(+), 15 deletions(-) diff --git a/pygit2/_libgit2/ffi.pyi b/pygit2/_libgit2/ffi.pyi index 4bf2637bc..4cb37caaa 100644 --- a/pygit2/_libgit2/ffi.pyi +++ b/pygit2/_libgit2/ffi.pyi @@ -35,6 +35,9 @@ NULL: NULL_TYPE = ... char = NewType('char', object) char_pointer = NewType('char_pointer', object) +class size_t: + def __getitem__(self, item: Literal[0]) -> int: ... + class _Pointer(Generic[T]): def __setitem__(self, item: Literal[0], a: T) -> None: ... @overload @@ -42,6 +45,9 @@ class _Pointer(Generic[T]): @overload def __getitem__(self, item: slice[None, None, None]) -> bytes: ... +class _MultiPointer(Generic[T]): + def __getitem__(self, item: int) -> T: ... + class ArrayC(Generic[T]): # incomplete! # def _len(self, ?) -> ?: ... @@ -83,6 +89,13 @@ class GitSubmoduleC: class GitSubmoduleUpdateOptionsC: fetch_opts: GitFetchOptionsC +class GitRemoteHeadC: + local: int + oid: GitOidC + loid: GitOidC + name: char_pointer + symref_target: char_pointer + class UnsignedIntC: def __getitem__(self, item: Literal[0]) -> int: ... @@ -284,6 +297,12 @@ def new(a: Literal['git_signature *']) -> GitSignatureC: ... @overload def new(a: Literal['git_signature **']) -> _Pointer[GitSignatureC]: ... @overload +def new( + a: Literal['git_remote_head ***'], +) -> _Pointer[_MultiPointer[GitRemoteHeadC]]: ... +@overload +def new(a: Literal['size_t *']) -> size_t: ... +@overload def new(a: Literal['git_stash_save_options *']) -> GitStashSaveOptionsC: ... @overload def new(a: Literal['git_strarray *']) -> GitStrrayC: ... diff --git a/pygit2/remotes.py b/pygit2/remotes.py index 195d48226..1a82d1ced 100644 --- a/pygit2/remotes.py +++ b/pygit2/remotes.py @@ -25,11 +25,12 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any - -from . import utils +from typing import TYPE_CHECKING, Any, TypedDict # Import from pygit2 +from pygit2 import RemoteCallbacks + +from . import utils from ._pygit2 import Oid from .callbacks import ( git_fetch_options, @@ -48,6 +49,14 @@ from .repository import BaseRepository +class LsRemotesDict(TypedDict): + local: bool + loid: None | Oid + name: str | None + symref_target: str | None + oid: Oid + + class TransferProgress: """Progress downloading and indexing data during a fetch.""" @@ -180,7 +189,9 @@ def fetch( return TransferProgress(C.git_remote_stats(self._remote)) - def ls_remotes(self, callbacks=None, proxy=None): + def ls_remotes( + self, callbacks: RemoteCallbacks | None = None, proxy: str | None | bool = None + ) -> list[LsRemotesDict]: """ Return a list of dicts that maps to `git_remote_head` from a `ls_remotes` call. @@ -209,13 +220,15 @@ def ls_remotes(self, callbacks=None, proxy=None): else: loid = None - remote = { - 'local': local, - 'loid': loid, - 'name': maybe_string(ref.name), - 'symref_target': maybe_string(ref.symref_target), - 'oid': Oid(raw=bytes(ffi.buffer(ref.oid.id)[:])), - } + remote = LsRemotesDict( + { + 'local': local, + 'loid': loid, + 'name': maybe_string(ref.name), + 'symref_target': maybe_string(ref.symref_target), + 'oid': Oid(raw=bytes(ffi.buffer(ref.oid.id)[:])), + } + ) results.append(remote) @@ -256,7 +269,14 @@ def push_refspecs(self): check_error(err) return strarray_to_strings(specs) - def push(self, specs, callbacks=None, proxy=None, push_options=None, threads=1): + def push( + self, + specs: list[str], + callbacks: RemoteCallbacks | None = None, + proxy: None | bool | str = None, + push_options: None | list[str] = None, + threads: int = 1, + ) -> None: """ Push the given refspec to the remote. Raises ``GitError`` on protocol error or unpack failure. @@ -272,6 +292,8 @@ def push(self, specs, callbacks=None, proxy=None, push_options=None, threads=1): specs : [str] Push refspecs to use. + callbacks : + proxy : None or True or str Proxy configuration. Can be one of: diff --git a/test/test_remote.py b/test/test_remote.py index e0e4318ce..67a6a34a0 100644 --- a/test/test_remote.py +++ b/test/test_remote.py @@ -282,11 +282,13 @@ def test_update_tips(emptyrepo: Repository) -> None: ] class MyCallbacks(pygit2.RemoteCallbacks): - def __init__(self, tips): + tips: list[tuple[str, pygit2.Oid, pygit2.Oid]] + + def __init__(self, tips: list[tuple[str, pygit2.Oid, pygit2.Oid]]) -> None: self.tips = tips self.i = 0 - def update_tips(self, name, old, new): + def update_tips(self, name: str, old: pygit2.Oid, new: pygit2.Oid) -> None: assert self.tips[self.i] == (name, old, new) self.i += 1 @@ -342,7 +344,7 @@ def clone(tmp_path: Path) -> Generator[Repository, None, None]: @pytest.fixture -def remote(origin, clone): +def remote(origin: Repository, clone: Repository) -> Generator[Remote, None, None]: yield clone.remotes.create('origin', origin.path) From cf67fab2ab6bb8129b000e6df864d824fed45764 Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Thu, 31 Jul 2025 20:25:18 +0200 Subject: [PATCH 050/225] improve types according to test/test_remote_prune.py --- pygit2/remotes.py | 2 +- test/test_remote_prune.py | 18 ++++++++++++------ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/pygit2/remotes.py b/pygit2/remotes.py index 1a82d1ced..93f184790 100644 --- a/pygit2/remotes.py +++ b/pygit2/remotes.py @@ -234,7 +234,7 @@ def ls_remotes( return results - def prune(self, callbacks=None): + def prune(self, callbacks: RemoteCallbacks | None = None) -> None: """Perform a prune against this remote.""" with git_remote_callbacks(callbacks) as payload: err = C.git_remote_prune(self._remote, payload.remote_callbacks) diff --git a/test/test_remote_prune.py b/test/test_remote_prune.py index 927d812c9..ea0b84897 100644 --- a/test/test_remote_prune.py +++ b/test/test_remote_prune.py @@ -23,14 +23,20 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. +from pathlib import Path +from typing import Generator + import pytest import pygit2 +from pygit2 import Oid, Repository from pygit2.enums import FetchPrune @pytest.fixture -def clonerepo(testrepo, tmp_path): +def clonerepo( + testrepo: Repository, tmp_path: Path +) -> Generator[Repository, None, None]: cloned_repo_path = tmp_path / 'test_remote_prune' pygit2.clone_repository(testrepo.workdir, cloned_repo_path) @@ -39,26 +45,26 @@ def clonerepo(testrepo, tmp_path): yield clonerepo -def test_fetch_remote_default(clonerepo): +def test_fetch_remote_default(clonerepo: Repository) -> None: clonerepo.remotes[0].fetch() assert 'origin/i18n' in clonerepo.branches -def test_fetch_remote_prune(clonerepo): +def test_fetch_remote_prune(clonerepo: Repository) -> None: clonerepo.remotes[0].fetch(prune=FetchPrune.PRUNE) assert 'origin/i18n' not in clonerepo.branches -def test_fetch_no_prune(clonerepo): +def test_fetch_no_prune(clonerepo: Repository) -> None: clonerepo.remotes[0].fetch(prune=FetchPrune.NO_PRUNE) assert 'origin/i18n' in clonerepo.branches -def test_remote_prune(clonerepo): +def test_remote_prune(clonerepo: Repository) -> None: pruned = [] class MyCallbacks(pygit2.RemoteCallbacks): - def update_tips(self, name, old, new): + def update_tips(self, name: str, old: Oid, new: Oid) -> None: pruned.append(name) callbacks = MyCallbacks() From 876b755ac3e004d0e6a9f25d47b43e4b5ed09022 Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Thu, 31 Jul 2025 20:26:47 +0200 Subject: [PATCH 051/225] add types to test/test_remote_utf8.py --- test/test_remote_utf8.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/test_remote_utf8.py b/test/test_remote_utf8.py index 1307e8979..4ed0bdd28 100644 --- a/test/test_remote_utf8.py +++ b/test/test_remote_utf8.py @@ -23,6 +23,9 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. +from pathlib import Path +from typing import Generator + import pytest import pygit2 @@ -31,11 +34,11 @@ @pytest.fixture -def repo(tmp_path): +def repo(tmp_path: Path) -> Generator[pygit2.Repository, None, None]: with utils.TemporaryRepository('utf8branchrepo.zip', tmp_path) as path: yield pygit2.Repository(path) -def test_fetch(repo): +def test_fetch(repo: pygit2.Repository) -> None: remote = repo.remotes.create('origin', repo.workdir) remote.fetch() From 4dd179d2cebe89661b84192ae36c070cf944e3dd Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Thu, 31 Jul 2025 22:35:57 +0200 Subject: [PATCH 052/225] improve types according to test/test_repository.py --- pygit2/_pygit2.pyi | 60 +++++++++++- pygit2/index.py | 4 +- test/test_index.py | 4 +- test/test_repository.py | 205 ++++++++++++++++++++++++---------------- 4 files changed, 183 insertions(+), 90 deletions(-) diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index 43decedb3..d7ca90b3d 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -17,7 +17,7 @@ from typing import ( overload, ) -from . import Index +from . import Index, IndexEntry from ._libgit2.ffi import ( GitCommitC, GitMergeOptionsC, @@ -28,7 +28,7 @@ from ._libgit2.ffi import ( _Pointer, ) from .blame import Blame -from .callbacks import CheckoutCallbacks +from .callbacks import CheckoutCallbacks, StashApplyCallbacks from .config import Config from .enums import ( ApplyLocation, @@ -59,6 +59,7 @@ from .enums import ( SortMode, ) from .filter import Filter +from .index import MergeFileResult from .packbuilder import PackBuilder from .remotes import Remote from .repository import BaseRepository @@ -765,7 +766,9 @@ class Repository: def _from_c(cls, ptr: 'GitRepositoryC', owned: bool) -> 'Repository': ... def __iter__(self) -> Iterator[Oid]: ... def __getitem__(self, key: str | Oid) -> Object: ... - def add_worktree(self, name: str, path: str, ref: Reference = ...) -> Worktree: ... + def add_worktree( + self, name: str, path: str | Path, ref: Reference = ... + ) -> Worktree: ... def amend_commit( self, commit: Commit | Oid | str, @@ -797,13 +800,14 @@ class Repository: ) -> Blame: ... def checkout( self, - refname: Optional[_OidArg], + refname: _OidArg | None | Reference = None, *, strategy: CheckoutStrategy | None = None, - directory: str | None = None, + directory: str | Path | None = None, paths: list[str] | None = None, callbacks: CheckoutCallbacks | None = None, ) -> None: ... + def ahead_behind(self, local: _OidArg, upstream: _OidArg) -> tuple[int, int]: ... def cherrypick(self, id: _OidArg) -> None: ... def compress_references(self) -> None: ... @property @@ -893,6 +897,12 @@ class Repository: commit: _OidArg | None = None, ) -> bool | None | str: ... def git_object_lookup_prefix(self, oid: _OidArg) -> Object: ... + def hashfile( + self, + path: str, + object_type: ObjectType = ObjectType.BLOB, + as_path: str | None = None, + ) -> Oid: ... def list_worktrees(self) -> list[str]: ... def listall_branches(self, flag: BranchType = BranchType.LOCAL) -> list[str]: ... def listall_mergeheads(self) -> list[Oid]: ... @@ -930,6 +940,13 @@ class Repository: flags: MergeFlag = MergeFlag.FIND_RENAMES, file_flags: MergeFileFlag = MergeFileFlag.DEFAULT, ) -> Index: ... + def merge_file_from_index( + self, + ancestor: IndexEntry | None, + ours: IndexEntry | None, + theirs: IndexEntry | None, + use_deprecated: bool = True, + ) -> str | MergeFileResult | None: ... @staticmethod def _merge_options( favor: int | MergeFavor, flags: int | MergeFlag, file_flags: int | MergeFileFlag @@ -971,12 +988,45 @@ class Repository: def revparse(self, revspec: str) -> RevSpec: ... def revparse_ext(self, revision: str) -> tuple[Object, Reference]: ... def revparse_single(self, revision: str) -> Object: ... + def revert(self, commit: Commit) -> None: ... + def revert_commit( + self, revert_commit: Commit, our_commit: Commit, mainline: int = 0 + ) -> Index: ... def set_ident(self, name: str, email: str) -> None: ... def set_odb(self, odb: Odb) -> None: ... def set_refdb(self, refdb: Refdb) -> None: ... def status( self, untracked_files: str = 'all', ignored: bool = False ) -> dict[str, int]: ... + def stash( + self, + stasher: Signature, + message: str | None = None, + keep_index: bool = False, + include_untracked: bool = False, + include_ignored: bool = False, + keep_all: bool = False, + paths: list[str] | None = None, + ) -> None: ... + def stash_apply( + self, + index: int = 0, + reinstate_index: bool | None = None, + include_untracked: bool | None = None, + message: str | None = None, + strategy: CheckoutStrategy | None = None, + callbacks: StashApplyCallbacks | None = None, + ) -> None: ... + def stash_pop( + self, + index: int = 0, + reinstate_index: bool | None = None, + include_untracked: bool | None = None, + message: str | None = None, + strategy: CheckoutStrategy | None = None, + callbacks: StashApplyCallbacks | None = None, + ) -> None: ... + def stash_drop(self, index: int = 0) -> None: ... def status_file(self, path: str) -> int: ... def state(self) -> RepositoryState: ... def state_cleanup(self) -> None: ... diff --git a/pygit2/index.py b/pygit2/index.py index 1b52b7976..3c9f9876f 100644 --- a/pygit2/index.py +++ b/pygit2/index.py @@ -83,7 +83,7 @@ def __contains__(self, path) -> bool: check_error(err) return True - def __getitem__(self, key): + def __getitem__(self, key: str | int | PathLike[str]) -> 'IndexEntry': centry = ffi.NULL if isinstance(key, str) or hasattr(key, '__fspath__'): centry = C.git_index_get_bypath(self._index, to_bytes(key), 0) @@ -389,7 +389,7 @@ class MergeFileResult: automergeable: bool 'True if the output was automerged, false if the output contains conflict markers' - path: typing.Union[str, None] + path: typing.Union[str, None, PathLike[str]] 'The path that the resultant merge file should use, or None if a filename conflict would occur' mode: FileMode diff --git a/test/test_index.py b/test/test_index.py index 086863b74..6f3f3824b 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -49,8 +49,8 @@ def test_read(testrepo: Repository) -> None: assert len(index) == 2 with pytest.raises(TypeError): - index[()] - utils.assertRaisesWithArg(ValueError, -4, lambda: index[-4]) + index[()] # type: ignore + utils.assertRaisesWithArg(ValueError, -4, lambda: index[-4]) # type: ignore utils.assertRaisesWithArg(KeyError, 'abc', lambda: index['abc']) sha = 'a520c24d85fbfc815d385957eed41406ca5a860b' diff --git a/test/test_repository.py b/test/test_repository.py index 3dd47bbc5..5802ae4b0 100644 --- a/test/test_repository.py +++ b/test/test_repository.py @@ -26,14 +26,19 @@ import shutil import tempfile from pathlib import Path +from typing import Optional import pytest # pygit2 import pygit2 from pygit2 import ( + Blob, + Commit, + DiffFile, IndexEntry, Oid, + Repository, clone_repository, discover_repository, init_repository, @@ -54,31 +59,31 @@ from . import utils -def test_is_empty(testrepo): +def test_is_empty(testrepo: Repository) -> None: assert not testrepo.is_empty -def test_is_bare(testrepo): +def test_is_bare(testrepo: Repository) -> None: assert not testrepo.is_bare -def test_get_path(testrepo_path): +def test_get_path(testrepo_path: tuple[Repository, Path]) -> None: testrepo, path = testrepo_path assert Path(testrepo.path).resolve() == (path / '.git').resolve() -def test_get_workdir(testrepo_path): +def test_get_workdir(testrepo_path: tuple[Repository, Path]) -> None: testrepo, path = testrepo_path assert Path(testrepo.workdir).resolve() == path.resolve() -def test_set_workdir(testrepo): +def test_set_workdir(testrepo: Repository) -> None: directory = tempfile.mkdtemp() testrepo.workdir = directory assert Path(testrepo.workdir).resolve() == Path(directory).resolve() -def test_checkout_ref(testrepo): +def test_checkout_ref(testrepo: Repository) -> None: ref_i18n = testrepo.lookup_reference('refs/heads/i18n') # checkout i18n with conflicts and default strategy should @@ -87,39 +92,48 @@ def test_checkout_ref(testrepo): testrepo.checkout(ref_i18n) # checkout i18n with GIT_CHECKOUT_FORCE - head = testrepo.head - head = testrepo[head.target] + head_object = testrepo.head + head = testrepo[head_object.target] assert 'new' not in head.tree testrepo.checkout(ref_i18n, strategy=CheckoutStrategy.FORCE) - head = testrepo.head - head = testrepo[head.target] + head_object = testrepo.head + head = testrepo[head_object.target] assert head.id == ref_i18n.target assert 'new' in head.tree assert 'bye.txt' not in testrepo.status() -def test_checkout_callbacks(testrepo): +def test_checkout_callbacks(testrepo: Repository) -> None: ref_i18n = testrepo.lookup_reference('refs/heads/i18n') class MyCheckoutCallbacks(pygit2.CheckoutCallbacks): - def __init__(self): + def __init__(self) -> None: super().__init__() - self.conflicting_paths = set() - self.updated_paths = set() + self.conflicting_paths: set[str] = set() + self.updated_paths: set[str] = set() self.completed_steps = -1 self.total_steps = -1 def checkout_notify_flags(self) -> CheckoutNotify: return CheckoutNotify.CONFLICT | CheckoutNotify.UPDATED - def checkout_notify(self, why, path, baseline, target, workdir): + def checkout_notify( + self, + why: CheckoutNotify, + path: str, + baseline: Optional[DiffFile], + target: Optional[DiffFile], + workdir: Optional[DiffFile], + ) -> None: if why == CheckoutNotify.CONFLICT: self.conflicting_paths.add(path) elif why == CheckoutNotify.UPDATED: self.updated_paths.add(path) - def checkout_progress(self, path: str, completed_steps: int, total_steps: int): + def checkout_progress( + self, path: str, completed_steps: int, total_steps: int + ) -> None: self.completed_steps = completed_steps self.total_steps = total_steps @@ -132,8 +146,8 @@ def checkout_progress(self, path: str, completed_steps: int, total_steps: int): assert -1 == callbacks.completed_steps # shouldn't have done anything # checkout i18n with GIT_CHECKOUT_FORCE - head = testrepo.head - head = testrepo[head.target] + head_object = testrepo.head + head = testrepo[head_object.target] assert 'new' not in head.tree callbacks = MyCheckoutCallbacks() testrepo.checkout(ref_i18n, strategy=CheckoutStrategy.FORCE, callbacks=callbacks) @@ -144,29 +158,38 @@ def checkout_progress(self, path: str, completed_steps: int, total_steps: int): assert callbacks.completed_steps == callbacks.total_steps -def test_checkout_aborted_from_callbacks(testrepo): +def test_checkout_aborted_from_callbacks(testrepo: Repository) -> None: ref_i18n = testrepo.lookup_reference('refs/heads/i18n') - def read_bye_txt(): - return testrepo[testrepo.create_blob_fromworkdir('bye.txt')].data + def read_bye_txt() -> bytes: + blob = testrepo[testrepo.create_blob_fromworkdir('bye.txt')] + assert isinstance(blob, Blob) + return blob.data s = testrepo.status() assert s == {'bye.txt': FileStatus.WT_NEW} class MyCheckoutCallbacks(pygit2.CheckoutCallbacks): - def __init__(self): + def __init__(self) -> None: super().__init__() self.invoked_times = 0 - def checkout_notify(self, why, path, baseline, target, workdir): + def checkout_notify( + self, + why: CheckoutNotify, + path: str, + baseline: Optional[DiffFile], + target: Optional[DiffFile], + workdir: Optional[DiffFile], + ) -> None: self.invoked_times += 1 # skip one file so we're certain that NO files are affected, # even if aborting the checkout from the second file if self.invoked_times == 2: raise InterruptedError('Stop the checkout!') - head = testrepo.head - head = testrepo[head.target] + head_object = testrepo.head + head = testrepo[head_object.target] assert 'new' not in head.tree assert b'bye world\n' == read_bye_txt() callbacks = MyCheckoutCallbacks() @@ -182,7 +205,7 @@ def checkout_notify(self, why, path, baseline, target, workdir): assert b'bye world\n' == read_bye_txt() -def test_checkout_branch(testrepo): +def test_checkout_branch(testrepo: Repository) -> None: branch_i18n = testrepo.lookup_branch('i18n') # checkout i18n with conflicts and default strategy should @@ -191,19 +214,19 @@ def test_checkout_branch(testrepo): testrepo.checkout(branch_i18n) # checkout i18n with GIT_CHECKOUT_FORCE - head = testrepo.head - head = testrepo[head.target] + head_object = testrepo.head + head = testrepo[head_object.target] assert 'new' not in head.tree testrepo.checkout(branch_i18n, strategy=CheckoutStrategy.FORCE) - head = testrepo.head - head = testrepo[head.target] + head_object = testrepo.head + head = testrepo[head_object.target] assert head.id == branch_i18n.target assert 'new' in head.tree assert 'bye.txt' not in testrepo.status() -def test_checkout_index(testrepo): +def test_checkout_index(testrepo: Repository) -> None: # some changes to working dir with (Path(testrepo.workdir) / 'hello.txt').open('w') as f: f.write('new content') @@ -214,7 +237,7 @@ def test_checkout_index(testrepo): assert 'hello.txt' not in testrepo.status() -def test_checkout_head(testrepo): +def test_checkout_head(testrepo: Repository) -> None: # some changes to the index with (Path(testrepo.workdir) / 'bye.txt').open('w') as f: f.write('new content') @@ -230,7 +253,7 @@ def test_checkout_head(testrepo): assert 'bye.txt' not in testrepo.status() -def test_checkout_alternative_dir(testrepo): +def test_checkout_alternative_dir(testrepo: Repository) -> None: ref_i18n = testrepo.lookup_reference('refs/heads/i18n') extra_dir = Path(testrepo.workdir) / 'extra-dir' extra_dir.mkdir() @@ -239,7 +262,7 @@ def test_checkout_alternative_dir(testrepo): assert not len(list(extra_dir.iterdir())) == 0 -def test_checkout_paths(testrepo): +def test_checkout_paths(testrepo: Repository) -> None: ref_i18n = testrepo.lookup_reference('refs/heads/i18n') ref_master = testrepo.lookup_reference('refs/heads/master') testrepo.checkout(ref_master) @@ -248,7 +271,7 @@ def test_checkout_paths(testrepo): assert status['new'] == FileStatus.INDEX_NEW -def test_merge_base(testrepo): +def test_merge_base(testrepo: Repository) -> None: commit = testrepo.merge_base( '5ebeeebb320790caf276b9fc8b24546d63316533', '4ec4389a8068641da2d6578db0419484972284c8', @@ -264,7 +287,7 @@ def test_merge_base(testrepo): assert testrepo.merge_base(indep, commit) is None -def test_descendent_of(testrepo): +def test_descendent_of(testrepo: Repository) -> None: assert not testrepo.descendant_of( '5ebeeebb320790caf276b9fc8b24546d63316533', '4ec4389a8068641da2d6578db0419484972284c8', @@ -289,7 +312,7 @@ def test_descendent_of(testrepo): ) -def test_ahead_behind(testrepo): +def test_ahead_behind(testrepo: Repository) -> None: ahead, behind = testrepo.ahead_behind( '5ebeeebb320790caf276b9fc8b24546d63316533', '4ec4389a8068641da2d6578db0419484972284c8', @@ -305,7 +328,7 @@ def test_ahead_behind(testrepo): assert 1 == behind -def test_reset_hard(testrepo): +def test_reset_hard(testrepo: Repository) -> None: ref = '5ebeeebb320790caf276b9fc8b24546d63316533' with (Path(testrepo.workdir) / 'hello.txt').open() as f: lines = f.readlines() @@ -322,7 +345,7 @@ def test_reset_hard(testrepo): assert 'bonjour le monde\n' not in lines -def test_reset_soft(testrepo): +def test_reset_soft(testrepo: Repository) -> None: ref = '5ebeeebb320790caf276b9fc8b24546d63316533' with (Path(testrepo.workdir) / 'hello.txt').open() as f: lines = f.readlines() @@ -343,7 +366,7 @@ def test_reset_soft(testrepo): diff[0] -def test_reset_mixed(testrepo): +def test_reset_mixed(testrepo: Repository) -> None: ref = '5ebeeebb320790caf276b9fc8b24546d63316533' with (Path(testrepo.workdir) / 'hello.txt').open() as f: lines = f.readlines() @@ -362,11 +385,12 @@ def test_reset_mixed(testrepo): # mixed reset will set the index to match working copy diff = testrepo.diff(cached=True) + assert diff.patch is not None assert 'hola mundo\n' in diff.patch assert 'bonjour le monde\n' in diff.patch -def test_stash(testrepo): +def test_stash(testrepo: Repository) -> None: stash_hash = '6aab5192f88018cb98a7ede99c242f43add5a2fd' stash_message = 'custom stash message' sig = pygit2.Signature( @@ -403,7 +427,7 @@ def test_stash(testrepo): testrepo.stash_pop() -def test_stash_partial(testrepo): +def test_stash_partial(testrepo: Repository) -> None: stash_message = 'custom stash message' sig = pygit2.Signature( name='Stasher', email='stasher@example.com', time=1641000000, offset=0 @@ -441,7 +465,7 @@ def stash_pathspecs(paths): assert stash_pathspecs(['hello.txt', 'bye.txt']) -def test_stash_progress_callback(testrepo): +def test_stash_progress_callback(testrepo: Repository) -> None: sig = pygit2.Signature( name='Stasher', email='stasher@example.com', time=1641000000, offset=0 ) @@ -474,7 +498,7 @@ def stash_apply_progress(self, progress: StashApplyProgress): ] -def test_stash_aborted_from_callbacks(testrepo): +def test_stash_aborted_from_callbacks(testrepo: Repository) -> None: sig = pygit2.Signature( name='Stasher', email='stasher@example.com', time=1641000000, offset=0 ) @@ -513,7 +537,7 @@ def stash_apply_progress(self, progress: StashApplyProgress): assert repo_stashes[0].message == 'On master: custom stash message' -def test_stash_apply_checkout_options(testrepo): +def test_stash_apply_checkout_options(testrepo: Repository) -> None: sig = pygit2.Signature( name='Stasher', email='stasher@example.com', time=1641000000, offset=0 ) @@ -529,7 +553,14 @@ def test_stash_apply_checkout_options(testrepo): # define callbacks that raise an InterruptedError when checkout detects a conflict class MyStashApplyCallbacks(pygit2.StashApplyCallbacks): - def checkout_notify(self, why, path, baseline, target, workdir): + def checkout_notify( + self, + why: CheckoutNotify, + path: str, + baseline: Optional[DiffFile], + target: Optional[DiffFile], + workdir: Optional[DiffFile], + ) -> None: if why == CheckoutNotify.CONFLICT: raise InterruptedError('Applying the stash would create a conflict') @@ -556,9 +587,12 @@ def checkout_notify(self, why, path, baseline, target, workdir): assert f.read() == 'stashed content' -def test_revert_commit(testrepo): +def test_revert_commit(testrepo: Repository) -> None: master = testrepo.head.peel() + assert isinstance(master, Commit) commit_to_revert = testrepo['4ec4389a8068641da2d6578db0419484972284c8'] + assert isinstance(commit_to_revert, Commit) + parent = commit_to_revert.parents[0] commit_diff_stats = parent.tree.diff_to_tree(commit_to_revert.tree).stats @@ -570,9 +604,10 @@ def test_revert_commit(testrepo): assert revert_diff_stats.files_changed == commit_diff_stats.files_changed -def test_revert(testrepo): +def test_revert(testrepo: Repository) -> None: hello_txt = Path(testrepo.workdir) / 'hello.txt' commit_to_revert = testrepo['4ec4389a8068641da2d6578db0419484972284c8'] + assert isinstance(commit_to_revert, Commit) assert testrepo.state() == RepositoryState.NONE assert not testrepo.message @@ -590,7 +625,7 @@ def test_revert(testrepo): ) -def test_default_signature(testrepo): +def test_default_signature(testrepo: Repository) -> None: config = testrepo.config config['user.name'] = 'Random J Hacker' config['user.email'] = 'rjh@example.com' @@ -797,7 +832,7 @@ def test_clone_with_proxy(tmp_path): # # assert repo.remotes[0].fetchspec == "refs/heads/test" -def test_worktree(testrepo): +def test_worktree(testrepo: Repository) -> None: worktree_name = 'foo' worktree_dir = Path(tempfile.mkdtemp()) # Delete temp path so that it's not present when we attempt to add the @@ -839,7 +874,7 @@ def _check_worktree(worktree): assert testrepo.list_worktrees() == [] -def test_worktree_aspath(testrepo): +def test_worktree_aspath(testrepo: Repository) -> None: worktree_name = 'foo' worktree_dir = Path(tempfile.mkdtemp()) # Delete temp path so that it's not present when we attempt to add the @@ -849,7 +884,7 @@ def test_worktree_aspath(testrepo): assert testrepo.list_worktrees() == [worktree_name] -def test_worktree_custom_ref(testrepo): +def test_worktree_custom_ref(testrepo: Repository) -> None: worktree_name = 'foo' worktree_dir = Path(tempfile.mkdtemp()) branch_name = 'version1' @@ -917,7 +952,7 @@ def test_open_extended(tmp_path): assert not repo.workdir -def test_is_shallow(testrepo): +def test_is_shallow(testrepo: Repository) -> None: assert not testrepo.is_shallow # create a dummy shallow file @@ -927,7 +962,7 @@ def test_is_shallow(testrepo): assert testrepo.is_shallow -def test_repository_hashfile(testrepo): +def test_repository_hashfile(testrepo: Repository) -> None: original_hash = testrepo.index['hello.txt'].id # Test simple use @@ -937,8 +972,8 @@ def test_repository_hashfile(testrepo): # Test absolute path # For best results on Windows, pass a pure POSIX path. (See https://github.com/libgit2/libgit2/issues/6825) absolute_path = Path(testrepo.workdir, 'hello.txt') - absolute_path = absolute_path.as_posix() # Windows compatibility - h = testrepo.hashfile(str(absolute_path)) + absolute_path_str = absolute_path.as_posix() # Windows compatibility + h = testrepo.hashfile(str(absolute_path_str)) assert h == original_hash # Test missing path @@ -950,7 +985,7 @@ def test_repository_hashfile(testrepo): testrepo.hashfile('hello.txt', ObjectType.OFS_DELTA) -def test_repository_hashfile_filter(testrepo): +def test_repository_hashfile_filter(testrepo: Repository) -> None: original_hash = testrepo.index['hello.txt'].id with open(Path(testrepo.workdir, 'hello.txt'), 'rb') as f: @@ -977,8 +1012,8 @@ def test_repository_hashfile_filter(testrepo): # Treat absolute path with filters. # For best results on Windows, pass a pure POSIX path. (See https://github.com/libgit2/libgit2/issues/6825) absolute_path = Path(testrepo.workdir, 'hellocrlf.txt') - absolute_path = absolute_path.as_posix() # Windows compatibility - h = testrepo.hashfile(str(absolute_path)) + absolute_path_str = absolute_path.as_posix() # Windows compatibility + h = testrepo.hashfile(str(absolute_path_str)) assert h == original_hash # Bypass filters @@ -995,65 +1030,75 @@ def test_repository_hashfile_filter(testrepo): h = testrepo.hashfile('hello.txt') -def test_merge_file_from_index_deprecated(testrepo): +def test_merge_file_from_index_deprecated(testrepo: Repository) -> None: hello_txt = testrepo.index['hello.txt'] hello_txt_executable = IndexEntry( hello_txt.path, hello_txt.id, FileMode.BLOB_EXECUTABLE ) hello_world = IndexEntry('hello_world.txt', hello_txt.id, hello_txt.mode) + def get_hello_txt_from_repo() -> str: + blob = testrepo.get(hello_txt.id) + assert isinstance(blob, Blob) + return blob.data.decode() + # no change res = testrepo.merge_file_from_index(hello_txt, hello_txt, hello_txt) - assert res == testrepo.get(hello_txt.id).data.decode() + assert res == get_hello_txt_from_repo() # executable switch on ours res = testrepo.merge_file_from_index(hello_txt, hello_txt_executable, hello_txt) - assert res == testrepo.get(hello_txt.id).data.decode() + assert res == get_hello_txt_from_repo() # executable switch on theirs res = testrepo.merge_file_from_index(hello_txt, hello_txt, hello_txt_executable) - assert res == testrepo.get(hello_txt.id).data.decode() + assert res == get_hello_txt_from_repo() # executable switch on both res = testrepo.merge_file_from_index( hello_txt, hello_txt_executable, hello_txt_executable ) - assert res == testrepo.get(hello_txt.id).data.decode() + assert res == get_hello_txt_from_repo() # path switch on ours res = testrepo.merge_file_from_index(hello_txt, hello_world, hello_txt) - assert res == testrepo.get(hello_txt.id).data.decode() + assert res == get_hello_txt_from_repo() # path switch on theirs res = testrepo.merge_file_from_index(hello_txt, hello_txt, hello_world) - assert res == testrepo.get(hello_txt.id).data.decode() + assert res == get_hello_txt_from_repo() # path switch on both res = testrepo.merge_file_from_index(hello_txt, hello_world, hello_world) - assert res == testrepo.get(hello_txt.id).data.decode() + assert res == get_hello_txt_from_repo() # path switch on ours, executable flag switch on theirs res = testrepo.merge_file_from_index(hello_txt, hello_world, hello_txt_executable) - assert res == testrepo.get(hello_txt.id).data.decode() + assert res == get_hello_txt_from_repo() # path switch on theirs, executable flag switch on ours res = testrepo.merge_file_from_index(hello_txt, hello_txt_executable, hello_world) - assert res == testrepo.get(hello_txt.id).data.decode() + assert res == get_hello_txt_from_repo() -def test_merge_file_from_index_non_deprecated(testrepo): +def test_merge_file_from_index_non_deprecated(testrepo: Repository) -> None: hello_txt = testrepo.index['hello.txt'] hello_txt_executable = IndexEntry( hello_txt.path, hello_txt.id, FileMode.BLOB_EXECUTABLE ) hello_world = IndexEntry('hello_world.txt', hello_txt.id, hello_txt.mode) + def get_hello_txt_from_repo() -> str: + blob = testrepo.get(hello_txt.id) + assert isinstance(blob, Blob) + return blob.data.decode() + # no change res = testrepo.merge_file_from_index( hello_txt, hello_txt, hello_txt, use_deprecated=False ) assert res == MergeFileResult( - True, hello_txt.path, hello_txt.mode, testrepo.get(hello_txt.id).data.decode() + True, hello_txt.path, hello_txt.mode, get_hello_txt_from_repo() ) # executable switch on ours @@ -1064,7 +1109,7 @@ def test_merge_file_from_index_non_deprecated(testrepo): True, hello_txt.path, hello_txt_executable.mode, - testrepo.get(hello_txt.id).data.decode(), + get_hello_txt_from_repo(), ) # executable switch on theirs @@ -1075,7 +1120,7 @@ def test_merge_file_from_index_non_deprecated(testrepo): True, hello_txt.path, hello_txt_executable.mode, - testrepo.get(hello_txt.id).data.decode(), + get_hello_txt_from_repo(), ) # executable switch on both @@ -1086,7 +1131,7 @@ def test_merge_file_from_index_non_deprecated(testrepo): True, hello_txt.path, hello_txt_executable.mode, - testrepo.get(hello_txt.id).data.decode(), + get_hello_txt_from_repo(), ) # path switch on ours @@ -1094,7 +1139,7 @@ def test_merge_file_from_index_non_deprecated(testrepo): hello_txt, hello_world, hello_txt, use_deprecated=False ) assert res == MergeFileResult( - True, hello_world.path, hello_txt.mode, testrepo.get(hello_txt.id).data.decode() + True, hello_world.path, hello_txt.mode, get_hello_txt_from_repo() ) # path switch on theirs @@ -1102,16 +1147,14 @@ def test_merge_file_from_index_non_deprecated(testrepo): hello_txt, hello_txt, hello_world, use_deprecated=False ) assert res == MergeFileResult( - True, hello_world.path, hello_txt.mode, testrepo.get(hello_txt.id).data.decode() + True, hello_world.path, hello_txt.mode, get_hello_txt_from_repo() ) # path switch on both res = testrepo.merge_file_from_index( hello_txt, hello_world, hello_world, use_deprecated=False ) - assert res == MergeFileResult( - True, None, hello_txt.mode, testrepo.get(hello_txt.id).data.decode() - ) + assert res == MergeFileResult(True, None, hello_txt.mode, get_hello_txt_from_repo()) # path switch on ours, executable flag switch on theirs res = testrepo.merge_file_from_index( @@ -1121,7 +1164,7 @@ def test_merge_file_from_index_non_deprecated(testrepo): True, hello_world.path, hello_txt_executable.mode, - testrepo.get(hello_txt.id).data.decode(), + get_hello_txt_from_repo(), ) # path switch on theirs, executable flag switch on ours @@ -1132,5 +1175,5 @@ def test_merge_file_from_index_non_deprecated(testrepo): True, hello_world.path, hello_txt_executable.mode, - testrepo.get(hello_txt.id).data.decode(), + get_hello_txt_from_repo(), ) From fc936574fd5a1b752f74cd4505a34c55ad863b44 Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Fri, 1 Aug 2025 08:22:05 +0200 Subject: [PATCH 053/225] improve types according to test/test_repository_bare.py --- pygit2/_pygit2.pyi | 6 +++- test/test_repository_bare.py | 68 ++++++++++++++++++++---------------- 2 files changed, 43 insertions(+), 31 deletions(-) diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index d7ca90b3d..c8e7ee36d 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -766,6 +766,7 @@ class Repository: def _from_c(cls, ptr: 'GitRepositoryC', owned: bool) -> 'Repository': ... def __iter__(self) -> Iterator[Oid]: ... def __getitem__(self, key: str | Oid) -> Object: ... + def __contains__(self, name: _OidArg) -> bool: ... def add_worktree( self, name: str, path: str | Path, ref: Reference = ... ) -> Worktree: ... @@ -976,6 +977,7 @@ class Repository: def raw_listall_references(self) -> list[bytes]: ... @property def raw_message(self) -> bytes: ... + def read(self, oid: _OidArg) -> tuple[int, bytes]: ... def remove_message(self) -> None: ... def references_iterator_init(self) -> Iterator[Reference]: ... def references_iterator_next( @@ -992,6 +994,7 @@ class Repository: def revert_commit( self, revert_commit: Commit, our_commit: Commit, mainline: int = 0 ) -> Index: ... + def set_head(self, target: _OidArg) -> None: ... def set_ident(self, name: str, email: str) -> None: ... def set_odb(self, odb: Odb) -> None: ... def set_refdb(self, refdb: Refdb) -> None: ... @@ -1033,6 +1036,7 @@ class Repository: def walk( self, oid: _OidArg | None, sort_mode: SortMode = SortMode.NONE ) -> Walker: ... + def write(self, type: int, data: bytes) -> Oid: ... def write_archive( self, treeish: str | Tree | Object | Oid, @@ -1145,7 +1149,7 @@ class Worktree: def discover_repository( path: str, across_fs: bool = False, ceiling_dirs: str = ... ) -> str | None: ... -def hash(data: bytes) -> Oid: ... +def hash(data: bytes | str) -> Oid: ... def hashfile(path: str) -> Oid: ... def init_file_backend(path: str, flags: int = 0) -> object: ... @overload diff --git a/test/test_repository_bare.py b/test/test_repository_bare.py index 833921550..4021fc8b3 100644 --- a/test/test_repository_bare.py +++ b/test/test_repository_bare.py @@ -28,10 +28,12 @@ import pathlib import sys import tempfile +from pathlib import Path import pytest import pygit2 +from pygit2 import Branch, Commit, Oid, Repository from pygit2.enums import FileMode, ObjectType from . import utils @@ -43,15 +45,15 @@ BLOB_OID = pygit2.Oid(raw=BLOB_RAW) -def test_is_empty(barerepo): +def test_is_empty(barerepo: Repository) -> None: assert not barerepo.is_empty -def test_is_bare(barerepo): +def test_is_bare(barerepo: Repository) -> None: assert barerepo.is_bare -def test_head(barerepo): +def test_head(barerepo: Repository) -> None: head = barerepo.head assert HEAD_SHA == head.target assert type(head) is pygit2.Reference @@ -59,7 +61,7 @@ def test_head(barerepo): assert not barerepo.head_is_detached -def test_set_head(barerepo): +def test_set_head(barerepo: Repository) -> None: # Test setting a detached HEAD. barerepo.set_head(pygit2.Oid(hex=PARENT_SHA)) assert barerepo.head.target == PARENT_SHA @@ -69,9 +71,9 @@ def test_set_head(barerepo): assert barerepo.head.target == HEAD_SHA -def test_read(barerepo): +def test_read(barerepo: Repository) -> None: with pytest.raises(TypeError): - barerepo.read(123) + barerepo.read(123) # type: ignore utils.assertRaisesWithArg(KeyError, '1' * 40, barerepo.read, '1' * 40) ab = barerepo.read(BLOB_OID) @@ -87,7 +89,7 @@ def test_read(barerepo): assert (ObjectType.BLOB, b'a contents\n') == a3 -def test_write(barerepo): +def test_write(barerepo: Repository) -> None: data = b'hello world' # invalid object type with pytest.raises(ValueError): @@ -97,9 +99,9 @@ def test_write(barerepo): assert type(oid) is pygit2.Oid -def test_contains(barerepo): +def test_contains(barerepo: Repository) -> None: with pytest.raises(TypeError): - 123 in barerepo + 123 in barerepo # type: ignore assert BLOB_OID in barerepo assert BLOB_HEX in barerepo assert BLOB_HEX[:10] in barerepo @@ -107,45 +109,47 @@ def test_contains(barerepo): assert ('a' * 20) not in barerepo -def test_iterable(barerepo): +def test_iterable(barerepo: Repository) -> None: oid = pygit2.Oid(hex=BLOB_HEX) assert oid in [obj for obj in barerepo] -def test_lookup_blob(barerepo): +def test_lookup_blob(barerepo: Repository) -> None: with pytest.raises(TypeError): - barerepo[123] + barerepo[123] # type: ignore assert barerepo[BLOB_OID].id == BLOB_HEX a = barerepo[BLOB_HEX] assert b'a contents\n' == a.read_raw() assert BLOB_HEX == a.id - assert ObjectType.BLOB == a.type + assert int(ObjectType.BLOB) == a.type -def test_lookup_blob_prefix(barerepo): +def test_lookup_blob_prefix(barerepo: Repository) -> None: a = barerepo[BLOB_HEX[:5]] assert b'a contents\n' == a.read_raw() assert BLOB_HEX == a.id - assert ObjectType.BLOB == a.type + assert int(ObjectType.BLOB) == a.type -def test_lookup_commit(barerepo): +def test_lookup_commit(barerepo: Repository) -> None: commit_sha = '5fe808e8953c12735680c257f56600cb0de44b10' commit = barerepo[commit_sha] assert commit_sha == commit.id - assert ObjectType.COMMIT == commit.type + assert int(ObjectType.COMMIT) == commit.type + assert isinstance(commit, Commit) assert commit.message == ( 'Second test data commit.\n\nThis commit has some additional text.\n' ) -def test_lookup_commit_prefix(barerepo): +def test_lookup_commit_prefix(barerepo: Repository) -> None: commit_sha = '5fe808e8953c12735680c257f56600cb0de44b10' commit_sha_prefix = commit_sha[:7] too_short_prefix = commit_sha[:3] commit = barerepo[commit_sha_prefix] assert commit_sha == commit.id - assert ObjectType.COMMIT == commit.type + assert int(ObjectType.COMMIT) == commit.type + assert isinstance(commit, Commit) assert ( 'Second test data commit.\n\n' 'This commit has some additional text.\n' == commit.message @@ -154,14 +158,14 @@ def test_lookup_commit_prefix(barerepo): barerepo.__getitem__(too_short_prefix) -def test_expand_id(barerepo): +def test_expand_id(barerepo: Repository) -> None: commit_sha = '5fe808e8953c12735680c257f56600cb0de44b10' expanded = barerepo.expand_id(commit_sha[:7]) assert commit_sha == expanded @utils.requires_refcount -def test_lookup_commit_refcount(barerepo): +def test_lookup_commit_refcount(barerepo: Repository) -> None: start = sys.getrefcount(barerepo) commit_sha = '5fe808e8953c12735680c257f56600cb0de44b10' commit = barerepo[commit_sha] @@ -170,30 +174,30 @@ def test_lookup_commit_refcount(barerepo): assert start == end -def test_get_path(barerepo_path): +def test_get_path(barerepo_path: tuple[Repository, Path]) -> None: barerepo, path = barerepo_path directory = pathlib.Path(barerepo.path).resolve() assert directory == path.resolve() -def test_get_workdir(barerepo): +def test_get_workdir(barerepo: Repository) -> None: assert barerepo.workdir is None -def test_revparse_single(barerepo): +def test_revparse_single(barerepo: Repository) -> None: parent = barerepo.revparse_single('HEAD^') assert parent.id == PARENT_SHA -def test_hash(barerepo): +def test_hash(barerepo: Repository) -> None: data = 'foobarbaz' hashed_sha1 = pygit2.hash(data) written_sha1 = barerepo.create_blob(data) assert hashed_sha1 == written_sha1 -def test_hashfile(barerepo): +def test_hashfile(barerepo: Repository) -> None: data = 'bazbarfoo' handle, tempfile_path = tempfile.mkstemp() with os.fdopen(handle, 'w') as fh: @@ -204,8 +208,8 @@ def test_hashfile(barerepo): assert hashed_sha1 == written_sha1 -def test_conflicts_in_bare_repository(barerepo): - def create_conflict_file(repo, branch, content): +def test_conflicts_in_bare_repository(barerepo: Repository) -> None: + def create_conflict_file(repo: Repository, branch: Branch, content: str) -> Oid: oid = repo.create_blob(content.encode('utf-8')) tb = repo.TreeBuilder() tb.insert('conflict', oid, FileMode.BLOB) @@ -218,9 +222,13 @@ def create_conflict_file(repo, branch, content): assert commit is not None return commit - b1 = barerepo.create_branch('b1', barerepo.head.peel()) + head_peeled = barerepo.head.peel() + assert isinstance(head_peeled, Commit) + b1 = barerepo.create_branch('b1', head_peeled) c1 = create_conflict_file(barerepo, b1, 'ASCII - abc') - b2 = barerepo.create_branch('b2', barerepo.head.peel()) + head_peeled = barerepo.head.peel() + assert isinstance(head_peeled, Commit) + b2 = barerepo.create_branch('b2', head_peeled) c2 = create_conflict_file(barerepo, b2, 'Unicode - äüö') index = barerepo.merge_commits(c1, c2) From 49d32f3542d2914f7bdb72872824c5393dee5dde Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Fri, 1 Aug 2025 09:06:42 +0200 Subject: [PATCH 054/225] improve types according to test/test_signature.py --- pygit2/_pygit2.pyi | 2 +- test/test_signature.py | 15 ++++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index c8e7ee36d..573a3639c 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -1061,7 +1061,7 @@ class Signature: time: int def __init__( self, - name: str, + name: str | bytes, email: str, time: int = -1, offset: int = 0, diff --git a/test/test_signature.py b/test/test_signature.py index a28a1e07f..e90f55392 100644 --- a/test/test_signature.py +++ b/test/test_signature.py @@ -29,9 +29,10 @@ import pytest import pygit2 +from pygit2 import Repository, Signature -def __assert(signature, encoding): +def __assert(signature: Signature, encoding: None | str) -> None: encoding = encoding or 'utf-8' assert signature._encoding == encoding assert signature.name == signature.raw_name.decode(encoding) @@ -41,25 +42,25 @@ def __assert(signature, encoding): @pytest.mark.parametrize('encoding', [None, 'utf-8', 'iso-8859-1']) -def test_encoding(encoding): +def test_encoding(encoding: None | str) -> None: signature = pygit2.Signature('Foo Ibáñez', 'foo@example.com', encoding=encoding) __assert(signature, encoding) assert abs(signature.time - time.time()) < 5 assert str(signature) == 'Foo Ibáñez ' -def test_default_encoding(): +def test_default_encoding() -> None: signature = pygit2.Signature('Foo Ibáñez', 'foo@example.com', 1322174594, 60) __assert(signature, 'utf-8') -def test_ascii(): +def test_ascii() -> None: with pytest.raises(UnicodeEncodeError): pygit2.Signature('Foo Ibáñez', 'foo@example.com', encoding='ascii') @pytest.mark.parametrize('encoding', [None, 'utf-8', 'iso-8859-1']) -def test_repr(encoding): +def test_repr(encoding: str | None) -> None: signature = pygit2.Signature( 'Foo Ibáñez', 'foo@bar.com', 1322174594, 60, encoding=encoding ) @@ -68,7 +69,7 @@ def test_repr(encoding): assert signature == eval(expected) -def test_repr_from_commit(barerepo): +def test_repr_from_commit(barerepo: Repository) -> None: repo = barerepo signature = pygit2.Signature('Foo Ibáñez', 'foo@example.com', encoding=None) tree = '967fce8df97cc71722d3c2a5930ef3e6f1d27b12' @@ -80,7 +81,7 @@ def test_repr_from_commit(barerepo): assert repr(signature) == repr(commit.committer) -def test_incorrect_encoding(): +def test_incorrect_encoding() -> None: gbk_bytes = 'Café'.encode('GBK') # deliberately specifying a mismatching encoding (mojibake) From 79a7de57f1fcc1ea2438663d9eae5d32d3036014 Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Fri, 1 Aug 2025 08:23:41 +0200 Subject: [PATCH 055/225] add types to several test files --- test/test_repository_custom.py | 8 +++++--- test/test_repository_empty.py | 8 +++++--- test/test_revparse.py | 16 ++++++++-------- test/test_revwalk.py | 21 +++++++++++---------- test/test_status.py | 13 +++++++++---- test/test_tag.py | 20 ++++++++++++-------- test/test_treebuilder.py | 13 +++++++++---- 7 files changed, 59 insertions(+), 40 deletions(-) diff --git a/test/test_repository_custom.py b/test/test_repository_custom.py index bc4f8b9de..336c20a80 100644 --- a/test/test_repository_custom.py +++ b/test/test_repository_custom.py @@ -24,15 +24,17 @@ # Boston, MA 02110-1301, USA. from pathlib import Path +from typing import Generator import pytest import pygit2 +from pygit2 import Repository from pygit2.enums import ObjectType @pytest.fixture -def repo(testrepopacked): +def repo(testrepopacked: Repository) -> Generator[Repository, None, None]: testrepo = testrepopacked odb = pygit2.Odb() @@ -49,7 +51,7 @@ def repo(testrepopacked): yield repo -def test_references(repo): +def test_references(repo: Repository) -> None: refs = [(ref.name, ref.target) for ref in repo.references.objects] assert sorted(refs) == [ ('refs/heads/i18n', '5470a671a80ac3789f1a6a8cefbcf43ce7af0563'), @@ -57,6 +59,6 @@ def test_references(repo): ] -def test_objects(repo): +def test_objects(repo: Repository) -> None: a = repo.read('323fae03f4606ea9991df8befbb2fca795e648fa') assert (ObjectType.BLOB, b'foobar\n') == a diff --git a/test/test_repository_empty.py b/test/test_repository_empty.py index ac44ad836..be8a8d342 100644 --- a/test/test_repository_empty.py +++ b/test/test_repository_empty.py @@ -23,15 +23,17 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. +from pygit2 import Repository -def test_is_empty(emptyrepo): + +def test_is_empty(emptyrepo: Repository) -> None: assert emptyrepo.is_empty -def test_is_base(emptyrepo): +def test_is_base(emptyrepo: Repository) -> None: assert not emptyrepo.is_bare -def test_head(emptyrepo): +def test_head(emptyrepo: Repository) -> None: assert emptyrepo.head_is_unborn assert not emptyrepo.head_is_detached diff --git a/test/test_revparse.py b/test/test_revparse.py index a83f77db3..d61df77d2 100644 --- a/test/test_revparse.py +++ b/test/test_revparse.py @@ -27,21 +27,21 @@ from pytest import raises -from pygit2 import InvalidSpecError +from pygit2 import InvalidSpecError, Repository from pygit2.enums import RevSpecFlag HEAD_SHA = '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98' PARENT_SHA = '5ebeeebb320790caf276b9fc8b24546d63316533' # HEAD^ -def test_revparse_single(testrepo): +def test_revparse_single(testrepo: Repository) -> None: assert testrepo.revparse_single('HEAD').id == HEAD_SHA assert testrepo.revparse_single('HEAD^').id == PARENT_SHA o = testrepo.revparse_single('@{-1}') assert o.id == '5470a671a80ac3789f1a6a8cefbcf43ce7af0563' -def test_revparse_ext(testrepo): +def test_revparse_ext(testrepo: Repository) -> None: o, r = testrepo.revparse_ext('master') assert o.id == HEAD_SHA assert r == testrepo.references['refs/heads/master'] @@ -55,21 +55,21 @@ def test_revparse_ext(testrepo): assert r == testrepo.references['refs/heads/i18n'] -def test_revparse_1(testrepo): +def test_revparse_1(testrepo: Repository) -> None: s = testrepo.revparse('master') assert s.from_object.id == HEAD_SHA assert s.to_object is None assert s.flags == RevSpecFlag.SINGLE -def test_revparse_range_1(testrepo): +def test_revparse_range_1(testrepo: Repository) -> None: s = testrepo.revparse('HEAD^1..acecd5e') assert s.from_object.id == PARENT_SHA assert str(s.to_object.id).startswith('acecd5e') assert s.flags == RevSpecFlag.RANGE -def test_revparse_range_2(testrepo): +def test_revparse_range_2(testrepo: Repository) -> None: s = testrepo.revparse('HEAD...i18n') assert str(s.from_object.id).startswith('2be5719') assert str(s.to_object.id).startswith('5470a67') @@ -77,7 +77,7 @@ def test_revparse_range_2(testrepo): assert testrepo.merge_base(s.from_object.id, s.to_object.id) is not None -def test_revparse_range_errors(testrepo): +def test_revparse_range_errors(testrepo: Repository) -> None: with raises(KeyError): testrepo.revparse('nope..2be571915') @@ -85,7 +85,7 @@ def test_revparse_range_errors(testrepo): testrepo.revparse('master............2be571915') -def test_revparse_repr(testrepo): +def test_revparse_repr(testrepo: Repository) -> None: s = testrepo.revparse('HEAD...i18n') assert ( repr(s) diff --git a/test/test_revwalk.py b/test/test_revwalk.py index fa97456d6..28cfc4061 100644 --- a/test/test_revwalk.py +++ b/test/test_revwalk.py @@ -25,6 +25,7 @@ """Tests for revision walk.""" +from pygit2 import Repository from pygit2.enums import SortMode # In the order given by git log @@ -50,42 +51,42 @@ ] -def test_log(testrepo): +def test_log(testrepo: Repository) -> None: ref = testrepo.lookup_reference('HEAD') for i, entry in enumerate(ref.log()): assert entry.committer.name == REVLOGS[i][0] assert entry.message == REVLOGS[i][1] -def test_walk(testrepo): +def test_walk(testrepo: Repository) -> None: walker = testrepo.walk(log[0], SortMode.TIME) assert [x.id for x in walker] == log -def test_reverse(testrepo): +def test_reverse(testrepo: Repository) -> None: walker = testrepo.walk(log[0], SortMode.TIME | SortMode.REVERSE) assert [x.id for x in walker] == list(reversed(log)) -def test_hide(testrepo): +def test_hide(testrepo: Repository) -> None: walker = testrepo.walk(log[0], SortMode.TIME) walker.hide('4ec4389a8068641da2d6578db0419484972284c8') assert len(list(walker)) == 2 -def test_hide_prefix(testrepo): +def test_hide_prefix(testrepo: Repository) -> None: walker = testrepo.walk(log[0], SortMode.TIME) walker.hide('4ec4389a') assert len(list(walker)) == 2 -def test_reset(testrepo): +def test_reset(testrepo: Repository) -> None: walker = testrepo.walk(log[0], SortMode.TIME) walker.reset() assert list(walker) == [] -def test_push(testrepo): +def test_push(testrepo: Repository) -> None: walker = testrepo.walk(log[-1], SortMode.TIME) assert [x.id for x in walker] == log[-1:] walker.reset() @@ -93,19 +94,19 @@ def test_push(testrepo): assert [x.id for x in walker] == log -def test_sort(testrepo): +def test_sort(testrepo: Repository) -> None: walker = testrepo.walk(log[0], SortMode.TIME) walker.sort(SortMode.TIME | SortMode.REVERSE) assert [x.id for x in walker] == list(reversed(log)) -def test_simplify_first_parent(testrepo): +def test_simplify_first_parent(testrepo: Repository) -> None: walker = testrepo.walk(log[0], SortMode.TIME) walker.simplify_first_parent() assert len(list(walker)) == 3 -def test_default_sorting(testrepo): +def test_default_sorting(testrepo: Repository) -> None: walker = testrepo.walk(log[0], SortMode.NONE) list1 = list([x.id for x in walker]) walker = testrepo.walk(log[0]) diff --git a/test/test_status.py b/test/test_status.py index a875fd9df..653ed93d3 100644 --- a/test/test_status.py +++ b/test/test_status.py @@ -25,10 +25,11 @@ import pytest +from pygit2 import Repository from pygit2.enums import FileStatus -def test_status(dirtyrepo): +def test_status(dirtyrepo: Repository) -> None: """ For every file in the status, check that the flags are correct. """ @@ -38,7 +39,7 @@ def test_status(dirtyrepo): assert status == git_status[filepath] -def test_status_untracked_no(dirtyrepo): +def test_status_untracked_no(dirtyrepo: Repository) -> None: git_status = dirtyrepo.status(untracked_files='no') assert not any(status & FileStatus.WT_NEW for status in git_status.values()) @@ -67,7 +68,9 @@ def test_status_untracked_no(dirtyrepo): ), ], ) -def test_status_untracked_normal(dirtyrepo, untracked_files, expected): +def test_status_untracked_normal( + dirtyrepo: Repository, untracked_files: str, expected: set[str] +) -> None: git_status = dirtyrepo.status(untracked_files=untracked_files) assert { file for file, status in git_status.items() if status & FileStatus.WT_NEW @@ -75,7 +78,9 @@ def test_status_untracked_normal(dirtyrepo, untracked_files, expected): @pytest.mark.parametrize('ignored,expected', [(True, {'ignored'}), (False, set())]) -def test_status_ignored(dirtyrepo, ignored, expected): +def test_status_ignored( + dirtyrepo: Repository, ignored: bool, expected: set[str] +) -> None: git_status = dirtyrepo.status(ignored=ignored) assert { file for file, status in git_status.items() if status & FileStatus.IGNORED diff --git a/test/test_tag.py b/test/test_tag.py index e641dded3..e4f017b16 100644 --- a/test/test_tag.py +++ b/test/test_tag.py @@ -28,18 +28,20 @@ import pytest import pygit2 +from pygit2 import Repository from pygit2.enums import ObjectType TAG_SHA = '3d2962987c695a29f1f80b6c3aa4ec046ef44369' -def test_read_tag(barerepo): +def test_read_tag(barerepo: Repository) -> None: repo = barerepo tag = repo[TAG_SHA] - target = repo[tag.target] assert isinstance(tag, pygit2.Tag) - assert ObjectType.TAG == tag.type - assert ObjectType.COMMIT == target.type + target = repo[tag.target] + assert isinstance(target, pygit2.Commit) + assert int(ObjectType.TAG) == tag.type + assert int(ObjectType.COMMIT) == target.type assert 'root' == tag.name assert 'Tagged root commit.\n' == tag.message assert 'Initial test data commit.\n' == target.message @@ -48,7 +50,7 @@ def test_read_tag(barerepo): ) -def test_new_tag(barerepo): +def test_new_tag(barerepo: Repository) -> None: name = 'thetag' target = 'af431f20fc541ed6d5afede3e2dc7160f6f01f16' message = 'Tag a blob.\n' @@ -57,10 +59,11 @@ def test_new_tag(barerepo): target_prefix = target[:5] too_short_prefix = target[:3] with pytest.raises(ValueError): - barerepo.create_tag(name, too_short_prefix, ObjectType.BLOB, tagger, message) + barerepo.create_tag(name, too_short_prefix, ObjectType.BLOB, tagger, message) # type: ignore sha = barerepo.create_tag(name, target_prefix, ObjectType.BLOB, tagger, message) tag = barerepo[sha] + assert isinstance(tag, pygit2.Tag) assert '3ee44658fd11660e828dfc96b9b5c5f38d5b49bb' == tag.id assert name == tag.name @@ -70,7 +73,7 @@ def test_new_tag(barerepo): assert name == barerepo[tag.id].name -def test_modify_tag(barerepo): +def test_modify_tag(barerepo: Repository) -> None: name = 'thetag' target = 'af431f20fc541ed6d5afede3e2dc7160f6f01f16' message = 'Tag a blob.\n' @@ -87,7 +90,8 @@ def test_modify_tag(barerepo): setattr(tag, 'message', message) -def test_get_object(barerepo): +def test_get_object(barerepo: Repository) -> None: repo = barerepo tag = repo[TAG_SHA] + assert isinstance(tag, pygit2.Tag) assert repo[tag.target].id == tag.get_object().id diff --git a/test/test_treebuilder.py b/test/test_treebuilder.py index fc7bc436f..99e4d6b3b 100644 --- a/test/test_treebuilder.py +++ b/test/test_treebuilder.py @@ -23,16 +23,18 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. +from pygit2 import Repository, Tree TREE_SHA = '967fce8df97cc71722d3c2a5930ef3e6f1d27b12' -def test_new_empty_treebuilder(barerepo): +def test_new_empty_treebuilder(barerepo: Repository) -> None: barerepo.TreeBuilder() -def test_noop_treebuilder(barerepo): +def test_noop_treebuilder(barerepo: Repository) -> None: tree = barerepo[TREE_SHA] + assert isinstance(tree, Tree) bld = barerepo.TreeBuilder(TREE_SHA) result = bld.write() @@ -40,8 +42,9 @@ def test_noop_treebuilder(barerepo): assert tree.id == result -def test_noop_treebuilder_from_tree(barerepo): +def test_noop_treebuilder_from_tree(barerepo: Repository) -> None: tree = barerepo[TREE_SHA] + assert isinstance(tree, Tree) bld = barerepo.TreeBuilder(tree) result = bld.write() @@ -49,11 +52,13 @@ def test_noop_treebuilder_from_tree(barerepo): assert tree.id == result -def test_rebuild_treebuilder(barerepo): +def test_rebuild_treebuilder(barerepo: Repository) -> None: tree = barerepo[TREE_SHA] + assert isinstance(tree, Tree) bld = barerepo.TreeBuilder() for entry in tree: name = entry.name + assert name is not None assert bld.get(name) is None bld.insert(name, entry.id, entry.filemode) assert bld.get(name).id == entry.id From 51814a91bfcd33ebe4c0c32748b0445742013737 Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Fri, 1 Aug 2025 10:05:08 +0200 Subject: [PATCH 056/225] improve types according to test/test_tree.py --- pygit2/_pygit2.pyi | 6 ++--- test/test_tree.py | 62 +++++++++++++++++++++++++++------------------- 2 files changed, 40 insertions(+), 28 deletions(-) diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index 573a3639c..69970ecd7 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -1117,11 +1117,11 @@ class Tree(Object): interhunk_lines: int = 0, ) -> Diff: ... def __contains__(self, other: str) -> bool: ... # Tree_contains - def __getitem__(self, index: str | int) -> Object: ... # Tree_subscript + def __getitem__(self, index: str | int) -> Tree | Blob: ... # Tree_subscript def __iter__(self) -> Iterator[Object]: ... def __len__(self) -> int: ... # Tree_len - def __rtruediv__(self, other: str) -> Object: ... - def __truediv__(self, other: str) -> Object: ... # Tree_divide + def __rtruediv__(self, other: str) -> Tree | Blob: ... + def __truediv__(self, other: str) -> Tree | Blob: ... # Tree_divide class TreeBuilder: def clear(self) -> None: ... diff --git a/test/test_tree.py b/test/test_tree.py index 85a225918..c5954004e 100644 --- a/test/test_tree.py +++ b/test/test_tree.py @@ -28,6 +28,7 @@ import pytest import pygit2 +from pygit2 import Object, Repository, Tree from pygit2.enums import FileMode, ObjectType from . import utils @@ -36,19 +37,20 @@ SUBTREE_SHA = '614fd9a3094bf618ea938fffc00e7d1a54f89ad0' -def assertTreeEntryEqual(entry, sha, name, filemode): +def assertTreeEntryEqual(entry: Object, sha: str, name: str, filemode: int) -> None: assert entry.id == sha assert entry.name == name assert entry.filemode == filemode assert entry.raw_name == name.encode('utf-8') -def test_read_tree(barerepo): +def test_read_tree(barerepo: Repository) -> None: tree = barerepo[TREE_SHA] + assert isinstance(tree, Tree) with pytest.raises(TypeError): - tree[()] + tree[()] # type: ignore with pytest.raises(TypeError): - tree / 123 + tree / 123 # type: ignore utils.assertRaisesWithArg(KeyError, 'abcd', lambda: tree['abcd']) utils.assertRaisesWithArg(IndexError, -4, lambda: tree[-4]) utils.assertRaisesWithArg(IndexError, 3, lambda: tree[3]) @@ -72,45 +74,50 @@ def test_read_tree(barerepo): sha = '297efb891a47de80be0cfe9c639e4b8c9b450989' assertTreeEntryEqual(tree['c/d'], sha, 'd', 0o0100644) assertTreeEntryEqual(tree / 'c/d', sha, 'd', 0o0100644) - assertTreeEntryEqual(tree / 'c' / 'd', sha, 'd', 0o0100644) - assertTreeEntryEqual(tree['c']['d'], sha, 'd', 0o0100644) - assertTreeEntryEqual((tree / 'c')['d'], sha, 'd', 0o0100644) + assertTreeEntryEqual(tree / 'c' / 'd', sha, 'd', 0o0100644) # type: ignore[operator] + assertTreeEntryEqual(tree['c']['d'], sha, 'd', 0o0100644) # type: ignore[index] + assertTreeEntryEqual((tree / 'c')['d'], sha, 'd', 0o0100644) # type: ignore[index] utils.assertRaisesWithArg(KeyError, 'ab/cd', lambda: tree['ab/cd']) utils.assertRaisesWithArg(KeyError, 'ab/cd', lambda: tree / 'ab/cd') - utils.assertRaisesWithArg(KeyError, 'ab', lambda: tree / 'c' / 'ab') + utils.assertRaisesWithArg(KeyError, 'ab', lambda: tree / 'c' / 'ab') # type: ignore[operator] with pytest.raises(TypeError): - tree / 'a' / 'cd' + tree / 'a' / 'cd' # type: ignore -def test_equality(barerepo): +def test_equality(barerepo: Repository) -> None: tree_a = barerepo['18e2d2e9db075f9eb43bcb2daa65a2867d29a15e'] tree_b = barerepo['2ad1d3456c5c4a1c9e40aeeddb9cd20b409623c8'] + assert isinstance(tree_a, Tree) + assert isinstance(tree_b, Tree) assert tree_a['a'] != tree_b['a'] assert tree_a['a'] != tree_b['b'] assert tree_a['b'] == tree_b['b'] -def test_sorting(barerepo): +def test_sorting(barerepo: Repository) -> None: tree_a = barerepo['18e2d2e9db075f9eb43bcb2daa65a2867d29a15e'] + assert isinstance(tree_a, Tree) assert list(tree_a) == sorted(reversed(list(tree_a)), key=pygit2.tree_entry_key) - assert list(tree_a) != reversed(list(tree_a)) + assert list(tree_a) != reversed(list(tree_a)) # type: ignore[comparison-overlap] -def test_read_subtree(barerepo): +def test_read_subtree(barerepo: Repository) -> None: tree = barerepo[TREE_SHA] + assert isinstance(tree, Tree) subtree_entry = tree['c'] assertTreeEntryEqual(subtree_entry, SUBTREE_SHA, 'c', 0o0040000) - assert subtree_entry.type == ObjectType.TREE + assert subtree_entry.type == int(ObjectType.TREE) assert subtree_entry.type_str == 'tree' subtree_entry = tree / 'c' assertTreeEntryEqual(subtree_entry, SUBTREE_SHA, 'c', 0o0040000) - assert subtree_entry.type == ObjectType.TREE + assert subtree_entry.type == int(ObjectType.TREE) assert subtree_entry.type_str == 'tree' subtree = barerepo[subtree_entry.id] + assert isinstance(subtree, Tree) assert 1 == len(subtree) sha = '297efb891a47de80be0cfe9c639e4b8c9b450989' assertTreeEntryEqual(subtree[0], sha, 'd', 0o0100644) @@ -119,7 +126,7 @@ def test_read_subtree(barerepo): assert subtree_entry == barerepo[subtree_entry.id] -def test_new_tree(barerepo): +def test_new_tree(barerepo: Repository) -> None: repo = barerepo b0 = repo.create_blob('1') b1 = repo.create_blob('2') @@ -138,8 +145,8 @@ def test_new_tree(barerepo): ('y', b1, pygit2.Blob, FileMode.BLOB_EXECUTABLE, ObjectType.BLOB, 'blob'), ('z', subtree.id, pygit2.Tree, FileMode.TREE, ObjectType.TREE, 'tree'), ]: - assert name in tree - obj = tree[name] + assert name in tree # type: ignore[operator] + obj = tree[name] # type: ignore[index] assert isinstance(obj, cls) assert obj.name == name assert obj.filemode == filemode @@ -148,7 +155,7 @@ def test_new_tree(barerepo): assert repo[obj.id].id == oid assert obj == repo[obj.id] - obj = tree / name + obj = tree / name # type: ignore[operator] assert isinstance(obj, cls) assert obj.name == name assert obj.filemode == filemode @@ -158,44 +165,49 @@ def test_new_tree(barerepo): assert obj == repo[obj.id] -def test_modify_tree(barerepo): +def test_modify_tree(barerepo: Repository) -> None: tree = barerepo[TREE_SHA] with pytest.raises(TypeError): - operator.setitem('c', tree['a']) + operator.setitem('c', tree['a']) # type: ignore with pytest.raises(TypeError): - operator.delitem('c') + operator.delitem('c') # type: ignore -def test_iterate_tree(barerepo): +def test_iterate_tree(barerepo: Repository) -> None: """ Testing that we're able to iterate of a Tree object and that the resulting sha strings are consistent with the sha strings we could get with other Tree access methods. """ tree = barerepo[TREE_SHA] + assert isinstance(tree, Tree) for tree_entry in tree: + assert tree_entry.name is not None assert tree_entry == tree[tree_entry.name] -def test_iterate_tree_nested(barerepo): +def test_iterate_tree_nested(barerepo: Repository) -> None: """ Testing that we're able to iterate of a Tree object and then iterate trees we receive as a result. """ tree = barerepo[TREE_SHA] + assert isinstance(tree, Tree) for tree_entry in tree: if isinstance(tree_entry, pygit2.Tree): for tree_entry2 in tree_entry: pass -def test_deep_contains(barerepo): +def test_deep_contains(barerepo: Repository) -> None: tree = barerepo[TREE_SHA] + assert isinstance(tree, Tree) assert 'a' in tree assert 'c' in tree assert 'c/d' in tree assert 'c/e' not in tree assert 'd' not in tree + assert isinstance(tree['c'], Tree) assert 'd' in tree['c'] assert 'e' not in tree['c'] From 5d1f6c5995ba44460d7333e528e79ac746b804ca Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Fri, 1 Aug 2025 10:19:32 +0200 Subject: [PATCH 057/225] add types to test/utils.py --- test/utils.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/test/utils.py b/test/utils.py index f4490ab40..fe42a7607 100644 --- a/test/utils.py +++ b/test/utils.py @@ -32,7 +32,7 @@ import zipfile from pathlib import Path from types import TracebackType -from typing import Callable, Optional, ParamSpec, TypeVar +from typing import Any, Callable, Optional, ParamSpec, TypeVar # Requirements import pytest @@ -75,7 +75,7 @@ ) -def gen_blob_sha1(data): +def gen_blob_sha1(data: bytes) -> str: # http://stackoverflow.com/questions/552659/assigning-git-sha1s-without-git m = hashlib.sha1() m.update(f'blob {len(data)}\0'.encode()) @@ -83,8 +83,13 @@ def gen_blob_sha1(data): return m.hexdigest() -def force_rm_handle(remove_path, path, excinfo): - path = Path(path) +def force_rm_handle( + # Callable[..., Any], str, , object + remove_path: Callable[..., Any], + path_str: str, + excinfo: tuple[type[BaseException], BaseException, TracebackType], +) -> None: + path = Path(path_str) path.chmod(path.stat().st_mode | stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH) remove_path(path) From 69dd9f446c38093fd143560711543d7b5d93b3f1 Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Fri, 1 Aug 2025 11:03:45 +0200 Subject: [PATCH 058/225] mypy: enable disallow_untyped_defs for test folder and fix remaining errors --- mypy.ini | 4 +++ pygit2/_pygit2.pyi | 12 +++---- test/conftest.py | 30 ++++++++++------- test/test_blame.py | 7 ++-- test/test_blob.py | 2 +- test/test_config.py | 22 ++++++------ test/test_credentials.py | 32 ++++++++++++------ test/test_diff.py | 2 +- test/test_index.py | 4 +-- test/test_patch.py | 2 +- test/test_refs.py | 16 ++++----- test/test_repository.py | 73 ++++++++++++++++++++++++---------------- 12 files changed, 120 insertions(+), 86 deletions(-) create mode 100644 mypy.ini diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 000000000..e06fd6ba0 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,4 @@ +[mypy] + +[mypy-test.*] +disallow_untyped_defs = True diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index 69970ecd7..1db0b4505 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -867,8 +867,8 @@ class Repository: ) -> Oid: ... def diff( self, - a: None | str | bytes | Oid | Reference = None, - b: None | str | bytes | Oid | Reference = None, + a: None | str | bytes | Commit | Oid | Reference = None, + b: None | str | bytes | Commit | Oid | Reference = None, cached: bool = False, flags: DiffOption = DiffOption.NORMAL, context_lines: int = 3, @@ -1010,7 +1010,7 @@ class Repository: include_ignored: bool = False, keep_all: bool = False, paths: list[str] | None = None, - ) -> None: ... + ) -> Oid: ... def stash_apply( self, index: int = 0, @@ -1036,7 +1036,7 @@ class Repository: def walk( self, oid: _OidArg | None, sort_mode: SortMode = SortMode.NONE ) -> Walker: ... - def write(self, type: int, data: bytes) -> Oid: ... + def write(self, type: int, data: bytes | str) -> Oid: ... def write_archive( self, treeish: str | Tree | Object | Oid, @@ -1147,7 +1147,7 @@ class Worktree: def prune(self, force=False) -> None: ... def discover_repository( - path: str, across_fs: bool = False, ceiling_dirs: str = ... + path: str | Path, across_fs: bool = False, ceiling_dirs: str = ... ) -> str | None: ... def hash(data: bytes | str) -> Oid: ... def hashfile(path: str) -> Oid: ... @@ -1211,7 +1211,7 @@ def option( Option.DISABLE_PACK_KEEP_FILE_CHECKS, Option.SET_OWNER_VALIDATION, ], - value: bool, + value: bool | Literal[0, 1], ) -> None: ... @overload def option(opt: Literal[Option.GET_OWNER_VALIDATION]) -> int: ... diff --git a/test/conftest.py b/test/conftest.py index 6feb9d239..4ea9b6888 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,15 +1,17 @@ import platform from pathlib import Path +from typing import Generator import pytest import pygit2 +from pygit2 import Repository from . import utils @pytest.fixture(scope='session', autouse=True) -def global_git_config(): +def global_git_config() -> None: # Do not use global config for better test reproducibility. # https://github.com/libgit2/pygit2/issues/989 levels = [ @@ -26,37 +28,39 @@ def global_git_config(): @pytest.fixture -def pygit2_empty_key(): +def pygit2_empty_key() -> tuple[Path, str, str]: path = Path(__file__).parent / 'keys' / 'pygit2_empty' return path, f'{path}.pub', 'empty' @pytest.fixture -def barerepo(tmp_path): +def barerepo(tmp_path: Path) -> Generator[Repository, None, None]: with utils.TemporaryRepository('barerepo.zip', tmp_path) as path: yield pygit2.Repository(path) @pytest.fixture -def barerepo_path(tmp_path): +def barerepo_path(tmp_path: Path) -> Generator[tuple[Repository, Path], None, None]: with utils.TemporaryRepository('barerepo.zip', tmp_path) as path: yield pygit2.Repository(path), path @pytest.fixture -def blameflagsrepo(tmp_path): +def blameflagsrepo(tmp_path: Path) -> Generator[Repository, None, None]: with utils.TemporaryRepository('blameflagsrepo.zip', tmp_path) as path: yield pygit2.Repository(path) @pytest.fixture -def dirtyrepo(tmp_path): +def dirtyrepo(tmp_path: Path) -> Generator[Repository, None, None]: with utils.TemporaryRepository('dirtyrepo.zip', tmp_path) as path: yield pygit2.Repository(path) @pytest.fixture -def emptyrepo(barerepo, tmp_path): +def emptyrepo( + barerepo: Repository, tmp_path: Path +) -> Generator[Repository, None, None]: with utils.TemporaryRepository('emptyrepo.zip', tmp_path) as path: repo = pygit2.Repository(path) repo.remotes.create('origin', barerepo.path) @@ -64,36 +68,36 @@ def emptyrepo(barerepo, tmp_path): @pytest.fixture -def encodingrepo(tmp_path): +def encodingrepo(tmp_path: Path) -> Generator[Repository, None, None]: with utils.TemporaryRepository('encoding.zip', tmp_path) as path: yield pygit2.Repository(path) @pytest.fixture -def mergerepo(tmp_path): +def mergerepo(tmp_path: Path) -> Generator[Repository, None, None]: with utils.TemporaryRepository('testrepoformerging.zip', tmp_path) as path: yield pygit2.Repository(path) @pytest.fixture -def testrepo(tmp_path): +def testrepo(tmp_path: Path) -> Generator[Repository, None, None]: with utils.TemporaryRepository('testrepo.zip', tmp_path) as path: yield pygit2.Repository(path) @pytest.fixture -def testrepo_path(tmp_path): +def testrepo_path(tmp_path: Path) -> Generator[tuple[Repository, Path], None, None]: with utils.TemporaryRepository('testrepo.zip', tmp_path) as path: yield pygit2.Repository(path), path @pytest.fixture -def testrepopacked(tmp_path): +def testrepopacked(tmp_path: Path) -> Generator[Repository, None, None]: with utils.TemporaryRepository('testrepopacked.zip', tmp_path) as path: yield pygit2.Repository(path) @pytest.fixture -def gpgsigned(tmp_path): +def gpgsigned(tmp_path: Path) -> Generator[Repository, None, None]: with utils.TemporaryRepository('gpgsigned.zip', tmp_path) as path: yield pygit2.Repository(path) diff --git a/test/test_blame.py b/test/test_blame.py index 4f1d8e2b0..cb1223954 100644 --- a/test/test_blame.py +++ b/test/test_blame.py @@ -97,12 +97,11 @@ def test_blame_flags(blameflagsrepo: Repository) -> None: def test_blame_with_invalid_index(testrepo: Repository) -> None: blame = testrepo.blame(PATH) - def test(): + with pytest.raises(IndexError): blame[100000] - blame[-1] - with pytest.raises(IndexError): - test() + with pytest.raises(OverflowError): + blame[-1] def test_blame_for_line(testrepo: Repository) -> None: diff --git a/test/test_blob.py b/test/test_blob.py index 47ced2609..dcce71f4c 100644 --- a/test/test_blob.py +++ b/test/test_blob.py @@ -110,7 +110,7 @@ def test_create_blob(testrepo: Repository) -> None: assert len(BLOB_NEW_CONTENT) == len(blob_buffer) assert BLOB_NEW_CONTENT == blob_buffer - def set_content(): + def set_content() -> None: blob_buffer[:2] = b'hi' with pytest.raises(TypeError): diff --git a/test/test_config.py b/test/test_config.py index 5d4e5a09d..d3416a9a0 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -44,11 +44,11 @@ def config(testrepo: Repository) -> Generator[object, None, None]: pass -def test_config(config): +def test_config(config: Config) -> None: assert config is not None -def test_global_config(): +def test_global_config() -> None: try: assert Config.get_global_config() is not None except IOError: @@ -56,7 +56,7 @@ def test_global_config(): pass -def test_system_config(): +def test_system_config() -> None: try: assert Config.get_system_config() is not None except IOError: @@ -64,7 +64,7 @@ def test_system_config(): pass -def test_new(): +def test_new() -> None: # Touch file open(CONFIG_FILENAME, 'w').close() @@ -81,7 +81,7 @@ def test_new(): assert config_read['core.editor'] == 'ed' -def test_add(): +def test_add() -> None: with open(CONFIG_FILENAME, 'w') as new_file: new_file.write('[this]\n\tthat = true\n') new_file.write('[something "other"]\n\there = false') @@ -94,7 +94,7 @@ def test_add(): assert not config.get_bool('something.other.here') -def test_add_aspath(): +def test_add_aspath() -> None: with open(CONFIG_FILENAME, 'w') as new_file: new_file.write('[this]\n\tthat = true\n') @@ -103,7 +103,7 @@ def test_add_aspath(): assert 'this.that' in config -def test_read(config): +def test_read(config: Config) -> None: with pytest.raises(TypeError): config[()] with pytest.raises(TypeError): @@ -121,7 +121,7 @@ def test_read(config): assert config.get_int('core.repositoryformatversion') == 0 -def test_write(config): +def test_write(config: Config) -> None: with pytest.raises(TypeError): config.__setitem__((), 'This should not work') @@ -148,7 +148,7 @@ def test_write(config): assert 'core.dummy3' not in config -def test_multivar(): +def test_multivar() -> None: with open(CONFIG_FILENAME, 'w') as new_file: new_file.write('[this]\n\tthat = foobar\n\tthat = foobeer\n') @@ -175,7 +175,7 @@ def test_multivar(): assert [] == list(config.get_multivar('this.that', '')) -def test_iterator(config): +def test_iterator(config: Config) -> None: lst = {} for entry in config: assert entry.level > -1 @@ -185,7 +185,7 @@ def test_iterator(config): assert lst['core.bare'] -def test_parsing(): +def test_parsing() -> None: assert Config.parse_bool('on') assert Config.parse_bool('1') diff --git a/test/test_credentials.py b/test/test_credentials.py index 1a5752579..dbc98823a 100644 --- a/test/test_credentials.py +++ b/test/test_credentials.py @@ -137,10 +137,15 @@ def test_keypair_from_memory( pygit2.clone_repository(url, tmp_path, callbacks=callbacks) -def test_callback(testrepo: Repository): +def test_callback(testrepo: Repository) -> None: class MyCallbacks(pygit2.RemoteCallbacks): - def credentials(testrepo, url, username, allowed): - assert allowed & CredentialType.USERPASS_PLAINTEXT + def credentials( + self, + url: str, + username_from_url: str | None, + allowed_types: CredentialType, + ) -> Username | UserPass | Keypair: + assert allowed_types & CredentialType.USERPASS_PLAINTEXT raise Exception("I don't know the password") url = 'https://github.com/github/github' @@ -150,10 +155,15 @@ def credentials(testrepo, url, username, allowed): @utils.requires_network -def test_bad_cred_type(testrepo: Repository): +def test_bad_cred_type(testrepo: Repository) -> None: class MyCallbacks(pygit2.RemoteCallbacks): - def credentials(testrepo, url, username, allowed): - assert allowed & CredentialType.USERPASS_PLAINTEXT + def credentials( + self, + url: str, + username_from_url: str | None, + allowed_types: CredentialType, + ) -> Username | UserPass | Keypair: + assert allowed_types & CredentialType.USERPASS_PLAINTEXT return Keypair('git', 'foo.pub', 'foo', 'sekkrit') url = 'https://github.com/github/github' @@ -163,9 +173,11 @@ def credentials(testrepo, url, username, allowed): @utils.requires_network -def test_fetch_certificate_check(testrepo: Repository): +def test_fetch_certificate_check(testrepo: Repository) -> None: class MyCallbacks(pygit2.RemoteCallbacks): - def certificate_check(testrepo, certificate, valid, host): + def certificate_check( + self, certificate: None, valid: bool, host: bytes + ) -> bool: assert certificate is None assert valid is True assert host == b'github.com' @@ -188,7 +200,7 @@ def certificate_check(testrepo, certificate, valid, host): @utils.requires_network -def test_user_pass(testrepo: Repository): +def test_user_pass(testrepo: Repository) -> None: credentials = UserPass('libgit2', 'libgit2') callbacks = pygit2.RemoteCallbacks(credentials=credentials) @@ -200,7 +212,7 @@ def test_user_pass(testrepo: Repository): @utils.requires_proxy @utils.requires_network @utils.requires_future_libgit2 -def test_proxy(testrepo: Repository): +def test_proxy(testrepo: Repository) -> None: credentials = UserPass('libgit2', 'libgit2') callbacks = pygit2.RemoteCallbacks(credentials=credentials) diff --git a/test/test_diff.py b/test/test_diff.py index dea3a92e2..7c75dc4da 100644 --- a/test/test_diff.py +++ b/test/test_diff.py @@ -433,7 +433,7 @@ def test_parse_diff_null() -> None: pygit2.Diff.parse_diff(None) # type: ignore -def test_parse_diff_bad(): +def test_parse_diff_bad() -> None: diff = textwrap.dedent( """ diff --git a/file1 b/file1 diff --git a/test/test_index.py b/test/test_index.py index 6f3f3824b..0fa2e5403 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -298,11 +298,11 @@ def test_entry_repr(testrepo: Repository) -> None: ) -def test_create_empty(): +def test_create_empty() -> None: Index() -def test_create_empty_read_tree_as_string(): +def test_create_empty_read_tree_as_string() -> None: index = Index() # no repo associated, so we don't know where to read from with pytest.raises(TypeError): diff --git a/test/test_patch.py b/test/test_patch.py index 1c27986e1..4b74dd572 100644 --- a/test/test_patch.py +++ b/test/test_patch.py @@ -81,7 +81,7 @@ """ -def test_patch_create_from_buffers(): +def test_patch_create_from_buffers() -> None: patch = pygit2.Patch.create_from( BLOB_OLD_CONTENT, BLOB_NEW_CONTENT, diff --git a/test/test_refs.py b/test/test_refs.py index e1eced7a8..5d441fccf 100644 --- a/test/test_refs.py +++ b/test/test_refs.py @@ -734,7 +734,7 @@ def test_peel(testrepo: Repository) -> None: assert commit.tree.id == ref.peel(Tree).id -def test_valid_reference_names_ascii(): +def test_valid_reference_names_ascii() -> None: assert reference_is_valid_name('HEAD') assert reference_is_valid_name('refs/heads/master') assert reference_is_valid_name('refs/heads/perfectly/valid') @@ -742,12 +742,12 @@ def test_valid_reference_names_ascii(): assert reference_is_valid_name('refs/special/ref') -def test_valid_reference_names_unicode(): +def test_valid_reference_names_unicode() -> None: assert reference_is_valid_name('refs/heads/ünicöde') assert reference_is_valid_name('refs/tags/😀') -def test_invalid_reference_names(): +def test_invalid_reference_names() -> None: assert not reference_is_valid_name('') assert not reference_is_valid_name(' refs/heads/master') assert not reference_is_valid_name('refs/heads/in..valid') @@ -762,12 +762,12 @@ def test_invalid_reference_names(): assert not reference_is_valid_name('refs/heads/foo//bar') -def test_invalid_arguments(): +def test_invalid_arguments() -> None: with pytest.raises(TypeError): - reference_is_valid_name() + reference_is_valid_name() # type: ignore with pytest.raises(TypeError): - reference_is_valid_name(None) + reference_is_valid_name(None) # type: ignore with pytest.raises(TypeError): - reference_is_valid_name(1) + reference_is_valid_name(1) # type: ignore with pytest.raises(TypeError): - reference_is_valid_name('too', 'many') + reference_is_valid_name('too', 'many') # type: ignore diff --git a/test/test_repository.py b/test/test_repository.py index 5802ae4b0..b98b1784b 100644 --- a/test/test_repository.py +++ b/test/test_repository.py @@ -38,14 +38,18 @@ DiffFile, IndexEntry, Oid, + Remote, Repository, + Worktree, clone_repository, discover_repository, init_repository, ) +from pygit2.credentials import Keypair, Username, UserPass from pygit2.enums import ( CheckoutNotify, CheckoutStrategy, + CredentialType, FileMode, FileStatus, ObjectType, @@ -446,7 +450,7 @@ def test_stash_partial(testrepo: Repository) -> None: assert testrepo.status()['bye.txt'] == FileStatus.WT_NEW assert testrepo.status()['untracked2.txt'] == FileStatus.WT_NEW - def stash_pathspecs(paths): + def stash_pathspecs(paths: list[str]) -> bool: stash_id = testrepo.stash( sig, message=stash_message, keep_all=True, paths=paths ) @@ -480,7 +484,7 @@ def test_stash_progress_callback(testrepo: Repository) -> None: progress_sequence = [] class MyStashApplyCallbacks(pygit2.StashApplyCallbacks): - def stash_apply_progress(self, progress: StashApplyProgress): + def stash_apply_progress(self, progress: StashApplyProgress) -> None: progress_sequence.append(progress) # apply the stash @@ -515,7 +519,7 @@ def test_stash_aborted_from_callbacks(testrepo: Repository) -> None: # define callbacks that will abort the unstash process # just as libgit2 is ready to write the files to disk class MyStashApplyCallbacks(pygit2.StashApplyCallbacks): - def stash_apply_progress(self, progress: StashApplyProgress): + def stash_apply_progress(self, progress: StashApplyProgress) -> None: if progress == StashApplyProgress.CHECKOUT_UNTRACKED: raise InterruptedError('Stop applying the stash!') @@ -635,7 +639,7 @@ def test_default_signature(testrepo: Repository) -> None: assert 'rjh@example.com' == sig.email -def test_new_repo(tmp_path): +def test_new_repo(tmp_path: Path) -> None: repo = init_repository(tmp_path, False) oid = repo.write(ObjectType.BLOB, 'Test') @@ -644,55 +648,57 @@ def test_new_repo(tmp_path): assert (tmp_path / '.git').exists() -def test_no_arg(tmp_path): +def test_no_arg(tmp_path: Path) -> None: repo = init_repository(tmp_path) assert not repo.is_bare -def test_no_arg_aspath(tmp_path): +def test_no_arg_aspath(tmp_path: Path) -> None: repo = init_repository(Path(tmp_path)) assert not repo.is_bare -def test_pos_arg_false(tmp_path): +def test_pos_arg_false(tmp_path: Path) -> None: repo = init_repository(tmp_path, False) assert not repo.is_bare -def test_pos_arg_true(tmp_path): +def test_pos_arg_true(tmp_path: Path) -> None: repo = init_repository(tmp_path, True) assert repo.is_bare -def test_keyword_arg_false(tmp_path): +def test_keyword_arg_false(tmp_path: Path) -> None: repo = init_repository(tmp_path, bare=False) assert not repo.is_bare -def test_keyword_arg_true(tmp_path): +def test_keyword_arg_true(tmp_path: Path) -> None: repo = init_repository(tmp_path, bare=True) assert repo.is_bare -def test_discover_repo(tmp_path): +def test_discover_repo(tmp_path: Path) -> None: repo = init_repository(tmp_path, False) subdir = tmp_path / 'test1' / 'test2' subdir.mkdir(parents=True) assert repo.path == discover_repository(str(subdir)) -def test_discover_repo_aspath(tmp_path): +def test_discover_repo_aspath(tmp_path: Path) -> None: repo = init_repository(Path(tmp_path), False) subdir = Path(tmp_path) / 'test1' / 'test2' subdir.mkdir(parents=True) assert repo.path == discover_repository(subdir) -def test_discover_repo_not_found(): - assert discover_repository(tempfile.tempdir) is None +def test_discover_repo_not_found() -> None: + tempdir = tempfile.tempdir + assert tempdir is not None + assert discover_repository(tempdir) is None -def test_repository_init(barerepo_path): +def test_repository_init(barerepo_path: tuple[Repository, Path]) -> None: barerepo, path = barerepo_path assert isinstance(path, Path) pygit2.Repository(path) @@ -700,7 +706,7 @@ def test_repository_init(barerepo_path): pygit2.Repository(bytes(path)) -def test_clone_repository(barerepo, tmp_path): +def test_clone_repository(barerepo: Repository, tmp_path: Path) -> None: assert barerepo.is_bare repo = clone_repository(Path(barerepo.path), tmp_path / 'clonepath') assert not repo.is_empty @@ -710,14 +716,14 @@ def test_clone_repository(barerepo, tmp_path): assert not repo.is_bare -def test_clone_bare_repository(barerepo, tmp_path): +def test_clone_bare_repository(barerepo: Repository, tmp_path: Path) -> None: repo = clone_repository(barerepo.path, tmp_path / 'clone', bare=True) assert not repo.is_empty assert repo.is_bare @utils.requires_network -def test_clone_shallow_repository(tmp_path): +def test_clone_shallow_repository(tmp_path: Path) -> None: # shallow cloning currently only works with remote repositories url = 'https://github.com/libgit2/TestGitRepository' repo = clone_repository(url, tmp_path / 'clone-shallow', depth=1) @@ -725,15 +731,17 @@ def test_clone_shallow_repository(tmp_path): assert repo.is_shallow -def test_clone_repository_and_remote_callbacks(barerepo, tmp_path): +def test_clone_repository_and_remote_callbacks( + barerepo: Repository, tmp_path: Path +) -> None: url = Path(barerepo.path).resolve().as_uri() repo_path = tmp_path / 'clone-into' - def create_repository(path, bare): + def create_repository(path: Path, bare: bool) -> Repository: return init_repository(path, bare) # here we override the name - def create_remote(repo, name, url): + def create_remote(repo: Repository, name: str, url: str) -> Remote: return repo.remotes.create('custom_remote', url) repo = clone_repository( @@ -746,7 +754,7 @@ def create_remote(repo, name, url): @utils.requires_network -def test_clone_with_credentials(tmp_path): +def test_clone_with_credentials(tmp_path: Path) -> None: url = 'https://github.com/libgit2/TestGitRepository' credentials = pygit2.UserPass('libgit2', 'libgit2') callbacks = pygit2.RemoteCallbacks(credentials=credentials) @@ -756,9 +764,14 @@ def test_clone_with_credentials(tmp_path): @utils.requires_network -def test_clone_bad_credentials(tmp_path): +def test_clone_bad_credentials(tmp_path: Path) -> None: class MyCallbacks(pygit2.RemoteCallbacks): - def credentials(self, url, username, allowed): + def credentials( + self, + url: str, + username_from_url: str | None, + allowed_types: CredentialType, + ) -> Username | UserPass | Keypair: raise RuntimeError('Unexpected error') url = 'https://github.com/github/github' @@ -767,12 +780,14 @@ def credentials(self, url, username, allowed): assert str(exc.value) == 'Unexpected error' -def test_clone_with_checkout_branch(barerepo, tmp_path): +def test_clone_with_checkout_branch(barerepo: Repository, tmp_path: Path) -> None: # create a test case which isolates the remote test_repo = clone_repository( barerepo.path, tmp_path / 'testrepo-orig.git', bare=True ) - test_repo.create_branch('test', test_repo[test_repo.head.target]) + commit = test_repo[test_repo.head.target] + assert isinstance(commit, Commit) + test_repo.create_branch('test', commit) repo = clone_repository( test_repo.path, tmp_path / 'testrepo.git', checkout_branch='test', bare=True ) @@ -781,7 +796,7 @@ def test_clone_with_checkout_branch(barerepo, tmp_path): @utils.requires_proxy @utils.requires_network -def test_clone_with_proxy(tmp_path): +def test_clone_with_proxy(tmp_path: Path) -> None: url = 'https://github.com/libgit2/TestGitRepository' repo = clone_repository( url, @@ -839,7 +854,7 @@ def test_worktree(testrepo: Repository) -> None: # worktree later worktree_dir.rmdir() - def _check_worktree(worktree): + def _check_worktree(worktree: Worktree) -> None: # Confirm the name attribute matches the specified name assert worktree.name == worktree_name # Confirm the path attribute points to the correct path @@ -918,7 +933,7 @@ def test_worktree_custom_ref(testrepo: Repository) -> None: assert branch_name in testrepo.branches -def test_open_extended(tmp_path): +def test_open_extended(tmp_path: Path) -> None: with utils.TemporaryRepository('dirtyrepo.zip', tmp_path) as path: orig_repo = pygit2.Repository(path) assert not orig_repo.is_bare From 468f196d4a94cba3250f8d0272abafd6bb048115 Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Fri, 1 Aug 2025 15:08:29 +0200 Subject: [PATCH 059/225] mypy: enable disallow_untyped_calls for test folder and fix remaining errors mainly driven by test/test_config.py --- mypy.ini | 1 + pygit2/_libgit2/ffi.pyi | 17 ++++++++++++ pygit2/config.py | 61 ++++++++++++++++++++++------------------- pygit2/index.py | 2 +- pygit2/refspec.py | 20 ++++++++------ pygit2/remotes.py | 2 +- pygit2/submodules.py | 14 ++++++---- test/test_config.py | 6 ++-- 8 files changed, 75 insertions(+), 48 deletions(-) diff --git a/mypy.ini b/mypy.ini index e06fd6ba0..e9f046b0a 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2,3 +2,4 @@ [mypy-test.*] disallow_untyped_defs = True +disallow_untyped_calls = True diff --git a/pygit2/_libgit2/ffi.pyi b/pygit2/_libgit2/ffi.pyi index 4cb37caaa..7d09007e4 100644 --- a/pygit2/_libgit2/ffi.pyi +++ b/pygit2/_libgit2/ffi.pyi @@ -38,6 +38,12 @@ char_pointer = NewType('char_pointer', object) class size_t: def __getitem__(self, item: Literal[0]) -> int: ... +class int_c: + def __getitem__(self, item: Literal[0]) -> int: ... + +class int64_t: + def __getitem__(self, item: Literal[0]) -> int: ... + class _Pointer(Generic[T]): def __setitem__(self, item: Literal[0], a: T) -> None: ... @overload @@ -141,9 +147,14 @@ class GitConfigC: # incomplete pass +class GitConfigIteratorC: + # incomplete + pass + class GitConfigEntryC: # incomplete name: char_pointer + value: char_pointer level: int class GitDescribeFormatOptionsC: @@ -269,6 +280,8 @@ def new(a: Literal['git_config *']) -> GitConfigC: ... @overload def new(a: Literal['git_config **']) -> _Pointer[GitConfigC]: ... @overload +def new(a: Literal['git_config_iterator **']) -> _Pointer[GitConfigIteratorC]: ... +@overload def new(a: Literal['git_config_entry **']) -> _Pointer[GitConfigEntryC]: ... @overload def new(a: Literal['git_describe_format_options *']) -> GitDescribeFormatOptionsC: ... @@ -297,6 +310,10 @@ def new(a: Literal['git_signature *']) -> GitSignatureC: ... @overload def new(a: Literal['git_signature **']) -> _Pointer[GitSignatureC]: ... @overload +def new(a: Literal['int *']) -> int_c: ... +@overload +def new(a: Literal['int64_t *']) -> int64_t: ... +@overload def new( a: Literal['git_remote_head ***'], ) -> _Pointer[_MultiPointer[GitRemoteHeadC]]: ... diff --git a/pygit2/config.py b/pygit2/config.py index 0f18d6cfd..b9fe5b130 100644 --- a/pygit2/config.py +++ b/pygit2/config.py @@ -24,7 +24,8 @@ # Boston, MA 02110-1301, USA. from os import PathLike -from typing import TYPE_CHECKING +from pathlib import Path +from typing import TYPE_CHECKING, Callable, Iterator try: from functools import cached_property @@ -48,20 +49,20 @@ def str_to_bytes(value: str | PathLike[str] | bytes, name: str) -> bytes: class ConfigIterator: - def __init__(self, config, ptr): + def __init__(self, config, ptr) -> None: self._iter = ptr self._config = config - def __del__(self): + def __del__(self) -> None: C.git_config_iterator_free(self._iter) - def __iter__(self): + def __iter__(self) -> 'ConfigIterator': return self - def __next__(self): + def __next__(self) -> 'ConfigEntry': return self._next_entry() - def _next_entry(self): + def _next_entry(self) -> 'ConfigEntry': centry = ffi.new('git_config_entry **') err = C.git_config_next(centry, self._iter) check_error(err) @@ -70,7 +71,7 @@ def _next_entry(self): class ConfigMultivarIterator(ConfigIterator): - def __next__(self): + def __next__(self) -> str: # type: ignore[override] entry = self._next_entry() return entry.value @@ -124,7 +125,7 @@ def _get_entry(self, key: str | bytes) -> 'ConfigEntry': check_error(err) return entry - def __contains__(self, key): + def __contains__(self, key: str | bytes) -> bool: err, cstr = self._get(key) if err == C.GIT_ENOTFOUND: @@ -134,7 +135,7 @@ def __contains__(self, key): return True - def __getitem__(self, key): + def __getitem__(self, key: str | bytes) -> str: """ When using the mapping interface, the value is returned as a string. In order to apply the git-config parsing rules, you can use @@ -144,7 +145,7 @@ def __getitem__(self, key): return entry.value - def __setitem__(self, key, value): + def __setitem__(self, key: str | bytes, value: bool | int | str | bytes) -> None: key = str_to_bytes(key, 'key') err = 0 @@ -157,13 +158,13 @@ def __setitem__(self, key, value): check_error(err) - def __delitem__(self, key): + def __delitem__(self, key: str | bytes) -> None: key = str_to_bytes(key, 'key') err = C.git_config_delete_entry(self._config, key) check_error(err) - def __iter__(self): + def __iter__(self) -> Iterator['ConfigEntry']: """ Iterate over configuration entries, returning a ``ConfigEntry`` objects. These contain the name, level, and value of each configuration @@ -176,22 +177,26 @@ def __iter__(self): return ConfigIterator(self, citer[0]) - def get_multivar(self, name, regex=None): + def get_multivar( + self, name: str | bytes, regex: str | None = None + ) -> ConfigMultivarIterator: """Get each value of a multivar ''name'' as a list of strings. The optional ''regex'' parameter is expected to be a regular expression to filter the variables we're interested in. """ name = str_to_bytes(name, 'name') - regex = to_bytes(regex or None) + regex_bytes = to_bytes(regex or None) citer = ffi.new('git_config_iterator **') - err = C.git_config_multivar_iterator_new(citer, self._config, name, regex) + err = C.git_config_multivar_iterator_new(citer, self._config, name, regex_bytes) check_error(err) return ConfigMultivarIterator(self, citer[0]) - def set_multivar(self, name, regex, value): + def set_multivar( + self, name: str | bytes, regex: str | bytes, value: str | bytes + ) -> None: """Set a multivar ''name'' to ''value''. ''regexp'' is a regular expression to indicate which values to replace. """ @@ -202,7 +207,7 @@ def set_multivar(self, name, regex, value): err = C.git_config_set_multivar(self._config, name, regex, value) check_error(err) - def delete_multivar(self, name, regex): + def delete_multivar(self, name: str | bytes, regex: str | bytes) -> None: """Delete a multivar ''name''. ''regexp'' is a regular expression to indicate which values to delete. """ @@ -212,7 +217,7 @@ def delete_multivar(self, name, regex): err = C.git_config_delete_multivar(self._config, name, regex) check_error(err) - def get_bool(self, key): + def get_bool(self, key: str | bytes) -> bool: """Look up *key* and parse its value as a boolean as per the git-config rules. Return a boolean value (True or False). @@ -227,7 +232,7 @@ def get_bool(self, key): return res[0] != 0 - def get_int(self, key): + def get_int(self, key: bytes | str) -> int: """Look up *key* and parse its value as an integer as per the git-config rules. Return an integer. @@ -242,7 +247,7 @@ def get_int(self, key): return res[0] - def add_file(self, path, level=0, force=0): + def add_file(self, path: str | Path, level: int = 0, force: int = 0) -> None: """Add a config file instance to an existing config.""" err = C.git_config_add_file_ondisk( @@ -250,7 +255,7 @@ def add_file(self, path, level=0, force=0): ) check_error(err) - def snapshot(self): + def snapshot(self) -> 'Config': """Create a snapshot from this Config object. This means that looking up multiple values will use the same version @@ -267,7 +272,7 @@ def snapshot(self): # @staticmethod - def parse_bool(text): + def parse_bool(text: str) -> bool: res = ffi.new('int *') err = C.git_config_parse_bool(res, to_bytes(text)) check_error(err) @@ -275,7 +280,7 @@ def parse_bool(text): return res[0] != 0 @staticmethod - def parse_int(text): + def parse_int(text: str) -> int: res = ffi.new('int64_t *') err = C.git_config_parse_int64(res, to_bytes(text)) check_error(err) @@ -287,7 +292,7 @@ def parse_int(text): # @staticmethod - def _from_found_config(fn): + def _from_found_config(fn: Callable) -> 'Config': buf = ffi.new('git_buf *', (ffi.NULL, 0)) err = fn(buf) check_error(err, io=True) @@ -297,17 +302,17 @@ def _from_found_config(fn): return Config(cpath) @staticmethod - def get_system_config(): + def get_system_config() -> 'Config': """Return a object representing the system configuration file.""" return Config._from_found_config(C.git_config_find_system) @staticmethod - def get_global_config(): + def get_global_config() -> 'Config': """Return a object representing the global configuration file.""" return Config._from_found_config(C.git_config_find_global) @staticmethod - def get_xdg_config(): + def get_xdg_config() -> 'Config': """Return a object representing the global configuration file.""" return Config._from_found_config(C.git_config_find_xdg) @@ -349,7 +354,7 @@ def __del__(self) -> None: C.git_config_entry_free(self._entry) @property - def c_value(self): + def c_value(self) -> 'ffi.char_pointer': """The raw ``cData`` entry value.""" return self._entry.value diff --git a/pygit2/index.py b/pygit2/index.py index 3c9f9876f..ea2d5ee51 100644 --- a/pygit2/index.py +++ b/pygit2/index.py @@ -103,7 +103,7 @@ def __getitem__(self, key: str | int | PathLike[str]) -> 'IndexEntry': def __iter__(self): return GenericIterator(self) - def read(self, force=True): + def read(self, force: bool = True) -> None: """ Update the contents of the Index by reading from a file. diff --git a/pygit2/refspec.py b/pygit2/refspec.py index 70b0f4999..968f7fdd0 100644 --- a/pygit2/refspec.py +++ b/pygit2/refspec.py @@ -23,6 +23,8 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. +from typing import Callable + # Import from pygit2 from .errors import check_error from .ffi import C, ffi @@ -37,22 +39,22 @@ def __init__(self, owner, ptr): self._refspec = ptr @property - def src(self): + def src(self) -> str: """Source or lhs of the refspec""" return ffi.string(C.git_refspec_src(self._refspec)).decode('utf-8') @property - def dst(self): + def dst(self) -> str: """Destination or rhs of the refspec""" return ffi.string(C.git_refspec_dst(self._refspec)).decode('utf-8') @property - def force(self): + def force(self) -> bool: """Whether this refspeca llows non-fast-forward updates""" return bool(C.git_refspec_force(self._refspec)) @property - def string(self): + def string(self) -> str: """String which was used to create this refspec""" return ffi.string(C.git_refspec_string(self._refspec)).decode('utf-8') @@ -61,18 +63,18 @@ def direction(self): """Direction of this refspec (fetch or push)""" return C.git_refspec_direction(self._refspec) - def src_matches(self, ref): + def src_matches(self, ref: str) -> bool: """Return True if the given string matches the source of this refspec, False otherwise. """ return bool(C.git_refspec_src_matches(self._refspec, to_bytes(ref))) - def dst_matches(self, ref): + def dst_matches(self, ref: str) -> bool: """Return True if the given string matches the destination of this refspec, False otherwise.""" return bool(C.git_refspec_dst_matches(self._refspec, to_bytes(ref))) - def _transform(self, ref, fn): + def _transform(self, ref: str, fn: Callable) -> str: buf = ffi.new('git_buf *', (ffi.NULL, 0)) err = fn(buf, self._refspec, to_bytes(ref)) check_error(err) @@ -82,13 +84,13 @@ def _transform(self, ref, fn): finally: C.git_buf_dispose(buf) - def transform(self, ref): + def transform(self, ref: str) -> str: """Transform a reference name according to this refspec from the lhs to the rhs. Return an string. """ return self._transform(ref, C.git_refspec_transform) - def rtransform(self, ref): + def rtransform(self, ref: str) -> str: """Transform a reference name according to this refspec from the lhs to the rhs. Return an string. """ diff --git a/pygit2/remotes.py b/pygit2/remotes.py index 93f184790..779cb80f2 100644 --- a/pygit2/remotes.py +++ b/pygit2/remotes.py @@ -246,7 +246,7 @@ def refspec_count(self): return C.git_remote_refspec_count(self._remote) - def get_refspec(self, n): + def get_refspec(self, n: int) -> Refspec: """Return the object at the given position.""" spec = C.git_remote_get_refspec(self._remote, n) return Refspec(self, spec) diff --git a/pygit2/submodules.py b/pygit2/submodules.py index 0f98e4f41..7e5bf081f 100644 --- a/pygit2/submodules.py +++ b/pygit2/submodules.py @@ -86,7 +86,7 @@ def update( init: bool = False, callbacks: Optional[RemoteCallbacks] = None, depth: int = 0, - ): + ) -> None: """ Update a submodule. This will clone a missing submodule and checkout the subrepository to the commit specified in the index of the @@ -119,7 +119,7 @@ def update( err = C.git_submodule_update(self._subm, int(init), opts) payload.check_error(err) - def reload(self, force: bool = False): + def reload(self, force: bool = False) -> None: """ Reread submodule info from config, index, and HEAD. @@ -272,7 +272,9 @@ def add( check_error(err) return submodule_instance - def init(self, submodules: Optional[Iterable[str]] = None, overwrite: bool = False): + def init( + self, submodules: Optional[Iterable[str]] = None, overwrite: bool = False + ) -> None: """ Initialize submodules in the repository. Just like "git submodule init", this copies information about the submodules into ".git/config". @@ -300,7 +302,7 @@ def update( init: bool = False, callbacks: Optional[RemoteCallbacks] = None, depth: int = 0, - ): + ) -> None: """ Update submodules. This will clone a missing submodule and checkout the subrepository to the commit specified in the index of the @@ -357,7 +359,7 @@ def status( check_error(err) return SubmoduleStatus(cstatus[0]) - def cache_all(self): + def cache_all(self) -> None: """ Load and cache all submodules in the repository. @@ -370,7 +372,7 @@ def cache_all(self): err = C.git_repository_submodule_cache_all(self._repository._repo) check_error(err) - def cache_clear(self): + def cache_clear(self) -> None: """ Clear the submodule cache populated by `submodule_cache_all`. If there is no cache, do nothing. diff --git a/test/test_config.py b/test/test_config.py index d3416a9a0..f7e63112a 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -105,9 +105,9 @@ def test_add_aspath() -> None: def test_read(config: Config) -> None: with pytest.raises(TypeError): - config[()] + config[()] # type: ignore with pytest.raises(TypeError): - config[-4] + config[-4] # type: ignore utils.assertRaisesWithArg( ValueError, "invalid config item name 'abc'", lambda: config['abc'] ) @@ -123,7 +123,7 @@ def test_read(config: Config) -> None: def test_write(config: Config) -> None: with pytest.raises(TypeError): - config.__setitem__((), 'This should not work') + config.__setitem__((), 'This should not work') # type: ignore assert 'core.dummy1' not in config config['core.dummy1'] = 42 From 6028542acbf1d1a58b2333ac683f697f0b35aa5c Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Fri, 1 Aug 2025 16:10:03 +0200 Subject: [PATCH 060/225] move types for References to implementation --- pygit2/_pygit2.pyi | 16 +--------------- pygit2/references.py | 29 +++++++++++++++++------------ pygit2/repository.py | 13 ++++++++++--- 3 files changed, 28 insertions(+), 30 deletions(-) diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index 1db0b4505..6df2dab67 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -61,6 +61,7 @@ from .enums import ( from .filter import Filter from .index import MergeFileResult from .packbuilder import PackBuilder +from .references import References from .remotes import Remote from .repository import BaseRepository from .submodules import SubmoduleCollection @@ -668,21 +669,6 @@ class RefdbBackend: class RefdbFsBackend(RefdbBackend): def __init__(self, *args, **kwargs) -> None: ... -class References: - def __init__(self, repository: BaseRepository) -> None: ... - def __getitem__(self, name: str) -> Reference: ... - def get(self, key: str) -> Reference: ... - def __iter__(self) -> Iterator[str]: ... - def iterator( - self, references_return_type: ReferenceFilter = ... - ) -> Iterator[Reference]: ... - def create(self, name: str, target: _OidArg, force: bool = False) -> Reference: ... - def delete(self, name: str) -> None: ... - def __contains__(self, name: str) -> bool: ... - @property - def objects(self) -> list[Reference]: ... - def compress(self) -> None: ... - _Proxy = None | Literal[True] | str class _StrArray: diff --git a/pygit2/references.py b/pygit2/references.py index 630c3fe5c..3fa39491c 100644 --- a/pygit2/references.py +++ b/pygit2/references.py @@ -25,29 +25,32 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Iterator + +from pygit2 import Oid from .enums import ReferenceFilter # Need BaseRepository for type hints, but don't let it cause a circular dependency if TYPE_CHECKING: + from ._pygit2 import Reference from .repository import BaseRepository class References: - def __init__(self, repository: BaseRepository): + def __init__(self, repository: BaseRepository) -> None: self._repository = repository - def __getitem__(self, name: str): + def __getitem__(self, name: str) -> 'Reference': return self._repository.lookup_reference(name) - def get(self, key: str): + def get(self, key: str) -> 'Reference': try: return self[key] except KeyError: - return None + return None # type: ignore # will be corrected in next commit! - def __iter__(self): + def __iter__(self) -> Iterator[str]: iter = self._repository.references_iterator_init() while True: ref = self._repository.references_iterator_next(iter) @@ -56,7 +59,9 @@ def __iter__(self): else: return - def iterator(self, references_return_type: ReferenceFilter = ReferenceFilter.ALL): + def iterator( + self, references_return_type: ReferenceFilter = ReferenceFilter.ALL + ) -> Iterator['Reference']: """Creates a new iterator and fetches references for a given repository. Can also filter and pass all refs or only branches or only tags. @@ -88,18 +93,18 @@ def iterator(self, references_return_type: ReferenceFilter = ReferenceFilter.ALL else: return - def create(self, name, target, force=False): + def create(self, name: str, target: Oid | str, force: bool = False) -> 'Reference': return self._repository.create_reference(name, target, force) - def delete(self, name: str): + def delete(self, name: str) -> None: self[name].delete() - def __contains__(self, name: str): + def __contains__(self, name: str) -> bool: return self.get(name) is not None @property - def objects(self): + def objects(self) -> list['Reference']: return self._repository.listall_reference_objects() - def compress(self): + def compress(self) -> None: return self._repository.compress_references() diff --git a/pygit2/repository.py b/pygit2/repository.py index df54065f5..c56c9650c 100644 --- a/pygit2/repository.py +++ b/pygit2/repository.py @@ -251,7 +251,13 @@ def config_snapshot(self): # # References # - def create_reference(self, name, target, force=False, message=None): + def create_reference( + self, + name: str, + target: Oid | str, + force: bool = False, + message: str | None = None, + ) -> 'Reference': """Create a new reference "name" which points to an object or to another reference. @@ -273,12 +279,13 @@ def create_reference(self, name, target, force=False, message=None): repo.create_reference('refs/tags/foo', 'refs/heads/master') repo.create_reference('refs/tags/foo', 'bbb78a9cec580') """ - direct = type(target) is Oid or ( + direct = isinstance(target, Oid) or ( all(c in hexdigits for c in target) and GIT_OID_MINPREFIXLEN <= len(target) <= GIT_OID_HEXSZ ) - if direct: + # duplicate isinstance call for mypy + if direct or isinstance(target, Oid): return self.create_reference_direct(name, target, force, message=message) return self.create_reference_symbolic(name, target, force, message=message) From e433a4c64e543c58a8e987ff96ba0e15b14a4827 Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Fri, 1 Aug 2025 16:18:54 +0200 Subject: [PATCH 061/225] typing: References.get can return None --- pygit2/references.py | 4 ++-- test/test_refs.py | 18 +++++++++++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/pygit2/references.py b/pygit2/references.py index 3fa39491c..5cbee8fd7 100644 --- a/pygit2/references.py +++ b/pygit2/references.py @@ -44,11 +44,11 @@ def __init__(self, repository: BaseRepository) -> None: def __getitem__(self, name: str) -> 'Reference': return self._repository.lookup_reference(name) - def get(self, key: str) -> 'Reference': + def get(self, key: str) -> 'Reference' | None: try: return self[key] except KeyError: - return None # type: ignore # will be corrected in next commit! + return None def __iter__(self) -> Iterator[str]: iter = self._repository.references_iterator_init() diff --git a/test/test_refs.py b/test/test_refs.py index 5d441fccf..9ad830b78 100644 --- a/test/test_refs.py +++ b/test/test_refs.py @@ -35,6 +35,7 @@ GitError, InvalidSpecError, Oid, + Reference, Repository, Signature, Tree, @@ -84,17 +85,20 @@ def test_refs_getitem(testrepo: Repository) -> None: # Test a lookup reference = testrepo.references.get('refs/heads/master') + assert reference is not None assert reference.name == 'refs/heads/master' def test_refs_get_sha(testrepo: Repository) -> None: reference = testrepo.references['refs/heads/master'] + assert reference is not None assert reference.target == LAST_COMMIT def test_refs_set_sha(testrepo: Repository) -> None: NEW_COMMIT = '5ebeeebb320790caf276b9fc8b24546d63316533' reference = testrepo.references.get('refs/heads/master') + assert reference is not None reference.set_target(NEW_COMMIT) assert reference.target == NEW_COMMIT @@ -102,23 +106,27 @@ def test_refs_set_sha(testrepo: Repository) -> None: def test_refs_set_sha_prefix(testrepo: Repository) -> None: NEW_COMMIT = '5ebeeebb320790caf276b9fc8b24546d63316533' reference = testrepo.references.get('refs/heads/master') + assert reference is not None reference.set_target(NEW_COMMIT[0:6]) assert reference.target == NEW_COMMIT def test_refs_get_type(testrepo: Repository) -> None: reference = testrepo.references.get('refs/heads/master') + assert reference is not None assert reference.type == ReferenceType.DIRECT def test_refs_get_target(testrepo: Repository) -> None: reference = testrepo.references.get('HEAD') + assert reference is not None assert reference.target == 'refs/heads/master' assert reference.raw_target == b'refs/heads/master' def test_refs_set_target(testrepo: Repository) -> None: reference = testrepo.references.get('HEAD') + assert reference is not None assert reference.target == 'refs/heads/master' assert reference.raw_target == b'refs/heads/master' reference.set_target('refs/heads/i18n') @@ -128,6 +136,7 @@ def test_refs_set_target(testrepo: Repository) -> None: def test_refs_get_shorthand(testrepo: Repository) -> None: reference = testrepo.references.get('refs/heads/master') + assert reference is not None assert reference.shorthand == 'master' reference = testrepo.references.create('refs/remotes/origin/master', LAST_COMMIT) assert reference.shorthand == 'origin/master' @@ -135,6 +144,7 @@ def test_refs_get_shorthand(testrepo: Repository) -> None: def test_refs_set_target_with_message(testrepo: Repository) -> None: reference = testrepo.references.get('HEAD') + assert reference is not None assert reference.target == 'refs/heads/master' assert reference.raw_target == b'refs/heads/master' sig = Signature('foo', 'bar') @@ -198,6 +208,7 @@ def test_refs_rename(testrepo: Repository) -> None: def test_refs_resolve(testrepo: Repository) -> None: reference = testrepo.references.get('HEAD') + assert reference is not None assert reference.type == ReferenceType.SYMBOLIC reference = reference.resolve() assert reference.type == ReferenceType.DIRECT @@ -206,16 +217,20 @@ def test_refs_resolve(testrepo: Repository) -> None: def test_refs_resolve_identity(testrepo: Repository) -> None: head = testrepo.references.get('HEAD') + assert head is not None ref = head.resolve() assert ref.resolve() is ref def test_refs_create(testrepo: Repository) -> None: # We add a tag as a new reference that points to "origin/master" - reference = testrepo.references.create('refs/tags/version1', LAST_COMMIT) + reference: Reference | None = testrepo.references.create( + 'refs/tags/version1', LAST_COMMIT + ) refs = testrepo.references assert 'refs/tags/version1' in refs reference = testrepo.references.get('refs/tags/version1') + assert reference is not None assert reference.target == LAST_COMMIT # try to create existing reference @@ -256,6 +271,7 @@ def test_refs_create_symbolic(testrepo: Repository) -> None: def test_refs_peel(testrepo: Repository) -> None: ref = testrepo.references.get('refs/heads/master') + assert ref is not None assert testrepo[ref.target].id == ref.peel().id assert not isinstance(ref.raw_target, bytes) assert testrepo[ref.raw_target].id == ref.peel().id From 58270d323f2e1543f170f9fd77998f01051530bb Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Sat, 2 Aug 2025 10:33:39 +0200 Subject: [PATCH 062/225] needed to add type ignores --- pygit2/callbacks.py | 4 ++-- pygit2/utils.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pygit2/callbacks.py b/pygit2/callbacks.py index e2b861d98..87914285f 100644 --- a/pygit2/callbacks.py +++ b/pygit2/callbacks.py @@ -718,7 +718,7 @@ def _checkout_notify_cb( pyworkdir = DiffFile.from_c(ptr_to_bytes(workdir)) try: - data.checkout_notify(why, pypath, pybaseline, pytarget, pyworkdir) + data.checkout_notify(why, pypath, pybaseline, pytarget, pyworkdir) # type: ignore[arg-type] except Passthrough: # Unlike most other operations with optional callbacks, checkout # doesn't support the GIT_PASSTHROUGH return code, so we must bypass @@ -733,7 +733,7 @@ def _checkout_notify_cb( @libgit2_callback_void def _checkout_progress_cb(path, completed_steps, total_steps, data: CheckoutCallbacks): - data.checkout_progress(maybe_string(path), completed_steps, total_steps) + data.checkout_progress(maybe_string(path), completed_steps, total_steps) # type: ignore[arg-type] def _git_checkout_options( diff --git a/pygit2/utils.py b/pygit2/utils.py index 8b01d3a16..7991e1f51 100644 --- a/pygit2/utils.py +++ b/pygit2/utils.py @@ -42,10 +42,10 @@ from .ffi import C, ffi if TYPE_CHECKING: - from ._libgit2.ffi import ArrayC, GitStrrayC, char + from ._libgit2.ffi import ArrayC, GitStrrayC, char, char_pointer -def maybe_string(ptr): +def maybe_string(ptr: char_pointer) -> str | None: if not ptr: return None From e08390d97c0d911970b6726ac8bbd7c9210bca4d Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Sat, 2 Aug 2025 10:43:13 +0200 Subject: [PATCH 063/225] add types to Remote --- pygit2/_pygit2.pyi | 2 +- pygit2/remotes.py | 38 ++++++++++++++++++++++---------------- pygit2/utils.py | 2 +- 3 files changed, 24 insertions(+), 18 deletions(-) diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index 6df2dab67..0effb5a1e 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -696,7 +696,7 @@ class RemoteCollection: def __len__(self) -> int: ... def __iter__(self): ... def __getitem__(self, name: str | int) -> Remote: ... - def names(self) -> Generator[str, None, None]: ... + def names(self) -> Generator[str | None, None, None]: ... def create(self, name: str, url: str, fetch: str | None = None) -> Remote: ... def create_anonymous(self, url: str) -> Remote: ... def rename(self, name: str, new_name: str) -> list[str]: ... diff --git a/pygit2/remotes.py b/pygit2/remotes.py index 779cb80f2..bff8432be 100644 --- a/pygit2/remotes.py +++ b/pygit2/remotes.py @@ -25,7 +25,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, TypedDict +from typing import TYPE_CHECKING, Any, Literal, TypedDict # Import from pygit2 from pygit2 import RemoteCallbacks @@ -46,6 +46,7 @@ # Need BaseRepository for type hints, but don't let it cause a circular dependency if TYPE_CHECKING: + from ._libgit2.ffi import GitRemoteC from .repository import BaseRepository @@ -92,34 +93,39 @@ def __init__(self, tp: Any) -> None: class Remote: - def __init__(self, repo: BaseRepository, ptr): + def __init__(self, repo: BaseRepository, ptr: 'GitRemoteC') -> None: """The constructor is for internal use only.""" self._repo = repo self._remote = ptr self._stored_exception = None - def __del__(self): + def __del__(self) -> None: C.git_remote_free(self._remote) @property - def name(self): + def name(self) -> str | None: """Name of the remote""" return maybe_string(C.git_remote_name(self._remote)) @property - def url(self): + def url(self) -> str | None: """Url of the remote""" return maybe_string(C.git_remote_url(self._remote)) @property - def push_url(self): + def push_url(self) -> str | None: """Push url of the remote""" return maybe_string(C.git_remote_pushurl(self._remote)) - def connect(self, callbacks=None, direction=C.GIT_DIRECTION_FETCH, proxy=None): + def connect( + self, + callbacks: RemoteCallbacks | None = None, + direction: int = C.GIT_DIRECTION_FETCH, + proxy: None | bool | str = None, + ) -> None: """Connect to the remote. Parameters: @@ -144,13 +150,13 @@ def connect(self, callbacks=None, direction=C.GIT_DIRECTION_FETCH, proxy=None): def fetch( self, - refspecs=None, - message=None, - callbacks=None, + refspecs: list[str] | None = None, + message: str | None = None, + callbacks: RemoteCallbacks | None = None, prune: FetchPrune = FetchPrune.UNSPECIFIED, - proxy=None, - depth=0, - ): + proxy: None | Literal[True] | str = None, + depth: int = 0, + ) -> TransferProgress: """Perform a fetch against this remote. Returns a object. @@ -241,7 +247,7 @@ def prune(self, callbacks: RemoteCallbacks | None = None) -> None: payload.check_error(err) @property - def refspec_count(self): + def refspec_count(self) -> int: """Total number of refspecs in this remote""" return C.git_remote_refspec_count(self._remote) @@ -252,7 +258,7 @@ def get_refspec(self, n: int) -> Refspec: return Refspec(self, spec) @property - def fetch_refspecs(self): + def fetch_refspecs(self) -> list[str]: """Refspecs that will be used for fetching""" specs = ffi.new('git_strarray *') @@ -261,7 +267,7 @@ def fetch_refspecs(self): return strarray_to_strings(specs) @property - def push_refspecs(self): + def push_refspecs(self) -> list[str]: """Refspecs that will be used for pushing""" specs = ffi.new('git_strarray *') diff --git a/pygit2/utils.py b/pygit2/utils.py index 7991e1f51..3baef30a5 100644 --- a/pygit2/utils.py +++ b/pygit2/utils.py @@ -45,7 +45,7 @@ from ._libgit2.ffi import ArrayC, GitStrrayC, char, char_pointer -def maybe_string(ptr: char_pointer) -> str | None: +def maybe_string(ptr: 'char_pointer') -> str | None: if not ptr: return None From 46c842505cef4232dd14a1695e92a612f8a25f8f Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Sat, 2 Aug 2025 10:49:41 +0200 Subject: [PATCH 064/225] move types for RemoteCollection to implementation --- pygit2/_pygit2.pyi | 17 +---------------- pygit2/remotes.py | 42 +++++++++++++++++++++--------------------- 2 files changed, 22 insertions(+), 37 deletions(-) diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index 0effb5a1e..ffb2736cd 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -62,7 +62,7 @@ from .filter import Filter from .index import MergeFileResult from .packbuilder import PackBuilder from .references import References -from .remotes import Remote +from .remotes import RemoteCollection from .repository import BaseRepository from .submodules import SubmoduleCollection @@ -691,21 +691,6 @@ class _LsRemotesDict(TypedDict): symref_target: str | None oid: Oid -class RemoteCollection: - def __init__(self, repo: BaseRepository) -> None: ... - def __len__(self) -> int: ... - def __iter__(self): ... - def __getitem__(self, name: str | int) -> Remote: ... - def names(self) -> Generator[str | None, None, None]: ... - def create(self, name: str, url: str, fetch: str | None = None) -> Remote: ... - def create_anonymous(self, url: str) -> Remote: ... - def rename(self, name: str, new_name: str) -> list[str]: ... - def delete(self, name: str) -> None: ... - def set_url(self, name: str, url: str) -> None: ... - def set_push_url(self, name: str, url: str) -> None: ... - def add_fetch(self, name: str, refspec: str) -> None: ... - def add_push(self, name: str, refspec: str) -> None: ... - class Branches: local: 'Branches' remote: 'Branches' diff --git a/pygit2/remotes.py b/pygit2/remotes.py index bff8432be..f020d81ea 100644 --- a/pygit2/remotes.py +++ b/pygit2/remotes.py @@ -25,7 +25,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Literal, TypedDict +from typing import TYPE_CHECKING, Any, Generator, Iterator, Literal, TypedDict # Import from pygit2 from pygit2 import RemoteCallbacks @@ -340,16 +340,16 @@ class RemoteCollection: >>> repo.remotes["origin"] """ - def __init__(self, repo: BaseRepository): + def __init__(self, repo: BaseRepository) -> None: self._repo = repo - def __len__(self): + def __len__(self) -> int: with utils.new_git_strarray() as names: err = C.git_remote_list(names, self._repo._repo) check_error(err) return names.count - def __iter__(self): + def __iter__(self) -> Iterator[Remote]: cremote = ffi.new('git_remote **') for name in self._ffi_names(): err = C.git_remote_lookup(cremote, self._repo._repo, name) @@ -357,7 +357,7 @@ def __iter__(self): yield Remote(self._repo, cremote[0]) - def __getitem__(self, name): + def __getitem__(self, name: str | int) -> Remote: if isinstance(name, int): return list(self)[name] @@ -374,12 +374,12 @@ def _ffi_names(self): for i in range(names.count): yield names.strings[i] - def names(self): + def names(self) -> Generator[str | None, None, None]: """An iterator over the names of the available remotes.""" for name in self._ffi_names(): yield maybe_string(name) - def create(self, name, url, fetch=None) -> Remote: + def create(self, name: str, url: str, fetch: str | None = None) -> Remote: """Create a new remote with the given name and url. Returns a object. @@ -388,31 +388,31 @@ def create(self, name, url, fetch=None) -> Remote: """ cremote = ffi.new('git_remote **') - name = to_bytes(name) - url = to_bytes(url) + name_bytes = to_bytes(name) + url_bytes = to_bytes(url) if fetch: - fetch = to_bytes(fetch) + fetch_bytes = to_bytes(fetch) err = C.git_remote_create_with_fetchspec( - cremote, self._repo._repo, name, url, fetch + cremote, self._repo._repo, name_bytes, url_bytes, fetch_bytes ) else: - err = C.git_remote_create(cremote, self._repo._repo, name, url) + err = C.git_remote_create(cremote, self._repo._repo, name_bytes, url_bytes) check_error(err) return Remote(self._repo, cremote[0]) - def create_anonymous(self, url): + def create_anonymous(self, url: str) -> Remote: """Create a new anonymous (in-memory only) remote with the given URL. Returns a object. """ cremote = ffi.new('git_remote **') - url = to_bytes(url) - err = C.git_remote_create_anonymous(cremote, self._repo._repo, url) + url_bytes = to_bytes(url) + err = C.git_remote_create_anonymous(cremote, self._repo._repo, url_bytes) check_error(err) return Remote(self._repo, cremote[0]) - def rename(self, name, new_name): + def rename(self, name: str, new_name: str) -> list[str]: """Rename a remote in the configuration. The refspecs in standard format will be renamed. @@ -433,7 +433,7 @@ def rename(self, name, new_name): check_error(err) return strarray_to_strings(problems) - def delete(self, name): + def delete(self, name: str) -> None: """Remove a remote from the configuration All remote-tracking branches and configuration settings for the remote will be removed. @@ -441,17 +441,17 @@ def delete(self, name): err = C.git_remote_delete(self._repo._repo, to_bytes(name)) check_error(err) - def set_url(self, name, url): + def set_url(self, name: str, url: str) -> None: """Set the URL for a remote""" err = C.git_remote_set_url(self._repo._repo, to_bytes(name), to_bytes(url)) check_error(err) - def set_push_url(self, name, url): + def set_push_url(self, name: str, url: str) -> None: """Set the push-URL for a remote""" err = C.git_remote_set_pushurl(self._repo._repo, to_bytes(name), to_bytes(url)) check_error(err) - def add_fetch(self, name, refspec): + def add_fetch(self, name: str, refspec: str) -> None: """Add a fetch refspec (str) to the remote""" err = C.git_remote_add_fetch( @@ -459,7 +459,7 @@ def add_fetch(self, name, refspec): ) check_error(err) - def add_push(self, name, refspec): + def add_push(self, name: str, refspec: str) -> None: """Add a push refspec (str) to the remote""" err = C.git_remote_add_push(self._repo._repo, to_bytes(name), to_bytes(refspec)) From 7c0831fb0444432979c99ff9c0174ac3bf6ae903 Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Sat, 2 Aug 2025 10:56:27 +0200 Subject: [PATCH 065/225] add types for _ffi_names in RemoteCollection --- pygit2/remotes.py | 6 +++--- pygit2/utils.py | 5 +++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/pygit2/remotes.py b/pygit2/remotes.py index f020d81ea..0c7d3c980 100644 --- a/pygit2/remotes.py +++ b/pygit2/remotes.py @@ -46,7 +46,7 @@ # Need BaseRepository for type hints, but don't let it cause a circular dependency if TYPE_CHECKING: - from ._libgit2.ffi import GitRemoteC + from ._libgit2.ffi import GitRemoteC, char_pointer from .repository import BaseRepository @@ -367,12 +367,12 @@ def __getitem__(self, name: str | int) -> Remote: return Remote(self._repo, cremote[0]) - def _ffi_names(self): + def _ffi_names(self) -> Generator['char_pointer', None, None]: with utils.new_git_strarray() as names: err = C.git_remote_list(names, self._repo._repo) check_error(err) for i in range(names.count): - yield names.strings[i] + yield names.strings[i] # type: ignore[index] def names(self) -> Generator[str | None, None, None]: """An iterator over the names of the available remotes.""" diff --git a/pygit2/utils.py b/pygit2/utils.py index 3baef30a5..7f338c571 100644 --- a/pygit2/utils.py +++ b/pygit2/utils.py @@ -29,6 +29,7 @@ from typing import ( TYPE_CHECKING, Generic, + Generator, Iterator, Optional, Protocol, @@ -45,7 +46,7 @@ from ._libgit2.ffi import ArrayC, GitStrrayC, char, char_pointer -def maybe_string(ptr: 'char_pointer') -> str | None: +def maybe_string(ptr: 'char_pointer' | None) -> str | None: if not ptr: return None @@ -105,7 +106,7 @@ def ptr_to_bytes(ptr_cdata): @contextlib.contextmanager -def new_git_strarray(): +def new_git_strarray() -> Generator[GitStrrayC, None, None]: strarray = ffi.new('git_strarray *') yield strarray C.git_strarray_dispose(strarray) From fa8a0e95f46e2f0bf95abeb7eee52841685f7cf3 Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Sat, 2 Aug 2025 11:34:04 +0200 Subject: [PATCH 066/225] move types for Branches to implementation --- pygit2/_pygit2.pyi | 22 +-------------------- pygit2/branches.py | 40 +++++++++++++++++++++++---------------- pygit2/utils.py | 6 +++--- test/test_branch.py | 5 ++++- test/test_branch_empty.py | 8 ++++++-- test/test_repository.py | 1 + 6 files changed, 39 insertions(+), 43 deletions(-) diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index ffb2736cd..68155df55 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -1,5 +1,4 @@ import tarfile -from collections.abc import Generator from io import DEFAULT_BUFFER_SIZE, IOBase from pathlib import Path from queue import Queue @@ -28,6 +27,7 @@ from ._libgit2.ffi import ( _Pointer, ) from .blame import Blame +from .branches import Branches from .callbacks import CheckoutCallbacks, StashApplyCallbacks from .config import Config from .enums import ( @@ -63,7 +63,6 @@ from .index import MergeFileResult from .packbuilder import PackBuilder from .references import References from .remotes import RemoteCollection -from .repository import BaseRepository from .submodules import SubmoduleCollection GIT_OBJ_BLOB = Literal[3] @@ -691,25 +690,6 @@ class _LsRemotesDict(TypedDict): symref_target: str | None oid: Oid -class Branches: - local: 'Branches' - remote: 'Branches' - def __init__( - self, - repository: BaseRepository, - flag: BranchType = ..., - commit: Commit | _OidArg | None = None, - ) -> None: ... - def __getitem__(self, name: str) -> Branch: ... - def get(self, key: str) -> Branch: ... - def __iter__(self) -> Iterator[str]: ... - def create( - self, name: str, commit: Object | Commit, force: bool = False - ) -> Branch: ... - def delete(self, name: str) -> None: ... - def with_commit(self, commit: Object | Commit | _OidArg | None) -> 'Branches': ... - def __contains__(self, name: _OidArg) -> bool: ... - class Repository: _pointer: GitRepositoryC _repo: GitRepositoryC diff --git a/pygit2/branches.py b/pygit2/branches.py index 9f5ac6f1c..ca32e1516 100644 --- a/pygit2/branches.py +++ b/pygit2/branches.py @@ -25,9 +25,9 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Iterator -from ._pygit2 import Commit, Oid +from ._pygit2 import Branch, Commit, Oid from .enums import BranchType, ReferenceType # Need BaseRepository for type hints, but don't let it cause a circular dependency @@ -36,9 +36,15 @@ class Branches: + local: 'Branches' + remote: 'Branches' + def __init__( - self, repository: BaseRepository, flag: BranchType = BranchType.ALL, commit=None - ): + self, + repository: BaseRepository, + flag: BranchType = BranchType.ALL, + commit: Commit | Oid | str | None = None, + ) -> None: self._repository = repository self._flag = flag if commit is not None: @@ -52,7 +58,7 @@ def __init__( self.local = Branches(repository, flag=BranchType.LOCAL, commit=commit) self.remote = Branches(repository, flag=BranchType.REMOTE, commit=commit) - def __getitem__(self, name: str): + def __getitem__(self, name: str) -> Branch: branch = None if self._flag & BranchType.LOCAL: branch = self._repository.lookup_branch(name, BranchType.LOCAL) @@ -65,36 +71,38 @@ def __getitem__(self, name: str): return branch - def get(self, key: str): + def get(self, key: str) -> Branch: try: return self[key] except KeyError: - return None + return None # type:ignore # next commit - def __iter__(self): + def __iter__(self) -> Iterator[str]: for branch_name in self._repository.listall_branches(self._flag): if self._commit is None or self.get(branch_name) is not None: yield branch_name - def create(self, name: str, commit, force=False): + def create(self, name: str, commit: Commit, force: bool = False) -> Branch: return self._repository.create_branch(name, commit, force) - def delete(self, name: str): + def delete(self, name: str) -> None: self[name].delete() - def _valid(self, branch): + def _valid(self, branch: Branch) -> bool: if branch.type == ReferenceType.SYMBOLIC: - branch = branch.resolve() + branch_direct = branch.resolve() + else: + branch_direct = branch return ( self._commit is None - or branch.target == self._commit - or self._repository.descendant_of(branch.target, self._commit) + or branch_direct.target == self._commit + or self._repository.descendant_of(branch_direct.target, self._commit) ) - def with_commit(self, commit): + def with_commit(self, commit: Commit | Oid | str | None) -> 'Branches': assert self._commit is None return Branches(self._repository, self._flag, commit) - def __contains__(self, name): + def __contains__(self, name: str) -> bool: return self.get(name) is not None diff --git a/pygit2/utils.py b/pygit2/utils.py index 7f338c571..d00b76261 100644 --- a/pygit2/utils.py +++ b/pygit2/utils.py @@ -28,8 +28,8 @@ from types import TracebackType from typing import ( TYPE_CHECKING, - Generic, Generator, + Generic, Iterator, Optional, Protocol, @@ -46,7 +46,7 @@ from ._libgit2.ffi import ArrayC, GitStrrayC, char, char_pointer -def maybe_string(ptr: 'char_pointer' | None) -> str | None: +def maybe_string(ptr: 'char_pointer | None') -> str | None: if not ptr: return None @@ -106,7 +106,7 @@ def ptr_to_bytes(ptr_cdata): @contextlib.contextmanager -def new_git_strarray() -> Generator[GitStrrayC, None, None]: +def new_git_strarray() -> Generator['GitStrrayC', None, None]: strarray = ffi.new('git_strarray *') yield strarray C.git_strarray_dispose(strarray) diff --git a/test/test_branch.py b/test/test_branch.py index f5bff9d3d..63923cd66 100644 --- a/test/test_branch.py +++ b/test/test_branch.py @@ -58,6 +58,7 @@ def test_branches(testrepo: Repository) -> None: def test_branches_create(testrepo: Repository) -> None: commit = testrepo[LAST_COMMIT] + assert isinstance(commit, Commit) reference = testrepo.branches.create('version1', commit) assert 'version1' in testrepo.branches reference = testrepo.branches['version1'] @@ -142,7 +143,9 @@ def test_branches_with_commit(testrepo: Repository) -> None: branches = testrepo.branches.with_commit(LAST_COMMIT) assert sorted(branches) == ['master'] - branches = testrepo.branches.with_commit(testrepo[LAST_COMMIT]) + commit = testrepo[LAST_COMMIT] + assert isinstance(commit, Commit) + branches = testrepo.branches.with_commit(commit) assert sorted(branches) == ['master'] branches = testrepo.branches.remote.with_commit(LAST_COMMIT) diff --git a/test/test_branch_empty.py b/test/test_branch_empty.py index c3a736df1..9bfe00884 100644 --- a/test/test_branch_empty.py +++ b/test/test_branch_empty.py @@ -58,7 +58,9 @@ def test_branches_remote_getitem(repo: Repository) -> None: def test_branches_upstream(repo: Repository) -> None: remote_master = repo.branches.remote['origin/master'] - master = repo.branches.create('master', repo[remote_master.target]) + commit = repo[remote_master.target] + assert isinstance(commit, Commit) + master = repo.branches.create('master', commit) assert master.upstream is None master.upstream = remote_master @@ -76,7 +78,9 @@ def set_bad_upstream(): def test_branches_upstream_name(repo: Repository) -> None: remote_master = repo.branches.remote['origin/master'] - master = repo.branches.create('master', repo[remote_master.target]) + commit = repo[remote_master.target] + assert isinstance(commit, Commit) + master = repo.branches.create('master', commit) master.upstream = remote_master assert master.upstream_name == 'refs/remotes/origin/master' diff --git a/test/test_repository.py b/test/test_repository.py index b98b1784b..87bd4fbdd 100644 --- a/test/test_repository.py +++ b/test/test_repository.py @@ -906,6 +906,7 @@ def test_worktree_custom_ref(testrepo: Repository) -> None: # New branch based on head tip = testrepo.revparse_single('HEAD') + assert isinstance(tip, Commit) worktree_ref = testrepo.branches.create(branch_name, tip) # Delete temp path so that it's not present when we attempt to add the # worktree later From 2af4bb24e4ae88196ccf6106a2123e11f146c843 Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Sat, 2 Aug 2025 23:38:09 +0200 Subject: [PATCH 067/225] explicitly import from ._pygit2 --- pygit2/__init__.py | 296 +++++++++++++++++++++++++++++++++++++++++- pygit2/packbuilder.py | 14 +- 2 files changed, 301 insertions(+), 9 deletions(-) diff --git a/pygit2/__init__.py b/pygit2/__init__.py index f97daa870..8d024232d 100644 --- a/pygit2/__init__.py +++ b/pygit2/__init__.py @@ -35,8 +35,298 @@ from ._build import __version__ # Low level API -from ._pygit2 import * -from ._pygit2 import _cache_enums +from ._pygit2 import ( + GIT_APPLY_LOCATION_BOTH, + GIT_APPLY_LOCATION_INDEX, + GIT_APPLY_LOCATION_WORKDIR, + GIT_BLAME_FIRST_PARENT, + GIT_BLAME_IGNORE_WHITESPACE, + GIT_BLAME_NORMAL, + GIT_BLAME_TRACK_COPIES_ANY_COMMIT_COPIES, + GIT_BLAME_TRACK_COPIES_SAME_COMMIT_COPIES, + GIT_BLAME_TRACK_COPIES_SAME_COMMIT_MOVES, + GIT_BLAME_TRACK_COPIES_SAME_FILE, + GIT_BLAME_USE_MAILMAP, + GIT_BLOB_FILTER_ATTRIBUTES_FROM_COMMIT, + GIT_BLOB_FILTER_ATTRIBUTES_FROM_HEAD, + GIT_BLOB_FILTER_CHECK_FOR_BINARY, + GIT_BLOB_FILTER_NO_SYSTEM_ATTRIBUTES, + GIT_BRANCH_ALL, + GIT_BRANCH_LOCAL, + GIT_BRANCH_REMOTE, + GIT_CHECKOUT_ALLOW_CONFLICTS, + GIT_CHECKOUT_CONFLICT_STYLE_DIFF3, + GIT_CHECKOUT_CONFLICT_STYLE_MERGE, + GIT_CHECKOUT_CONFLICT_STYLE_ZDIFF3, + GIT_CHECKOUT_DISABLE_PATHSPEC_MATCH, + GIT_CHECKOUT_DONT_OVERWRITE_IGNORED, + GIT_CHECKOUT_DONT_REMOVE_EXISTING, + GIT_CHECKOUT_DONT_UPDATE_INDEX, + GIT_CHECKOUT_DONT_WRITE_INDEX, + GIT_CHECKOUT_DRY_RUN, + GIT_CHECKOUT_FORCE, + GIT_CHECKOUT_NO_REFRESH, + GIT_CHECKOUT_NONE, + GIT_CHECKOUT_RECREATE_MISSING, + GIT_CHECKOUT_REMOVE_IGNORED, + GIT_CHECKOUT_REMOVE_UNTRACKED, + GIT_CHECKOUT_SAFE, + GIT_CHECKOUT_SKIP_LOCKED_DIRECTORIES, + GIT_CHECKOUT_SKIP_UNMERGED, + GIT_CHECKOUT_UPDATE_ONLY, + GIT_CHECKOUT_USE_OURS, + GIT_CHECKOUT_USE_THEIRS, + GIT_CONFIG_HIGHEST_LEVEL, + GIT_CONFIG_LEVEL_APP, + GIT_CONFIG_LEVEL_GLOBAL, + GIT_CONFIG_LEVEL_LOCAL, + GIT_CONFIG_LEVEL_PROGRAMDATA, + GIT_CONFIG_LEVEL_SYSTEM, + GIT_CONFIG_LEVEL_WORKTREE, + GIT_CONFIG_LEVEL_XDG, + GIT_DELTA_ADDED, + GIT_DELTA_CONFLICTED, + GIT_DELTA_COPIED, + GIT_DELTA_DELETED, + GIT_DELTA_IGNORED, + GIT_DELTA_MODIFIED, + GIT_DELTA_RENAMED, + GIT_DELTA_TYPECHANGE, + GIT_DELTA_UNMODIFIED, + GIT_DELTA_UNREADABLE, + GIT_DELTA_UNTRACKED, + GIT_DESCRIBE_ALL, + GIT_DESCRIBE_DEFAULT, + GIT_DESCRIBE_TAGS, + GIT_DIFF_BREAK_REWRITES, + GIT_DIFF_BREAK_REWRITES_FOR_RENAMES_ONLY, + GIT_DIFF_DISABLE_PATHSPEC_MATCH, + GIT_DIFF_ENABLE_FAST_UNTRACKED_DIRS, + GIT_DIFF_FIND_ALL, + GIT_DIFF_FIND_AND_BREAK_REWRITES, + GIT_DIFF_FIND_BY_CONFIG, + GIT_DIFF_FIND_COPIES, + GIT_DIFF_FIND_COPIES_FROM_UNMODIFIED, + GIT_DIFF_FIND_DONT_IGNORE_WHITESPACE, + GIT_DIFF_FIND_EXACT_MATCH_ONLY, + GIT_DIFF_FIND_FOR_UNTRACKED, + GIT_DIFF_FIND_IGNORE_LEADING_WHITESPACE, + GIT_DIFF_FIND_IGNORE_WHITESPACE, + GIT_DIFF_FIND_REMOVE_UNMODIFIED, + GIT_DIFF_FIND_RENAMES, + GIT_DIFF_FIND_RENAMES_FROM_REWRITES, + GIT_DIFF_FIND_REWRITES, + GIT_DIFF_FLAG_BINARY, + GIT_DIFF_FLAG_EXISTS, + GIT_DIFF_FLAG_NOT_BINARY, + GIT_DIFF_FLAG_VALID_ID, + GIT_DIFF_FLAG_VALID_SIZE, + GIT_DIFF_FORCE_BINARY, + GIT_DIFF_FORCE_TEXT, + GIT_DIFF_IGNORE_BLANK_LINES, + GIT_DIFF_IGNORE_CASE, + GIT_DIFF_IGNORE_FILEMODE, + GIT_DIFF_IGNORE_SUBMODULES, + GIT_DIFF_IGNORE_WHITESPACE, + GIT_DIFF_IGNORE_WHITESPACE_CHANGE, + GIT_DIFF_IGNORE_WHITESPACE_EOL, + GIT_DIFF_INCLUDE_CASECHANGE, + GIT_DIFF_INCLUDE_IGNORED, + GIT_DIFF_INCLUDE_TYPECHANGE, + GIT_DIFF_INCLUDE_TYPECHANGE_TREES, + GIT_DIFF_INCLUDE_UNMODIFIED, + GIT_DIFF_INCLUDE_UNREADABLE, + GIT_DIFF_INCLUDE_UNREADABLE_AS_UNTRACKED, + GIT_DIFF_INCLUDE_UNTRACKED, + GIT_DIFF_INDENT_HEURISTIC, + GIT_DIFF_MINIMAL, + GIT_DIFF_NORMAL, + GIT_DIFF_PATIENCE, + GIT_DIFF_RECURSE_IGNORED_DIRS, + GIT_DIFF_RECURSE_UNTRACKED_DIRS, + GIT_DIFF_REVERSE, + GIT_DIFF_SHOW_BINARY, + GIT_DIFF_SHOW_UNMODIFIED, + GIT_DIFF_SHOW_UNTRACKED_CONTENT, + GIT_DIFF_SKIP_BINARY_CHECK, + GIT_DIFF_STATS_FULL, + GIT_DIFF_STATS_INCLUDE_SUMMARY, + GIT_DIFF_STATS_NONE, + GIT_DIFF_STATS_NUMBER, + GIT_DIFF_STATS_SHORT, + GIT_DIFF_UPDATE_INDEX, + GIT_FILEMODE_BLOB, + GIT_FILEMODE_BLOB_EXECUTABLE, + GIT_FILEMODE_COMMIT, + GIT_FILEMODE_LINK, + GIT_FILEMODE_TREE, + GIT_FILEMODE_UNREADABLE, + GIT_FILTER_ALLOW_UNSAFE, + GIT_FILTER_ATTRIBUTES_FROM_COMMIT, + GIT_FILTER_ATTRIBUTES_FROM_HEAD, + GIT_FILTER_CLEAN, + GIT_FILTER_DEFAULT, + GIT_FILTER_DRIVER_PRIORITY, + GIT_FILTER_NO_SYSTEM_ATTRIBUTES, + GIT_FILTER_SMUDGE, + GIT_FILTER_TO_ODB, + GIT_FILTER_TO_WORKTREE, + GIT_MERGE_ANALYSIS_FASTFORWARD, + GIT_MERGE_ANALYSIS_NONE, + GIT_MERGE_ANALYSIS_NORMAL, + GIT_MERGE_ANALYSIS_UNBORN, + GIT_MERGE_ANALYSIS_UP_TO_DATE, + GIT_MERGE_PREFERENCE_FASTFORWARD_ONLY, + GIT_MERGE_PREFERENCE_NO_FASTFORWARD, + GIT_MERGE_PREFERENCE_NONE, + GIT_OBJECT_ANY, + GIT_OBJECT_BLOB, + GIT_OBJECT_COMMIT, + GIT_OBJECT_INVALID, + GIT_OBJECT_OFS_DELTA, + GIT_OBJECT_REF_DELTA, + GIT_OBJECT_TAG, + GIT_OBJECT_TREE, + GIT_OID_HEX_ZERO, + GIT_OID_HEXSZ, + GIT_OID_MINPREFIXLEN, + GIT_OID_RAWSZ, + GIT_OPT_DISABLE_PACK_KEEP_FILE_CHECKS, + GIT_OPT_ENABLE_CACHING, + GIT_OPT_ENABLE_FSYNC_GITDIR, + GIT_OPT_ENABLE_OFS_DELTA, + GIT_OPT_ENABLE_STRICT_HASH_VERIFICATION, + GIT_OPT_ENABLE_STRICT_OBJECT_CREATION, + GIT_OPT_ENABLE_STRICT_SYMBOLIC_REF_CREATION, + GIT_OPT_ENABLE_UNSAVED_INDEX_SAFETY, + GIT_OPT_GET_CACHED_MEMORY, + GIT_OPT_GET_MWINDOW_FILE_LIMIT, + GIT_OPT_GET_MWINDOW_MAPPED_LIMIT, + GIT_OPT_GET_MWINDOW_SIZE, + GIT_OPT_GET_OWNER_VALIDATION, + GIT_OPT_GET_PACK_MAX_OBJECTS, + GIT_OPT_GET_SEARCH_PATH, + GIT_OPT_GET_TEMPLATE_PATH, + GIT_OPT_GET_USER_AGENT, + GIT_OPT_GET_WINDOWS_SHAREMODE, + GIT_OPT_SET_ALLOCATOR, + GIT_OPT_SET_CACHE_MAX_SIZE, + GIT_OPT_SET_CACHE_OBJECT_LIMIT, + GIT_OPT_SET_MWINDOW_FILE_LIMIT, + GIT_OPT_SET_MWINDOW_MAPPED_LIMIT, + GIT_OPT_SET_MWINDOW_SIZE, + GIT_OPT_SET_OWNER_VALIDATION, + GIT_OPT_SET_PACK_MAX_OBJECTS, + GIT_OPT_SET_SEARCH_PATH, + GIT_OPT_SET_SSL_CERT_LOCATIONS, + GIT_OPT_SET_SSL_CIPHERS, + GIT_OPT_SET_TEMPLATE_PATH, + GIT_OPT_SET_USER_AGENT, + GIT_OPT_SET_WINDOWS_SHAREMODE, + GIT_REFERENCES_ALL, + GIT_REFERENCES_BRANCHES, + GIT_REFERENCES_TAGS, + GIT_RESET_HARD, + GIT_RESET_MIXED, + GIT_RESET_SOFT, + GIT_REVSPEC_MERGE_BASE, + GIT_REVSPEC_RANGE, + GIT_REVSPEC_SINGLE, + GIT_SORT_NONE, + GIT_SORT_REVERSE, + GIT_SORT_TIME, + GIT_SORT_TOPOLOGICAL, + GIT_STASH_APPLY_DEFAULT, + GIT_STASH_APPLY_REINSTATE_INDEX, + GIT_STASH_DEFAULT, + GIT_STASH_INCLUDE_IGNORED, + GIT_STASH_INCLUDE_UNTRACKED, + GIT_STASH_KEEP_ALL, + GIT_STASH_KEEP_INDEX, + GIT_STATUS_CONFLICTED, + GIT_STATUS_CURRENT, + GIT_STATUS_IGNORED, + GIT_STATUS_INDEX_DELETED, + GIT_STATUS_INDEX_MODIFIED, + GIT_STATUS_INDEX_NEW, + GIT_STATUS_INDEX_RENAMED, + GIT_STATUS_INDEX_TYPECHANGE, + GIT_STATUS_WT_DELETED, + GIT_STATUS_WT_MODIFIED, + GIT_STATUS_WT_NEW, + GIT_STATUS_WT_RENAMED, + GIT_STATUS_WT_TYPECHANGE, + GIT_STATUS_WT_UNREADABLE, + GIT_SUBMODULE_IGNORE_ALL, + GIT_SUBMODULE_IGNORE_DIRTY, + GIT_SUBMODULE_IGNORE_NONE, + GIT_SUBMODULE_IGNORE_UNSPECIFIED, + GIT_SUBMODULE_IGNORE_UNTRACKED, + GIT_SUBMODULE_STATUS_IN_CONFIG, + GIT_SUBMODULE_STATUS_IN_HEAD, + GIT_SUBMODULE_STATUS_IN_INDEX, + GIT_SUBMODULE_STATUS_IN_WD, + GIT_SUBMODULE_STATUS_INDEX_ADDED, + GIT_SUBMODULE_STATUS_INDEX_DELETED, + GIT_SUBMODULE_STATUS_INDEX_MODIFIED, + GIT_SUBMODULE_STATUS_WD_ADDED, + GIT_SUBMODULE_STATUS_WD_DELETED, + GIT_SUBMODULE_STATUS_WD_INDEX_MODIFIED, + GIT_SUBMODULE_STATUS_WD_MODIFIED, + GIT_SUBMODULE_STATUS_WD_UNINITIALIZED, + GIT_SUBMODULE_STATUS_WD_UNTRACKED, + GIT_SUBMODULE_STATUS_WD_WD_MODIFIED, + LIBGIT2_VER_MAJOR, + LIBGIT2_VER_MINOR, + LIBGIT2_VER_REVISION, + LIBGIT2_VERSION, + AlreadyExistsError, + Blob, + Branch, + Commit, + Diff, + DiffDelta, + DiffFile, + DiffHunk, + DiffLine, + DiffStats, + FilterSource, + GitError, + InvalidSpecError, + Mailmap, + Note, + Object, + Odb, + OdbBackend, + OdbBackendLoose, + OdbBackendPack, + Oid, + Patch, + Refdb, + RefdbBackend, + RefdbFsBackend, + Reference, + RefLogEntry, + Repository, + RevSpec, + Signature, + Stash, + Tag, + Tree, + TreeBuilder, + Walker, + Worktree, + _cache_enums, + discover_repository, + filter_register, + filter_unregister, + hash, + hashfile, + init_file_backend, + option, + reference_is_valid_name, + tree_entry_cmp, +) from .blame import Blame, BlameHunk from .blob import BlobIO from .callbacks import ( @@ -58,7 +348,7 @@ from .legacyenums import * from .packbuilder import PackBuilder from .remotes import Remote -from .repository import Repository +from .repository import Repository # noqa: F811 from .settings import Settings from .submodules import Submodule from .utils import to_bytes, to_str diff --git a/pygit2/packbuilder.py b/pygit2/packbuilder.py index b7325c043..3edc86b08 100644 --- a/pygit2/packbuilder.py +++ b/pygit2/packbuilder.py @@ -24,17 +24,19 @@ # Boston, MA 02110-1301, USA. from os import PathLike +from typing import TYPE_CHECKING # Import from pygit2 -from pygit2 import Oid, Repository - from .errors import check_error from .ffi import C, ffi from .utils import to_bytes +if TYPE_CHECKING: + from pygit2 import Oid, Repository + class PackBuilder: - def __init__(self, repo: Repository) -> None: + def __init__(self, repo: 'Repository') -> None: cpackbuilder = ffi.new('git_packbuilder **') err = C.git_packbuilder_new(cpackbuilder, repo._repo) check_error(err) @@ -54,17 +56,17 @@ def __len__(self) -> int: return C.git_packbuilder_object_count(self._packbuilder) @staticmethod - def __convert_object_to_oid(oid: Oid) -> 'ffi.GitOidC': + def __convert_object_to_oid(oid: 'Oid') -> 'ffi.GitOidC': git_oid = ffi.new('git_oid *') ffi.buffer(git_oid)[:] = oid.raw[:] return git_oid - def add(self, oid: Oid) -> None: + def add(self, oid: 'Oid') -> None: git_oid = self.__convert_object_to_oid(oid) err = C.git_packbuilder_insert(self._packbuilder, git_oid, ffi.NULL) check_error(err) - def add_recur(self, oid: Oid) -> None: + def add_recur(self, oid: 'Oid') -> None: git_oid = self.__convert_object_to_oid(oid) err = C.git_packbuilder_insert_recur(self._packbuilder, git_oid, ffi.NULL) check_error(err) From 9efa2389b66a258c8efd5b71a53d7dd0788a7cec Mon Sep 17 00:00:00 2001 From: Benedikt Seidl Date: Sat, 2 Aug 2025 23:48:29 +0200 Subject: [PATCH 068/225] move types for Repository to implementation --- pygit2/__init__.py | 3 +- pygit2/_libgit2/ffi.pyi | 7 +- pygit2/_pygit2.pyi | 219 +--------------------------- pygit2/config.py | 7 +- pygit2/index.py | 7 +- pygit2/packbuilder.py | 3 +- pygit2/repository.py | 311 ++++++++++++++++++++++++++-------------- pygit2/submodules.py | 2 +- 8 files changed, 220 insertions(+), 339 deletions(-) diff --git a/pygit2/__init__.py b/pygit2/__init__.py index 8d024232d..38ae2c385 100644 --- a/pygit2/__init__.py +++ b/pygit2/__init__.py @@ -307,7 +307,6 @@ RefdbFsBackend, Reference, RefLogEntry, - Repository, RevSpec, Signature, Stash, @@ -348,7 +347,7 @@ from .legacyenums import * from .packbuilder import PackBuilder from .remotes import Remote -from .repository import Repository # noqa: F811 +from .repository import Repository from .settings import Settings from .submodules import Submodule from .utils import to_bytes, to_str diff --git a/pygit2/_libgit2/ffi.pyi b/pygit2/_libgit2/ffi.pyi index 7d09007e4..0e865e311 100644 --- a/pygit2/_libgit2/ffi.pyi +++ b/pygit2/_libgit2/ffi.pyi @@ -25,8 +25,6 @@ from typing import Any, Generic, Literal, NewType, SupportsIndex, TypeVar, overload -from pygit2._pygit2 import Repository - T = TypeVar('T') NULL_TYPE = NewType('NULL_TYPE', object) @@ -82,7 +80,8 @@ class GitHunkC: class GitRepositoryC: # incomplete # TODO: this has to be unified with pygit2._pygit2(pyi).Repository - def _from_c(cls, ptr: 'GitRepositoryC', owned: bool) -> 'Repository': ... + # def _from_c(cls, ptr: 'GitRepositoryC', owned: bool) -> 'Repository': ... + pass class GitFetchOptionsC: # TODO: FetchOptions exist in _pygit2.pyi @@ -318,7 +317,7 @@ def new( a: Literal['git_remote_head ***'], ) -> _Pointer[_MultiPointer[GitRemoteHeadC]]: ... @overload -def new(a: Literal['size_t *']) -> size_t: ... +def new(a: Literal['size_t *', 'size_t*']) -> size_t: ... @overload def new(a: Literal['git_stash_save_options *']) -> GitStashSaveOptionsC: ... @overload diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index 68155df55..7ca7d2692 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -1,10 +1,8 @@ -import tarfile from io import DEFAULT_BUFFER_SIZE, IOBase from pathlib import Path from queue import Queue from threading import Event from typing import ( - Callable, Generic, Iterator, Literal, @@ -16,54 +14,35 @@ from typing import ( overload, ) -from . import Index, IndexEntry +from . import Index from ._libgit2.ffi import ( GitCommitC, - GitMergeOptionsC, GitObjectC, GitProxyOptionsC, - GitRepositoryC, GitSignatureC, _Pointer, ) -from .blame import Blame -from .branches import Branches -from .callbacks import CheckoutCallbacks, StashApplyCallbacks -from .config import Config from .enums import ( ApplyLocation, - AttrCheck, - BlameFlag, BlobFilter, BranchType, - CheckoutStrategy, ConfigLevel, DeltaStatus, - DescribeStrategy, DiffFind, DiffFlag, DiffOption, DiffStatsFormat, FileMode, MergeAnalysis, - MergeFavor, - MergeFileFlag, - MergeFlag, MergePreference, ObjectType, Option, ReferenceFilter, ReferenceType, - RepositoryState, ResetMode, SortMode, ) from .filter import Filter -from .index import MergeFileResult -from .packbuilder import PackBuilder -from .references import References -from .remotes import RemoteCollection -from .submodules import SubmoduleCollection GIT_OBJ_BLOB = Literal[3] GIT_OBJ_COMMIT = Literal[1] @@ -575,7 +554,7 @@ class Odb: def add_disk_alternate(self, path: str | Path) -> None: ... def exists(self, oid: _OidArg) -> bool: ... def read(self, oid: _OidArg) -> tuple[int, bytes]: ... - def write(self, type: int, data: bytes) -> Oid: ... + def write(self, type: int, data: bytes | str) -> Oid: ... def __contains__(self, other: _OidArg) -> bool: ... def __iter__(self) -> Iterator[Oid]: ... # Odb_as_iter @@ -691,46 +670,11 @@ class _LsRemotesDict(TypedDict): oid: Oid class Repository: - _pointer: GitRepositoryC - _repo: GitRepositoryC - backend: RefdbBackend - default_signature: Signature - head: Reference - head_is_detached: bool - head_is_unborn: bool - is_bare: bool - is_empty: bool - is_shallow: bool - odb: Odb - path: str - refdb: Refdb - workdir: str - references: References - remotes: RemoteCollection - branches: Branches - submodules: SubmoduleCollection - index: Index - def __init__(self, *args, **kwargs) -> None: ... def TreeBuilder(self, src: Tree | _OidArg = ...) -> TreeBuilder: ... def _disown(self, *args, **kwargs) -> None: ... - @classmethod - def _from_c(cls, ptr: 'GitRepositoryC', owned: bool) -> 'Repository': ... - def __iter__(self) -> Iterator[Oid]: ... - def __getitem__(self, key: str | Oid) -> Object: ... - def __contains__(self, name: _OidArg) -> bool: ... def add_worktree( self, name: str, path: str | Path, ref: Reference = ... ) -> Worktree: ... - def amend_commit( - self, - commit: Commit | Oid | str, - refname: Reference | str | None, - author: Signature | None = None, - committer: Signature | None = None, - message: str | None = None, - tree: Tree | Oid | str | None = None, - encoding: str = 'UTF-8', - ) -> Oid: ... def applies( self, diff: Diff, @@ -740,30 +684,8 @@ class Repository: def apply( self, diff: Diff, location: ApplyLocation = ApplyLocation.WORKDIR ) -> None: ... - def blame( - self, - path: str, - flags: BlameFlag = BlameFlag.NORMAL, - min_match_characters: int | None = None, - newest_commit: _OidArg | None = None, - oldest_commit: _OidArg | None = None, - min_line: int | None = None, - max_line: int | None = None, - ) -> Blame: ... - def checkout( - self, - refname: _OidArg | None | Reference = None, - *, - strategy: CheckoutStrategy | None = None, - directory: str | Path | None = None, - paths: list[str] | None = None, - callbacks: CheckoutCallbacks | None = None, - ) -> None: ... - def ahead_behind(self, local: _OidArg, upstream: _OidArg) -> tuple[int, int]: ... def cherrypick(self, id: _OidArg) -> None: ... def compress_references(self) -> None: ... - @property - def config(self) -> Config: ... def create_blob(self, data: str | bytes) -> Oid: ... def create_blob_fromdisk(self, path: str) -> Oid: ... def create_blob_fromiobase(self, iobase: IOBase) -> Oid: ... @@ -800,13 +722,6 @@ class Repository: ref: str = 'refs/notes/commits', force: bool = False, ) -> Oid: ... - def create_reference( - self, - name: str, - target: _OidArg, - force: bool = False, - message: str | None = None, - ) -> Reference: ... def create_reference_direct( self, name: str, target: _OidArg, force: bool, message: Optional[str] = None ) -> Reference: ... @@ -816,50 +731,13 @@ class Repository: def create_tag( self, name: str, oid: _OidArg, type: ObjectType, tagger: Signature, message: str ) -> Oid: ... - def diff( - self, - a: None | str | bytes | Commit | Oid | Reference = None, - b: None | str | bytes | Commit | Oid | Reference = None, - cached: bool = False, - flags: DiffOption = DiffOption.NORMAL, - context_lines: int = 3, - interhunk_lines: int = 0, - ) -> Diff: ... def descendant_of(self, oid1: _OidArg, oid2: _OidArg) -> bool: ... - def describe( - self, - committish: str | Reference | Commit | None = None, - max_candidates_tags: int | None = None, - describe_strategy: DescribeStrategy = DescribeStrategy.DEFAULT, - pattern: str | None = None, - only_follow_first_parent: bool | None = None, - show_commit_oid_as_fallback: bool | None = None, - abbreviated_size: object | None = None, - always_use_long_format: bool | None = None, - dirty_suffix: str | None = None, - ) -> str: ... def expand_id(self, hex: str) -> Oid: ... def free(self) -> None: ... - def get(self, key: _OidArg, default: Optional[Commit] = None) -> None | Object: ... - def get_attr( - self, - path: str | bytes | Path, - name: str | bytes, - flags: AttrCheck = AttrCheck.FILE_THEN_INDEX, - commit: _OidArg | None = None, - ) -> bool | None | str: ... def git_object_lookup_prefix(self, oid: _OidArg) -> Object: ... - def hashfile( - self, - path: str, - object_type: ObjectType = ObjectType.BLOB, - as_path: str | None = None, - ) -> Oid: ... def list_worktrees(self) -> list[str]: ... def listall_branches(self, flag: BranchType = BranchType.LOCAL) -> list[str]: ... def listall_mergeheads(self) -> list[Oid]: ... - def listall_references(self) -> list[str]: ... - def listall_reference_objects(self) -> list[Reference]: ... def listall_stashes(self) -> list[Stash]: ... def listall_submodules(self) -> list[str]: ... def lookup_branch( @@ -871,65 +749,18 @@ class Repository: def lookup_reference(self, name: str) -> Reference: ... def lookup_reference_dwim(self, name: str) -> Reference: ... def lookup_worktree(self, name: str) -> Worktree: ... - def merge( - self, - source: Reference | Commit | Oid | str, - favor: MergeFavor = MergeFavor.NORMAL, - flags: MergeFlag = MergeFlag.FIND_RENAMES, - file_flags: MergeFileFlag = MergeFileFlag.DEFAULT, - ) -> None: ... def merge_analysis( self, their_head: _OidArg, our_ref: str = 'HEAD' ) -> tuple[MergeAnalysis, MergePreference]: ... def merge_base(self, oid1: _OidArg, oid2: _OidArg) -> Oid: ... def merge_base_many(self, oids: list[_OidArg]) -> Oid: ... def merge_base_octopus(self, oids: list[_OidArg]) -> Oid: ... - def merge_commits( - self, - ours: str | Oid | Commit, - theirs: str | Oid | Commit, - favor: MergeFavor = MergeFavor.NORMAL, - flags: MergeFlag = MergeFlag.FIND_RENAMES, - file_flags: MergeFileFlag = MergeFileFlag.DEFAULT, - ) -> Index: ... - def merge_file_from_index( - self, - ancestor: IndexEntry | None, - ours: IndexEntry | None, - theirs: IndexEntry | None, - use_deprecated: bool = True, - ) -> str | MergeFileResult | None: ... - @staticmethod - def _merge_options( - favor: int | MergeFavor, flags: int | MergeFlag, file_flags: int | MergeFileFlag - ) -> GitMergeOptionsC: ... - def merge_trees( - self, - ancestor: str | Oid | Tree, - ours: str | Oid | Tree, - theirs: str | Oid | Tree, - favor: MergeFavor = MergeFavor.NORMAL, - flags: MergeFlag = MergeFlag.FIND_RENAMES, - file_flags: MergeFileFlag = MergeFileFlag.DEFAULT, - ) -> Index: ... - @property - def message(self) -> str: ... def notes(self) -> Iterator[Note]: ... - def pack( - self, - path: str | Path | None = None, - pack_delegate: Callable[[PackBuilder], None] | None = None, - n_threads: int | None = None, - ) -> bool: ... def path_is_ignored(self, path: str) -> bool: ... def raw_listall_branches( self, flag: BranchType = BranchType.LOCAL ) -> list[bytes]: ... def raw_listall_references(self) -> list[bytes]: ... - @property - def raw_message(self) -> bytes: ... - def read(self, oid: _OidArg) -> tuple[int, bytes]: ... - def remove_message(self) -> None: ... def references_iterator_init(self) -> Iterator[Reference]: ... def references_iterator_next( self, @@ -937,64 +768,18 @@ class Repository: references_return_type: ReferenceFilter = ReferenceFilter.ALL, ) -> Reference: ... def reset(self, oid: _OidArg, reset_type: ResetMode) -> None: ... - def resolve_refish(self, refresh: str) -> tuple[Commit, Reference]: ... def revparse(self, revspec: str) -> RevSpec: ... def revparse_ext(self, revision: str) -> tuple[Object, Reference]: ... def revparse_single(self, revision: str) -> Object: ... - def revert(self, commit: Commit) -> None: ... - def revert_commit( - self, revert_commit: Commit, our_commit: Commit, mainline: int = 0 - ) -> Index: ... - def set_head(self, target: _OidArg) -> None: ... - def set_ident(self, name: str, email: str) -> None: ... def set_odb(self, odb: Odb) -> None: ... def set_refdb(self, refdb: Refdb) -> None: ... def status( self, untracked_files: str = 'all', ignored: bool = False ) -> dict[str, int]: ... - def stash( - self, - stasher: Signature, - message: str | None = None, - keep_index: bool = False, - include_untracked: bool = False, - include_ignored: bool = False, - keep_all: bool = False, - paths: list[str] | None = None, - ) -> Oid: ... - def stash_apply( - self, - index: int = 0, - reinstate_index: bool | None = None, - include_untracked: bool | None = None, - message: str | None = None, - strategy: CheckoutStrategy | None = None, - callbacks: StashApplyCallbacks | None = None, - ) -> None: ... - def stash_pop( - self, - index: int = 0, - reinstate_index: bool | None = None, - include_untracked: bool | None = None, - message: str | None = None, - strategy: CheckoutStrategy | None = None, - callbacks: StashApplyCallbacks | None = None, - ) -> None: ... - def stash_drop(self, index: int = 0) -> None: ... def status_file(self, path: str) -> int: ... - def state(self) -> RepositoryState: ... - def state_cleanup(self) -> None: ... def walk( self, oid: _OidArg | None, sort_mode: SortMode = SortMode.NONE ) -> Walker: ... - def write(self, type: int, data: bytes | str) -> Oid: ... - def write_archive( - self, - treeish: str | Tree | Object | Oid, - archive: tarfile.TarFile, - timestamp: int | None = None, - prefix: str = '', - ) -> None: ... class RevSpec: flags: int diff --git a/pygit2/config.py b/pygit2/config.py index b9fe5b130..ec97f5430 100644 --- a/pygit2/config.py +++ b/pygit2/config.py @@ -38,7 +38,8 @@ from .utils import to_bytes if TYPE_CHECKING: - from ._libgit2.ffi import GitConfigC, GitConfigEntryC, GitRepositoryC + from ._libgit2.ffi import GitConfigC, GitConfigEntryC + from .repository import BaseRepository def str_to_bytes(value: str | PathLike[str] | bytes, name: str) -> bytes: @@ -79,7 +80,7 @@ def __next__(self) -> str: # type: ignore[override] class Config: """Git configuration management.""" - _repo: 'GitRepositoryC' + _repo: 'BaseRepository' _config: 'GitConfigC' def __init__(self, path: str | None = None) -> None: @@ -95,7 +96,7 @@ def __init__(self, path: str | None = None) -> None: self._config = cconfig[0] @classmethod - def from_c(cls, repo: 'GitRepositoryC', ptr: 'GitConfigC') -> 'Config': + def from_c(cls, repo: 'BaseRepository', ptr: 'GitConfigC') -> 'Config': config = cls.__new__(cls) config._repo = repo config._config = ptr diff --git a/pygit2/index.py b/pygit2/index.py index ea2d5ee51..3fedf7c6d 100644 --- a/pygit2/index.py +++ b/pygit2/index.py @@ -29,12 +29,15 @@ from os import PathLike # Import from pygit2 -from ._pygit2 import Diff, Oid, Repository, Tree +from ._pygit2 import Diff, Oid, Tree from .enums import DiffOption, FileMode from .errors import check_error from .ffi import C, ffi from .utils import GenericIterator, StrArray, to_bytes, to_str +if typing.TYPE_CHECKING: + from .repository import Repository + class Index: # XXX Implement the basic features in C (_pygit2.Index) and make @@ -152,7 +155,7 @@ def read_tree(self, tree: Oid | Tree | str) -> None: err = C.git_index_read_tree(self._index, tree_cptr[0]) check_error(err) - def write_tree(self, repo: Repository | None = None) -> Oid: + def write_tree(self, repo: 'Repository | None' = None) -> Oid: """Create a tree out of the Index. Return the object of the written tree. diff --git a/pygit2/packbuilder.py b/pygit2/packbuilder.py index 3edc86b08..baa546303 100644 --- a/pygit2/packbuilder.py +++ b/pygit2/packbuilder.py @@ -33,10 +33,11 @@ if TYPE_CHECKING: from pygit2 import Oid, Repository + from pygit2.repository import BaseRepository class PackBuilder: - def __init__(self, repo: 'Repository') -> None: + def __init__(self, repo: 'Repository | BaseRepository') -> None: cpackbuilder = ffi.new('git_packbuilder **') err = C.git_packbuilder_new(cpackbuilder, repo._repo) check_error(err) diff --git a/pygit2/repository.py b/pygit2/repository.py index c56c9650c..94e8e0917 100644 --- a/pygit2/repository.py +++ b/pygit2/repository.py @@ -22,34 +22,39 @@ # along with this program; see the file COPYING. If not, write to # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. + import tarfile -import typing import warnings from io import BytesIO -from os import PathLike +from pathlib import Path from string import hexdigits from time import time -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Callable, Iterator, Optional, overload +# Import from pygit2 from ._pygit2 import ( GIT_OID_HEXSZ, GIT_OID_MINPREFIXLEN, Blob, Commit, + Diff, InvalidSpecError, Object, Oid, + Patch, Reference, Signature, Tree, init_file_backend, ) - -# Import from pygit2 from ._pygit2 import Repository as _Repository from .blame import Blame from .branches import Branches -from .callbacks import git_checkout_options, git_stash_apply_options +from .callbacks import ( + StashApplyCallbacks, + git_checkout_options, + git_stash_apply_options, +) from .config import Config from .enums import ( AttrCheck, @@ -75,15 +80,41 @@ from .utils import StrArray, to_bytes if TYPE_CHECKING: - from pygit2._libgit2.ffi import GitRepositoryC + from pygit2._libgit2.ffi import ( + ArrayC, + GitMergeOptionsC, + GitRepositoryC, + _Pointer, + char, + ) + from pygit2._pygit2 import Odb, Refdb, RefdbBackend class BaseRepository(_Repository): - def __init__(self, *args, **kwargs): + _pointer: '_Pointer[GitRepositoryC]' + _repo: 'GitRepositoryC' + backend: 'RefdbBackend' + default_signature: Signature + head: Reference + head_is_detached: bool + head_is_unborn: bool + is_bare: bool + is_empty: bool + is_shallow: bool + odb: 'Odb' + path: str + refdb: 'Refdb' + workdir: str + references: References + remotes: RemoteCollection + branches: Branches + submodules: SubmoduleCollection + + def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self._common_init() - def _common_init(self): + def _common_init(self) -> None: self.branches = Branches(self) self.references = References(self) self.remotes = RemoteCollection(self) @@ -96,22 +127,27 @@ def _common_init(self): self._repo = repo_cptr[0] # Backwards compatible ODB access - def read(self, *args, **kwargs): + def read(self, oid: Oid | str) -> tuple[int, bytes]: """read(oid) -> type, data, size Read raw object data from the repository. """ - return self.odb.read(*args, **kwargs) + return self.odb.read(oid) - def write(self, *args, **kwargs): + def write(self, type: int, data: bytes | str) -> Oid: """write(type, data) -> Oid Write raw object data into the repository. First arg is the object type, the second one a buffer with data. Return the Oid of the created object.""" - return self.odb.write(*args, **kwargs) + return self.odb.write(type, data) - def pack(self, path=None, pack_delegate=None, n_threads=None): + def pack( + self, + path: str | Path | None = None, + pack_delegate: Callable[[PackBuilder], None] | None = None, + n_threads: int | None = None, + ) -> int: """Pack the objects in the odb chosen by the pack_delegate function and write `.pack` and `.idx` files for them. @@ -147,8 +183,8 @@ def hashfile( self, path: str, object_type: ObjectType = ObjectType.BLOB, - as_path: typing.Optional[str] = None, - ): + as_path: str | None = None, + ) -> Oid: """Calculate the hash of a file using repository filtering rules. If you simply want to calculate the hash of a file on disk with no filters, @@ -196,13 +232,13 @@ def hashfile( oid = Oid(raw=bytes(ffi.buffer(c_oid.id)[:])) return oid - def __iter__(self): + def __iter__(self) -> Iterator[Oid]: return iter(self.odb) # # Mapping interface # - def get(self, key: str | Oid, default: Optional[Commit] = None) -> Object: + def get(self, key: Oid | str, default: Optional[Commit] = None) -> None | Object: value = self.git_object_lookup_prefix(key) return value if (value is not None) else default @@ -222,7 +258,7 @@ def __repr__(self) -> str: # Configuration # @property - def config(self): + def config(self) -> Config: """The configuration file for this repository. If a the configuration hasn't been set yet, the default config for @@ -290,15 +326,15 @@ def create_reference( return self.create_reference_symbolic(name, target, force, message=message) - def listall_references(self) -> typing.List[str]: + def listall_references(self) -> list[str]: """Return a list with all the references in the repository.""" return list(x.name for x in self.references.iterator()) - def listall_reference_objects(self) -> typing.List[Reference]: + def listall_reference_objects(self) -> list[Reference]: """Return a list with all the reference objects in the repository.""" return list(x for x in self.references.iterator()) - def resolve_refish(self, refish): + def resolve_refish(self, refish: str) -> tuple[Commit, Reference]: """Convert a reference-like short name "ref-ish" to a valid (commit, reference) pair. @@ -318,9 +354,9 @@ def resolve_refish(self, refish): reference = None commit = self.revparse_single(refish) else: - commit = reference.peel(Commit) + commit = reference.peel(Commit) # type: ignore - return (commit, reference) + return (commit, reference) # type: ignore # # Checkout @@ -359,7 +395,11 @@ def checkout_tree(self, treeish, **kwargs): err = C.git_checkout_tree(self._repo, cptr[0], payload.checkout_options) payload.check_error(err) - def checkout(self, refname=None, **kwargs): + def checkout( + self, + refname: str | None | Reference = None, + **kwargs, + ) -> None: """ Checkout the given reference using the given strategy, and update the HEAD. @@ -427,7 +467,7 @@ def checkout(self, refname=None, **kwargs): # # Setting HEAD # - def set_head(self, target): + def set_head(self, target: Oid | str) -> None: """ Set HEAD to point to the given target. @@ -474,15 +514,35 @@ def __whatever_to_tree_or_blob(self, obj): return obj + @overload def diff( self, - a=None, - b=None, - cached=False, + a: None | str | bytes | Commit | Oid | Reference = None, + b: None | str | bytes | Commit | Oid | Reference = None, + cached: bool = False, flags: DiffOption = DiffOption.NORMAL, context_lines: int = 3, interhunk_lines: int = 0, - ): + ) -> Diff: ... + @overload + def diff( + self, + a: Blob | None = None, + b: Blob | None = None, + cached: bool = False, + flags: DiffOption = DiffOption.NORMAL, + context_lines: int = 3, + interhunk_lines: int = 0, + ) -> Patch: ... + def diff( + self, + a: None | Blob | str | bytes | Commit | Oid | Reference = None, + b: None | Blob | str | bytes | Commit | Oid | Reference = None, + cached: bool = False, + flags: DiffOption = DiffOption.NORMAL, + context_lines: int = 3, + interhunk_lines: int = 0, + ) -> Diff | Patch: """ Show changes between the working tree and the index or a tree, changes between the index and a tree, changes between two trees, or @@ -583,7 +643,7 @@ def state(self) -> RepositoryState: # Some value not in the IntEnum - newer libgit2 version? return cstate # type: ignore[return-value] - def state_cleanup(self): + def state_cleanup(self) -> None: """Remove all the metadata associated with an ongoing command like merge, revert, cherry-pick, etc. For example: MERGE_HEAD, MERGE_MSG, etc. @@ -595,14 +655,14 @@ def state_cleanup(self): # def blame( self, - path, + path: str, flags: BlameFlag = BlameFlag.NORMAL, - min_match_characters=None, - newest_commit=None, - oldest_commit=None, - min_line=None, - max_line=None, - ): + min_match_characters: int | None = None, + newest_commit: Oid | str | None = None, + oldest_commit: Oid | str | None = None, + min_line: int | None = None, + max_line: int | None = None, + ) -> Blame: """ Return a Blame object for a single file. @@ -679,7 +739,7 @@ def index(self): @staticmethod def _merge_options( favor: int | MergeFavor, flags: int | MergeFlag, file_flags: int | MergeFileFlag - ): + ) -> 'GitMergeOptionsC': """Return a 'git_merge_opts *'""" # Check arguments type @@ -704,11 +764,11 @@ def _merge_options( def merge_file_from_index( self, - ancestor: typing.Union[None, IndexEntry], - ours: typing.Union[None, IndexEntry], - theirs: typing.Union[None, IndexEntry], + ancestor: 'IndexEntry | None', + ours: 'IndexEntry | None', + theirs: 'IndexEntry | None', use_deprecated: bool = True, - ) -> typing.Union[str, typing.Union[MergeFileResult, None]]: + ) -> 'str | MergeFileResult | None': """Merge files from index. Returns: A string with the content of the file containing @@ -758,12 +818,12 @@ def merge_file_from_index( def merge_commits( self, - ours: typing.Union[str, Oid, Commit], - theirs: typing.Union[str, Oid, Commit], - favor=MergeFavor.NORMAL, - flags=MergeFlag.FIND_RENAMES, - file_flags=MergeFileFlag.DEFAULT, - ) -> Index: + ours: str | Oid | Commit, + theirs: str | Oid | Commit, + favor: MergeFavor = MergeFavor.NORMAL, + flags: MergeFlag = MergeFlag.FIND_RENAMES, + file_flags: MergeFileFlag = MergeFileFlag.DEFAULT, + ) -> 'Index': """ Merge two arbitrary commits. @@ -820,13 +880,13 @@ def merge_commits( def merge_trees( self, - ancestor: typing.Union[str, Oid, Tree], - ours: typing.Union[str, Oid, Tree], - theirs: typing.Union[str, Oid, Tree], - favor=MergeFavor.NORMAL, - flags=MergeFlag.FIND_RENAMES, - file_flags=MergeFileFlag.DEFAULT, - ): + ancestor: str | Oid | Tree, + ours: str | Oid | Tree, + theirs: str | Oid | Tree, + favor: MergeFavor = MergeFavor.NORMAL, + flags: MergeFlag = MergeFlag.FIND_RENAMES, + file_flags: MergeFileFlag = MergeFileFlag.DEFAULT, + ) -> 'Index': """ Merge two trees. @@ -877,11 +937,11 @@ def merge_trees( def merge( self, - source: typing.Union[Reference, Commit, Oid, str], - favor=MergeFavor.NORMAL, - flags=MergeFlag.FIND_RENAMES, - file_flags=MergeFileFlag.DEFAULT, - ): + source: Reference | Commit | Oid | str, + favor: MergeFavor = MergeFavor.NORMAL, + flags: MergeFlag = MergeFlag.FIND_RENAMES, + file_flags: MergeFileFlag = MergeFileFlag.DEFAULT, + ) -> None: """ Merges the given Reference or Commit into HEAD. @@ -990,7 +1050,7 @@ def message(self) -> str: """ return self.raw_message.decode('utf-8') - def remove_message(self): + def remove_message(self) -> None: """ Remove git's prepared message. """ @@ -1002,16 +1062,16 @@ def remove_message(self): # def describe( self, - committish=None, - max_candidates_tags=None, + committish: str | Reference | Commit | None = None, + max_candidates_tags: int | None = None, describe_strategy: DescribeStrategy = DescribeStrategy.DEFAULT, - pattern=None, - only_follow_first_parent=None, - show_commit_oid_as_fallback=None, - abbreviated_size=None, - always_use_long_format=None, - dirty_suffix=None, - ): + pattern: str | None = None, + only_follow_first_parent: bool | None = None, + show_commit_oid_as_fallback: bool | None = None, + abbreviated_size: int | None = None, + always_use_long_format: bool | None = None, + dirty_suffix: str | None = None, + ) -> str: """ Describe a commit-ish or the current working tree. @@ -1085,10 +1145,13 @@ def describe( result = ffi.new('git_describe_result **') if committish: + committish_rev: Object | Reference | Commit if isinstance(committish, str): - committish = self.revparse_single(committish) + committish_rev = self.revparse_single(committish) + else: + committish_rev = committish - commit = committish.peel(Commit) + commit = committish_rev.peel(Commit) cptr = ffi.new('git_object **') ffi.buffer(cptr)[:] = commit._pointer[:] @@ -1131,13 +1194,13 @@ def describe( def stash( self, stasher: Signature, - message: typing.Optional[str] = None, + message: str | None = None, keep_index: bool = False, include_untracked: bool = False, include_ignored: bool = False, keep_all: bool = False, - paths: typing.Optional[typing.List[str]] = None, - ): + paths: list[str] | None = None, + ) -> Oid: """ Save changes to the working directory to the stash. @@ -1201,7 +1264,13 @@ def stash( return Oid(raw=bytes(ffi.buffer(coid)[:])) - def stash_apply(self, index=0, **kwargs): + def stash_apply( + self, + index: int = 0, + reinstate_index: bool = False, + strategy: CheckoutStrategy | None = None, + callbacks: StashApplyCallbacks | None = None, + ) -> None: """ Apply a stashed state in the stash list to the working directory. @@ -1235,11 +1304,15 @@ def stash_apply(self, index=0, **kwargs): >>> repo.stash(repo.default_signature(), 'WIP: stashing') >>> repo.stash_apply(strategy=CheckoutStrategy.ALLOW_CONFLICTS) """ - with git_stash_apply_options(**kwargs) as payload: + with git_stash_apply_options( + reinstate_index=reinstate_index, + strategy=strategy, + callbacks=callbacks, + ) as payload: err = C.git_stash_apply(self._repo, index, payload.stash_apply_options) payload.check_error(err) - def stash_drop(self, index=0): + def stash_drop(self, index: int = 0) -> None: """ Remove a stashed state from the stash list. @@ -1251,19 +1324,35 @@ def stash_drop(self, index=0): """ check_error(C.git_stash_drop(self._repo, index)) - def stash_pop(self, index=0, **kwargs): + def stash_pop( + self, + index: int = 0, + reinstate_index: bool = False, + strategy: CheckoutStrategy | None = None, + callbacks: StashApplyCallbacks | None = None, + ) -> None: """Apply a stashed state and remove it from the stash list. For arguments, see Repository.stash_apply(). """ - with git_stash_apply_options(**kwargs) as payload: + with git_stash_apply_options( + reinstate_index=reinstate_index, + strategy=strategy, + callbacks=callbacks, + ) as payload: err = C.git_stash_pop(self._repo, index, payload.stash_apply_options) payload.check_error(err) # # Utility for writing a tree into an archive # - def write_archive(self, treeish, archive, timestamp=None, prefix=''): + def write_archive( + self, + treeish: str | Tree | Object | Oid, + archive: tarfile.TarFile, + timestamp: int | None = None, + prefix: str = '', + ) -> None: """ Write treeish into an archive. @@ -1335,7 +1424,7 @@ def write_archive(self, treeish, archive, timestamp=None, prefix=''): # # Ahead-behind, which mostly lives on its own namespace # - def ahead_behind(self, local, upstream): + def ahead_behind(self, local: Oid | str, upstream: Oid | str) -> tuple[int, int]: """ Calculate how many different commits are in the non-common parts of the history between the two given ids. @@ -1375,11 +1464,11 @@ def ahead_behind(self, local, upstream): # def get_attr( self, - path: typing.Union[str, bytes, PathLike], - name: typing.Union[str, bytes], + path: str | bytes | Path, + name: str | bytes, flags: AttrCheck = AttrCheck.FILE_THEN_INDEX, - commit: typing.Union[Oid, str, None] = None, - ) -> typing.Union[bool, None, str]: + commit: Oid | str | None = None, + ) -> bool | None | str: """ Retrieve an attribute for a file by path. @@ -1452,7 +1541,7 @@ def ident(self): return (ffi.string(cname).decode('utf-8'), ffi.string(cemail).decode('utf-8')) - def set_ident(self, name, email): + def set_ident(self, name: str, email: str) -> None: """Set the identity to be used for reference operations. Updates to some references also append data to their @@ -1463,7 +1552,7 @@ def set_ident(self, name, email): err = C.git_repository_set_ident(self._repo, to_bytes(name), to_bytes(email)) check_error(err) - def revert(self, commit: Commit): + def revert(self, commit: Commit) -> None: """ Revert the given commit, producing changes in the index and working directory. @@ -1476,7 +1565,9 @@ def revert(self, commit: Commit): err = C.git_revert(self._repo, commit_ptr[0], ffi.NULL) check_error(err) - def revert_commit(self, revert_commit, our_commit, mainline=0): + def revert_commit( + self, revert_commit: Commit, our_commit: Commit, mainline: int = 0 + ) -> Index: """ Revert the given Commit against the given "our" Commit, producing an Index that reflects the result of the revert. @@ -1517,14 +1608,14 @@ def revert_commit(self, revert_commit, our_commit, mainline=0): # def amend_commit( self, - commit, - refname, - author=None, - committer=None, - message=None, - tree=None, - encoding='UTF-8', - ): + commit: Commit | Oid | str, + refname: Reference | str | None, + author: Signature | None = None, + committer: Signature | None = None, + message: str | None = None, + tree: Tree | Oid | str | None = None, + encoding: str = 'UTF-8', + ) -> Oid: """ Amend an existing commit by replacing only explicitly passed values, return the rewritten commit's oid. @@ -1573,24 +1664,24 @@ def amend_commit( # Note: the pointers are all initialized to NULL by default. coid = ffi.new('git_oid *') commit_cptr = ffi.new('git_commit **') - refname_cstr = ffi.NULL + refname_cstr: 'ArrayC[char]' | 'ffi.NULL_TYPE' = ffi.NULL author_cptr = ffi.new('git_signature **') committer_cptr = ffi.new('git_signature **') - message_cstr = ffi.NULL - encoding_cstr = ffi.NULL + message_cstr: 'ArrayC[char]' | 'ffi.NULL_TYPE' = ffi.NULL + encoding_cstr: 'ArrayC[char]' | 'ffi.NULL_TYPE' = ffi.NULL tree_cptr = ffi.new('git_tree **') # Get commit as pointer to git_commit. if isinstance(commit, (str, Oid)): - commit = self[commit] + commit_object = self[commit] + commit_commit = commit_object.peel(Commit) elif isinstance(commit, Commit): - pass + commit_commit = commit elif commit is None: raise ValueError('the commit to amend cannot be None') else: raise TypeError('the commit to amend must be a Commit, str, or Oid') - commit = commit.peel(Commit) - ffi.buffer(commit_cptr)[:] = commit._pointer[:] + ffi.buffer(commit_cptr)[:] = commit_commit._pointer[:] # Get refname as C string. if isinstance(refname, Reference): @@ -1620,9 +1711,11 @@ def amend_commit( # Get tree as pointer to git_tree. if tree is not None: if isinstance(tree, (str, Oid)): - tree = self[tree] - tree = tree.peel(Tree) - ffi.buffer(tree_cptr)[:] = tree._pointer[:] + tree_object = self[tree] + else: + tree_object = tree + tree_tree = tree_object.peel(Tree) + ffi.buffer(tree_cptr)[:] = tree_tree._pointer[:] # Amend the commit. err = C.git_commit_amend( @@ -1648,7 +1741,7 @@ def __ensure_tree(self, maybe_tree: str | Oid | Tree) -> Tree: class Repository(BaseRepository): def __init__( self, - path: typing.Optional[str] = None, + path: str | bytes | None | Path = None, flags: RepositoryOpenFlag = RepositoryOpenFlag.DEFAULT, ): """ diff --git a/pygit2/submodules.py b/pygit2/submodules.py index 7e5bf081f..46227e779 100644 --- a/pygit2/submodules.py +++ b/pygit2/submodules.py @@ -65,7 +65,7 @@ def open(self) -> Repository: err = C.git_submodule_open(crepo, self._subm) check_error(err) - return self._repo._from_c(crepo[0], True) + return self._repo._from_c(crepo[0], True) # type: ignore[attr-defined] def init(self, overwrite: bool = False) -> None: """ From 80f815ee20ee35dec99cd4872fdceb3650b7df01 Mon Sep 17 00:00:00 2001 From: Iliyas Jorio Date: Mon, 4 Aug 2025 17:14:58 +0200 Subject: [PATCH 069/225] Add RemoteCallbacks.push_negotiation --- pygit2/callbacks.py | 25 ++++++++++++++++++++- pygit2/decl/callbacks.h | 5 +++++ pygit2/remotes.py | 28 ++++++++++++++++++++++++ test/test_remote.py | 48 ++++++++++++++++++++++++++++++++++++++--- 4 files changed, 102 insertions(+), 4 deletions(-) diff --git a/pygit2/callbacks.py b/pygit2/callbacks.py index 87914285f..3261ce608 100644 --- a/pygit2/callbacks.py +++ b/pygit2/callbacks.py @@ -81,7 +81,7 @@ from pygit2._libgit2.ffi import GitProxyOptionsC from ._pygit2 import CloneOptions, PushOptions - from .remotes import TransferProgress + from .remotes import PushUpdate, TransferProgress # # The payload is the way to pass information from the pygit2 API, through # libgit2, to the Python callbacks. And back. @@ -198,6 +198,15 @@ def certificate_check(self, certificate: None, valid: bool, host: bytes) -> bool raise Passthrough + def push_negotiation(self, updates: list['PushUpdate']) -> None: + """ + During a push, called once between the negotiation step and the upload. + Provides information about what updates will be performed. + + Override with your own function to check the pending updates + and possibly reject them (by raising an exception). + """ + def transfer_progress(self, stats: 'TransferProgress') -> None: """ During the download of new data, this will be regularly called with @@ -427,6 +436,7 @@ def git_push_options(payload, opts=None): opts.callbacks.credentials = C._credentials_cb opts.callbacks.certificate_check = C._certificate_check_cb opts.callbacks.push_update_reference = C._push_update_reference_cb + opts.callbacks.push_negotiation = C._push_negotiation_cb # Per libgit2 sources, push_transfer_progress may incur a performance hit. # So, set it only if the user has overridden the no-op stub. if ( @@ -559,6 +569,19 @@ def _credentials_cb(cred_out, url, username, allowed, data): return 0 +@libgit2_callback +def _push_negotiation_cb(updates, num_updates, data): + from .remotes import PushUpdate + + push_negotiation = getattr(data, 'push_negotiation', None) + if not push_negotiation: + return 0 + + py_updates = [PushUpdate(updates[i]) for i in range(num_updates)] + push_negotiation(py_updates) + return 0 + + @libgit2_callback def _push_update_reference_cb(ref, msg, data): push_update_reference = getattr(data, 'push_update_reference', None) diff --git a/pygit2/decl/callbacks.h b/pygit2/decl/callbacks.h index 9d5409dee..64582718e 100644 --- a/pygit2/decl/callbacks.h +++ b/pygit2/decl/callbacks.h @@ -16,6 +16,11 @@ extern "Python" int _push_update_reference_cb( const char *status, void *data); +extern "Python" int _push_negotiation_cb( + const git_push_update **updates, + size_t len, + void *data); + extern "Python" int _remote_create_cb( git_remote **out, git_repository *repo, diff --git a/pygit2/remotes.py b/pygit2/remotes.py index 0c7d3c980..d4ddbc555 100644 --- a/pygit2/remotes.py +++ b/pygit2/remotes.py @@ -58,6 +58,34 @@ class LsRemotesDict(TypedDict): oid: Oid +class PushUpdate: + """ + Represents an update which will be performed on the remote during push. + """ + + src_refname: str + """The source name of the reference""" + + dst_refname: str + """The name of the reference to update on the server""" + + src: Oid + """The current target of the reference""" + + dst: Oid + """The new target for the reference""" + + def __init__(self, c_struct: Any) -> None: + src_refname = maybe_string(c_struct.src_refname) + dst_refname = maybe_string(c_struct.dst_refname) + assert src_refname is not None, 'libgit2 returned null src_refname' + assert dst_refname is not None, 'libgit2 returned null dst_refname' + self.src_refname = src_refname + self.dst_refname = dst_refname + self.src = Oid(raw=bytes(ffi.buffer(c_struct.src.id)[:])) + self.dst = Oid(raw=bytes(ffi.buffer(c_struct.dst.id)[:])) + + class TransferProgress: """Progress downloading and indexing data during a fetch.""" diff --git a/test/test_remote.py b/test/test_remote.py index 67a6a34a0..09315ee4b 100644 --- a/test/test_remote.py +++ b/test/test_remote.py @@ -31,7 +31,7 @@ import pygit2 from pygit2 import Remote, Repository -from pygit2.remotes import TransferProgress +from pygit2.remotes import PushUpdate, TransferProgress from . import utils @@ -406,9 +406,12 @@ def push_transfer_progress( assert origin.branches['master'].target == new_tip_id +@pytest.mark.parametrize('reject_from', ['push_transfer_progress', 'push_negotiation']) def test_push_interrupted_from_callbacks( - origin: Repository, clone: Repository, remote: Remote + origin: Repository, clone: Repository, remote: Remote, reject_from: str ) -> None: + reject_message = 'retreat! retreat!' + tip = clone[clone.head.target] clone.create_commit( 'refs/heads/master', @@ -420,10 +423,15 @@ def test_push_interrupted_from_callbacks( ) class MyCallbacks(pygit2.RemoteCallbacks): + def push_negotiation(self, updates: list[PushUpdate]) -> None: + if reject_from == 'push_negotiation': + raise InterruptedError(reject_message) + def push_transfer_progress( self, objects_pushed: int, total_objects: int, bytes_pushed: int ) -> None: - raise InterruptedError('retreat! retreat!') + if reject_from == 'push_transfer_progress': + raise InterruptedError(reject_message) assert origin.branches['master'].target == tip.id @@ -504,3 +512,37 @@ def test_push_threads(origin: Repository, clone: Repository, remote: Remote) -> callbacks = RemoteCallbacks() remote.push(['refs/heads/master'], callbacks, threads=1) assert callbacks.push_options.pb_parallelism == 1 + + +def test_push_negotiation( + origin: Repository, clone: Repository, remote: Remote +) -> None: + old_tip = clone[clone.head.target] + new_tip_id = clone.create_commit( + 'refs/heads/master', + old_tip.author, + old_tip.author, + 'empty commit', + old_tip.tree.id, + [old_tip.id], + ) + + the_updates: list[PushUpdate] = [] + + class MyCallbacks(pygit2.RemoteCallbacks): + def push_negotiation(self, updates: list[PushUpdate]) -> None: + the_updates.extend(updates) + + assert origin.branches['master'].target == old_tip.id + assert 'new_branch' not in origin.branches + + callbacks = MyCallbacks() + remote.push(['refs/heads/master'], callbacks=callbacks) + + assert len(the_updates) == 1 + assert the_updates[0].src_refname == 'refs/heads/master' + assert the_updates[0].dst_refname == 'refs/heads/master' + assert the_updates[0].src == old_tip.id + assert the_updates[0].dst == new_tip_id + + assert origin.branches['master'].target == new_tip_id From d7b67e4b55e3332b8d1397969a6968dc6e52920d Mon Sep 17 00:00:00 2001 From: Iliyas Jorio Date: Mon, 4 Aug 2025 17:33:19 +0200 Subject: [PATCH 070/225] Allow bypassing automatic connection in Remote.ls_remotes() Remote.ls_remotes() used to force a new connection to the remote. This can be harmful if a connection was already set up for other purposes, e.g. when calling ls_remotes from RemoteCallbacks.push_negotiation. This new argument `ls_remotes(..., connect)` lets you bypass the automatic connection. --- pygit2/remotes.py | 12 ++++++++++-- test/test_remote.py | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/pygit2/remotes.py b/pygit2/remotes.py index d4ddbc555..ad34ee45d 100644 --- a/pygit2/remotes.py +++ b/pygit2/remotes.py @@ -224,7 +224,10 @@ def fetch( return TransferProgress(C.git_remote_stats(self._remote)) def ls_remotes( - self, callbacks: RemoteCallbacks | None = None, proxy: str | None | bool = None + self, + callbacks: RemoteCallbacks | None = None, + proxy: str | None | bool = None, + connect: bool = True, ) -> list[LsRemotesDict]: """ Return a list of dicts that maps to `git_remote_head` from a @@ -235,9 +238,14 @@ def ls_remotes( callbacks : Passed to connect() proxy : Passed to connect() + + connect : Whether to connect to the remote first. You can pass False + if the remote has already connected. The list remains available after + disconnecting as long as a new connection is not initiated. """ - self.connect(callbacks=callbacks, proxy=proxy) + if connect: + self.connect(callbacks=callbacks, proxy=proxy) refs = ffi.new('git_remote_head ***') refs_len = ffi.new('size_t *') diff --git a/test/test_remote.py b/test/test_remote.py index 09315ee4b..5a7a50279 100644 --- a/test/test_remote.py +++ b/test/test_remote.py @@ -204,6 +204,22 @@ def test_ls_remotes(testrepo: Repository) -> None: assert next(iter(r for r in refs if r['name'] == 'refs/tags/v0.28.2')) +@utils.requires_network +def test_ls_remotes_without_implicit_connect(testrepo: Repository) -> None: + assert 1 == len(testrepo.remotes) + remote = testrepo.remotes[0] + + with pytest.raises(pygit2.GitError, match='this remote has never connected'): + remote.ls_remotes(connect=False) + + remote.connect() + refs = remote.ls_remotes(connect=False) + assert refs + + # Check that a known ref is returned. + assert next(iter(r for r in refs if r['name'] == 'refs/tags/v0.28.2')) + + def test_remote_collection(testrepo: Repository) -> None: remote = testrepo.remotes['origin'] assert REMOTE_NAME == remote.name From 93bbff72cebeb9476719ff65f75968986f855a4d Mon Sep 17 00:00:00 2001 From: Iliyas Jorio Date: Tue, 5 Aug 2025 12:46:01 +0200 Subject: [PATCH 071/225] Improve usability of Remote.ls_remotes's result --- pygit2/remotes.py | 81 ++++++++++++++++++++++++++++----------------- test/test_remote.py | 18 ++++++++-- 2 files changed, 67 insertions(+), 32 deletions(-) diff --git a/pygit2/remotes.py b/pygit2/remotes.py index ad34ee45d..93bb158ed 100644 --- a/pygit2/remotes.py +++ b/pygit2/remotes.py @@ -25,7 +25,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Generator, Iterator, Literal, TypedDict +import warnings +from typing import TYPE_CHECKING, Any, Generator, Iterator, Literal # Import from pygit2 from pygit2 import RemoteCallbacks @@ -50,12 +51,50 @@ from .repository import BaseRepository -class LsRemotesDict(TypedDict): +class RemoteHead: + """ + Description of a reference advertised by a remote server, + given out on `Remote.ls_remotes` calls. + """ + local: bool - loid: None | Oid + """Available locally""" + + oid: Oid + + loid: Oid | None + name: str | None + symref_target: str | None - oid: Oid + """ + If the server sent a symref mapping for this ref, this will + point to the target. + """ + + def __init__(self, c_struct: Any) -> None: + self.local = bool(c_struct.local) + if self.local: + self.loid = Oid(raw=bytes(ffi.buffer(c_struct.loid.id)[:])) + else: + self.loid = None + + self.oid = Oid(raw=bytes(ffi.buffer(c_struct.oid.id)[:])) + self.name = maybe_string(c_struct.name) + self.symref_target = maybe_string(c_struct.symref_target) + + def __getitem__(self, item: str) -> Any: + """ + DEPRECATED: Backwards compatibility with legacy user code + that expects this object to be a dictionary with string keys. + """ + warnings.warn( + 'ls_remotes no longer returns a dict. ' + 'Update your code to read from fields instead ' + '(e.g. result["name"] --> result.name)', + DeprecationWarning, + ) + return getattr(self, item) class PushUpdate: @@ -228,10 +267,10 @@ def ls_remotes( callbacks: RemoteCallbacks | None = None, proxy: str | None | bool = None, connect: bool = True, - ) -> list[LsRemotesDict]: + ) -> list[RemoteHead]: """ - Return a list of dicts that maps to `git_remote_head` from a - `ls_remotes` call. + Get the list of references with which the server responds to a new + connection. Parameters: @@ -247,32 +286,14 @@ def ls_remotes( if connect: self.connect(callbacks=callbacks, proxy=proxy) - refs = ffi.new('git_remote_head ***') - refs_len = ffi.new('size_t *') + refs_ptr = ffi.new('git_remote_head ***') + size_ptr = ffi.new('size_t *') - err = C.git_remote_ls(refs, refs_len, self._remote) + err = C.git_remote_ls(refs_ptr, size_ptr, self._remote) check_error(err) - results = [] - for i in range(int(refs_len[0])): - ref = refs[0][i] - local = bool(ref.local) - if local: - loid = Oid(raw=bytes(ffi.buffer(ref.loid.id)[:])) - else: - loid = None - - remote = LsRemotesDict( - { - 'local': local, - 'loid': loid, - 'name': maybe_string(ref.name), - 'symref_target': maybe_string(ref.symref_target), - 'oid': Oid(raw=bytes(ffi.buffer(ref.oid.id)[:])), - } - ) - - results.append(remote) + num_refs = int(size_ptr[0]) + results = [RemoteHead(refs_ptr[0][i]) for i in range(num_refs)] return results diff --git a/test/test_remote.py b/test/test_remote.py index 5a7a50279..d13bb38b7 100644 --- a/test/test_remote.py +++ b/test/test_remote.py @@ -201,7 +201,21 @@ def test_ls_remotes(testrepo: Repository) -> None: assert refs # Check that a known ref is returned. - assert next(iter(r for r in refs if r['name'] == 'refs/tags/v0.28.2')) + assert next(iter(r for r in refs if r.name == 'refs/tags/v0.28.2')) + + +@utils.requires_network +def test_ls_remotes_backwards_compatibility(testrepo: Repository) -> None: + assert 1 == len(testrepo.remotes) + remote = testrepo.remotes[0] + refs = remote.ls_remotes() + ref = refs[0] + + for field in ('name', 'oid', 'loid', 'local', 'symref_target'): + new_value = getattr(ref, field) + with pytest.warns(DeprecationWarning, match='no longer returns a dict'): + old_value = ref[field] + assert new_value == old_value @utils.requires_network @@ -217,7 +231,7 @@ def test_ls_remotes_without_implicit_connect(testrepo: Repository) -> None: assert refs # Check that a known ref is returned. - assert next(iter(r for r in refs if r['name'] == 'refs/tags/v0.28.2')) + assert next(iter(r for r in refs if r.name == 'refs/tags/v0.28.2')) def test_remote_collection(testrepo: Repository) -> None: From 6caa06deb7db06d7f8e1c4977abd7791a69db3b3 Mon Sep 17 00:00:00 2001 From: Iliyas Jorio Date: Tue, 5 Aug 2025 12:57:25 +0200 Subject: [PATCH 072/225] RemoteHead.loid is Oid zero if not available locally --- pygit2/remotes.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/pygit2/remotes.py b/pygit2/remotes.py index 93bb158ed..b3da78556 100644 --- a/pygit2/remotes.py +++ b/pygit2/remotes.py @@ -62,7 +62,7 @@ class RemoteHead: oid: Oid - loid: Oid | None + loid: Oid name: str | None @@ -74,12 +74,8 @@ class RemoteHead: def __init__(self, c_struct: Any) -> None: self.local = bool(c_struct.local) - if self.local: - self.loid = Oid(raw=bytes(ffi.buffer(c_struct.loid.id)[:])) - else: - self.loid = None - self.oid = Oid(raw=bytes(ffi.buffer(c_struct.oid.id)[:])) + self.loid = Oid(raw=bytes(ffi.buffer(c_struct.loid.id)[:])) self.name = maybe_string(c_struct.name) self.symref_target = maybe_string(c_struct.symref_target) From ddceff9b7709867b4d85cf43b018106bcd511ca2 Mon Sep 17 00:00:00 2001 From: kbaikov Date: Tue, 5 Aug 2025 21:50:46 +0200 Subject: [PATCH 073/225] Fix the first stage of mypy stricter errors Following this doc: https://mypy.readthedocs.io/en/stable/existing_code.html#getting-to-strict Total 13 error fixed. --- pygit2/blame.py | 2 +- pygit2/callbacks.py | 2 +- pygit2/ffi.py | 2 +- pygit2/repository.py | 2 +- test/test_index.py | 4 ++-- test/test_tag.py | 2 +- test/test_tree.py | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pygit2/blame.py b/pygit2/blame.py index c5e982b3c..02685e129 100644 --- a/pygit2/blame.py +++ b/pygit2/blame.py @@ -127,7 +127,7 @@ def __del__(self) -> None: C.git_blame_free(self._blame) def __len__(self) -> int: - return C.git_blame_get_hunk_count(self._blame) # type: ignore[no-any-return] + return C.git_blame_get_hunk_count(self._blame) def __getitem__(self, index: int) -> BlameHunk: chunk = C.git_blame_get_hunk_byindex(self._blame, index) diff --git a/pygit2/callbacks.py b/pygit2/callbacks.py index 3261ce608..3e365ebcd 100644 --- a/pygit2/callbacks.py +++ b/pygit2/callbacks.py @@ -414,7 +414,7 @@ def git_proxy_options( elif type(proxy) is str: opts.type = C.GIT_PROXY_SPECIFIED # Keep url in memory, otherwise memory is freed and bad things happen - payload.__proxy_url = ffi.new('char[]', to_bytes(proxy)) # type: ignore[attr-defined, no-untyped-call] + payload.__proxy_url = ffi.new('char[]', to_bytes(proxy)) # type: ignore[attr-defined] opts.url = payload.__proxy_url # type: ignore[attr-defined] else: raise TypeError('Proxy must be None, True, or a string') diff --git a/pygit2/ffi.py b/pygit2/ffi.py index 41ebec4c5..166227e19 100644 --- a/pygit2/ffi.py +++ b/pygit2/ffi.py @@ -24,5 +24,5 @@ # Boston, MA 02110-1301, USA. # Import from pygit2 -from ._libgit2 import ffi # type: ignore # noqa: F401 +from ._libgit2 import ffi # noqa: F401 from ._libgit2 import lib as C # type: ignore # noqa: F401 diff --git a/pygit2/repository.py b/pygit2/repository.py index 94e8e0917..dfb96e329 100644 --- a/pygit2/repository.py +++ b/pygit2/repository.py @@ -615,7 +615,7 @@ def diff( # Case 2: Index to workdir elif a is None and b is None: - return self.index.diff_to_workdir(**options) # type: ignore[arg-type] + return self.index.diff_to_workdir(**options) # Case 3: Diff tree to index or workdir elif isinstance(a, Tree) and b is None: diff --git a/test/test_index.py b/test/test_index.py index 0fa2e5403..01b04417c 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -49,8 +49,8 @@ def test_read(testrepo: Repository) -> None: assert len(index) == 2 with pytest.raises(TypeError): - index[()] # type: ignore - utils.assertRaisesWithArg(ValueError, -4, lambda: index[-4]) # type: ignore + index[()] + utils.assertRaisesWithArg(ValueError, -4, lambda: index[-4]) utils.assertRaisesWithArg(KeyError, 'abc', lambda: index['abc']) sha = 'a520c24d85fbfc815d385957eed41406ca5a860b' diff --git a/test/test_tag.py b/test/test_tag.py index e4f017b16..a3be72c28 100644 --- a/test/test_tag.py +++ b/test/test_tag.py @@ -59,7 +59,7 @@ def test_new_tag(barerepo: Repository) -> None: target_prefix = target[:5] too_short_prefix = target[:3] with pytest.raises(ValueError): - barerepo.create_tag(name, too_short_prefix, ObjectType.BLOB, tagger, message) # type: ignore + barerepo.create_tag(name, too_short_prefix, ObjectType.BLOB, tagger, message) sha = barerepo.create_tag(name, target_prefix, ObjectType.BLOB, tagger, message) tag = barerepo[sha] diff --git a/test/test_tree.py b/test/test_tree.py index c5954004e..556c3751a 100644 --- a/test/test_tree.py +++ b/test/test_tree.py @@ -99,7 +99,7 @@ def test_sorting(barerepo: Repository) -> None: tree_a = barerepo['18e2d2e9db075f9eb43bcb2daa65a2867d29a15e'] assert isinstance(tree_a, Tree) assert list(tree_a) == sorted(reversed(list(tree_a)), key=pygit2.tree_entry_key) - assert list(tree_a) != reversed(list(tree_a)) # type: ignore[comparison-overlap] + assert list(tree_a) != reversed(list(tree_a)) def test_read_subtree(barerepo: Repository) -> None: From 0947a213e0924a891e089dcdd15aa528d88f339b Mon Sep 17 00:00:00 2001 From: kbaikov Date: Tue, 5 Aug 2025 21:43:05 +0200 Subject: [PATCH 074/225] Add ignores to setup.py Don't want to mess with setup.py and types since they are not that important here. --- setup.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/setup.py b/setup.py index 1ad618218..7506d6236 100644 --- a/setup.py +++ b/setup.py @@ -23,6 +23,8 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. +# mypy: disable-error-code="import-not-found, import-untyped" + # Import setuptools before distutils to avoid user warning import os import sys From 2a57752a5bf66019e78644dc01602af487d8147f Mon Sep 17 00:00:00 2001 From: kbaikov Date: Tue, 5 Aug 2025 21:42:19 +0200 Subject: [PATCH 075/225] Introduce stricter mypy options This is a first step according to the documentation https://mypy.readthedocs.io/en/stable/existing_code.html#getting-to-strict --- mypy.ini | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/mypy.ini b/mypy.ini index e9f046b0a..4431c8ff4 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,5 +1,9 @@ [mypy] +warn_unused_configs = True +warn_redundant_casts = True +warn_unused_ignores = True + [mypy-test.*] disallow_untyped_defs = True disallow_untyped_calls = True From 7cfc65177af7f2fee3af08881b0f42473db799a8 Mon Sep 17 00:00:00 2001 From: nrybowski Date: Mon, 4 Aug 2025 16:54:54 +0200 Subject: [PATCH 076/225] Instrument git_submodule_set_url. This commit adds a setter for the url property of the Submodule class. The new setter leverages 'git_submodule_set_url' from libgit2. --- pygit2/decl/submodule.h | 2 ++ pygit2/submodules.py | 8 ++++++++ test/test_submodule.py | 11 +++++++++++ 3 files changed, 21 insertions(+) diff --git a/pygit2/decl/submodule.h b/pygit2/decl/submodule.h index fda915a56..b16f4b031 100644 --- a/pygit2/decl/submodule.h +++ b/pygit2/decl/submodule.h @@ -41,3 +41,5 @@ const char * git_submodule_branch(git_submodule *submodule); const git_oid * git_submodule_head_id(git_submodule *submodule); int git_submodule_status(unsigned int *status, git_repository *repo, const char *name, git_submodule_ignore_t ignore); + +int git_submodule_set_url(git_repository *repo, const char *name, const char *url); diff --git a/pygit2/submodules.py b/pygit2/submodules.py index 46227e779..4cd036a8f 100644 --- a/pygit2/submodules.py +++ b/pygit2/submodules.py @@ -152,6 +152,14 @@ def url(self) -> Union[str, None]: url = C.git_submodule_url(self._subm) return maybe_string(url) + @url.setter + def url(self, url: str) -> None: + crepo = self._repo._repo + cname = ffi.new('char[]', to_bytes(self.name)) + curl = ffi.new('char[]', to_bytes(url)) + err = C.git_submodule_set_url(crepo, cname, curl) + check_error(err) + @property def branch(self): """Branch that is to be tracked by the submodule.""" diff --git a/test/test_submodule.py b/test/test_submodule.py index 52c55b2be..453a5abfd 100644 --- a/test/test_submodule.py +++ b/test/test_submodule.py @@ -124,6 +124,17 @@ def test_url(repo: Repository) -> None: assert SUBM_URL == s.url +def test_set_url(repo: Repository) -> None: + new_url = 'ssh://git@127.0.0.1:2222/my_repo' + s = repo.submodules[SUBM_PATH] + s.url = new_url + assert new_url == repo.submodules[SUBM_PATH].url + # Ensure .gitmodules has been correctly altered + with open(Path(repo.workdir, '.gitmodules'), 'r') as fd: + modules = fd.read() + assert new_url in modules + + def test_missing_url(repo: Repository) -> None: # Remove "url" from .gitmodules with open(Path(repo.workdir, '.gitmodules'), 'wt') as f: From 1d9d0bef2cf88b31153f770b03cee09edf9d6fbb Mon Sep 17 00:00:00 2001 From: kbaikov Date: Wed, 6 Aug 2025 14:46:49 +0200 Subject: [PATCH 077/225] Use modern python syntax for types and others Since the minium declared python version is 3.10 we don't need the Union and Option and many type imports should be done from collections.abc. so i ran fd --extension py --type f --exec pyupgrade --py310-plus on the repo. All changes are automated, i.e. no manual edit. --- pygit2/__init__.py | 6 ++---- pygit2/_pygit2.pyi | 5 ++--- pygit2/blame.py | 3 ++- pygit2/branches.py | 3 ++- pygit2/callbacks.py | 5 +++-- pygit2/config.py | 3 ++- pygit2/filter.py | 4 ++-- pygit2/index.py | 2 +- pygit2/references.py | 3 ++- pygit2/refspec.py | 2 +- pygit2/remotes.py | 3 ++- pygit2/repository.py | 3 ++- pygit2/submodules.py | 9 +++++---- pygit2/utils.py | 6 ++---- pyproject.toml | 2 +- test/conftest.py | 2 +- test/test_branch_empty.py | 2 +- test/test_commit_trailer.py | 2 +- test/test_config.py | 2 +- test/test_diff.py | 2 +- test/test_diff_binary.py | 2 +- test/test_filter.py | 2 +- test/test_odb.py | 2 +- test/test_odb_backend.py | 2 +- test/test_packbuilder.py | 2 +- test/test_refdb_backend.py | 2 +- test/test_remote_prune.py | 2 +- test/test_remote_utf8.py | 2 +- test/test_repository_custom.py | 2 +- test/test_submodule.py | 2 +- test/utils.py | 3 ++- 31 files changed, 48 insertions(+), 44 deletions(-) diff --git a/pygit2/__init__.py b/pygit2/__init__.py index 38ae2c385..7b01d37c5 100644 --- a/pygit2/__init__.py +++ b/pygit2/__init__.py @@ -364,12 +364,10 @@ def init_repository( - path: typing.Union[str, bytes, os.PathLike, None], + path: str | bytes | os.PathLike | None, bare: bool = False, flags: enums.RepositoryInitFlag = enums.RepositoryInitFlag.MKPATH, - mode: typing.Union[ - int, enums.RepositoryInitMode - ] = enums.RepositoryInitMode.SHARED_UMASK, + mode: int | enums.RepositoryInitMode = enums.RepositoryInitMode.SHARED_UMASK, workdir_path: typing.Optional[str] = None, description: typing.Optional[str] = None, template_path: typing.Optional[str] = None, diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index 7ca7d2692..105acc2a9 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -1,13 +1,12 @@ +from collections.abc import Iterator, Sequence from io import DEFAULT_BUFFER_SIZE, IOBase from pathlib import Path from queue import Queue from threading import Event -from typing import ( +from typing import ( # noqa: UP035 Generic, - Iterator, Literal, Optional, - Sequence, Type, TypedDict, TypeVar, diff --git a/pygit2/blame.py b/pygit2/blame.py index 02685e129..9854f3afd 100644 --- a/pygit2/blame.py +++ b/pygit2/blame.py @@ -23,7 +23,8 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. -from typing import TYPE_CHECKING, Iterator +from collections.abc import Iterator +from typing import TYPE_CHECKING from ._pygit2 import Oid, Repository, Signature diff --git a/pygit2/branches.py b/pygit2/branches.py index ca32e1516..b729a21e5 100644 --- a/pygit2/branches.py +++ b/pygit2/branches.py @@ -25,7 +25,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Iterator +from collections.abc import Iterator +from typing import TYPE_CHECKING from ._pygit2 import Branch, Commit, Oid from .enums import BranchType, ReferenceType diff --git a/pygit2/callbacks.py b/pygit2/callbacks.py index 3e365ebcd..3423e2db6 100644 --- a/pygit2/callbacks.py +++ b/pygit2/callbacks.py @@ -63,9 +63,10 @@ """ # Standard Library +from collections.abc import Callable, Generator from contextlib import contextmanager from functools import wraps -from typing import TYPE_CHECKING, Callable, Generator, Optional, Union +from typing import TYPE_CHECKING, Optional # pygit2 from ._pygit2 import DiffFile, Oid @@ -151,7 +152,7 @@ def sideband_progress(self, string: str) -> None: def credentials( self, url: str, - username_from_url: Union[str, None], + username_from_url: str | None, allowed_types: CredentialType, ) -> _Credentials: """ diff --git a/pygit2/config.py b/pygit2/config.py index ec97f5430..76d114fef 100644 --- a/pygit2/config.py +++ b/pygit2/config.py @@ -23,9 +23,10 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. +from collections.abc import Callable, Iterator from os import PathLike from pathlib import Path -from typing import TYPE_CHECKING, Callable, Iterator +from typing import TYPE_CHECKING try: from functools import cached_property diff --git a/pygit2/filter.py b/pygit2/filter.py index abf3eb351..5c89a0d19 100644 --- a/pygit2/filter.py +++ b/pygit2/filter.py @@ -23,7 +23,7 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. -from typing import Callable, List, Optional +from collections.abc import Callable from ._pygit2 import FilterSource @@ -58,7 +58,7 @@ class Filter: def nattrs(cls) -> int: return len(cls.attributes.split()) - def check(self, src: FilterSource, attr_values: List[Optional[str]]) -> None: + def check(self, src: FilterSource, attr_values: list[str | None]) -> None: """ Check whether this filter should be applied to the given source. diff --git a/pygit2/index.py b/pygit2/index.py index 3fedf7c6d..928a62223 100644 --- a/pygit2/index.py +++ b/pygit2/index.py @@ -392,7 +392,7 @@ class MergeFileResult: automergeable: bool 'True if the output was automerged, false if the output contains conflict markers' - path: typing.Union[str, None, PathLike[str]] + path: str | None | PathLike[str] 'The path that the resultant merge file should use, or None if a filename conflict would occur' mode: FileMode diff --git a/pygit2/references.py b/pygit2/references.py index 5cbee8fd7..93c370eab 100644 --- a/pygit2/references.py +++ b/pygit2/references.py @@ -25,7 +25,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Iterator +from collections.abc import Iterator +from typing import TYPE_CHECKING from pygit2 import Oid diff --git a/pygit2/refspec.py b/pygit2/refspec.py index 968f7fdd0..cb900e75f 100644 --- a/pygit2/refspec.py +++ b/pygit2/refspec.py @@ -23,7 +23,7 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. -from typing import Callable +from collections.abc import Callable # Import from pygit2 from .errors import check_error diff --git a/pygit2/remotes.py b/pygit2/remotes.py index b3da78556..52974646b 100644 --- a/pygit2/remotes.py +++ b/pygit2/remotes.py @@ -26,7 +26,8 @@ from __future__ import annotations import warnings -from typing import TYPE_CHECKING, Any, Generator, Iterator, Literal +from collections.abc import Generator, Iterator +from typing import TYPE_CHECKING, Any, Literal # Import from pygit2 from pygit2 import RemoteCallbacks diff --git a/pygit2/repository.py b/pygit2/repository.py index dfb96e329..509b4d0f1 100644 --- a/pygit2/repository.py +++ b/pygit2/repository.py @@ -25,11 +25,12 @@ import tarfile import warnings +from collections.abc import Callable, Iterator from io import BytesIO from pathlib import Path from string import hexdigits from time import time -from typing import TYPE_CHECKING, Callable, Iterator, Optional, overload +from typing import TYPE_CHECKING, Optional, overload # Import from pygit2 from ._pygit2 import ( diff --git a/pygit2/submodules.py b/pygit2/submodules.py index 4cd036a8f..4ff00980d 100644 --- a/pygit2/submodules.py +++ b/pygit2/submodules.py @@ -25,8 +25,9 @@ from __future__ import annotations +from collections.abc import Iterable, Iterator from pathlib import Path -from typing import TYPE_CHECKING, Iterable, Iterator, Optional, Union +from typing import TYPE_CHECKING, Optional from ._pygit2 import Oid from .callbacks import RemoteCallbacks, git_fetch_options @@ -147,7 +148,7 @@ def path(self): return ffi.string(path).decode('utf-8') @property - def url(self) -> Union[str, None]: + def url(self) -> str | None: """URL of the submodule.""" url = C.git_submodule_url(self._subm) return maybe_string(url) @@ -167,7 +168,7 @@ def branch(self): return ffi.string(branch).decode('utf-8') @property - def head_id(self) -> Union[Oid, None]: + def head_id(self) -> Oid | None: """ The submodule's HEAD commit id (as recorded in the superproject's current HEAD tree). @@ -205,7 +206,7 @@ def __iter__(self) -> Iterator[Submodule]: for s in self._repository.listall_submodules(): yield self[s] - def get(self, name: str) -> Union[Submodule, None]: + def get(self, name: str) -> Submodule | None: """ Look up submodule information by name or path. Unlike __getitem__, this returns None if the submodule is not found. diff --git a/pygit2/utils.py b/pygit2/utils.py index d00b76261..b3d5d1416 100644 --- a/pygit2/utils.py +++ b/pygit2/utils.py @@ -25,15 +25,13 @@ import contextlib import os +from collections.abc import Generator, Iterator, Sequence from types import TracebackType from typing import ( TYPE_CHECKING, - Generator, Generic, - Iterator, Optional, Protocol, - Sequence, TypeVar, Union, overload, @@ -55,7 +53,7 @@ def maybe_string(ptr: 'char_pointer | None') -> str | None: @overload def to_bytes( - s: Union[str, bytes, os.PathLike[str]], + s: str | bytes | os.PathLike[str], encoding: str = 'utf-8', errors: str = 'strict', ) -> bytes: ... diff --git a/pyproject.toml b/pyproject.toml index 219efcac9..a9a25a5c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ extend-exclude = [ ".cache", ".coverage", "build", "site-packages", "venv*"] target-version = "py310" # oldest supported Python version [tool.ruff.lint] -select = ["E4", "E7", "E9", "F", "I"] +select = ["E4", "E7", "E9", "F", "I", "UP035", "UP007"] [tool.ruff.format] quote-style = "single" diff --git a/test/conftest.py b/test/conftest.py index 4ea9b6888..6052346f8 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,6 +1,6 @@ import platform +from collections.abc import Generator from pathlib import Path -from typing import Generator import pytest diff --git a/test/test_branch_empty.py b/test/test_branch_empty.py index 9bfe00884..5b2beabd8 100644 --- a/test/test_branch_empty.py +++ b/test/test_branch_empty.py @@ -23,7 +23,7 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. -from typing import Generator +from collections.abc import Generator import pytest diff --git a/test/test_commit_trailer.py b/test/test_commit_trailer.py index 51043ff80..efe5434f2 100644 --- a/test/test_commit_trailer.py +++ b/test/test_commit_trailer.py @@ -23,8 +23,8 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. +from collections.abc import Generator from pathlib import Path -from typing import Generator import pytest diff --git a/test/test_config.py b/test/test_config.py index f7e63112a..247d55c51 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -23,8 +23,8 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. +from collections.abc import Generator from pathlib import Path -from typing import Generator import pytest diff --git a/test/test_diff.py b/test/test_diff.py index 7c75dc4da..838d4dd08 100644 --- a/test/test_diff.py +++ b/test/test_diff.py @@ -26,8 +26,8 @@ """Tests for Diff objects.""" import textwrap +from collections.abc import Iterator from itertools import chain -from typing import Iterator import pytest diff --git a/test/test_diff_binary.py b/test/test_diff_binary.py index 9eb3a38c9..2947e403d 100644 --- a/test/test_diff_binary.py +++ b/test/test_diff_binary.py @@ -23,8 +23,8 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. +from collections.abc import Generator from pathlib import Path -from typing import Generator import pytest diff --git a/test/test_filter.py b/test/test_filter.py index d29b8b7ef..26a45095f 100644 --- a/test/test_filter.py +++ b/test/test_filter.py @@ -1,6 +1,6 @@ import codecs +from collections.abc import Callable, Generator from io import BytesIO -from typing import Callable, Generator import pytest diff --git a/test/test_odb.py b/test/test_odb.py index a9b33e188..4d023135f 100644 --- a/test/test_odb.py +++ b/test/test_odb.py @@ -27,8 +27,8 @@ # Standard Library import binascii +from collections.abc import Generator from pathlib import Path -from typing import Generator import pytest diff --git a/test/test_odb_backend.py b/test/test_odb_backend.py index ab1ea5888..89c215726 100644 --- a/test/test_odb_backend.py +++ b/test/test_odb_backend.py @@ -27,8 +27,8 @@ # Standard Library import binascii +from collections.abc import Generator, Iterator from pathlib import Path -from typing import Generator, Iterator import pytest diff --git a/test/test_packbuilder.py b/test/test_packbuilder.py index d6b6a8e14..5309f3f13 100644 --- a/test/test_packbuilder.py +++ b/test/test_packbuilder.py @@ -25,8 +25,8 @@ """Tests for Index files.""" +from collections.abc import Callable from pathlib import Path -from typing import Callable import pygit2 from pygit2 import Oid, PackBuilder, Repository diff --git a/test/test_refdb_backend.py b/test/test_refdb_backend.py index 903b93889..bfc375515 100644 --- a/test/test_refdb_backend.py +++ b/test/test_refdb_backend.py @@ -25,8 +25,8 @@ """Tests for Refdb objects.""" +from collections.abc import Generator, Iterator from pathlib import Path -from typing import Generator, Iterator import pytest diff --git a/test/test_remote_prune.py b/test/test_remote_prune.py index ea0b84897..2fdaab0dd 100644 --- a/test/test_remote_prune.py +++ b/test/test_remote_prune.py @@ -23,8 +23,8 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. +from collections.abc import Generator from pathlib import Path -from typing import Generator import pytest diff --git a/test/test_remote_utf8.py b/test/test_remote_utf8.py index 4ed0bdd28..03edd6b4c 100644 --- a/test/test_remote_utf8.py +++ b/test/test_remote_utf8.py @@ -23,8 +23,8 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. +from collections.abc import Generator from pathlib import Path -from typing import Generator import pytest diff --git a/test/test_repository_custom.py b/test/test_repository_custom.py index 336c20a80..40698df05 100644 --- a/test/test_repository_custom.py +++ b/test/test_repository_custom.py @@ -23,8 +23,8 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. +from collections.abc import Generator from pathlib import Path -from typing import Generator import pytest diff --git a/test/test_submodule.py b/test/test_submodule.py index 453a5abfd..90bdc7586 100644 --- a/test/test_submodule.py +++ b/test/test_submodule.py @@ -25,8 +25,8 @@ """Tests for Submodule objects.""" +from collections.abc import Generator from pathlib import Path -from typing import Generator import pytest diff --git a/test/utils.py b/test/utils.py index fe42a7607..a3b14cfdb 100644 --- a/test/utils.py +++ b/test/utils.py @@ -30,9 +30,10 @@ import stat import sys import zipfile +from collections.abc import Callable from pathlib import Path from types import TracebackType -from typing import Any, Callable, Optional, ParamSpec, TypeVar +from typing import Any, Optional, ParamSpec, TypeVar # Requirements import pytest From 44bac2edd52a45397f1538bf2925ddedece65bf4 Mon Sep 17 00:00:00 2001 From: kbaikov Date: Thu, 7 Aug 2025 10:18:36 +0200 Subject: [PATCH 078/225] Add types-cffi to test requirements This hopefully helps with future typings. --- pygit2/_run.py | 2 +- requirements-test.txt | 1 + setup.py | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/pygit2/_run.py b/pygit2/_run.py index bb8238c93..f64ba0faf 100644 --- a/pygit2/_run.py +++ b/pygit2/_run.py @@ -33,7 +33,7 @@ from pathlib import Path # Import from cffi -from cffi import FFI # type: ignore +from cffi import FFI # Import from pygit2 try: diff --git a/requirements-test.txt b/requirements-test.txt index a10711068..15e9ecf87 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,3 +1,4 @@ pytest pytest-cov mypy +types-cffi diff --git a/setup.py b/setup.py index 7506d6236..ff904fa9e 100644 --- a/setup.py +++ b/setup.py @@ -28,7 +28,7 @@ # Import setuptools before distutils to avoid user warning import os import sys -from distutils import log +from distutils import log # type: ignore[attr-defined] from distutils.command.build import build from distutils.command.sdist import sdist from pathlib import Path @@ -129,7 +129,7 @@ def run(self): # On Windows we package up the dlls with the plugin. if os.name == 'nt': - cmdclass['build'] = BuildWithDLLs + cmdclass['build'] = BuildWithDLLs # type: ignore[assignment] src = __dir__ / 'src' pygit2_exts = [str(path) for path in sorted(src.iterdir()) if path.suffix == '.c'] From 2932e12c0f9fb213399fca6773a09275c8cb9d23 Mon Sep 17 00:00:00 2001 From: kbaikov Date: Thu, 7 Aug 2025 19:35:54 +0200 Subject: [PATCH 079/225] Fix mypy no implicit reexports errors Just for the future reference used this asnwer from so https://stackoverflow.com/questions/79371127/module-does-not-explicitly-export-attribute-attr-defined --- mypy.ini | 1 + pygit2/errors.py | 2 ++ pygit2/ffi.py | 2 ++ 3 files changed, 5 insertions(+) diff --git a/mypy.ini b/mypy.ini index 4431c8ff4..719fad283 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3,6 +3,7 @@ warn_unused_configs = True warn_redundant_casts = True warn_unused_ignores = True +no_implicit_reexport = True [mypy-test.*] disallow_untyped_defs = True diff --git a/pygit2/errors.py b/pygit2/errors.py index 8bfa8ff51..72a4af396 100644 --- a/pygit2/errors.py +++ b/pygit2/errors.py @@ -27,6 +27,8 @@ from ._pygit2 import GitError from .ffi import C, ffi +__all__ = ['GitError'] + value_errors = set([C.GIT_EEXISTS, C.GIT_EINVALIDSPEC, C.GIT_EAMBIGUOUS]) diff --git a/pygit2/ffi.py b/pygit2/ffi.py index 166227e19..2a5d86042 100644 --- a/pygit2/ffi.py +++ b/pygit2/ffi.py @@ -26,3 +26,5 @@ # Import from pygit2 from ._libgit2 import ffi # noqa: F401 from ._libgit2 import lib as C # type: ignore # noqa: F401 + +__all__ = ['C', 'ffi'] From ff66108e986e803f9c7721e6ae9849f127f89239 Mon Sep 17 00:00:00 2001 From: kbaikov Date: Thu, 7 Aug 2025 19:58:08 +0200 Subject: [PATCH 080/225] Fix mypy untyped decorators error This was straight forward using legacy ParamSpec syntax: https://mypy.readthedocs.io/en/stable/generics.html#declaring-decorators --- mypy.ini | 2 ++ pygit2/callbacks.py | 13 ++++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/mypy.ini b/mypy.ini index 719fad283..ea5a4ae13 100644 --- a/mypy.ini +++ b/mypy.ini @@ -4,6 +4,8 @@ warn_unused_configs = True warn_redundant_casts = True warn_unused_ignores = True no_implicit_reexport = True +disallow_subclassing_any = True +disallow_untyped_decorators = True [mypy-test.*] disallow_untyped_defs = True diff --git a/pygit2/callbacks.py b/pygit2/callbacks.py index 3423e2db6..adcfca528 100644 --- a/pygit2/callbacks.py +++ b/pygit2/callbacks.py @@ -66,7 +66,7 @@ from collections.abc import Callable, Generator from contextlib import contextmanager from functools import wraps -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Optional, ParamSpec, TypeVar # pygit2 from ._pygit2 import DiffFile, Oid @@ -490,8 +490,11 @@ def git_remote_callbacks(payload): # exception. # +P = ParamSpec('P') +T = TypeVar('T') -def libgit2_callback(f): + +def libgit2_callback(f: Callable[P, T]) -> Callable[P, T]: @wraps(f) def wrapper(*args): data = ffi.from_handle(args[-1]) @@ -509,10 +512,10 @@ def wrapper(*args): data._stored_exception = e return C.GIT_EUSER - return ffi.def_extern()(wrapper) + return ffi.def_extern()(wrapper) # type: ignore[attr-defined] -def libgit2_callback_void(f): +def libgit2_callback_void(f: Callable[P, T]) -> Callable[P, T]: @wraps(f) def wrapper(*args): data = ffi.from_handle(args[-1]) @@ -529,7 +532,7 @@ def wrapper(*args): data._stored_exception = e pass # Function returns void, so we can't do much here. - return ffi.def_extern()(wrapper) + return ffi.def_extern()(wrapper) # type: ignore[attr-defined] @libgit2_callback From bc42a5a05c217be2935333823905304ebb1784e0 Mon Sep 17 00:00:00 2001 From: kbaikov Date: Fri, 8 Aug 2025 21:33:54 +0200 Subject: [PATCH 081/225] Fix part of mypy disallow_any_generics Not all fixes done yet. --- pygit2/__init__.py | 6 +++--- pygit2/_pygit2.pyi | 2 +- pygit2/utils.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pygit2/__init__.py b/pygit2/__init__.py index 7b01d37c5..56d2d643d 100644 --- a/pygit2/__init__.py +++ b/pygit2/__init__.py @@ -364,7 +364,7 @@ def init_repository( - path: str | bytes | os.PathLike | None, + path: str | bytes | os.PathLike[str] | os.PathLike[bytes] | None, bare: bool = False, flags: enums.RepositoryInitFlag = enums.RepositoryInitFlag.MKPATH, mode: int | enums.RepositoryInitMode = enums.RepositoryInitMode.SHARED_UMASK, @@ -444,8 +444,8 @@ def init_repository( def clone_repository( - url: str | bytes | os.PathLike, - path: str | bytes | os.PathLike, + url: str | bytes | os.PathLike[str] | os.PathLike[bytes], + path: str | bytes | os.PathLike[str] | os.PathLike[bytes], bare: bool = False, repository: typing.Callable | None = None, remote: typing.Callable | None = None, diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index 105acc2a9..df4c3e26c 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -763,7 +763,7 @@ class Repository: def references_iterator_init(self) -> Iterator[Reference]: ... def references_iterator_next( self, - iter: Iterator, + iter: Iterator[T], references_return_type: ReferenceFilter = ReferenceFilter.ALL, ) -> Reference: ... def reset(self, oid: _OidArg, reset_type: ResetMode) -> None: ... diff --git a/pygit2/utils.py b/pygit2/utils.py index b3d5d1416..4e77d98f8 100644 --- a/pygit2/utils.py +++ b/pygit2/utils.py @@ -53,7 +53,7 @@ def maybe_string(ptr: 'char_pointer | None') -> str | None: @overload def to_bytes( - s: str | bytes | os.PathLike[str], + s: str | bytes | os.PathLike[str] | os.PathLike[bytes], encoding: str = 'utf-8', errors: str = 'strict', ) -> bytes: ... @@ -64,7 +64,7 @@ def to_bytes( errors: str = 'strict', ) -> Union['ffi.NULL_TYPE']: ... def to_bytes( - s: Union[str, bytes, 'ffi.NULL_TYPE', os.PathLike[str], None], + s: Union[str, bytes, 'ffi.NULL_TYPE', os.PathLike[str], os.PathLike[bytes], None], encoding: str = 'utf-8', errors: str = 'strict', ) -> Union[bytes, 'ffi.NULL_TYPE']: From ecd6ff2a061660bd3ad46ffe745fae733e0205bc Mon Sep 17 00:00:00 2001 From: kbaikov Date: Sat, 9 Aug 2025 07:39:39 +0200 Subject: [PATCH 082/225] Fix some more mypy disallow_untyped_calls --- pygit2/_libgit2/ffi.pyi | 2 ++ pygit2/config.py | 2 +- pygit2/errors.py | 2 +- pygit2/utils.py | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/pygit2/_libgit2/ffi.pyi b/pygit2/_libgit2/ffi.pyi index 0e865e311..08bc41965 100644 --- a/pygit2/_libgit2/ffi.pyi +++ b/pygit2/_libgit2/ffi.pyi @@ -329,6 +329,8 @@ def new(a: Literal['git_buf *'], b: tuple[NULL_TYPE, Literal[0]]) -> GitBufC: .. @overload def new(a: Literal['char **']) -> _Pointer[char_pointer]: ... @overload +def new(a: Literal['void **'], b: bytes) -> _Pointer[bytes]: ... +@overload def new(a: Literal['char[]', 'char []'], b: bytes | NULL_TYPE) -> ArrayC[char]: ... def addressof(a: object, attribute: str) -> _Pointer[object]: ... diff --git a/pygit2/config.py b/pygit2/config.py index 76d114fef..f05a8d13a 100644 --- a/pygit2/config.py +++ b/pygit2/config.py @@ -110,7 +110,7 @@ def __del__(self) -> None: except AttributeError: pass - def _get(self, key: str | bytes) -> tuple[object, 'ConfigEntry']: + def _get(self, key: str | bytes) -> tuple[int, 'ConfigEntry']: key = str_to_bytes(key, 'key') entry = ffi.new('git_config_entry **') diff --git a/pygit2/errors.py b/pygit2/errors.py index 72a4af396..00108e32f 100644 --- a/pygit2/errors.py +++ b/pygit2/errors.py @@ -32,7 +32,7 @@ value_errors = set([C.GIT_EEXISTS, C.GIT_EINVALIDSPEC, C.GIT_EAMBIGUOUS]) -def check_error(err, io=False): +def check_error(err: int, io=False) -> None: if err >= 0: return diff --git a/pygit2/utils.py b/pygit2/utils.py index b3d5d1416..bdc6dfe44 100644 --- a/pygit2/utils.py +++ b/pygit2/utils.py @@ -93,7 +93,7 @@ def to_str(s): raise TypeError(f'unexpected type "{repr(s)}"') -def ptr_to_bytes(ptr_cdata): +def ptr_to_bytes(ptr_cdata) -> bytes: """ Convert a pointer coming from C code () to a byte buffer containing the address that the pointer refers to. From 9d7df463a17ea192c0b31b3c3cbb67032c716077 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Mon, 11 Aug 2025 12:45:35 +0200 Subject: [PATCH 083/225] Move option handling to CFFI --- .gitignore | 2 + build.sh | 2 +- pygit2/__init__.py | 68 +++---- pygit2/_libgit2/ffi.pyi | 10 + pygit2/_pygit2.pyi | 105 ----------- pygit2/_run.py | 1 + pygit2/decl/options.h | 50 +++++ pygit2/enums.py | 75 ++++---- pygit2/options.py | 395 ++++++++++++++++++++++++++++++++++++++++ pygit2/settings.py | 2 +- src/options.c | 309 ------------------------------- src/options.h | 72 -------- src/pygit2.c | 35 ---- test/conftest.py | 2 +- test/test_options.py | 8 +- 15 files changed, 538 insertions(+), 598 deletions(-) create mode 100644 pygit2/decl/options.h create mode 100644 pygit2/options.py delete mode 100644 src/options.c delete mode 100644 src/options.h diff --git a/.gitignore b/.gitignore index 60e8a5500..0f5d52267 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ __pycache__/ *.pyc *.so *.swp +/pygit2/_libgit2.c +/pygit2/_libgit2.o diff --git a/build.sh b/build.sh index 31fea2443..c710a076c 100644 --- a/build.sh +++ b/build.sh @@ -178,7 +178,7 @@ if [ -n "$LIBGIT2_VERSION" ]; then wget https://github.com/libgit2/libgit2/archive/refs/tags/v$LIBGIT2_VERSION.tar.gz -N -O $FILENAME.tar.gz tar xf $FILENAME.tar.gz cd $FILENAME - mkdir build -p + mkdir -p build cd build if [ "$KERNEL" = "Darwin" ] && [ "$CIBUILDWHEEL" = "1" ]; then CMAKE_PREFIX_PATH=$OPENSSL_PREFIX:$PREFIX cmake .. \ diff --git a/pygit2/__init__.py b/pygit2/__init__.py index 7b01d37c5..9f8acacb1 100644 --- a/pygit2/__init__.py +++ b/pygit2/__init__.py @@ -191,38 +191,6 @@ GIT_OID_HEXSZ, GIT_OID_MINPREFIXLEN, GIT_OID_RAWSZ, - GIT_OPT_DISABLE_PACK_KEEP_FILE_CHECKS, - GIT_OPT_ENABLE_CACHING, - GIT_OPT_ENABLE_FSYNC_GITDIR, - GIT_OPT_ENABLE_OFS_DELTA, - GIT_OPT_ENABLE_STRICT_HASH_VERIFICATION, - GIT_OPT_ENABLE_STRICT_OBJECT_CREATION, - GIT_OPT_ENABLE_STRICT_SYMBOLIC_REF_CREATION, - GIT_OPT_ENABLE_UNSAVED_INDEX_SAFETY, - GIT_OPT_GET_CACHED_MEMORY, - GIT_OPT_GET_MWINDOW_FILE_LIMIT, - GIT_OPT_GET_MWINDOW_MAPPED_LIMIT, - GIT_OPT_GET_MWINDOW_SIZE, - GIT_OPT_GET_OWNER_VALIDATION, - GIT_OPT_GET_PACK_MAX_OBJECTS, - GIT_OPT_GET_SEARCH_PATH, - GIT_OPT_GET_TEMPLATE_PATH, - GIT_OPT_GET_USER_AGENT, - GIT_OPT_GET_WINDOWS_SHAREMODE, - GIT_OPT_SET_ALLOCATOR, - GIT_OPT_SET_CACHE_MAX_SIZE, - GIT_OPT_SET_CACHE_OBJECT_LIMIT, - GIT_OPT_SET_MWINDOW_FILE_LIMIT, - GIT_OPT_SET_MWINDOW_MAPPED_LIMIT, - GIT_OPT_SET_MWINDOW_SIZE, - GIT_OPT_SET_OWNER_VALIDATION, - GIT_OPT_SET_PACK_MAX_OBJECTS, - GIT_OPT_SET_SEARCH_PATH, - GIT_OPT_SET_SSL_CERT_LOCATIONS, - GIT_OPT_SET_SSL_CIPHERS, - GIT_OPT_SET_TEMPLATE_PATH, - GIT_OPT_SET_USER_AGENT, - GIT_OPT_SET_WINDOWS_SHAREMODE, GIT_REFERENCES_ALL, GIT_REFERENCES_BRANCHES, GIT_REFERENCES_TAGS, @@ -322,12 +290,46 @@ hash, hashfile, init_file_backend, - option, reference_is_valid_name, tree_entry_cmp, ) from .blame import Blame, BlameHunk from .blob import BlobIO +from .options import ( + option, + GIT_OPT_DISABLE_PACK_KEEP_FILE_CHECKS, + GIT_OPT_ENABLE_CACHING, + GIT_OPT_ENABLE_FSYNC_GITDIR, + GIT_OPT_ENABLE_OFS_DELTA, + GIT_OPT_ENABLE_STRICT_HASH_VERIFICATION, + GIT_OPT_ENABLE_STRICT_OBJECT_CREATION, + GIT_OPT_ENABLE_STRICT_SYMBOLIC_REF_CREATION, + GIT_OPT_ENABLE_UNSAVED_INDEX_SAFETY, + GIT_OPT_GET_CACHED_MEMORY, + GIT_OPT_GET_MWINDOW_FILE_LIMIT, + GIT_OPT_GET_MWINDOW_MAPPED_LIMIT, + GIT_OPT_GET_MWINDOW_SIZE, + GIT_OPT_GET_OWNER_VALIDATION, + GIT_OPT_GET_PACK_MAX_OBJECTS, + GIT_OPT_GET_SEARCH_PATH, + GIT_OPT_GET_TEMPLATE_PATH, + GIT_OPT_GET_USER_AGENT, + GIT_OPT_GET_WINDOWS_SHAREMODE, + GIT_OPT_SET_ALLOCATOR, + GIT_OPT_SET_CACHE_MAX_SIZE, + GIT_OPT_SET_CACHE_OBJECT_LIMIT, + GIT_OPT_SET_MWINDOW_FILE_LIMIT, + GIT_OPT_SET_MWINDOW_MAPPED_LIMIT, + GIT_OPT_SET_MWINDOW_SIZE, + GIT_OPT_SET_OWNER_VALIDATION, + GIT_OPT_SET_PACK_MAX_OBJECTS, + GIT_OPT_SET_SEARCH_PATH, + GIT_OPT_SET_SSL_CERT_LOCATIONS, + GIT_OPT_SET_SSL_CIPHERS, + GIT_OPT_SET_TEMPLATE_PATH, + GIT_OPT_SET_USER_AGENT, + GIT_OPT_SET_WINDOWS_SHAREMODE, +) from .callbacks import ( CheckoutCallbacks, Payload, diff --git a/pygit2/_libgit2/ffi.pyi b/pygit2/_libgit2/ffi.pyi index 0e865e311..9feb1791a 100644 --- a/pygit2/_libgit2/ffi.pyi +++ b/pygit2/_libgit2/ffi.pyi @@ -42,6 +42,9 @@ class int_c: class int64_t: def __getitem__(self, item: Literal[0]) -> int: ... +class ssize_t: + def __getitem__(self, item: Literal[0]) -> int: ... + class _Pointer(Generic[T]): def __setitem__(self, item: Literal[0], a: T) -> None: ... @overload @@ -319,6 +322,8 @@ def new( @overload def new(a: Literal['size_t *', 'size_t*']) -> size_t: ... @overload +def new(a: Literal['ssize_t *', 'ssize_t*']) -> ssize_t: ... +@overload def new(a: Literal['git_stash_save_options *']) -> GitStashSaveOptionsC: ... @overload def new(a: Literal['git_strarray *']) -> GitStrrayC: ... @@ -340,4 +345,9 @@ class buffer(bytes): @overload def __getitem__(self, item: slice[Any, Any, Any]) -> bytes: ... +@overload def cast(a: Literal['int'], b: object) -> int: ... +@overload +def cast(a: Literal['size_t'], b: object) -> int: ... +@overload +def cast(a: Literal['ssize_t'], b: object) -> int: ... diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index 105acc2a9..0a2245be9 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -52,38 +52,6 @@ LIBGIT2_VER_MAJOR: int LIBGIT2_VER_MINOR: int LIBGIT2_VER_REVISION: int LIBGIT2_VERSION: str -GIT_OPT_GET_MWINDOW_SIZE: int -GIT_OPT_SET_MWINDOW_SIZE: int -GIT_OPT_GET_MWINDOW_MAPPED_LIMIT: int -GIT_OPT_SET_MWINDOW_MAPPED_LIMIT: int -GIT_OPT_GET_SEARCH_PATH: int -GIT_OPT_SET_SEARCH_PATH: int -GIT_OPT_SET_CACHE_OBJECT_LIMIT: int -GIT_OPT_SET_CACHE_MAX_SIZE: int -GIT_OPT_ENABLE_CACHING: int -GIT_OPT_GET_CACHED_MEMORY: int -GIT_OPT_GET_TEMPLATE_PATH: int -GIT_OPT_SET_TEMPLATE_PATH: int -GIT_OPT_SET_SSL_CERT_LOCATIONS: int -GIT_OPT_SET_USER_AGENT: int -GIT_OPT_ENABLE_STRICT_OBJECT_CREATION: int -GIT_OPT_ENABLE_STRICT_SYMBOLIC_REF_CREATION: int -GIT_OPT_SET_SSL_CIPHERS: int -GIT_OPT_GET_USER_AGENT: int -GIT_OPT_ENABLE_OFS_DELTA: int -GIT_OPT_ENABLE_FSYNC_GITDIR: int -GIT_OPT_GET_WINDOWS_SHAREMODE: int -GIT_OPT_SET_WINDOWS_SHAREMODE: int -GIT_OPT_ENABLE_STRICT_HASH_VERIFICATION: int -GIT_OPT_SET_ALLOCATOR: int -GIT_OPT_ENABLE_UNSAVED_INDEX_SAFETY: int -GIT_OPT_GET_PACK_MAX_OBJECTS: int -GIT_OPT_SET_PACK_MAX_OBJECTS: int -GIT_OPT_DISABLE_PACK_KEEP_FILE_CHECKS: int -GIT_OPT_GET_OWNER_VALIDATION: int -GIT_OPT_SET_OWNER_VALIDATION: int -GIT_OPT_GET_MWINDOW_FILE_LIMIT: int -GIT_OPT_SET_MWINDOW_FILE_LIMIT: int GIT_OID_RAWSZ: int GIT_OID_HEXSZ: int GIT_OID_HEX_ZERO: str @@ -887,79 +855,6 @@ def discover_repository( def hash(data: bytes | str) -> Oid: ... def hashfile(path: str) -> Oid: ... def init_file_backend(path: str, flags: int = 0) -> object: ... -@overload -def option( - opt: Literal[ - Option.GET_MWINDOW_FILE_LIMIT, - Option.GET_MWINDOW_MAPPED_LIMIT, - Option.GET_MWINDOW_SIZE, - ], -) -> int: ... -@overload -def option( - opt: Literal[ - Option.SET_MWINDOW_FILE_LIMIT, - Option.SET_MWINDOW_MAPPED_LIMIT, - Option.SET_MWINDOW_SIZE, - ], - value: int, -) -> None: ... -@overload -def option(opt: Literal[Option.GET_SEARCH_PATH], level: ConfigLevel) -> str: ... -@overload -def option( - opt: Literal[Option.SET_SEARCH_PATH], level: ConfigLevel, value: str -) -> None: ... -@overload -def option( - opt: Literal[Option.SET_CACHE_OBJECT_LIMIT], object_type: ObjectType, limit: int -) -> None: ... -@overload -def option(opt: Literal[Option.SET_CACHE_MAX_SIZE], max_size: int) -> None: ... -@overload -def option(opt: Literal[Option.GET_CACHED_MEMORY]) -> tuple[int, int]: ... - -# not implemented: -# Option.GET_TEMPLATE_PATH -# Option.SET_TEMPLATE_PATH - -@overload -def option( - opt: Literal[Option.SET_SSL_CERT_LOCATIONS], - file: str | bytes | None, - dir: str | bytes | None, -) -> None: ... - -# not implemented: -# Option.SET_USER_AGENT - -@overload -def option( - opt: Literal[ - Option.ENABLE_CACHING, - Option.ENABLE_STRICT_OBJECT_CREATION, - Option.ENABLE_STRICT_SYMBOLIC_REF_CREATION, - Option.ENABLE_OFS_DELTA, - Option.ENABLE_FSYNC_GITDIR, - Option.ENABLE_STRICT_HASH_VERIFICATION, - Option.ENABLE_UNSAVED_INDEX_SAFETY, - Option.DISABLE_PACK_KEEP_FILE_CHECKS, - Option.SET_OWNER_VALIDATION, - ], - value: bool | Literal[0, 1], -) -> None: ... -@overload -def option(opt: Literal[Option.GET_OWNER_VALIDATION]) -> int: ... - -# not implemented: -# Option.SET_SSL_CIPHERS -# Option.GET_USER_AGENT -# Option.GET_WINDOWS_SHAREMODE -# Option.SET_WINDOWS_SHAREMODE -# Option.SET_ALLOCATOR -# Option.GET_PACK_MAX_OBJECTS -# Option.SET_PACK_MAX_OBJECTS - def reference_is_valid_name(refname: str) -> bool: ... def tree_entry_cmp(a: Object, b: Object) -> int: ... def _cache_enums() -> None: ... diff --git a/pygit2/_run.py b/pygit2/_run.py index f64ba0faf..44f30344e 100644 --- a/pygit2/_run.py +++ b/pygit2/_run.py @@ -81,6 +81,7 @@ 'revert.h', 'stash.h', 'submodule.h', + 'options.h', 'callbacks.h', # Bridge from libgit2 to Python ] h_source = [] diff --git a/pygit2/decl/options.h b/pygit2/decl/options.h new file mode 100644 index 000000000..f6556d5e2 --- /dev/null +++ b/pygit2/decl/options.h @@ -0,0 +1,50 @@ +typedef enum { + GIT_OPT_GET_MWINDOW_SIZE, + GIT_OPT_SET_MWINDOW_SIZE, + GIT_OPT_GET_MWINDOW_MAPPED_LIMIT, + GIT_OPT_SET_MWINDOW_MAPPED_LIMIT, + GIT_OPT_GET_SEARCH_PATH, + GIT_OPT_SET_SEARCH_PATH, + GIT_OPT_SET_CACHE_OBJECT_LIMIT, + GIT_OPT_SET_CACHE_MAX_SIZE, + GIT_OPT_ENABLE_CACHING, + GIT_OPT_GET_CACHED_MEMORY, + GIT_OPT_GET_TEMPLATE_PATH, + GIT_OPT_SET_TEMPLATE_PATH, + GIT_OPT_SET_SSL_CERT_LOCATIONS, + GIT_OPT_SET_USER_AGENT, + GIT_OPT_ENABLE_STRICT_OBJECT_CREATION, + GIT_OPT_ENABLE_STRICT_SYMBOLIC_REF_CREATION, + GIT_OPT_SET_SSL_CIPHERS, + GIT_OPT_GET_USER_AGENT, + GIT_OPT_ENABLE_OFS_DELTA, + GIT_OPT_ENABLE_FSYNC_GITDIR, + GIT_OPT_GET_WINDOWS_SHAREMODE, + GIT_OPT_SET_WINDOWS_SHAREMODE, + GIT_OPT_ENABLE_STRICT_HASH_VERIFICATION, + GIT_OPT_SET_ALLOCATOR, + GIT_OPT_ENABLE_UNSAVED_INDEX_SAFETY, + GIT_OPT_GET_PACK_MAX_OBJECTS, + GIT_OPT_SET_PACK_MAX_OBJECTS, + GIT_OPT_DISABLE_PACK_KEEP_FILE_CHECKS, + GIT_OPT_ENABLE_HTTP_EXPECT_CONTINUE, + GIT_OPT_GET_MWINDOW_FILE_LIMIT, + GIT_OPT_SET_MWINDOW_FILE_LIMIT, + GIT_OPT_SET_ODB_PACKED_PRIORITY, + GIT_OPT_SET_ODB_LOOSE_PRIORITY, + GIT_OPT_GET_EXTENSIONS, + GIT_OPT_SET_EXTENSIONS, + GIT_OPT_GET_OWNER_VALIDATION, + GIT_OPT_SET_OWNER_VALIDATION, + GIT_OPT_GET_HOMEDIR, + GIT_OPT_SET_HOMEDIR, + GIT_OPT_SET_SERVER_CONNECT_TIMEOUT, + GIT_OPT_GET_SERVER_CONNECT_TIMEOUT, + GIT_OPT_SET_SERVER_TIMEOUT, + GIT_OPT_GET_SERVER_TIMEOUT, + GIT_OPT_SET_USER_AGENT_PRODUCT, + GIT_OPT_GET_USER_AGENT_PRODUCT, + GIT_OPT_ADD_SSL_X509_CERT +} git_libgit2_opt_t; + +int git_libgit2_opts(int option, ...); \ No newline at end of file diff --git a/pygit2/enums.py b/pygit2/enums.py index fe6421686..80be63a5b 100644 --- a/pygit2/enums.py +++ b/pygit2/enums.py @@ -26,6 +26,7 @@ from enum import IntEnum, IntFlag from . import _pygit2 +from . import options from .ffi import C @@ -948,45 +949,45 @@ class Option(IntEnum): """Global libgit2 library options""" # Commented out values --> exists in libgit2 but not supported in pygit2's options.c yet - GET_MWINDOW_SIZE = _pygit2.GIT_OPT_GET_MWINDOW_SIZE - SET_MWINDOW_SIZE = _pygit2.GIT_OPT_SET_MWINDOW_SIZE - GET_MWINDOW_MAPPED_LIMIT = _pygit2.GIT_OPT_GET_MWINDOW_MAPPED_LIMIT - SET_MWINDOW_MAPPED_LIMIT = _pygit2.GIT_OPT_SET_MWINDOW_MAPPED_LIMIT - GET_SEARCH_PATH = _pygit2.GIT_OPT_GET_SEARCH_PATH - SET_SEARCH_PATH = _pygit2.GIT_OPT_SET_SEARCH_PATH - SET_CACHE_OBJECT_LIMIT = _pygit2.GIT_OPT_SET_CACHE_OBJECT_LIMIT - SET_CACHE_MAX_SIZE = _pygit2.GIT_OPT_SET_CACHE_MAX_SIZE - ENABLE_CACHING = _pygit2.GIT_OPT_ENABLE_CACHING - GET_CACHED_MEMORY = _pygit2.GIT_OPT_GET_CACHED_MEMORY - GET_TEMPLATE_PATH = _pygit2.GIT_OPT_GET_TEMPLATE_PATH - SET_TEMPLATE_PATH = _pygit2.GIT_OPT_SET_TEMPLATE_PATH - SET_SSL_CERT_LOCATIONS = _pygit2.GIT_OPT_SET_SSL_CERT_LOCATIONS - SET_USER_AGENT = _pygit2.GIT_OPT_SET_USER_AGENT - ENABLE_STRICT_OBJECT_CREATION = _pygit2.GIT_OPT_ENABLE_STRICT_OBJECT_CREATION + GET_MWINDOW_SIZE = options.GIT_OPT_GET_MWINDOW_SIZE + SET_MWINDOW_SIZE = options.GIT_OPT_SET_MWINDOW_SIZE + GET_MWINDOW_MAPPED_LIMIT = options.GIT_OPT_GET_MWINDOW_MAPPED_LIMIT + SET_MWINDOW_MAPPED_LIMIT = options.GIT_OPT_SET_MWINDOW_MAPPED_LIMIT + GET_SEARCH_PATH = options.GIT_OPT_GET_SEARCH_PATH + SET_SEARCH_PATH = options.GIT_OPT_SET_SEARCH_PATH + SET_CACHE_OBJECT_LIMIT = options.GIT_OPT_SET_CACHE_OBJECT_LIMIT + SET_CACHE_MAX_SIZE = options.GIT_OPT_SET_CACHE_MAX_SIZE + ENABLE_CACHING = options.GIT_OPT_ENABLE_CACHING + GET_CACHED_MEMORY = options.GIT_OPT_GET_CACHED_MEMORY + GET_TEMPLATE_PATH = options.GIT_OPT_GET_TEMPLATE_PATH + SET_TEMPLATE_PATH = options.GIT_OPT_SET_TEMPLATE_PATH + SET_SSL_CERT_LOCATIONS = options.GIT_OPT_SET_SSL_CERT_LOCATIONS + SET_USER_AGENT = options.GIT_OPT_SET_USER_AGENT + ENABLE_STRICT_OBJECT_CREATION = options.GIT_OPT_ENABLE_STRICT_OBJECT_CREATION ENABLE_STRICT_SYMBOLIC_REF_CREATION = ( - _pygit2.GIT_OPT_ENABLE_STRICT_SYMBOLIC_REF_CREATION + options.GIT_OPT_ENABLE_STRICT_SYMBOLIC_REF_CREATION ) - SET_SSL_CIPHERS = _pygit2.GIT_OPT_SET_SSL_CIPHERS - GET_USER_AGENT = _pygit2.GIT_OPT_GET_USER_AGENT - ENABLE_OFS_DELTA = _pygit2.GIT_OPT_ENABLE_OFS_DELTA - ENABLE_FSYNC_GITDIR = _pygit2.GIT_OPT_ENABLE_FSYNC_GITDIR - GET_WINDOWS_SHAREMODE = _pygit2.GIT_OPT_GET_WINDOWS_SHAREMODE - SET_WINDOWS_SHAREMODE = _pygit2.GIT_OPT_SET_WINDOWS_SHAREMODE - ENABLE_STRICT_HASH_VERIFICATION = _pygit2.GIT_OPT_ENABLE_STRICT_HASH_VERIFICATION - SET_ALLOCATOR = _pygit2.GIT_OPT_SET_ALLOCATOR - ENABLE_UNSAVED_INDEX_SAFETY = _pygit2.GIT_OPT_ENABLE_UNSAVED_INDEX_SAFETY - GET_PACK_MAX_OBJECTS = _pygit2.GIT_OPT_GET_PACK_MAX_OBJECTS - SET_PACK_MAX_OBJECTS = _pygit2.GIT_OPT_SET_PACK_MAX_OBJECTS - DISABLE_PACK_KEEP_FILE_CHECKS = _pygit2.GIT_OPT_DISABLE_PACK_KEEP_FILE_CHECKS - # ENABLE_HTTP_EXPECT_CONTINUE = _pygit2.GIT_OPT_ENABLE_HTTP_EXPECT_CONTINUE - GET_MWINDOW_FILE_LIMIT = _pygit2.GIT_OPT_GET_MWINDOW_FILE_LIMIT - SET_MWINDOW_FILE_LIMIT = _pygit2.GIT_OPT_SET_MWINDOW_FILE_LIMIT - # SET_ODB_PACKED_PRIORITY = _pygit2.GIT_OPT_SET_ODB_PACKED_PRIORITY - # SET_ODB_LOOSE_PRIORITY = _pygit2.GIT_OPT_SET_ODB_LOOSE_PRIORITY - # GET_EXTENSIONS = _pygit2.GIT_OPT_GET_EXTENSIONS - # SET_EXTENSIONS = _pygit2.GIT_OPT_SET_EXTENSIONS - GET_OWNER_VALIDATION = _pygit2.GIT_OPT_GET_OWNER_VALIDATION - SET_OWNER_VALIDATION = _pygit2.GIT_OPT_SET_OWNER_VALIDATION + SET_SSL_CIPHERS = options.GIT_OPT_SET_SSL_CIPHERS + GET_USER_AGENT = options.GIT_OPT_GET_USER_AGENT + ENABLE_OFS_DELTA = options.GIT_OPT_ENABLE_OFS_DELTA + ENABLE_FSYNC_GITDIR = options.GIT_OPT_ENABLE_FSYNC_GITDIR + GET_WINDOWS_SHAREMODE = options.GIT_OPT_GET_WINDOWS_SHAREMODE + SET_WINDOWS_SHAREMODE = options.GIT_OPT_SET_WINDOWS_SHAREMODE + ENABLE_STRICT_HASH_VERIFICATION = options.GIT_OPT_ENABLE_STRICT_HASH_VERIFICATION + SET_ALLOCATOR = options.GIT_OPT_SET_ALLOCATOR + ENABLE_UNSAVED_INDEX_SAFETY = options.GIT_OPT_ENABLE_UNSAVED_INDEX_SAFETY + GET_PACK_MAX_OBJECTS = options.GIT_OPT_GET_PACK_MAX_OBJECTS + SET_PACK_MAX_OBJECTS = options.GIT_OPT_SET_PACK_MAX_OBJECTS + DISABLE_PACK_KEEP_FILE_CHECKS = options.GIT_OPT_DISABLE_PACK_KEEP_FILE_CHECKS + # ENABLE_HTTP_EXPECT_CONTINUE = options.GIT_OPT_ENABLE_HTTP_EXPECT_CONTINUE + GET_MWINDOW_FILE_LIMIT = options.GIT_OPT_GET_MWINDOW_FILE_LIMIT + SET_MWINDOW_FILE_LIMIT = options.GIT_OPT_SET_MWINDOW_FILE_LIMIT + # SET_ODB_PACKED_PRIORITY = options.GIT_OPT_SET_ODB_PACKED_PRIORITY + # SET_ODB_LOOSE_PRIORITY = options.GIT_OPT_SET_ODB_LOOSE_PRIORITY + # GET_EXTENSIONS = options.GIT_OPT_GET_EXTENSIONS + # SET_EXTENSIONS = options.GIT_OPT_SET_EXTENSIONS + GET_OWNER_VALIDATION = options.GIT_OPT_GET_OWNER_VALIDATION + SET_OWNER_VALIDATION = options.GIT_OPT_SET_OWNER_VALIDATION # GET_HOMEDIR = _pygit2.GIT_OPT_GET_HOMEDIR # SET_HOMEDIR = _pygit2.GIT_OPT_SET_HOMEDIR # SET_SERVER_CONNECT_TIMEOUT = _pygit2.GIT_OPT_SET_SERVER_CONNECT_TIMEOUT diff --git a/pygit2/options.py b/pygit2/options.py new file mode 100644 index 000000000..4f63234e8 --- /dev/null +++ b/pygit2/options.py @@ -0,0 +1,395 @@ +from __future__ import annotations +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file 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; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +""" +Libgit2 global options management using CFFI. +""" + +from typing import Any, Literal, Optional, Tuple, Union, overload + +from .ffi import C, ffi +from .errors import check_error +from .utils import to_bytes, to_str + +# Import only for type checking to avoid circular imports +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .enums import ConfigLevel, ObjectType, Option + from ._libgit2.ffi import ArrayC, NULL_TYPE, char + +# Export GIT_OPT constants for backward compatibility +GIT_OPT_GET_MWINDOW_SIZE: int = C.GIT_OPT_GET_MWINDOW_SIZE +GIT_OPT_SET_MWINDOW_SIZE: int = C.GIT_OPT_SET_MWINDOW_SIZE +GIT_OPT_GET_MWINDOW_MAPPED_LIMIT: int = C.GIT_OPT_GET_MWINDOW_MAPPED_LIMIT +GIT_OPT_SET_MWINDOW_MAPPED_LIMIT: int = C.GIT_OPT_SET_MWINDOW_MAPPED_LIMIT +GIT_OPT_GET_SEARCH_PATH: int = C.GIT_OPT_GET_SEARCH_PATH +GIT_OPT_SET_SEARCH_PATH: int = C.GIT_OPT_SET_SEARCH_PATH +GIT_OPT_SET_CACHE_OBJECT_LIMIT: int = C.GIT_OPT_SET_CACHE_OBJECT_LIMIT +GIT_OPT_SET_CACHE_MAX_SIZE: int = C.GIT_OPT_SET_CACHE_MAX_SIZE +GIT_OPT_ENABLE_CACHING: int = C.GIT_OPT_ENABLE_CACHING +GIT_OPT_GET_CACHED_MEMORY: int = C.GIT_OPT_GET_CACHED_MEMORY +GIT_OPT_GET_TEMPLATE_PATH: int = C.GIT_OPT_GET_TEMPLATE_PATH +GIT_OPT_SET_TEMPLATE_PATH: int = C.GIT_OPT_SET_TEMPLATE_PATH +GIT_OPT_SET_SSL_CERT_LOCATIONS: int = C.GIT_OPT_SET_SSL_CERT_LOCATIONS +GIT_OPT_SET_USER_AGENT: int = C.GIT_OPT_SET_USER_AGENT +GIT_OPT_ENABLE_STRICT_OBJECT_CREATION: int = C.GIT_OPT_ENABLE_STRICT_OBJECT_CREATION +GIT_OPT_ENABLE_STRICT_SYMBOLIC_REF_CREATION: int = ( + C.GIT_OPT_ENABLE_STRICT_SYMBOLIC_REF_CREATION +) +GIT_OPT_SET_SSL_CIPHERS: int = C.GIT_OPT_SET_SSL_CIPHERS +GIT_OPT_GET_USER_AGENT: int = C.GIT_OPT_GET_USER_AGENT +GIT_OPT_ENABLE_OFS_DELTA: int = C.GIT_OPT_ENABLE_OFS_DELTA +GIT_OPT_ENABLE_FSYNC_GITDIR: int = C.GIT_OPT_ENABLE_FSYNC_GITDIR +GIT_OPT_GET_WINDOWS_SHAREMODE: int = C.GIT_OPT_GET_WINDOWS_SHAREMODE +GIT_OPT_SET_WINDOWS_SHAREMODE: int = C.GIT_OPT_SET_WINDOWS_SHAREMODE +GIT_OPT_ENABLE_STRICT_HASH_VERIFICATION: int = C.GIT_OPT_ENABLE_STRICT_HASH_VERIFICATION +GIT_OPT_SET_ALLOCATOR: int = C.GIT_OPT_SET_ALLOCATOR +GIT_OPT_ENABLE_UNSAVED_INDEX_SAFETY: int = C.GIT_OPT_ENABLE_UNSAVED_INDEX_SAFETY +GIT_OPT_GET_PACK_MAX_OBJECTS: int = C.GIT_OPT_GET_PACK_MAX_OBJECTS +GIT_OPT_SET_PACK_MAX_OBJECTS: int = C.GIT_OPT_SET_PACK_MAX_OBJECTS +GIT_OPT_DISABLE_PACK_KEEP_FILE_CHECKS: int = C.GIT_OPT_DISABLE_PACK_KEEP_FILE_CHECKS +GIT_OPT_GET_MWINDOW_FILE_LIMIT: int = C.GIT_OPT_GET_MWINDOW_FILE_LIMIT +GIT_OPT_SET_MWINDOW_FILE_LIMIT: int = C.GIT_OPT_SET_MWINDOW_FILE_LIMIT +GIT_OPT_GET_OWNER_VALIDATION: int = C.GIT_OPT_GET_OWNER_VALIDATION +GIT_OPT_SET_OWNER_VALIDATION: int = C.GIT_OPT_SET_OWNER_VALIDATION + + +NOT_PASSED = object() + + +def check_args(option: Option, arg1: Any, arg2: Any, expected: int) -> None: + if expected == 0 and (arg1 is not NOT_PASSED or arg2 is not NOT_PASSED): + raise TypeError(f"option({option}) takes no additional arguments") + + if expected == 1 and (arg1 is NOT_PASSED or arg2 is not NOT_PASSED): + raise TypeError(f"option({option}, x) requires 1 additional argument") + + if expected == 2 and (arg1 is NOT_PASSED or arg2 is NOT_PASSED): + raise TypeError(f"option({option}, x, y) requires 2 additional arguments") + + +@overload +def option( + option_type: Union[ + Literal[Option.GET_MWINDOW_SIZE], + Literal[Option.GET_MWINDOW_MAPPED_LIMIT], + Literal[Option.GET_MWINDOW_FILE_LIMIT], + ], +) -> int: ... + + +@overload +def option( + option_type: Union[ + Literal[Option.SET_MWINDOW_SIZE], + Literal[Option.SET_MWINDOW_MAPPED_LIMIT], + Literal[Option.SET_MWINDOW_FILE_LIMIT], + Literal[Option.SET_CACHE_MAX_SIZE], + ], + arg1: int, # value +) -> None: ... + + +@overload +def option( + option_type: Literal[Option.GET_SEARCH_PATH], + arg1: ConfigLevel, # value +) -> str: ... + + +@overload +def option( + option_type: Literal[Option.SET_SEARCH_PATH], + arg1: ConfigLevel, # type + arg2: str, # value +) -> None: ... + + +@overload +def option( + option_type: Literal[Option.SET_CACHE_OBJECT_LIMIT], + arg1: ObjectType, # type + arg2: int, # limit +) -> None: ... + + +@overload +def option(option_type: Literal[Option.GET_CACHED_MEMORY]) -> Tuple[int, int]: ... + + +@overload +def option( + option_type: Literal[Option.SET_SSL_CERT_LOCATIONS], + arg1: Optional[str | bytes], # cert_file + arg2: Optional[str | bytes], # cert_dir +) -> None: ... + + +@overload +def option( + option_type: Union[ + Literal[Option.ENABLE_CACHING], + Literal[Option.ENABLE_STRICT_OBJECT_CREATION], + Literal[Option.ENABLE_STRICT_SYMBOLIC_REF_CREATION], + Literal[Option.ENABLE_OFS_DELTA], + Literal[Option.ENABLE_FSYNC_GITDIR], + Literal[Option.ENABLE_STRICT_HASH_VERIFICATION], + Literal[Option.ENABLE_UNSAVED_INDEX_SAFETY], + Literal[Option.DISABLE_PACK_KEEP_FILE_CHECKS], + Literal[Option.SET_OWNER_VALIDATION], + ], + arg1: bool, # value +) -> None: ... + + +@overload +def option(option_type: Literal[Option.GET_OWNER_VALIDATION]) -> bool: ... + + +# Fallback overload for generic Option values (used in tests) +@overload +def option(option_type: Option, arg1: Any = ..., arg2: Any = ...) -> Any: ... + + +def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) -> Any: + """ + Get or set a libgit2 option. + + Parameters: + + GIT_OPT_GET_SEARCH_PATH, level + Get the config search path for the given level. + + GIT_OPT_SET_SEARCH_PATH, level, path + Set the config search path for the given level. + + GIT_OPT_GET_MWINDOW_SIZE + Get the maximum mmap window size. + + GIT_OPT_SET_MWINDOW_SIZE, size + Set the maximum mmap window size. + + GIT_OPT_GET_MWINDOW_FILE_LIMIT + Get the maximum number of files that will be mapped at any time by the library. + + GIT_OPT_SET_MWINDOW_FILE_LIMIT, size + Set the maximum number of files that can be mapped at any time by the library. The default (0) is unlimited. + + GIT_OPT_GET_OWNER_VALIDATION + Gets the owner validation setting for repository directories. + + GIT_OPT_SET_OWNER_VALIDATION, enabled + Set that repository directories should be owned by the current user. + The default is to validate ownership. + """ + + # Handle GET options with size_t output + if option_type in ( + C.GIT_OPT_GET_MWINDOW_SIZE, + C.GIT_OPT_GET_MWINDOW_MAPPED_LIMIT, + C.GIT_OPT_GET_MWINDOW_FILE_LIMIT, + ): + check_args(option_type, arg1, arg2, 0) + + size_ptr = ffi.new("size_t *") + err = C.git_libgit2_opts(option_type, size_ptr) + check_error(err) + return size_ptr[0] + + # Handle SET options with size_t input + elif option_type in ( + C.GIT_OPT_SET_MWINDOW_SIZE, + C.GIT_OPT_SET_MWINDOW_MAPPED_LIMIT, + C.GIT_OPT_SET_MWINDOW_FILE_LIMIT, + ): + check_args(option_type, arg1, arg2, 1) + + if not isinstance(arg1, int): + raise TypeError( + f"option value must be an integer, not {type(arg1)}" + ) + size = arg1 + if size < 0: + raise ValueError("size must be non-negative") + + err = C.git_libgit2_opts(option_type, ffi.cast("size_t", size)) + check_error(err) + return None + + # Handle GET_SEARCH_PATH + elif option_type == C.GIT_OPT_GET_SEARCH_PATH: + check_args(option_type, arg1, arg2, 1) + + level = int(arg1) # Convert enum to int + buf = ffi.new("git_buf *") + err = C.git_libgit2_opts(option_type, ffi.cast("int", level), buf) + check_error(err) + + try: + if buf.ptr != ffi.NULL: + result = to_str(ffi.string(buf.ptr)) + else: + result = None + finally: + C.git_buf_dispose(buf) + + return result + + # Handle SET_SEARCH_PATH + elif option_type == C.GIT_OPT_SET_SEARCH_PATH: + check_args(option_type, arg1, arg2, 2) + + level = int(arg1) # Convert enum to int + path = arg2 + + path_cdata: ArrayC[char] | NULL_TYPE + if path is None: + path_cdata = ffi.NULL + else: + path_bytes = to_bytes(path) + path_cdata = ffi.new("char[]", path_bytes) + + err = C.git_libgit2_opts(option_type, ffi.cast("int", level), path_cdata) + check_error(err) + return None + + # Handle SET_CACHE_OBJECT_LIMIT + elif option_type == C.GIT_OPT_SET_CACHE_OBJECT_LIMIT: + check_args(option_type, arg1, arg2, 2) + + object_type = int(arg1) # Convert enum to int + if not isinstance(arg2, int): + raise TypeError( + f"option value must be an integer, not {type(arg2).__name__}" + ) + size = arg2 + if size < 0: + raise ValueError("size must be non-negative") + + err = C.git_libgit2_opts( + option_type, ffi.cast("int", object_type), ffi.cast("size_t", size) + ) + check_error(err) + return None + + # Handle SET_CACHE_MAX_SIZE + elif option_type == C.GIT_OPT_SET_CACHE_MAX_SIZE: + check_args(option_type, arg1, arg2, 1) + + size = arg1 + if not isinstance(size, int): + raise TypeError( + f"option value must be an integer, not {type(size).__name__}" + ) + + err = C.git_libgit2_opts(option_type, ffi.cast("ssize_t", size)) + check_error(err) + return None + + # Handle GET_CACHED_MEMORY + elif option_type == C.GIT_OPT_GET_CACHED_MEMORY: + check_args(option_type, arg1, arg2, 0) + + current_ptr = ffi.new("ssize_t *") + allowed_ptr = ffi.new("ssize_t *") + err = C.git_libgit2_opts(option_type, current_ptr, allowed_ptr) + check_error(err) + return (current_ptr[0], allowed_ptr[0]) + + # Handle SET_SSL_CERT_LOCATIONS + elif option_type == C.GIT_OPT_SET_SSL_CERT_LOCATIONS: + check_args(option_type, arg1, arg2, 2) + + cert_file = arg1 + cert_dir = arg2 + + cert_file_cdata: ArrayC[char] | NULL_TYPE + if cert_file is None: + cert_file_cdata = ffi.NULL + else: + cert_file_bytes = to_bytes(cert_file) + cert_file_cdata = ffi.new("char[]", cert_file_bytes) + + cert_dir_cdata: ArrayC[char] | NULL_TYPE + if cert_dir is None: + cert_dir_cdata = ffi.NULL + else: + cert_dir_bytes = to_bytes(cert_dir) + cert_dir_cdata = ffi.new("char[]", cert_dir_bytes) + + err = C.git_libgit2_opts(option_type, cert_file_cdata, cert_dir_cdata) + check_error(err) + return None + + # Handle boolean/int enable/disable options + elif option_type in ( + C.GIT_OPT_ENABLE_CACHING, + C.GIT_OPT_ENABLE_STRICT_OBJECT_CREATION, + C.GIT_OPT_ENABLE_STRICT_SYMBOLIC_REF_CREATION, + C.GIT_OPT_ENABLE_OFS_DELTA, + C.GIT_OPT_ENABLE_FSYNC_GITDIR, + C.GIT_OPT_ENABLE_STRICT_HASH_VERIFICATION, + C.GIT_OPT_ENABLE_UNSAVED_INDEX_SAFETY, + C.GIT_OPT_DISABLE_PACK_KEEP_FILE_CHECKS, + C.GIT_OPT_SET_OWNER_VALIDATION, + ): + check_args(option_type, arg1, arg2, 1) + + enabled = arg1 + # Convert to int (0 or 1) + value = 1 if enabled else 0 + + err = C.git_libgit2_opts(option_type, ffi.cast("int", value)) + check_error(err) + return None + + # Handle GET_OWNER_VALIDATION + elif option_type == C.GIT_OPT_GET_OWNER_VALIDATION: + check_args(option_type, arg1, arg2, 0) + + enabled_ptr = ffi.new("int *") + err = C.git_libgit2_opts(option_type, enabled_ptr) + check_error(err) + return bool(enabled_ptr[0]) + + # Not implemented options + elif option_type in ( + C.GIT_OPT_GET_TEMPLATE_PATH, + C.GIT_OPT_SET_TEMPLATE_PATH, + C.GIT_OPT_SET_USER_AGENT, + C.GIT_OPT_SET_SSL_CIPHERS, + C.GIT_OPT_GET_USER_AGENT, + C.GIT_OPT_GET_WINDOWS_SHAREMODE, + C.GIT_OPT_SET_WINDOWS_SHAREMODE, + C.GIT_OPT_SET_ALLOCATOR, + C.GIT_OPT_GET_PACK_MAX_OBJECTS, + C.GIT_OPT_SET_PACK_MAX_OBJECTS, + ): + return NotImplemented + + else: + raise ValueError(f"Invalid option {option_type}") diff --git a/pygit2/settings.py b/pygit2/settings.py index da63d9798..a6e2a100c 100644 --- a/pygit2/settings.py +++ b/pygit2/settings.py @@ -32,7 +32,7 @@ import pygit2.enums -from ._pygit2 import option +from .options import option from .enums import ConfigLevel, Option from .errors import GitError diff --git a/src/options.c b/src/options.c deleted file mode 100644 index 11711400b..000000000 --- a/src/options.c +++ /dev/null @@ -1,309 +0,0 @@ -/* - * Copyright 2010-2025 The pygit2 contributors - * - * This file is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License, version 2, - * as published by the Free Software Foundation. - * - * In addition to the permissions in the GNU General Public License, - * the authors give you unlimited permission to link the compiled - * version of this file into combinations with other programs, - * and to distribute those combinations without any restriction - * coming from the use of this file. (The General Public License - * restrictions do apply in other respects; for example, they cover - * modification of the file, and distribution when not linked into - * a combined executable.) - * - * This file 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; see the file COPYING. If not, write to - * the Free Software Foundation, 51 Franklin Street, Fifth Floor, - * Boston, MA 02110-1301, USA. - */ - -#define PY_SSIZE_T_CLEAN -#include -#include -#include "error.h" -#include "types.h" -#include "utils.h" - -extern PyObject *GitError; - -static PyObject * -get_search_path(long level) -{ - git_buf buf = {NULL}; - PyObject *py_path; - int err; - - err = git_libgit2_opts(GIT_OPT_GET_SEARCH_PATH, level, &buf); - if (err < 0) - return Error_set(err); - - py_path = to_unicode_n(buf.ptr, buf.size, NULL, NULL); - git_buf_dispose(&buf); - - if (!py_path) - return NULL; - - return py_path; -} - -PyObject * -option(PyObject *self, PyObject *args) -{ - long option; - int error; - PyObject *py_option; - - py_option = PyTuple_GetItem(args, 0); - if (!py_option) - return NULL; - - if (!PyLong_Check(py_option)) - return Error_type_error( - "option should be an integer, got %.200s", py_option); - - option = PyLong_AsLong(py_option); - - switch (option) { - - case GIT_OPT_GET_MWINDOW_FILE_LIMIT: - case GIT_OPT_GET_MWINDOW_MAPPED_LIMIT: - case GIT_OPT_GET_MWINDOW_SIZE: - { - size_t value; - - error = git_libgit2_opts(option, &value); - if (error < 0) - return Error_set(error); - - return PyLong_FromSize_t(value); - } - - case GIT_OPT_SET_MWINDOW_FILE_LIMIT: - case GIT_OPT_SET_MWINDOW_MAPPED_LIMIT: - case GIT_OPT_SET_MWINDOW_SIZE: - { - PyObject *py_value = PyTuple_GetItem(args, 1); - if (!py_value) - return NULL; - - if (!PyLong_Check(py_value)) - return Error_type_error("expected integer, got %.200s", py_value); - - size_t value = PyLong_AsSize_t(py_value); - error = git_libgit2_opts(option, value); - if (error < 0) - return Error_set(error); - - Py_RETURN_NONE; - } - - case GIT_OPT_GET_SEARCH_PATH: - { - PyObject *py_level = PyTuple_GetItem(args, 1); - if (!py_level) - return NULL; - - if (!PyLong_Check(py_level)) - return Error_type_error("level should be an integer, got %.200s", py_level); - - return get_search_path(PyLong_AsLong(py_level)); - } - - case GIT_OPT_SET_SEARCH_PATH: - { - PyObject *py_level = PyTuple_GetItem(args, 1); - if (!py_level) - return NULL; - - PyObject *py_path = PyTuple_GetItem(args, 2); - if (!py_path) - return NULL; - - if (!PyLong_Check(py_level)) - return Error_type_error("level should be an integer, got %.200s", py_level); - - const char *path = pgit_borrow(py_path); - if (!path) - return NULL; - - int err = git_libgit2_opts(option, PyLong_AsLong(py_level), path); - if (err < 0) - return Error_set(err); - - Py_RETURN_NONE; - } - - case GIT_OPT_SET_CACHE_OBJECT_LIMIT: - { - size_t limit; - int object_type; - PyObject *py_object_type, *py_limit; - - py_object_type = PyTuple_GetItem(args, 1); - if (!py_object_type) - return NULL; - - py_limit = PyTuple_GetItem(args, 2); - if (!py_limit) - return NULL; - - if (!PyLong_Check(py_limit)) - return Error_type_error( - "limit should be an integer, got %.200s", py_limit); - - object_type = PyLong_AsLong(py_object_type); - limit = PyLong_AsSize_t(py_limit); - error = git_libgit2_opts(option, object_type, limit); - - if (error < 0) - return Error_set(error); - - Py_RETURN_NONE; - } - - case GIT_OPT_SET_CACHE_MAX_SIZE: - { - size_t max_size; - PyObject *py_max_size; - - py_max_size = PyTuple_GetItem(args, 1); - if (!py_max_size) - return NULL; - - if (!PyLong_Check(py_max_size)) - return Error_type_error( - "max_size should be an integer, got %.200s", py_max_size); - - max_size = PyLong_AsSize_t(py_max_size); - error = git_libgit2_opts(option, max_size); - if (error < 0) - return Error_set(error); - - Py_RETURN_NONE; - } - - case GIT_OPT_GET_CACHED_MEMORY: - { - size_t current; - size_t allowed; - PyObject* tup = PyTuple_New(2); - - error = git_libgit2_opts(option, ¤t, &allowed); - if (error < 0) - return Error_set(error); - - PyTuple_SetItem(tup, 0, PyLong_FromLong(current)); - PyTuple_SetItem(tup, 1, PyLong_FromLong(allowed)); - - return tup; - } - - case GIT_OPT_GET_TEMPLATE_PATH: - case GIT_OPT_SET_TEMPLATE_PATH: - { - Py_INCREF(Py_NotImplemented); - return Py_NotImplemented; - } - - case GIT_OPT_SET_SSL_CERT_LOCATIONS: - { - PyObject *py_file, *py_dir; - char *file_path=NULL, *dir_path=NULL; - int err; - - py_file = PyTuple_GetItem(args, 1); - if (!py_file) - return NULL; - py_dir = PyTuple_GetItem(args, 2); - if (!py_dir) - return NULL; - - /* py_file and py_dir are only valid if they are strings */ - PyObject *tvalue_file = NULL; - if (PyUnicode_Check(py_file) || PyBytes_Check(py_file)) - file_path = pgit_borrow_fsdefault(py_file, &tvalue_file); - - PyObject *tvalue_dir = NULL; - if (PyUnicode_Check(py_dir) || PyBytes_Check(py_dir)) - dir_path = pgit_borrow_fsdefault(py_dir, &tvalue_dir); - - err = git_libgit2_opts(option, file_path, dir_path); - Py_XDECREF(tvalue_file); - Py_XDECREF(tvalue_dir); - - if (err) - return Error_set(err); - - Py_RETURN_NONE; - } - - case GIT_OPT_SET_USER_AGENT: - { - Py_INCREF(Py_NotImplemented); - return Py_NotImplemented; - } - - // int enabled - case GIT_OPT_ENABLE_CACHING: - case GIT_OPT_ENABLE_STRICT_OBJECT_CREATION: - case GIT_OPT_ENABLE_STRICT_SYMBOLIC_REF_CREATION: - case GIT_OPT_ENABLE_OFS_DELTA: - case GIT_OPT_ENABLE_FSYNC_GITDIR: - case GIT_OPT_ENABLE_STRICT_HASH_VERIFICATION: - case GIT_OPT_ENABLE_UNSAVED_INDEX_SAFETY: - case GIT_OPT_DISABLE_PACK_KEEP_FILE_CHECKS: - case GIT_OPT_SET_OWNER_VALIDATION: - { - PyObject *py_value = PyTuple_GetItem(args, 1); - if (!py_value) - return NULL; - - if (!PyLong_Check(py_value)) - return Error_type_error("expected integer, got %.200s", py_value); - - int value = PyLong_AsSize_t(py_value); - error = git_libgit2_opts(option, value); - if (error < 0) - return Error_set(error); - - Py_RETURN_NONE; - } - - // int enabled getter - case GIT_OPT_GET_OWNER_VALIDATION: - { - int enabled; - - error = git_libgit2_opts(option, &enabled); - if (error < 0) - return Error_set(error); - - return PyLong_FromLong(enabled); - } - - // Not implemented - case GIT_OPT_SET_SSL_CIPHERS: - case GIT_OPT_GET_USER_AGENT: - case GIT_OPT_GET_WINDOWS_SHAREMODE: - case GIT_OPT_SET_WINDOWS_SHAREMODE: - case GIT_OPT_SET_ALLOCATOR: - case GIT_OPT_GET_PACK_MAX_OBJECTS: - case GIT_OPT_SET_PACK_MAX_OBJECTS: - { - Py_INCREF(Py_NotImplemented); - return Py_NotImplemented; - } - - } - - PyErr_SetString(PyExc_ValueError, "unknown/unsupported option value"); - return NULL; -} diff --git a/src/options.h b/src/options.h deleted file mode 100644 index f8b9a08e1..000000000 --- a/src/options.h +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright 2010-2025 The pygit2 contributors - * - * This file is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License, version 2, - * as published by the Free Software Foundation. - * - * In addition to the permissions in the GNU General Public License, - * the authors give you unlimited permission to link the compiled - * version of this file into combinations with other programs, - * and to distribute those combinations without any restriction - * coming from the use of this file. (The General Public License - * restrictions do apply in other respects; for example, they cover - * modification of the file, and distribution when not linked into - * a combined executable.) - * - * This file 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; see the file COPYING. If not, write to - * the Free Software Foundation, 51 Franklin Street, Fifth Floor, - * Boston, MA 02110-1301, USA. - */ - -#ifndef INCLUDE_pygit2_blame_h -#define INCLUDE_pygit2_blame_h - -#define PY_SSIZE_T_CLEAN -#include -#include -#include "types.h" - -PyDoc_STRVAR(option__doc__, - "option(option, ...)\n" - "\n" - "Get or set a libgit2 option.\n" - "\n" - "Parameters:\n" - "\n" - "GIT_OPT_GET_SEARCH_PATH, level\n" - " Get the config search path for the given level.\n" - "\n" - "GIT_OPT_SET_SEARCH_PATH, level, path\n" - " Set the config search path for the given level.\n" - "\n" - "GIT_OPT_GET_MWINDOW_SIZE\n" - " Get the maximum mmap window size.\n" - "\n" - "GIT_OPT_SET_MWINDOW_SIZE, size\n" - " Set the maximum mmap window size.\n" - "\n" - "GIT_OPT_GET_MWINDOW_FILE_LIMIT\n" - " Get the maximum number of files that will be mapped at any time by the library.\n" - "\n" - "GIT_OPT_SET_MWINDOW_FILE_LIMIT, size\n" - " Set the maximum number of files that can be mapped at any time by the library. The default (0) is unlimited.\n" - "\n" - "GIT_OPT_GET_OWNER_VALIDATION\n" - " Gets the owner validation setting for repository directories.\n" - "\n" - "GIT_OPT_SET_OWNER_VALIDATION, enabled\n" - " Set that repository directories should be owned by the current user.\n" - " The default is to validate ownership.\n" - ); - - -PyObject *option(PyObject *self, PyObject *args); - -#endif diff --git a/src/pygit2.c b/src/pygit2.c index ca4739680..c2bac5e5e 100644 --- a/src/pygit2.c +++ b/src/pygit2.c @@ -35,7 +35,6 @@ #include "utils.h" #include "repository.h" #include "oid.h" -#include "options.h" #include "filter.h" PyObject *GitError; @@ -439,7 +438,6 @@ PyMethodDef module_methods[] = { {"hash", hash, METH_VARARGS, hash__doc__}, {"hashfile", hashfile, METH_VARARGS, hashfile__doc__}, {"init_file_backend", init_file_backend, METH_VARARGS, init_file_backend__doc__}, - {"option", option, METH_VARARGS, option__doc__}, {"reference_is_valid_name", reference_is_valid_name, METH_O, reference_is_valid_name__doc__}, {"tree_entry_cmp", tree_entry_cmp, METH_VARARGS, tree_entry_cmp__doc__}, {"filter_register", (PyCFunction)filter_register, METH_VARARGS | METH_KEYWORDS, filter_register__doc__}, @@ -473,39 +471,6 @@ PyInit__pygit2(void) ADD_CONSTANT_INT(m, LIBGIT2_VER_REVISION) ADD_CONSTANT_STR(m, LIBGIT2_VERSION) - /* libgit2 options */ - ADD_CONSTANT_INT(m, GIT_OPT_GET_MWINDOW_SIZE); - ADD_CONSTANT_INT(m, GIT_OPT_SET_MWINDOW_SIZE); - ADD_CONSTANT_INT(m, GIT_OPT_GET_MWINDOW_MAPPED_LIMIT); - ADD_CONSTANT_INT(m, GIT_OPT_SET_MWINDOW_MAPPED_LIMIT); - ADD_CONSTANT_INT(m, GIT_OPT_GET_SEARCH_PATH); - ADD_CONSTANT_INT(m, GIT_OPT_SET_SEARCH_PATH); - ADD_CONSTANT_INT(m, GIT_OPT_SET_CACHE_OBJECT_LIMIT); - ADD_CONSTANT_INT(m, GIT_OPT_SET_CACHE_MAX_SIZE); - ADD_CONSTANT_INT(m, GIT_OPT_ENABLE_CACHING); - ADD_CONSTANT_INT(m, GIT_OPT_GET_CACHED_MEMORY); - ADD_CONSTANT_INT(m, GIT_OPT_GET_TEMPLATE_PATH); - ADD_CONSTANT_INT(m, GIT_OPT_SET_TEMPLATE_PATH); - ADD_CONSTANT_INT(m, GIT_OPT_SET_SSL_CERT_LOCATIONS); - ADD_CONSTANT_INT(m, GIT_OPT_SET_USER_AGENT); - ADD_CONSTANT_INT(m, GIT_OPT_ENABLE_STRICT_OBJECT_CREATION); - ADD_CONSTANT_INT(m, GIT_OPT_ENABLE_STRICT_SYMBOLIC_REF_CREATION); - ADD_CONSTANT_INT(m, GIT_OPT_SET_SSL_CIPHERS); - ADD_CONSTANT_INT(m, GIT_OPT_GET_USER_AGENT); - ADD_CONSTANT_INT(m, GIT_OPT_ENABLE_OFS_DELTA); - ADD_CONSTANT_INT(m, GIT_OPT_ENABLE_FSYNC_GITDIR); - ADD_CONSTANT_INT(m, GIT_OPT_GET_WINDOWS_SHAREMODE); - ADD_CONSTANT_INT(m, GIT_OPT_SET_WINDOWS_SHAREMODE); - ADD_CONSTANT_INT(m, GIT_OPT_ENABLE_STRICT_HASH_VERIFICATION); - ADD_CONSTANT_INT(m, GIT_OPT_SET_ALLOCATOR); - ADD_CONSTANT_INT(m, GIT_OPT_ENABLE_UNSAVED_INDEX_SAFETY); - ADD_CONSTANT_INT(m, GIT_OPT_GET_PACK_MAX_OBJECTS); - ADD_CONSTANT_INT(m, GIT_OPT_SET_PACK_MAX_OBJECTS); - ADD_CONSTANT_INT(m, GIT_OPT_DISABLE_PACK_KEEP_FILE_CHECKS); - ADD_CONSTANT_INT(m, GIT_OPT_GET_OWNER_VALIDATION); - ADD_CONSTANT_INT(m, GIT_OPT_SET_OWNER_VALIDATION); - ADD_CONSTANT_INT(m, GIT_OPT_GET_MWINDOW_FILE_LIMIT); - ADD_CONSTANT_INT(m, GIT_OPT_SET_MWINDOW_FILE_LIMIT); /* Exceptions */ ADD_EXC(m, GitError, NULL); diff --git a/test/conftest.py b/test/conftest.py index 6052346f8..6b8a2737d 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -24,7 +24,7 @@ def global_git_config() -> None: # Fix tests running in AppVeyor if platform.system() == 'Windows': - pygit2.option(pygit2.enums.Option.SET_OWNER_VALIDATION, 0) + pygit2.option(pygit2.enums.Option.SET_OWNER_VALIDATION, False) @pytest.fixture diff --git a/test/test_options.py b/test/test_options.py index 790f459f4..a9169bd00 100644 --- a/test/test_options.py +++ b/test/test_options.py @@ -29,11 +29,11 @@ def __option(getter: Option, setter: Option, value: object) -> None: - old_value = option(getter) # type: ignore[call-overload] - option(setter, value) # type: ignore[call-overload] - assert value == option(getter) # type: ignore[call-overload] + old_value = option(getter) + option(setter, value) + assert value == option(getter) # Reset to avoid side effects in later tests - option(setter, old_value) # type: ignore[call-overload] + option(setter, old_value) def __proxy(name: str, value: object) -> None: From e035a87ca43e502cc38df12dcfd7e2413ae9425c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Mon, 11 Aug 2025 15:31:13 +0200 Subject: [PATCH 084/225] Implement not implemented options --- pygit2/_libgit2/ffi.pyi | 15 +- pygit2/enums.py | 25 +- pygit2/options.py | 505 +++++++++++++++++++++++++++++++++++++--- pygit2/utils.py | 4 +- test/test_options.py | 170 ++++++++++++++ 5 files changed, 670 insertions(+), 49 deletions(-) diff --git a/pygit2/_libgit2/ffi.pyi b/pygit2/_libgit2/ffi.pyi index 9feb1791a..537bae43f 100644 --- a/pygit2/_libgit2/ffi.pyi +++ b/pygit2/_libgit2/ffi.pyi @@ -58,7 +58,8 @@ class _MultiPointer(Generic[T]): class ArrayC(Generic[T]): # incomplete! # def _len(self, ?) -> ?: ... - pass + def __getitem__(self, index: int) -> T: ... + def __setitem__(self, index: int, value: T) -> None: ... class GitTimeC: # incomplete @@ -199,7 +200,7 @@ class GitStashSaveOptionsC: class GitStrrayC: # incomplete? - strings: NULL_TYPE | ArrayC[char] + strings: NULL_TYPE | ArrayC[char_pointer] count: int class GitTreeC: @@ -335,6 +336,12 @@ def new(a: Literal['git_buf *'], b: tuple[NULL_TYPE, Literal[0]]) -> GitBufC: .. def new(a: Literal['char **']) -> _Pointer[char_pointer]: ... @overload def new(a: Literal['char[]', 'char []'], b: bytes | NULL_TYPE) -> ArrayC[char]: ... +@overload +def new(a: Literal['char ***']) -> Any: ... # For extensions_ptr in GET_EXTENSIONS +@overload +def new(a: Literal['char *[]'], b: int) -> ArrayC[char_pointer]: ... # For ext_array in SET_EXTENSIONS +@overload +def new(a: Literal['char *[]'], b: list[Any]) -> ArrayC[char_pointer]: ... # For string arrays def addressof(a: object, attribute: str) -> _Pointer[object]: ... class buffer(bytes): @@ -348,6 +355,10 @@ class buffer(bytes): @overload def cast(a: Literal['int'], b: object) -> int: ... @overload +def cast(a: Literal['unsigned int'], b: object) -> int: ... +@overload def cast(a: Literal['size_t'], b: object) -> int: ... @overload def cast(a: Literal['ssize_t'], b: object) -> int: ... +@overload +def cast(a: Literal['char *'], b: object) -> char_pointer: ... diff --git a/pygit2/enums.py b/pygit2/enums.py index 80be63a5b..71239ca91 100644 --- a/pygit2/enums.py +++ b/pygit2/enums.py @@ -979,21 +979,24 @@ class Option(IntEnum): GET_PACK_MAX_OBJECTS = options.GIT_OPT_GET_PACK_MAX_OBJECTS SET_PACK_MAX_OBJECTS = options.GIT_OPT_SET_PACK_MAX_OBJECTS DISABLE_PACK_KEEP_FILE_CHECKS = options.GIT_OPT_DISABLE_PACK_KEEP_FILE_CHECKS - # ENABLE_HTTP_EXPECT_CONTINUE = options.GIT_OPT_ENABLE_HTTP_EXPECT_CONTINUE + ENABLE_HTTP_EXPECT_CONTINUE = options.GIT_OPT_ENABLE_HTTP_EXPECT_CONTINUE GET_MWINDOW_FILE_LIMIT = options.GIT_OPT_GET_MWINDOW_FILE_LIMIT SET_MWINDOW_FILE_LIMIT = options.GIT_OPT_SET_MWINDOW_FILE_LIMIT - # SET_ODB_PACKED_PRIORITY = options.GIT_OPT_SET_ODB_PACKED_PRIORITY - # SET_ODB_LOOSE_PRIORITY = options.GIT_OPT_SET_ODB_LOOSE_PRIORITY - # GET_EXTENSIONS = options.GIT_OPT_GET_EXTENSIONS - # SET_EXTENSIONS = options.GIT_OPT_SET_EXTENSIONS + SET_ODB_PACKED_PRIORITY = options.GIT_OPT_SET_ODB_PACKED_PRIORITY + SET_ODB_LOOSE_PRIORITY = options.GIT_OPT_SET_ODB_LOOSE_PRIORITY + GET_EXTENSIONS = options.GIT_OPT_GET_EXTENSIONS + SET_EXTENSIONS = options.GIT_OPT_SET_EXTENSIONS GET_OWNER_VALIDATION = options.GIT_OPT_GET_OWNER_VALIDATION SET_OWNER_VALIDATION = options.GIT_OPT_SET_OWNER_VALIDATION - # GET_HOMEDIR = _pygit2.GIT_OPT_GET_HOMEDIR - # SET_HOMEDIR = _pygit2.GIT_OPT_SET_HOMEDIR - # SET_SERVER_CONNECT_TIMEOUT = _pygit2.GIT_OPT_SET_SERVER_CONNECT_TIMEOUT - # GET_SERVER_CONNECT_TIMEOUT = _pygit2.GIT_OPT_GET_SERVER_CONNECT_TIMEOUT - # SET_SERVER_TIMEOUT = _pygit2.GIT_OPT_SET_SERVER_TIMEOUT - # GET_SERVER_TIMEOUT = _pygit2.GIT_OPT_GET_SERVER_TIMEOUT + GET_HOMEDIR = options.GIT_OPT_GET_HOMEDIR + SET_HOMEDIR = options.GIT_OPT_SET_HOMEDIR + SET_SERVER_CONNECT_TIMEOUT = options.GIT_OPT_SET_SERVER_CONNECT_TIMEOUT + GET_SERVER_CONNECT_TIMEOUT = options.GIT_OPT_GET_SERVER_CONNECT_TIMEOUT + SET_SERVER_TIMEOUT = options.GIT_OPT_SET_SERVER_TIMEOUT + GET_SERVER_TIMEOUT = options.GIT_OPT_GET_SERVER_TIMEOUT + GET_USER_AGENT_PRODUCT = options.GIT_OPT_GET_USER_AGENT_PRODUCT + SET_USER_AGENT_PRODUCT = options.GIT_OPT_SET_USER_AGENT_PRODUCT + ADD_SSL_X509_CERT = options.GIT_OPT_ADD_SSL_X509_CERT class ReferenceFilter(IntEnum): diff --git a/pygit2/options.py b/pygit2/options.py index 4f63234e8..9fa13b397 100644 --- a/pygit2/options.py +++ b/pygit2/options.py @@ -28,7 +28,7 @@ Libgit2 global options management using CFFI. """ -from typing import Any, Literal, Optional, Tuple, Union, overload +from typing import Any, Literal, overload, cast from .ffi import C, ffi from .errors import check_error @@ -39,7 +39,7 @@ if TYPE_CHECKING: from .enums import ConfigLevel, ObjectType, Option - from ._libgit2.ffi import ArrayC, NULL_TYPE, char + from ._libgit2.ffi import ArrayC, NULL_TYPE, char, char_pointer # Export GIT_OPT constants for backward compatibility GIT_OPT_GET_MWINDOW_SIZE: int = C.GIT_OPT_GET_MWINDOW_SIZE @@ -76,6 +76,20 @@ GIT_OPT_SET_MWINDOW_FILE_LIMIT: int = C.GIT_OPT_SET_MWINDOW_FILE_LIMIT GIT_OPT_GET_OWNER_VALIDATION: int = C.GIT_OPT_GET_OWNER_VALIDATION GIT_OPT_SET_OWNER_VALIDATION: int = C.GIT_OPT_SET_OWNER_VALIDATION +GIT_OPT_ENABLE_HTTP_EXPECT_CONTINUE: int = C.GIT_OPT_ENABLE_HTTP_EXPECT_CONTINUE +GIT_OPT_SET_ODB_PACKED_PRIORITY: int = C.GIT_OPT_SET_ODB_PACKED_PRIORITY +GIT_OPT_SET_ODB_LOOSE_PRIORITY: int = C.GIT_OPT_SET_ODB_LOOSE_PRIORITY +GIT_OPT_GET_EXTENSIONS: int = C.GIT_OPT_GET_EXTENSIONS +GIT_OPT_SET_EXTENSIONS: int = C.GIT_OPT_SET_EXTENSIONS +GIT_OPT_GET_HOMEDIR: int = C.GIT_OPT_GET_HOMEDIR +GIT_OPT_SET_HOMEDIR: int = C.GIT_OPT_SET_HOMEDIR +GIT_OPT_SET_SERVER_CONNECT_TIMEOUT: int = C.GIT_OPT_SET_SERVER_CONNECT_TIMEOUT +GIT_OPT_GET_SERVER_CONNECT_TIMEOUT: int = C.GIT_OPT_GET_SERVER_CONNECT_TIMEOUT +GIT_OPT_SET_SERVER_TIMEOUT: int = C.GIT_OPT_SET_SERVER_TIMEOUT +GIT_OPT_GET_SERVER_TIMEOUT: int = C.GIT_OPT_GET_SERVER_TIMEOUT +GIT_OPT_GET_USER_AGENT_PRODUCT: int = C.GIT_OPT_GET_USER_AGENT_PRODUCT +GIT_OPT_SET_USER_AGENT_PRODUCT: int = C.GIT_OPT_SET_USER_AGENT_PRODUCT +GIT_OPT_ADD_SSL_X509_CERT: int = C.GIT_OPT_ADD_SSL_X509_CERT NOT_PASSED = object() @@ -94,21 +108,21 @@ def check_args(option: Option, arg1: Any, arg2: Any, expected: int) -> None: @overload def option( - option_type: Union[ - Literal[Option.GET_MWINDOW_SIZE], - Literal[Option.GET_MWINDOW_MAPPED_LIMIT], - Literal[Option.GET_MWINDOW_FILE_LIMIT], + option_type: Literal[ + Option.GET_MWINDOW_SIZE, + Option.GET_MWINDOW_MAPPED_LIMIT, + Option.GET_MWINDOW_FILE_LIMIT, ], ) -> int: ... @overload def option( - option_type: Union[ - Literal[Option.SET_MWINDOW_SIZE], - Literal[Option.SET_MWINDOW_MAPPED_LIMIT], - Literal[Option.SET_MWINDOW_FILE_LIMIT], - Literal[Option.SET_CACHE_MAX_SIZE], + option_type: Literal[ + Option.SET_MWINDOW_SIZE, + Option.SET_MWINDOW_MAPPED_LIMIT, + Option.SET_MWINDOW_FILE_LIMIT, + Option.SET_CACHE_MAX_SIZE, ], arg1: int, # value ) -> None: ... @@ -138,29 +152,29 @@ def option( @overload -def option(option_type: Literal[Option.GET_CACHED_MEMORY]) -> Tuple[int, int]: ... +def option(option_type: Literal[Option.GET_CACHED_MEMORY]) -> tuple[int, int]: ... @overload def option( option_type: Literal[Option.SET_SSL_CERT_LOCATIONS], - arg1: Optional[str | bytes], # cert_file - arg2: Optional[str | bytes], # cert_dir + arg1: str | bytes | None, # cert_file + arg2: str | bytes | None, # cert_dir ) -> None: ... @overload def option( - option_type: Union[ - Literal[Option.ENABLE_CACHING], - Literal[Option.ENABLE_STRICT_OBJECT_CREATION], - Literal[Option.ENABLE_STRICT_SYMBOLIC_REF_CREATION], - Literal[Option.ENABLE_OFS_DELTA], - Literal[Option.ENABLE_FSYNC_GITDIR], - Literal[Option.ENABLE_STRICT_HASH_VERIFICATION], - Literal[Option.ENABLE_UNSAVED_INDEX_SAFETY], - Literal[Option.DISABLE_PACK_KEEP_FILE_CHECKS], - Literal[Option.SET_OWNER_VALIDATION], + option_type: Literal[ + Option.ENABLE_CACHING, + Option.ENABLE_STRICT_OBJECT_CREATION, + Option.ENABLE_STRICT_SYMBOLIC_REF_CREATION, + Option.ENABLE_OFS_DELTA, + Option.ENABLE_FSYNC_GITDIR, + Option.ENABLE_STRICT_HASH_VERIFICATION, + Option.ENABLE_UNSAVED_INDEX_SAFETY, + Option.DISABLE_PACK_KEEP_FILE_CHECKS, + Option.SET_OWNER_VALIDATION, ], arg1: bool, # value ) -> None: ... @@ -170,6 +184,75 @@ def option( def option(option_type: Literal[Option.GET_OWNER_VALIDATION]) -> bool: ... +@overload +def option( + option_type: Literal[ + Option.GET_TEMPLATE_PATH, + Option.GET_USER_AGENT, + Option.GET_HOMEDIR, + Option.GET_USER_AGENT_PRODUCT, + ], +) -> str | None: ... + + +@overload +def option( + option_type: Literal[ + Option.SET_TEMPLATE_PATH, + Option.SET_USER_AGENT, + Option.SET_SSL_CIPHERS, + Option.SET_HOMEDIR, + Option.SET_USER_AGENT_PRODUCT, + ], + arg1: str | bytes, # value +) -> None: ... + + +@overload +def option( + option_type: Literal[ + Option.GET_WINDOWS_SHAREMODE, + Option.GET_PACK_MAX_OBJECTS, + Option.GET_SERVER_CONNECT_TIMEOUT, + Option.GET_SERVER_TIMEOUT, + ], +) -> int: ... + + +@overload +def option( + option_type: Literal[ + Option.SET_WINDOWS_SHAREMODE, + Option.SET_PACK_MAX_OBJECTS, + Option.ENABLE_HTTP_EXPECT_CONTINUE, + Option.SET_ODB_PACKED_PRIORITY, + Option.SET_ODB_LOOSE_PRIORITY, + Option.SET_SERVER_CONNECT_TIMEOUT, + Option.SET_SERVER_TIMEOUT, + ], + arg1: int, # value +) -> None: ... + + +@overload +def option(option_type: Literal[Option.GET_EXTENSIONS]) -> list[str]: ... + + +@overload +def option( + option_type: Literal[Option.SET_EXTENSIONS], + arg1: list[str], # extensions + arg2: int, # length +) -> None: ... + + +@overload +def option( + option_type: Literal[Option.ADD_SSL_X509_CERT], + arg1: str | bytes, # certificate +) -> None: ... + + # Fallback overload for generic Option values (used in tests) @overload def option(option_type: Option, arg1: Any = ..., arg2: Any = ...) -> Any: ... @@ -205,6 +288,24 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) GIT_OPT_SET_OWNER_VALIDATION, enabled Set that repository directories should be owned by the current user. The default is to validate ownership. + + GIT_OPT_GET_TEMPLATE_PATH + Get the default template path. + + GIT_OPT_SET_TEMPLATE_PATH, path + Set the default template path. + + GIT_OPT_GET_USER_AGENT + Get the user agent string. + + GIT_OPT_SET_USER_AGENT, user_agent + Set the user agent string. + + GIT_OPT_GET_PACK_MAX_OBJECTS + Get the maximum number of objects to include in a pack. + + GIT_OPT_SET_PACK_MAX_OBJECTS, count + Set the maximum number of objects to include in a pack. """ # Handle GET options with size_t output @@ -376,18 +477,354 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) check_error(err) return bool(enabled_ptr[0]) - # Not implemented options + # Handle GET_TEMPLATE_PATH + elif option_type == C.GIT_OPT_GET_TEMPLATE_PATH: + check_args(option_type, arg1, arg2, 0) + + buf = ffi.new("git_buf *") + err = C.git_libgit2_opts(option_type, buf) + check_error(err) + + try: + if buf.ptr != ffi.NULL: + result = to_str(ffi.string(buf.ptr)) + else: + result = None + finally: + C.git_buf_dispose(buf) + + return result + + # Handle SET_TEMPLATE_PATH + elif option_type == C.GIT_OPT_SET_TEMPLATE_PATH: + check_args(option_type, arg1, arg2, 1) + + path = arg1 + template_path_cdata: ArrayC[char] | NULL_TYPE + if path is None: + template_path_cdata = ffi.NULL + else: + path_bytes = to_bytes(path) + template_path_cdata = ffi.new("char[]", path_bytes) + + err = C.git_libgit2_opts(option_type, template_path_cdata) + check_error(err) + return None + + # Handle GET_USER_AGENT + elif option_type == C.GIT_OPT_GET_USER_AGENT: + check_args(option_type, arg1, arg2, 0) + + buf = ffi.new("git_buf *") + err = C.git_libgit2_opts(option_type, buf) + check_error(err) + + try: + if buf.ptr != ffi.NULL: + result = to_str(ffi.string(buf.ptr)) + else: + result = None + finally: + C.git_buf_dispose(buf) + + return result + + # Handle SET_USER_AGENT + elif option_type == C.GIT_OPT_SET_USER_AGENT: + check_args(option_type, arg1, arg2, 1) + + agent = arg1 + agent_bytes = to_bytes(agent) + agent_cdata = ffi.new("char[]", agent_bytes) + + err = C.git_libgit2_opts(option_type, agent_cdata) + check_error(err) + return None + + # Handle SET_SSL_CIPHERS + elif option_type == C.GIT_OPT_SET_SSL_CIPHERS: + check_args(option_type, arg1, arg2, 1) + + ciphers = arg1 + ciphers_bytes = to_bytes(ciphers) + ciphers_cdata = ffi.new("char[]", ciphers_bytes) + + err = C.git_libgit2_opts(option_type, ciphers_cdata) + check_error(err) + return None + + # Handle GET_WINDOWS_SHAREMODE + elif option_type == C.GIT_OPT_GET_WINDOWS_SHAREMODE: + check_args(option_type, arg1, arg2, 0) + + value_ptr = ffi.new("unsigned int *") + err = C.git_libgit2_opts(option_type, value_ptr) + check_error(err) + return value_ptr[0] + + # Handle SET_WINDOWS_SHAREMODE + elif option_type == C.GIT_OPT_SET_WINDOWS_SHAREMODE: + check_args(option_type, arg1, arg2, 1) + + if not isinstance(arg1, int): + raise TypeError( + f"option value must be an integer, not {type(arg1).__name__}" + ) + value = arg1 + if value < 0: + raise ValueError("value must be non-negative") + + err = C.git_libgit2_opts(option_type, ffi.cast("unsigned int", value)) + check_error(err) + return None + + # Handle GET_PACK_MAX_OBJECTS + elif option_type == C.GIT_OPT_GET_PACK_MAX_OBJECTS: + check_args(option_type, arg1, arg2, 0) + + size_ptr = ffi.new("size_t *") + err = C.git_libgit2_opts(option_type, size_ptr) + check_error(err) + return size_ptr[0] + + # Handle SET_PACK_MAX_OBJECTS + elif option_type == C.GIT_OPT_SET_PACK_MAX_OBJECTS: + check_args(option_type, arg1, arg2, 1) + + if not isinstance(arg1, int): + raise TypeError( + f"option value must be an integer, not {type(arg1).__name__}" + ) + size = arg1 + if size < 0: + raise ValueError("size must be non-negative") + + err = C.git_libgit2_opts(option_type, ffi.cast("size_t", size)) + check_error(err) + return None + + # Handle ENABLE_HTTP_EXPECT_CONTINUE + elif option_type == C.GIT_OPT_ENABLE_HTTP_EXPECT_CONTINUE: + check_args(option_type, arg1, arg2, 1) + + enabled = arg1 + # Convert to int (0 or 1) + value = 1 if enabled else 0 + + err = C.git_libgit2_opts(option_type, ffi.cast("int", value)) + check_error(err) + return None + + # Handle SET_ODB_PACKED_PRIORITY + elif option_type == C.GIT_OPT_SET_ODB_PACKED_PRIORITY: + check_args(option_type, arg1, arg2, 1) + + if not isinstance(arg1, int): + raise TypeError( + f"option value must be an integer, not {type(arg1).__name__}" + ) + priority = arg1 + + err = C.git_libgit2_opts(option_type, ffi.cast("int", priority)) + check_error(err) + return None + + # Handle SET_ODB_LOOSE_PRIORITY + elif option_type == C.GIT_OPT_SET_ODB_LOOSE_PRIORITY: + check_args(option_type, arg1, arg2, 1) + + if not isinstance(arg1, int): + raise TypeError( + f"option value must be an integer, not {type(arg1).__name__}" + ) + priority = arg1 + + err = C.git_libgit2_opts(option_type, ffi.cast("int", priority)) + check_error(err) + return None + + # Handle GET_EXTENSIONS + elif option_type == C.GIT_OPT_GET_EXTENSIONS: + check_args(option_type, arg1, arg2, 0) + + # GET_EXTENSIONS expects a git_strarray pointer + strarray = ffi.new("git_strarray *") + err = C.git_libgit2_opts(option_type, strarray) + check_error(err) + + result = [] + try: + if strarray.strings != ffi.NULL: + # Cast to the non-NULL type for type checking + strings = cast('ArrayC[char_pointer]', strarray.strings) + for i in range(strarray.count): + if strings[i] != ffi.NULL: + result.append(to_str(ffi.string(strings[i]))) + finally: + # Must dispose of the strarray to free the memory + C.git_strarray_dispose(strarray) + + return result + + # Handle SET_EXTENSIONS + elif option_type == C.GIT_OPT_SET_EXTENSIONS: + check_args(option_type, arg1, arg2, 2) + + extensions = arg1 + length = arg2 + + if not isinstance(extensions, list): + raise TypeError("extensions must be a list of strings") + if not isinstance(length, int): + raise TypeError("length must be an integer") + + # Create array of char pointers + # libgit2 will make its own copies with git__strdup + ext_array: ArrayC[char_pointer] = ffi.new("char *[]", len(extensions)) + ext_strings: list[ArrayC[char]] = [] # Keep references during the call + + for i, ext in enumerate(extensions): + ext_bytes = to_bytes(ext) + ext_string: ArrayC[char] = ffi.new("char[]", ext_bytes) + ext_strings.append(ext_string) + ext_array[i] = ffi.cast("char *", ext_string) + + err = C.git_libgit2_opts(option_type, ext_array, ffi.cast("size_t", length)) + check_error(err) + return None + + # Handle GET_HOMEDIR + elif option_type == C.GIT_OPT_GET_HOMEDIR: + check_args(option_type, arg1, arg2, 0) + + buf = ffi.new("git_buf *") + err = C.git_libgit2_opts(option_type, buf) + check_error(err) + + try: + if buf.ptr != ffi.NULL: + result = to_str(ffi.string(buf.ptr)) + else: + result = None + finally: + C.git_buf_dispose(buf) + + return result + + # Handle SET_HOMEDIR + elif option_type == C.GIT_OPT_SET_HOMEDIR: + check_args(option_type, arg1, arg2, 1) + + path = arg1 + homedir_cdata: ArrayC[char] | NULL_TYPE + if path is None: + homedir_cdata = ffi.NULL + else: + path_bytes = to_bytes(path) + homedir_cdata = ffi.new("char[]", path_bytes) + + err = C.git_libgit2_opts(option_type, homedir_cdata) + check_error(err) + return None + + # Handle GET_SERVER_CONNECT_TIMEOUT + elif option_type == C.GIT_OPT_GET_SERVER_CONNECT_TIMEOUT: + check_args(option_type, arg1, arg2, 0) + + timeout_ptr = ffi.new("int *") + err = C.git_libgit2_opts(option_type, timeout_ptr) + check_error(err) + return timeout_ptr[0] + + # Handle SET_SERVER_CONNECT_TIMEOUT + elif option_type == C.GIT_OPT_SET_SERVER_CONNECT_TIMEOUT: + check_args(option_type, arg1, arg2, 1) + + if not isinstance(arg1, int): + raise TypeError( + f"option value must be an integer, not {type(arg1).__name__}" + ) + timeout = arg1 + + err = C.git_libgit2_opts(option_type, ffi.cast("int", timeout)) + check_error(err) + return None + + # Handle GET_SERVER_TIMEOUT + elif option_type == C.GIT_OPT_GET_SERVER_TIMEOUT: + check_args(option_type, arg1, arg2, 0) + + timeout_ptr = ffi.new("int *") + err = C.git_libgit2_opts(option_type, timeout_ptr) + check_error(err) + return timeout_ptr[0] + + # Handle SET_SERVER_TIMEOUT + elif option_type == C.GIT_OPT_SET_SERVER_TIMEOUT: + check_args(option_type, arg1, arg2, 1) + + if not isinstance(arg1, int): + raise TypeError( + f"option value must be an integer, not {type(arg1).__name__}" + ) + timeout = arg1 + + err = C.git_libgit2_opts(option_type, ffi.cast("int", timeout)) + check_error(err) + return None + + # Handle GET_USER_AGENT_PRODUCT + elif option_type == C.GIT_OPT_GET_USER_AGENT_PRODUCT: + check_args(option_type, arg1, arg2, 0) + + buf = ffi.new("git_buf *") + err = C.git_libgit2_opts(option_type, buf) + check_error(err) + + try: + if buf.ptr != ffi.NULL: + result = to_str(ffi.string(buf.ptr)) + else: + result = None + finally: + C.git_buf_dispose(buf) + + return result + + # Handle SET_USER_AGENT_PRODUCT + elif option_type == C.GIT_OPT_SET_USER_AGENT_PRODUCT: + check_args(option_type, arg1, arg2, 1) + + product = arg1 + product_bytes = to_bytes(product) + product_cdata = ffi.new("char[]", product_bytes) + + err = C.git_libgit2_opts(option_type, product_cdata) + check_error(err) + return None + + # Handle ADD_SSL_X509_CERT + elif option_type == C.GIT_OPT_ADD_SSL_X509_CERT: + check_args(option_type, arg1, arg2, 1) + + cert = arg1 + if isinstance(cert, (str, bytes)): + cert_bytes = to_bytes(cert) + cert_cdata = ffi.new("char[]", cert_bytes) + cert_len = len(cert_bytes) + else: + raise TypeError("certificate must be a string or bytes") + + err = C.git_libgit2_opts(option_type, cert_cdata, ffi.cast("size_t", cert_len)) + check_error(err) + return None + + # Not implemented - SET_ALLOCATOR is not feasible from Python level + # because it requires providing C function pointers for memory management + # (malloc, free, etc.) that must handle raw memory at the C level, + # which cannot be safely implemented in pure Python. elif option_type in ( - C.GIT_OPT_GET_TEMPLATE_PATH, - C.GIT_OPT_SET_TEMPLATE_PATH, - C.GIT_OPT_SET_USER_AGENT, - C.GIT_OPT_SET_SSL_CIPHERS, - C.GIT_OPT_GET_USER_AGENT, - C.GIT_OPT_GET_WINDOWS_SHAREMODE, - C.GIT_OPT_SET_WINDOWS_SHAREMODE, C.GIT_OPT_SET_ALLOCATOR, - C.GIT_OPT_GET_PACK_MAX_OBJECTS, - C.GIT_OPT_SET_PACK_MAX_OBJECTS, ): return NotImplemented diff --git a/pygit2/utils.py b/pygit2/utils.py index b3d5d1416..dcc77167c 100644 --- a/pygit2/utils.py +++ b/pygit2/utils.py @@ -145,7 +145,7 @@ class StrArray: __array: 'GitStrrayC | ffi.NULL_TYPE' __strings: list['None | ArrayC[char]'] - __arr: 'ArrayC[char]' + __arr: 'ArrayC[char_pointer]' def __init__(self, lst: None | Sequence[str | os.PathLike[str]]): # Allow passing in None as lg2 typically considers them the same as empty @@ -164,7 +164,7 @@ def __init__(self, lst: None | Sequence[str | os.PathLike[str]]): strings[i] = ffi.new('char []', to_bytes(li)) - self.__arr = ffi.new('char *[]', strings) # type: ignore[call-overload] + self.__arr = ffi.new('char *[]', strings) self.__strings = strings self.__array = ffi.new('git_strarray *', [self.__arr, len(strings)]) # type: ignore[call-overload] diff --git a/test/test_options.py b/test/test_options.py index a9169bd00..ddb262c77 100644 --- a/test/test_options.py +++ b/test/test_options.py @@ -130,3 +130,173 @@ def test_search_path_proxy() -> None: def test_owner_validation() -> None: __option(Option.GET_OWNER_VALIDATION, Option.SET_OWNER_VALIDATION, 0) + + +def test_template_path() -> None: + # Get the initial template path + original_path = option(Option.GET_TEMPLATE_PATH) + + # Set a new template path + test_path = '/tmp/test_templates' + option(Option.SET_TEMPLATE_PATH, test_path) + assert option(Option.GET_TEMPLATE_PATH) == test_path + + # Reset to original path + if original_path: + option(Option.SET_TEMPLATE_PATH, original_path) + else: + option(Option.SET_TEMPLATE_PATH, None) + + +def test_user_agent() -> None: + # Get the initial user agent + original_agent = option(Option.GET_USER_AGENT) + + # Set a new user agent + test_agent = 'test-agent/1.0' + option(Option.SET_USER_AGENT, test_agent) + assert option(Option.GET_USER_AGENT) == test_agent + + # Reset to original agent + if original_agent: + option(Option.SET_USER_AGENT, original_agent) + + +def test_pack_max_objects() -> None: + __option(Option.GET_PACK_MAX_OBJECTS, Option.SET_PACK_MAX_OBJECTS, 100000) + + +def test_windows_sharemode() -> None: + # This test might not work on non-Windows platforms + try: + __option(Option.GET_WINDOWS_SHAREMODE, Option.SET_WINDOWS_SHAREMODE, 1) + except Exception: + # May fail on non-Windows platforms + pass + + +def test_ssl_ciphers() -> None: + # Setting SSL ciphers (no getter available) + try: + option(Option.SET_SSL_CIPHERS, 'DEFAULT') + except pygit2.GitError as e: + # May fail if TLS backend doesn't support custom ciphers + if "TLS backend doesn't support custom ciphers" not in str(e): + raise + + +def test_enable_http_expect_continue() -> None: + # Enable and disable HTTP expect continue + option(Option.ENABLE_HTTP_EXPECT_CONTINUE, True) + option(Option.ENABLE_HTTP_EXPECT_CONTINUE, False) + + +def test_odb_priorities() -> None: + # Set ODB priorities + option(Option.SET_ODB_PACKED_PRIORITY, 1) + option(Option.SET_ODB_LOOSE_PRIORITY, 2) + + +def test_extensions() -> None: + # Get initial extensions list + original_extensions = option(Option.GET_EXTENSIONS) + assert isinstance(original_extensions, list) + + # Try to set extensions (this might fail depending on the setup) + try: + test_extensions = ['objectformat', 'worktreeconfig'] + option(Option.SET_EXTENSIONS, test_extensions, len(test_extensions)) + + # Verify they were set + new_extensions = option(Option.GET_EXTENSIONS) + assert isinstance(new_extensions, list) + + # Check that our extensions are present + # Note: libgit2 may add its own built-in extensions and sort them + for ext in test_extensions: + assert ext in new_extensions, f"Extension '{ext}' not found in {new_extensions}" + + # Test with empty list + option(Option.SET_EXTENSIONS, [], 0) + empty_extensions = option(Option.GET_EXTENSIONS) + assert isinstance(empty_extensions, list) + # Even with empty input, libgit2 may have built-in extensions + + # Test with a custom extension + custom_extensions = ['myextension', 'objectformat'] + option(Option.SET_EXTENSIONS, custom_extensions, len(custom_extensions)) + custom_result = option(Option.GET_EXTENSIONS) + assert 'myextension' in custom_result + assert 'objectformat' in custom_result + + # Restore original extensions + if original_extensions: + option(Option.SET_EXTENSIONS, original_extensions, len(original_extensions)) + else: + # Reset to empty list if there were no extensions + option(Option.SET_EXTENSIONS, [], 0) + + # Verify restoration + final_extensions = option(Option.GET_EXTENSIONS) + assert set(final_extensions) == set(original_extensions) + except Exception: + # May fail if extensions cannot be modified + pass + + +def test_homedir() -> None: + # Get the initial home directory + original_homedir = option(Option.GET_HOMEDIR) + + # Set a new home directory + test_homedir = '/tmp/test_home' + option(Option.SET_HOMEDIR, test_homedir) + assert option(Option.GET_HOMEDIR) == test_homedir + + # Reset to original home directory + if original_homedir: + option(Option.SET_HOMEDIR, original_homedir) + else: + option(Option.SET_HOMEDIR, None) + + +def test_server_timeouts() -> None: + # Test connect timeout + original_connect = option(Option.GET_SERVER_CONNECT_TIMEOUT) + option(Option.SET_SERVER_CONNECT_TIMEOUT, 5000) + assert option(Option.GET_SERVER_CONNECT_TIMEOUT) == 5000 + option(Option.SET_SERVER_CONNECT_TIMEOUT, original_connect) + + # Test server timeout + original_timeout = option(Option.GET_SERVER_TIMEOUT) + option(Option.SET_SERVER_TIMEOUT, 10000) + assert option(Option.GET_SERVER_TIMEOUT) == 10000 + option(Option.SET_SERVER_TIMEOUT, original_timeout) + + +def test_user_agent_product() -> None: + # Get the initial user agent product + original_product = option(Option.GET_USER_AGENT_PRODUCT) + + # Set a new user agent product + test_product = 'test-product' + option(Option.SET_USER_AGENT_PRODUCT, test_product) + assert option(Option.GET_USER_AGENT_PRODUCT) == test_product + + # Reset to original product + if original_product: + option(Option.SET_USER_AGENT_PRODUCT, original_product) + + +def test_add_ssl_x509_cert() -> None: + # Test adding an SSL certificate (basic test, just ensure it doesn't crash) + test_cert = "-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----" + try: + option(Option.ADD_SSL_X509_CERT, test_cert) + except Exception: + # May fail depending on SSL backend + pass + + +def test_mwindow_file_limit() -> None: + __option(Option.GET_MWINDOW_FILE_LIMIT, Option.SET_MWINDOW_FILE_LIMIT, 100) From cfd7d94daebf5cb059a0deb09d22c1e3e80376ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Mon, 11 Aug 2025 17:41:09 +0200 Subject: [PATCH 085/225] Fix nits I noticed --- .gitignore | 1 + pygit2/_libgit2/ffi.pyi | 2 - test/test_options.py | 91 +++++++++++++++++++++-------------------- 3 files changed, 48 insertions(+), 46 deletions(-) diff --git a/.gitignore b/.gitignore index 0f5d52267..12c3a9701 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ /.envrc /.tox/ /build/ +/ci/ /dist/ /docs/_build/ /MANIFEST diff --git a/pygit2/_libgit2/ffi.pyi b/pygit2/_libgit2/ffi.pyi index 537bae43f..5936d2f21 100644 --- a/pygit2/_libgit2/ffi.pyi +++ b/pygit2/_libgit2/ffi.pyi @@ -337,8 +337,6 @@ def new(a: Literal['char **']) -> _Pointer[char_pointer]: ... @overload def new(a: Literal['char[]', 'char []'], b: bytes | NULL_TYPE) -> ArrayC[char]: ... @overload -def new(a: Literal['char ***']) -> Any: ... # For extensions_ptr in GET_EXTENSIONS -@overload def new(a: Literal['char *[]'], b: int) -> ArrayC[char_pointer]: ... # For ext_array in SET_EXTENSIONS @overload def new(a: Literal['char *[]'], b: list[Any]) -> ArrayC[char_pointer]: ... # For string arrays diff --git a/test/test_options.py b/test/test_options.py index ddb262c77..78a533688 100644 --- a/test/test_options.py +++ b/test/test_options.py @@ -201,47 +201,43 @@ def test_extensions() -> None: # Get initial extensions list original_extensions = option(Option.GET_EXTENSIONS) assert isinstance(original_extensions, list) - - # Try to set extensions (this might fail depending on the setup) - try: - test_extensions = ['objectformat', 'worktreeconfig'] - option(Option.SET_EXTENSIONS, test_extensions, len(test_extensions)) - - # Verify they were set - new_extensions = option(Option.GET_EXTENSIONS) - assert isinstance(new_extensions, list) - - # Check that our extensions are present - # Note: libgit2 may add its own built-in extensions and sort them - for ext in test_extensions: - assert ext in new_extensions, f"Extension '{ext}' not found in {new_extensions}" - - # Test with empty list + + # Set extensions + test_extensions = ['objectformat', 'worktreeconfig'] + option(Option.SET_EXTENSIONS, test_extensions, len(test_extensions)) + + # Verify they were set + new_extensions = option(Option.GET_EXTENSIONS) + assert isinstance(new_extensions, list) + + # Check that our extensions are present + # Note: libgit2 may add its own built-in extensions and sort them + for ext in test_extensions: + assert ext in new_extensions, f"Extension '{ext}' not found in {new_extensions}" + + # Test with empty list + option(Option.SET_EXTENSIONS, [], 0) + empty_extensions = option(Option.GET_EXTENSIONS) + assert isinstance(empty_extensions, list) + # Even with empty input, libgit2 may have built-in extensions + + # Test with a custom extension + custom_extensions = ['myextension', 'objectformat'] + option(Option.SET_EXTENSIONS, custom_extensions, len(custom_extensions)) + custom_result = option(Option.GET_EXTENSIONS) + assert 'myextension' in custom_result + assert 'objectformat' in custom_result + + # Restore original extensions + if original_extensions: + option(Option.SET_EXTENSIONS, original_extensions, len(original_extensions)) + else: + # Reset to empty list if there were no extensions option(Option.SET_EXTENSIONS, [], 0) - empty_extensions = option(Option.GET_EXTENSIONS) - assert isinstance(empty_extensions, list) - # Even with empty input, libgit2 may have built-in extensions - - # Test with a custom extension - custom_extensions = ['myextension', 'objectformat'] - option(Option.SET_EXTENSIONS, custom_extensions, len(custom_extensions)) - custom_result = option(Option.GET_EXTENSIONS) - assert 'myextension' in custom_result - assert 'objectformat' in custom_result - - # Restore original extensions - if original_extensions: - option(Option.SET_EXTENSIONS, original_extensions, len(original_extensions)) - else: - # Reset to empty list if there were no extensions - option(Option.SET_EXTENSIONS, [], 0) - - # Verify restoration - final_extensions = option(Option.GET_EXTENSIONS) - assert set(final_extensions) == set(original_extensions) - except Exception: - # May fail if extensions cannot be modified - pass + + # Verify restoration + final_extensions = option(Option.GET_EXTENSIONS) + assert set(final_extensions) == set(original_extensions) def test_homedir() -> None: @@ -289,13 +285,20 @@ def test_user_agent_product() -> None: def test_add_ssl_x509_cert() -> None: - # Test adding an SSL certificate (basic test, just ensure it doesn't crash) + # Test adding an SSL certificate + # This is a minimal test certificate (not valid, but tests the API) test_cert = "-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----" + try: option(Option.ADD_SSL_X509_CERT, test_cert) - except Exception: - # May fail depending on SSL backend - pass + except pygit2.GitError as e: + # May fail if TLS backend doesn't support adding raw certs + # or if the certificate format is invalid + if ( + "TLS backend doesn't support" not in str(e) + and "invalid" not in str(e).lower() + ): + raise def test_mwindow_file_limit() -> None: From 09ce9c48855cbe454aa6d9365a9b784c94b366a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Mon, 11 Aug 2025 21:37:35 +0200 Subject: [PATCH 086/225] Appease formatting gods --- pygit2/_libgit2/ffi.pyi | 8 ++- pygit2/options.py | 132 +++++++++++++++++++--------------------- test/test_options.py | 6 +- 3 files changed, 74 insertions(+), 72 deletions(-) diff --git a/pygit2/_libgit2/ffi.pyi b/pygit2/_libgit2/ffi.pyi index 5936d2f21..c1156fa81 100644 --- a/pygit2/_libgit2/ffi.pyi +++ b/pygit2/_libgit2/ffi.pyi @@ -337,9 +337,13 @@ def new(a: Literal['char **']) -> _Pointer[char_pointer]: ... @overload def new(a: Literal['char[]', 'char []'], b: bytes | NULL_TYPE) -> ArrayC[char]: ... @overload -def new(a: Literal['char *[]'], b: int) -> ArrayC[char_pointer]: ... # For ext_array in SET_EXTENSIONS +def new( + a: Literal['char *[]'], b: int +) -> ArrayC[char_pointer]: ... # For ext_array in SET_EXTENSIONS @overload -def new(a: Literal['char *[]'], b: list[Any]) -> ArrayC[char_pointer]: ... # For string arrays +def new( + a: Literal['char *[]'], b: list[Any] +) -> ArrayC[char_pointer]: ... # For string arrays def addressof(a: object, attribute: str) -> _Pointer[object]: ... class buffer(bytes): diff --git a/pygit2/options.py b/pygit2/options.py index 9fa13b397..036b8b0ba 100644 --- a/pygit2/options.py +++ b/pygit2/options.py @@ -97,13 +97,13 @@ def check_args(option: Option, arg1: Any, arg2: Any, expected: int) -> None: if expected == 0 and (arg1 is not NOT_PASSED or arg2 is not NOT_PASSED): - raise TypeError(f"option({option}) takes no additional arguments") + raise TypeError(f'option({option}) takes no additional arguments') if expected == 1 and (arg1 is NOT_PASSED or arg2 is not NOT_PASSED): - raise TypeError(f"option({option}, x) requires 1 additional argument") + raise TypeError(f'option({option}, x) requires 1 additional argument') if expected == 2 and (arg1 is NOT_PASSED or arg2 is NOT_PASSED): - raise TypeError(f"option({option}, x, y) requires 2 additional arguments") + raise TypeError(f'option({option}, x, y) requires 2 additional arguments') @overload @@ -316,7 +316,7 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) ): check_args(option_type, arg1, arg2, 0) - size_ptr = ffi.new("size_t *") + size_ptr = ffi.new('size_t *') err = C.git_libgit2_opts(option_type, size_ptr) check_error(err) return size_ptr[0] @@ -330,14 +330,12 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) check_args(option_type, arg1, arg2, 1) if not isinstance(arg1, int): - raise TypeError( - f"option value must be an integer, not {type(arg1)}" - ) + raise TypeError(f'option value must be an integer, not {type(arg1)}') size = arg1 if size < 0: - raise ValueError("size must be non-negative") + raise ValueError('size must be non-negative') - err = C.git_libgit2_opts(option_type, ffi.cast("size_t", size)) + err = C.git_libgit2_opts(option_type, ffi.cast('size_t', size)) check_error(err) return None @@ -346,8 +344,8 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) check_args(option_type, arg1, arg2, 1) level = int(arg1) # Convert enum to int - buf = ffi.new("git_buf *") - err = C.git_libgit2_opts(option_type, ffi.cast("int", level), buf) + buf = ffi.new('git_buf *') + err = C.git_libgit2_opts(option_type, ffi.cast('int', level), buf) check_error(err) try: @@ -372,9 +370,9 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) path_cdata = ffi.NULL else: path_bytes = to_bytes(path) - path_cdata = ffi.new("char[]", path_bytes) + path_cdata = ffi.new('char[]', path_bytes) - err = C.git_libgit2_opts(option_type, ffi.cast("int", level), path_cdata) + err = C.git_libgit2_opts(option_type, ffi.cast('int', level), path_cdata) check_error(err) return None @@ -385,14 +383,14 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) object_type = int(arg1) # Convert enum to int if not isinstance(arg2, int): raise TypeError( - f"option value must be an integer, not {type(arg2).__name__}" + f'option value must be an integer, not {type(arg2).__name__}' ) size = arg2 if size < 0: - raise ValueError("size must be non-negative") + raise ValueError('size must be non-negative') err = C.git_libgit2_opts( - option_type, ffi.cast("int", object_type), ffi.cast("size_t", size) + option_type, ffi.cast('int', object_type), ffi.cast('size_t', size) ) check_error(err) return None @@ -404,10 +402,10 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) size = arg1 if not isinstance(size, int): raise TypeError( - f"option value must be an integer, not {type(size).__name__}" + f'option value must be an integer, not {type(size).__name__}' ) - err = C.git_libgit2_opts(option_type, ffi.cast("ssize_t", size)) + err = C.git_libgit2_opts(option_type, ffi.cast('ssize_t', size)) check_error(err) return None @@ -415,8 +413,8 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) elif option_type == C.GIT_OPT_GET_CACHED_MEMORY: check_args(option_type, arg1, arg2, 0) - current_ptr = ffi.new("ssize_t *") - allowed_ptr = ffi.new("ssize_t *") + current_ptr = ffi.new('ssize_t *') + allowed_ptr = ffi.new('ssize_t *') err = C.git_libgit2_opts(option_type, current_ptr, allowed_ptr) check_error(err) return (current_ptr[0], allowed_ptr[0]) @@ -433,14 +431,14 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) cert_file_cdata = ffi.NULL else: cert_file_bytes = to_bytes(cert_file) - cert_file_cdata = ffi.new("char[]", cert_file_bytes) + cert_file_cdata = ffi.new('char[]', cert_file_bytes) cert_dir_cdata: ArrayC[char] | NULL_TYPE if cert_dir is None: cert_dir_cdata = ffi.NULL else: cert_dir_bytes = to_bytes(cert_dir) - cert_dir_cdata = ffi.new("char[]", cert_dir_bytes) + cert_dir_cdata = ffi.new('char[]', cert_dir_bytes) err = C.git_libgit2_opts(option_type, cert_file_cdata, cert_dir_cdata) check_error(err) @@ -464,7 +462,7 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) # Convert to int (0 or 1) value = 1 if enabled else 0 - err = C.git_libgit2_opts(option_type, ffi.cast("int", value)) + err = C.git_libgit2_opts(option_type, ffi.cast('int', value)) check_error(err) return None @@ -472,7 +470,7 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) elif option_type == C.GIT_OPT_GET_OWNER_VALIDATION: check_args(option_type, arg1, arg2, 0) - enabled_ptr = ffi.new("int *") + enabled_ptr = ffi.new('int *') err = C.git_libgit2_opts(option_type, enabled_ptr) check_error(err) return bool(enabled_ptr[0]) @@ -481,7 +479,7 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) elif option_type == C.GIT_OPT_GET_TEMPLATE_PATH: check_args(option_type, arg1, arg2, 0) - buf = ffi.new("git_buf *") + buf = ffi.new('git_buf *') err = C.git_libgit2_opts(option_type, buf) check_error(err) @@ -505,7 +503,7 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) template_path_cdata = ffi.NULL else: path_bytes = to_bytes(path) - template_path_cdata = ffi.new("char[]", path_bytes) + template_path_cdata = ffi.new('char[]', path_bytes) err = C.git_libgit2_opts(option_type, template_path_cdata) check_error(err) @@ -515,7 +513,7 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) elif option_type == C.GIT_OPT_GET_USER_AGENT: check_args(option_type, arg1, arg2, 0) - buf = ffi.new("git_buf *") + buf = ffi.new('git_buf *') err = C.git_libgit2_opts(option_type, buf) check_error(err) @@ -535,7 +533,7 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) agent = arg1 agent_bytes = to_bytes(agent) - agent_cdata = ffi.new("char[]", agent_bytes) + agent_cdata = ffi.new('char[]', agent_bytes) err = C.git_libgit2_opts(option_type, agent_cdata) check_error(err) @@ -547,7 +545,7 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) ciphers = arg1 ciphers_bytes = to_bytes(ciphers) - ciphers_cdata = ffi.new("char[]", ciphers_bytes) + ciphers_cdata = ffi.new('char[]', ciphers_bytes) err = C.git_libgit2_opts(option_type, ciphers_cdata) check_error(err) @@ -557,7 +555,7 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) elif option_type == C.GIT_OPT_GET_WINDOWS_SHAREMODE: check_args(option_type, arg1, arg2, 0) - value_ptr = ffi.new("unsigned int *") + value_ptr = ffi.new('unsigned int *') err = C.git_libgit2_opts(option_type, value_ptr) check_error(err) return value_ptr[0] @@ -568,13 +566,13 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) if not isinstance(arg1, int): raise TypeError( - f"option value must be an integer, not {type(arg1).__name__}" + f'option value must be an integer, not {type(arg1).__name__}' ) value = arg1 if value < 0: - raise ValueError("value must be non-negative") + raise ValueError('value must be non-negative') - err = C.git_libgit2_opts(option_type, ffi.cast("unsigned int", value)) + err = C.git_libgit2_opts(option_type, ffi.cast('unsigned int', value)) check_error(err) return None @@ -582,7 +580,7 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) elif option_type == C.GIT_OPT_GET_PACK_MAX_OBJECTS: check_args(option_type, arg1, arg2, 0) - size_ptr = ffi.new("size_t *") + size_ptr = ffi.new('size_t *') err = C.git_libgit2_opts(option_type, size_ptr) check_error(err) return size_ptr[0] @@ -593,13 +591,13 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) if not isinstance(arg1, int): raise TypeError( - f"option value must be an integer, not {type(arg1).__name__}" + f'option value must be an integer, not {type(arg1).__name__}' ) size = arg1 if size < 0: - raise ValueError("size must be non-negative") + raise ValueError('size must be non-negative') - err = C.git_libgit2_opts(option_type, ffi.cast("size_t", size)) + err = C.git_libgit2_opts(option_type, ffi.cast('size_t', size)) check_error(err) return None @@ -611,7 +609,7 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) # Convert to int (0 or 1) value = 1 if enabled else 0 - err = C.git_libgit2_opts(option_type, ffi.cast("int", value)) + err = C.git_libgit2_opts(option_type, ffi.cast('int', value)) check_error(err) return None @@ -621,11 +619,11 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) if not isinstance(arg1, int): raise TypeError( - f"option value must be an integer, not {type(arg1).__name__}" + f'option value must be an integer, not {type(arg1).__name__}' ) priority = arg1 - err = C.git_libgit2_opts(option_type, ffi.cast("int", priority)) + err = C.git_libgit2_opts(option_type, ffi.cast('int', priority)) check_error(err) return None @@ -635,11 +633,11 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) if not isinstance(arg1, int): raise TypeError( - f"option value must be an integer, not {type(arg1).__name__}" + f'option value must be an integer, not {type(arg1).__name__}' ) priority = arg1 - err = C.git_libgit2_opts(option_type, ffi.cast("int", priority)) + err = C.git_libgit2_opts(option_type, ffi.cast('int', priority)) check_error(err) return None @@ -648,7 +646,7 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) check_args(option_type, arg1, arg2, 0) # GET_EXTENSIONS expects a git_strarray pointer - strarray = ffi.new("git_strarray *") + strarray = ffi.new('git_strarray *') err = C.git_libgit2_opts(option_type, strarray) check_error(err) @@ -663,7 +661,7 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) finally: # Must dispose of the strarray to free the memory C.git_strarray_dispose(strarray) - + return result # Handle SET_EXTENSIONS @@ -674,22 +672,22 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) length = arg2 if not isinstance(extensions, list): - raise TypeError("extensions must be a list of strings") + raise TypeError('extensions must be a list of strings') if not isinstance(length, int): - raise TypeError("length must be an integer") + raise TypeError('length must be an integer') # Create array of char pointers # libgit2 will make its own copies with git__strdup - ext_array: ArrayC[char_pointer] = ffi.new("char *[]", len(extensions)) + ext_array: ArrayC[char_pointer] = ffi.new('char *[]', len(extensions)) ext_strings: list[ArrayC[char]] = [] # Keep references during the call - + for i, ext in enumerate(extensions): ext_bytes = to_bytes(ext) - ext_string: ArrayC[char] = ffi.new("char[]", ext_bytes) + ext_string: ArrayC[char] = ffi.new('char[]', ext_bytes) ext_strings.append(ext_string) - ext_array[i] = ffi.cast("char *", ext_string) + ext_array[i] = ffi.cast('char *', ext_string) - err = C.git_libgit2_opts(option_type, ext_array, ffi.cast("size_t", length)) + err = C.git_libgit2_opts(option_type, ext_array, ffi.cast('size_t', length)) check_error(err) return None @@ -697,7 +695,7 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) elif option_type == C.GIT_OPT_GET_HOMEDIR: check_args(option_type, arg1, arg2, 0) - buf = ffi.new("git_buf *") + buf = ffi.new('git_buf *') err = C.git_libgit2_opts(option_type, buf) check_error(err) @@ -721,7 +719,7 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) homedir_cdata = ffi.NULL else: path_bytes = to_bytes(path) - homedir_cdata = ffi.new("char[]", path_bytes) + homedir_cdata = ffi.new('char[]', path_bytes) err = C.git_libgit2_opts(option_type, homedir_cdata) check_error(err) @@ -731,7 +729,7 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) elif option_type == C.GIT_OPT_GET_SERVER_CONNECT_TIMEOUT: check_args(option_type, arg1, arg2, 0) - timeout_ptr = ffi.new("int *") + timeout_ptr = ffi.new('int *') err = C.git_libgit2_opts(option_type, timeout_ptr) check_error(err) return timeout_ptr[0] @@ -742,11 +740,11 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) if not isinstance(arg1, int): raise TypeError( - f"option value must be an integer, not {type(arg1).__name__}" + f'option value must be an integer, not {type(arg1).__name__}' ) timeout = arg1 - err = C.git_libgit2_opts(option_type, ffi.cast("int", timeout)) + err = C.git_libgit2_opts(option_type, ffi.cast('int', timeout)) check_error(err) return None @@ -754,7 +752,7 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) elif option_type == C.GIT_OPT_GET_SERVER_TIMEOUT: check_args(option_type, arg1, arg2, 0) - timeout_ptr = ffi.new("int *") + timeout_ptr = ffi.new('int *') err = C.git_libgit2_opts(option_type, timeout_ptr) check_error(err) return timeout_ptr[0] @@ -765,11 +763,11 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) if not isinstance(arg1, int): raise TypeError( - f"option value must be an integer, not {type(arg1).__name__}" + f'option value must be an integer, not {type(arg1).__name__}' ) timeout = arg1 - err = C.git_libgit2_opts(option_type, ffi.cast("int", timeout)) + err = C.git_libgit2_opts(option_type, ffi.cast('int', timeout)) check_error(err) return None @@ -777,7 +775,7 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) elif option_type == C.GIT_OPT_GET_USER_AGENT_PRODUCT: check_args(option_type, arg1, arg2, 0) - buf = ffi.new("git_buf *") + buf = ffi.new('git_buf *') err = C.git_libgit2_opts(option_type, buf) check_error(err) @@ -797,7 +795,7 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) product = arg1 product_bytes = to_bytes(product) - product_cdata = ffi.new("char[]", product_bytes) + product_cdata = ffi.new('char[]', product_bytes) err = C.git_libgit2_opts(option_type, product_cdata) check_error(err) @@ -810,12 +808,12 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) cert = arg1 if isinstance(cert, (str, bytes)): cert_bytes = to_bytes(cert) - cert_cdata = ffi.new("char[]", cert_bytes) + cert_cdata = ffi.new('char[]', cert_bytes) cert_len = len(cert_bytes) else: - raise TypeError("certificate must be a string or bytes") + raise TypeError('certificate must be a string or bytes') - err = C.git_libgit2_opts(option_type, cert_cdata, ffi.cast("size_t", cert_len)) + err = C.git_libgit2_opts(option_type, cert_cdata, ffi.cast('size_t', cert_len)) check_error(err) return None @@ -823,10 +821,8 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) # because it requires providing C function pointers for memory management # (malloc, free, etc.) that must handle raw memory at the C level, # which cannot be safely implemented in pure Python. - elif option_type in ( - C.GIT_OPT_SET_ALLOCATOR, - ): + elif option_type in (C.GIT_OPT_SET_ALLOCATOR,): return NotImplemented else: - raise ValueError(f"Invalid option {option_type}") + raise ValueError(f'Invalid option {option_type}') diff --git a/test/test_options.py b/test/test_options.py index 78a533688..a92a62318 100644 --- a/test/test_options.py +++ b/test/test_options.py @@ -294,9 +294,11 @@ def test_add_ssl_x509_cert() -> None: except pygit2.GitError as e: # May fail if TLS backend doesn't support adding raw certs # or if the certificate format is invalid + msg = str(e).lower() if ( - "TLS backend doesn't support" not in str(e) - and "invalid" not in str(e).lower() + "tls backend doesn't support" not in msg + and "invalid" not in msg + and "failed to add raw x509 certificate" not in msg ): raise From c4ad77db2c807209768dddde3b43002dea45dc06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Tue, 12 Aug 2025 11:18:57 +0200 Subject: [PATCH 087/225] Include the new names in `__all__` and the high-level `settings` sub-module --- pygit2/__init__.py | 28 ++++ pygit2/settings.py | 143 +++++++++++++++++++ test/test_settings.py | 312 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 483 insertions(+) create mode 100644 test/test_settings.py diff --git a/pygit2/__init__.py b/pygit2/__init__.py index 9f8acacb1..f5250a604 100644 --- a/pygit2/__init__.py +++ b/pygit2/__init__.py @@ -297,37 +297,51 @@ from .blob import BlobIO from .options import ( option, + GIT_OPT_ADD_SSL_X509_CERT, GIT_OPT_DISABLE_PACK_KEEP_FILE_CHECKS, GIT_OPT_ENABLE_CACHING, GIT_OPT_ENABLE_FSYNC_GITDIR, + GIT_OPT_ENABLE_HTTP_EXPECT_CONTINUE, GIT_OPT_ENABLE_OFS_DELTA, GIT_OPT_ENABLE_STRICT_HASH_VERIFICATION, GIT_OPT_ENABLE_STRICT_OBJECT_CREATION, GIT_OPT_ENABLE_STRICT_SYMBOLIC_REF_CREATION, GIT_OPT_ENABLE_UNSAVED_INDEX_SAFETY, GIT_OPT_GET_CACHED_MEMORY, + GIT_OPT_GET_EXTENSIONS, + GIT_OPT_GET_HOMEDIR, GIT_OPT_GET_MWINDOW_FILE_LIMIT, GIT_OPT_GET_MWINDOW_MAPPED_LIMIT, GIT_OPT_GET_MWINDOW_SIZE, GIT_OPT_GET_OWNER_VALIDATION, GIT_OPT_GET_PACK_MAX_OBJECTS, GIT_OPT_GET_SEARCH_PATH, + GIT_OPT_GET_SERVER_CONNECT_TIMEOUT, + GIT_OPT_GET_SERVER_TIMEOUT, GIT_OPT_GET_TEMPLATE_PATH, GIT_OPT_GET_USER_AGENT, + GIT_OPT_GET_USER_AGENT_PRODUCT, GIT_OPT_GET_WINDOWS_SHAREMODE, GIT_OPT_SET_ALLOCATOR, GIT_OPT_SET_CACHE_MAX_SIZE, GIT_OPT_SET_CACHE_OBJECT_LIMIT, + GIT_OPT_SET_EXTENSIONS, + GIT_OPT_SET_HOMEDIR, GIT_OPT_SET_MWINDOW_FILE_LIMIT, GIT_OPT_SET_MWINDOW_MAPPED_LIMIT, GIT_OPT_SET_MWINDOW_SIZE, + GIT_OPT_SET_ODB_LOOSE_PRIORITY, + GIT_OPT_SET_ODB_PACKED_PRIORITY, GIT_OPT_SET_OWNER_VALIDATION, GIT_OPT_SET_PACK_MAX_OBJECTS, GIT_OPT_SET_SEARCH_PATH, + GIT_OPT_SET_SERVER_CONNECT_TIMEOUT, + GIT_OPT_SET_SERVER_TIMEOUT, GIT_OPT_SET_SSL_CERT_LOCATIONS, GIT_OPT_SET_SSL_CIPHERS, GIT_OPT_SET_TEMPLATE_PATH, GIT_OPT_SET_USER_AGENT, + GIT_OPT_SET_USER_AGENT_PRODUCT, GIT_OPT_SET_WINDOWS_SHAREMODE, ) from .callbacks import ( @@ -747,37 +761,51 @@ def clone_repository( 'GIT_OBJECT_REF_DELTA', 'GIT_OBJECT_TAG', 'GIT_OBJECT_TREE', + 'GIT_OPT_ADD_SSL_X509_CERT', 'GIT_OPT_DISABLE_PACK_KEEP_FILE_CHECKS', 'GIT_OPT_ENABLE_CACHING', 'GIT_OPT_ENABLE_FSYNC_GITDIR', + 'GIT_OPT_ENABLE_HTTP_EXPECT_CONTINUE', 'GIT_OPT_ENABLE_OFS_DELTA', 'GIT_OPT_ENABLE_STRICT_HASH_VERIFICATION', 'GIT_OPT_ENABLE_STRICT_OBJECT_CREATION', 'GIT_OPT_ENABLE_STRICT_SYMBOLIC_REF_CREATION', 'GIT_OPT_ENABLE_UNSAVED_INDEX_SAFETY', 'GIT_OPT_GET_CACHED_MEMORY', + 'GIT_OPT_GET_EXTENSIONS', + 'GIT_OPT_GET_HOMEDIR', 'GIT_OPT_GET_MWINDOW_FILE_LIMIT', 'GIT_OPT_GET_MWINDOW_MAPPED_LIMIT', 'GIT_OPT_GET_MWINDOW_SIZE', 'GIT_OPT_GET_OWNER_VALIDATION', 'GIT_OPT_GET_PACK_MAX_OBJECTS', 'GIT_OPT_GET_SEARCH_PATH', + 'GIT_OPT_GET_SERVER_CONNECT_TIMEOUT', + 'GIT_OPT_GET_SERVER_TIMEOUT', 'GIT_OPT_GET_TEMPLATE_PATH', 'GIT_OPT_GET_USER_AGENT', + 'GIT_OPT_GET_USER_AGENT_PRODUCT', 'GIT_OPT_GET_WINDOWS_SHAREMODE', 'GIT_OPT_SET_ALLOCATOR', 'GIT_OPT_SET_CACHE_MAX_SIZE', 'GIT_OPT_SET_CACHE_OBJECT_LIMIT', + 'GIT_OPT_SET_EXTENSIONS', + 'GIT_OPT_SET_HOMEDIR', 'GIT_OPT_SET_MWINDOW_FILE_LIMIT', 'GIT_OPT_SET_MWINDOW_MAPPED_LIMIT', 'GIT_OPT_SET_MWINDOW_SIZE', + 'GIT_OPT_SET_ODB_LOOSE_PRIORITY', + 'GIT_OPT_SET_ODB_PACKED_PRIORITY', 'GIT_OPT_SET_OWNER_VALIDATION', 'GIT_OPT_SET_PACK_MAX_OBJECTS', 'GIT_OPT_SET_SEARCH_PATH', + 'GIT_OPT_SET_SERVER_CONNECT_TIMEOUT', + 'GIT_OPT_SET_SERVER_TIMEOUT', 'GIT_OPT_SET_SSL_CERT_LOCATIONS', 'GIT_OPT_SET_SSL_CIPHERS', 'GIT_OPT_SET_TEMPLATE_PATH', 'GIT_OPT_SET_USER_AGENT', + 'GIT_OPT_SET_USER_AGENT_PRODUCT', 'GIT_OPT_SET_WINDOWS_SHAREMODE', 'GIT_REFERENCES_ALL', 'GIT_REFERENCES_BRANCHES', diff --git a/pygit2/settings.py b/pygit2/settings.py index a6e2a100c..a3d1e186d 100644 --- a/pygit2/settings.py +++ b/pygit2/settings.py @@ -105,6 +105,15 @@ def mwindow_mapped_limit(self) -> int: def mwindow_mapped_limit(self, value: int) -> None: option(Option.SET_MWINDOW_MAPPED_LIMIT, value) + @property + def mwindow_file_limit(self) -> int: + """Get or set the maximum number of files to be mapped at any time""" + return option(Option.GET_MWINDOW_FILE_LIMIT) + + @mwindow_file_limit.setter + def mwindow_file_limit(self, value: int) -> None: + option(Option.SET_MWINDOW_FILE_LIMIT, value) + @property def cached_memory(self) -> tuple[int, int]: """ @@ -207,3 +216,137 @@ def set_ssl_cert_locations( option(Option.SET_SSL_CERT_LOCATIONS, cert_file, cert_dir) self._ssl_cert_file = cert_file self._ssl_cert_dir = cert_dir + + @property + def template_path(self) -> str | None: + """Get or set the default template path for new repositories""" + return option(Option.GET_TEMPLATE_PATH) + + @template_path.setter + def template_path(self, value: str | bytes) -> None: + option(Option.SET_TEMPLATE_PATH, value) + + @property + def user_agent(self) -> str | None: + """Get or set the user agent string for network operations""" + return option(Option.GET_USER_AGENT) + + @user_agent.setter + def user_agent(self, value: str | bytes) -> None: + option(Option.SET_USER_AGENT, value) + + @property + def user_agent_product(self) -> str | None: + """Get or set the user agent product name""" + return option(Option.GET_USER_AGENT_PRODUCT) + + @user_agent_product.setter + def user_agent_product(self, value: str | bytes) -> None: + option(Option.SET_USER_AGENT_PRODUCT, value) + + def set_ssl_ciphers(self, ciphers: str | bytes) -> None: + """Set the SSL ciphers to use for HTTPS connections""" + option(Option.SET_SSL_CIPHERS, ciphers) + + def add_ssl_x509_cert(self, certificate: str | bytes) -> None: + """Add a trusted X.509 certificate for SSL/TLS connections""" + option(Option.ADD_SSL_X509_CERT, certificate) + + def enable_strict_object_creation(self, value: bool = True) -> None: + """Enable or disable strict object creation validation""" + option(Option.ENABLE_STRICT_OBJECT_CREATION, value) + + def enable_strict_symbolic_ref_creation(self, value: bool = True) -> None: + """Enable or disable strict symbolic reference creation validation""" + option(Option.ENABLE_STRICT_SYMBOLIC_REF_CREATION, value) + + def enable_ofs_delta(self, value: bool = True) -> None: + """Enable or disable offset delta encoding""" + option(Option.ENABLE_OFS_DELTA, value) + + def enable_fsync_gitdir(self, value: bool = True) -> None: + """Enable or disable fsync for git directory operations""" + option(Option.ENABLE_FSYNC_GITDIR, value) + + def enable_strict_hash_verification(self, value: bool = True) -> None: + """Enable or disable strict hash verification""" + option(Option.ENABLE_STRICT_HASH_VERIFICATION, value) + + def enable_unsaved_index_safety(self, value: bool = True) -> None: + """Enable or disable unsaved index safety checks""" + option(Option.ENABLE_UNSAVED_INDEX_SAFETY, value) + + def enable_http_expect_continue(self, value: bool = True) -> None: + """Enable or disable HTTP Expect/Continue for large pushes""" + option(Option.ENABLE_HTTP_EXPECT_CONTINUE, value) + + @property + def windows_sharemode(self) -> int: + """Get or set the Windows share mode for opening files""" + return option(Option.GET_WINDOWS_SHAREMODE) + + @windows_sharemode.setter + def windows_sharemode(self, value: int) -> None: + option(Option.SET_WINDOWS_SHAREMODE, value) + + @property + def pack_max_objects(self) -> int: + """Get or set the maximum number of objects in a pack""" + return option(Option.GET_PACK_MAX_OBJECTS) + + @pack_max_objects.setter + def pack_max_objects(self, value: int) -> None: + option(Option.SET_PACK_MAX_OBJECTS, value) + + @property + def owner_validation(self) -> bool: + """Get or set repository directory ownership validation""" + return option(Option.GET_OWNER_VALIDATION) + + @owner_validation.setter + def owner_validation(self, value: bool) -> None: + option(Option.SET_OWNER_VALIDATION, value) + + def set_odb_packed_priority(self, priority: int) -> None: + """Set the priority for packed ODB backend (default 1)""" + option(Option.SET_ODB_PACKED_PRIORITY, priority) + + def set_odb_loose_priority(self, priority: int) -> None: + """Set the priority for loose ODB backend (default 2)""" + option(Option.SET_ODB_LOOSE_PRIORITY, priority) + + @property + def extensions(self) -> list[str]: + """Get the list of enabled extensions""" + return option(Option.GET_EXTENSIONS) + + def set_extensions(self, extensions: list[str]) -> None: + """Set the list of enabled extensions""" + option(Option.SET_EXTENSIONS, extensions, len(extensions)) + + @property + def homedir(self) -> str | None: + """Get or set the home directory""" + return option(Option.GET_HOMEDIR) + + @homedir.setter + def homedir(self, value: str | bytes) -> None: + option(Option.SET_HOMEDIR, value) + + @property + def server_connect_timeout(self) -> int: + """Get or set the server connection timeout in milliseconds""" + return option(Option.GET_SERVER_CONNECT_TIMEOUT) + + @server_connect_timeout.setter + def server_connect_timeout(self, value: int) -> None: + option(Option.SET_SERVER_CONNECT_TIMEOUT, value) + + @property + def server_timeout(self) -> int: + """Get or set the server timeout in milliseconds""" + return option(Option.GET_SERVER_TIMEOUT) + + @server_timeout.setter + def server_timeout(self, value: int) -> None: + option(Option.SET_SERVER_TIMEOUT, value) diff --git a/test/test_settings.py b/test/test_settings.py new file mode 100644 index 000000000..dfe1d4c29 --- /dev/null +++ b/test/test_settings.py @@ -0,0 +1,312 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file 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; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +"""Test the Settings class.""" + +import sys +import pytest +import pygit2 +from pygit2.enums import ConfigLevel, ObjectType + + +def test_mwindow_size() -> None: + """Test get/set mwindow size""" + original = pygit2.settings.mwindow_size + try: + test_size = 200 * 1024 + pygit2.settings.mwindow_size = test_size + assert pygit2.settings.mwindow_size == test_size + finally: + pygit2.settings.mwindow_size = original + + +def test_mwindow_mapped_limit() -> None: + """Test get/set mwindow mapped limit""" + original = pygit2.settings.mwindow_mapped_limit + try: + test_limit = 300 * 1024 + pygit2.settings.mwindow_mapped_limit = test_limit + assert pygit2.settings.mwindow_mapped_limit == test_limit + finally: + pygit2.settings.mwindow_mapped_limit = original + + +def test_cached_memory() -> None: + """Test get cached memory""" + cached = pygit2.settings.cached_memory + assert isinstance(cached, tuple) + assert len(cached) == 2 + assert isinstance(cached[0], int) + assert isinstance(cached[1], int) + + +def test_enable_caching() -> None: + """Test enable/disable caching""" + # Verify the method exists and accepts boolean values without raising + assert hasattr(pygit2.settings, 'enable_caching') + assert callable(pygit2.settings.enable_caching) + + # Should not raise exceptions + pygit2.settings.enable_caching(False) + pygit2.settings.enable_caching(True) + + +def test_disable_pack_keep_file_checks() -> None: + """Test disable pack keep file checks""" + assert hasattr(pygit2.settings, 'disable_pack_keep_file_checks') + assert callable(pygit2.settings.disable_pack_keep_file_checks) + + # Should not raise exceptions + pygit2.settings.disable_pack_keep_file_checks(False) + pygit2.settings.disable_pack_keep_file_checks(True) + pygit2.settings.disable_pack_keep_file_checks(False) + + +def test_cache_max_size() -> None: + """Test set cache max size""" + original_max_size = pygit2.settings.cached_memory[1] + try: + pygit2.settings.cache_max_size(128 * 1024**2) + assert pygit2.settings.cached_memory[1] == 128 * 1024**2 + pygit2.settings.cache_max_size(256 * 1024**2) + assert pygit2.settings.cached_memory[1] == 256 * 1024**2 + finally: + pygit2.settings.cache_max_size(original_max_size) + + +@pytest.mark.parametrize("object_type,test_size,default_size", [ + (ObjectType.BLOB, 2 * 1024, 0), + (ObjectType.COMMIT, 8 * 1024, 4096), + (ObjectType.TREE, 8 * 1024, 4096), + (ObjectType.TAG, 8 * 1024, 4096), + (ObjectType.BLOB, 0, 0), +]) +def test_cache_object_limit(object_type: ObjectType, test_size: int, default_size: int) -> None: + """Test set cache object limit""" + assert callable(pygit2.settings.cache_object_limit) + + pygit2.settings.cache_object_limit(object_type, test_size) + pygit2.settings.cache_object_limit(object_type, default_size) + + +@pytest.mark.parametrize("level,test_path", [ + (ConfigLevel.GLOBAL, '/tmp/test_global'), + (ConfigLevel.XDG, '/tmp/test_xdg'), + (ConfigLevel.SYSTEM, '/tmp/test_system'), +]) +def test_search_path(level: ConfigLevel, test_path: str) -> None: + """Test get/set search paths""" + original = pygit2.settings.search_path[level] + try: + pygit2.settings.search_path[level] = test_path + assert pygit2.settings.search_path[level] == test_path + finally: + pygit2.settings.search_path[level] = original + + +def test_template_path() -> None: + """Test get/set template path""" + original = pygit2.settings.template_path + try: + pygit2.settings.template_path = '/tmp/test_templates' + assert pygit2.settings.template_path == '/tmp/test_templates' + finally: + if original: + pygit2.settings.template_path = original + + +def test_user_agent() -> None: + """Test get/set user agent""" + original = pygit2.settings.user_agent + try: + pygit2.settings.user_agent = 'test-agent/1.0' + assert pygit2.settings.user_agent == 'test-agent/1.0' + finally: + if original: + pygit2.settings.user_agent = original + + +def test_user_agent_product() -> None: + """Test get/set user agent product""" + original = pygit2.settings.user_agent_product + try: + pygit2.settings.user_agent_product = 'test-product' + assert pygit2.settings.user_agent_product == 'test-product' + finally: + if original: + pygit2.settings.user_agent_product = original + + +def test_pack_max_objects() -> None: + """Test get/set pack max objects""" + original = pygit2.settings.pack_max_objects + try: + pygit2.settings.pack_max_objects = 100000 + assert pygit2.settings.pack_max_objects == 100000 + finally: + pygit2.settings.pack_max_objects = original + + +def test_owner_validation() -> None: + """Test get/set owner validation""" + original = pygit2.settings.owner_validation + try: + pygit2.settings.owner_validation = False + assert pygit2.settings.owner_validation == False # noqa: E712 + pygit2.settings.owner_validation = True + assert pygit2.settings.owner_validation == True # noqa: E712 + finally: + pygit2.settings.owner_validation = original + + +def test_mwindow_file_limit() -> None: + """Test get/set mwindow file limit""" + original = pygit2.settings.mwindow_file_limit + try: + pygit2.settings.mwindow_file_limit = 100 + assert pygit2.settings.mwindow_file_limit == 100 + finally: + pygit2.settings.mwindow_file_limit = original + + +def test_homedir() -> None: + """Test get/set home directory""" + original = pygit2.settings.homedir + try: + pygit2.settings.homedir = '/tmp/test_home' + assert pygit2.settings.homedir == '/tmp/test_home' + finally: + if original: + pygit2.settings.homedir = original + + +def test_server_timeouts() -> None: + """Test get/set server timeouts""" + original_connect = pygit2.settings.server_connect_timeout + original_timeout = pygit2.settings.server_timeout + try: + pygit2.settings.server_connect_timeout = 5000 + assert pygit2.settings.server_connect_timeout == 5000 + + pygit2.settings.server_timeout = 10000 + assert pygit2.settings.server_timeout == 10000 + finally: + pygit2.settings.server_connect_timeout = original_connect + pygit2.settings.server_timeout = original_timeout + + +def test_extensions() -> None: + """Test get/set extensions""" + original = pygit2.settings.extensions + try: + test_extensions = ['objectformat', 'worktreeconfig'] + pygit2.settings.set_extensions(test_extensions) + + new_extensions = pygit2.settings.extensions + for ext in test_extensions: + assert ext in new_extensions + finally: + if original: + pygit2.settings.set_extensions(original) + + +@pytest.mark.parametrize("method_name,default_value", [ + ('enable_strict_object_creation', True), + ('enable_strict_symbolic_ref_creation', True), + ('enable_ofs_delta', True), + ('enable_fsync_gitdir', False), + ('enable_strict_hash_verification', True), + ('enable_unsaved_index_safety', False), + ('enable_http_expect_continue', False), +]) +def test_enable_methods(method_name: str, default_value: bool) -> None: + """Test various enable methods""" + assert hasattr(pygit2.settings, method_name) + method = getattr(pygit2.settings, method_name) + assert callable(method) + + method(True) + method(False) + method(default_value) + + +@pytest.mark.parametrize("priority", [1, 5, 10, 0, -1, -2]) +def test_odb_priorities(priority: int) -> None: + """Test setting ODB priorities""" + assert hasattr(pygit2.settings, 'set_odb_packed_priority') + assert hasattr(pygit2.settings, 'set_odb_loose_priority') + assert callable(pygit2.settings.set_odb_packed_priority) + assert callable(pygit2.settings.set_odb_loose_priority) + + pygit2.settings.set_odb_packed_priority(priority) + pygit2.settings.set_odb_loose_priority(priority) + + pygit2.settings.set_odb_packed_priority(1) + pygit2.settings.set_odb_loose_priority(2) + + +def test_ssl_methods() -> None: + """Test SSL-related methods""" + assert callable(pygit2.settings.set_ssl_ciphers) + assert callable(pygit2.settings.add_ssl_x509_cert) + + ssl_ciphers_supported = True + try: + pygit2.settings.set_ssl_ciphers('DEFAULT') + except pygit2.GitError as e: + msg = str(e).lower() + if "tls backend doesn't support" not in msg: + raise + ssl_ciphers_supported = False + + test_cert = "-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----" + x509_cert_supported = True + try: + pygit2.settings.add_ssl_x509_cert(test_cert) + except pygit2.GitError as e: + msg = str(e).lower() + if ( + "tls backend doesn't support" not in msg + and "invalid" not in msg + and "failed to add raw x509 certificate" not in msg + ): + raise + x509_cert_supported = False + + # At least verify the methods exist even if not supported + assert not ssl_ciphers_supported or not x509_cert_supported or True + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific feature") +def test_windows_sharemode() -> None: + """Test get/set Windows share mode""" + original = pygit2.settings.windows_sharemode + try: + pygit2.settings.windows_sharemode = 1 + assert pygit2.settings.windows_sharemode == 1 + pygit2.settings.windows_sharemode = 2 + assert pygit2.settings.windows_sharemode == 2 + finally: + pygit2.settings.windows_sharemode = original \ No newline at end of file From 230fad999765a118cfe06c7cc74577b5bd25c3bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Tue, 12 Aug 2025 11:19:40 +0200 Subject: [PATCH 088/225] More reformatting --- test/test_options.py | 6 +-- test/test_settings.py | 89 ++++++++++++++++++++++++------------------- 2 files changed, 53 insertions(+), 42 deletions(-) diff --git a/test/test_options.py b/test/test_options.py index a92a62318..ed746824b 100644 --- a/test/test_options.py +++ b/test/test_options.py @@ -287,7 +287,7 @@ def test_user_agent_product() -> None: def test_add_ssl_x509_cert() -> None: # Test adding an SSL certificate # This is a minimal test certificate (not valid, but tests the API) - test_cert = "-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----" + test_cert = '-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----' try: option(Option.ADD_SSL_X509_CERT, test_cert) @@ -297,8 +297,8 @@ def test_add_ssl_x509_cert() -> None: msg = str(e).lower() if ( "tls backend doesn't support" not in msg - and "invalid" not in msg - and "failed to add raw x509 certificate" not in msg + and 'invalid' not in msg + and 'failed to add raw x509 certificate' not in msg ): raise diff --git a/test/test_settings.py b/test/test_settings.py index dfe1d4c29..f24ddfb3f 100644 --- a/test/test_settings.py +++ b/test/test_settings.py @@ -67,7 +67,7 @@ def test_enable_caching() -> None: # Verify the method exists and accepts boolean values without raising assert hasattr(pygit2.settings, 'enable_caching') assert callable(pygit2.settings.enable_caching) - + # Should not raise exceptions pygit2.settings.enable_caching(False) pygit2.settings.enable_caching(True) @@ -77,7 +77,7 @@ def test_disable_pack_keep_file_checks() -> None: """Test disable pack keep file checks""" assert hasattr(pygit2.settings, 'disable_pack_keep_file_checks') assert callable(pygit2.settings.disable_pack_keep_file_checks) - + # Should not raise exceptions pygit2.settings.disable_pack_keep_file_checks(False) pygit2.settings.disable_pack_keep_file_checks(True) @@ -96,26 +96,34 @@ def test_cache_max_size() -> None: pygit2.settings.cache_max_size(original_max_size) -@pytest.mark.parametrize("object_type,test_size,default_size", [ - (ObjectType.BLOB, 2 * 1024, 0), - (ObjectType.COMMIT, 8 * 1024, 4096), - (ObjectType.TREE, 8 * 1024, 4096), - (ObjectType.TAG, 8 * 1024, 4096), - (ObjectType.BLOB, 0, 0), -]) -def test_cache_object_limit(object_type: ObjectType, test_size: int, default_size: int) -> None: +@pytest.mark.parametrize( + 'object_type,test_size,default_size', + [ + (ObjectType.BLOB, 2 * 1024, 0), + (ObjectType.COMMIT, 8 * 1024, 4096), + (ObjectType.TREE, 8 * 1024, 4096), + (ObjectType.TAG, 8 * 1024, 4096), + (ObjectType.BLOB, 0, 0), + ], +) +def test_cache_object_limit( + object_type: ObjectType, test_size: int, default_size: int +) -> None: """Test set cache object limit""" assert callable(pygit2.settings.cache_object_limit) - + pygit2.settings.cache_object_limit(object_type, test_size) pygit2.settings.cache_object_limit(object_type, default_size) -@pytest.mark.parametrize("level,test_path", [ - (ConfigLevel.GLOBAL, '/tmp/test_global'), - (ConfigLevel.XDG, '/tmp/test_xdg'), - (ConfigLevel.SYSTEM, '/tmp/test_system'), -]) +@pytest.mark.parametrize( + 'level,test_path', + [ + (ConfigLevel.GLOBAL, '/tmp/test_global'), + (ConfigLevel.XDG, '/tmp/test_xdg'), + (ConfigLevel.SYSTEM, '/tmp/test_system'), + ], +) def test_search_path(level: ConfigLevel, test_path: str) -> None: """Test get/set search paths""" original = pygit2.settings.search_path[level] @@ -209,7 +217,7 @@ def test_server_timeouts() -> None: try: pygit2.settings.server_connect_timeout = 5000 assert pygit2.settings.server_connect_timeout == 5000 - + pygit2.settings.server_timeout = 10000 assert pygit2.settings.server_timeout == 10000 finally: @@ -223,7 +231,7 @@ def test_extensions() -> None: try: test_extensions = ['objectformat', 'worktreeconfig'] pygit2.settings.set_extensions(test_extensions) - + new_extensions = pygit2.settings.extensions for ext in test_extensions: assert ext in new_extensions @@ -232,37 +240,40 @@ def test_extensions() -> None: pygit2.settings.set_extensions(original) -@pytest.mark.parametrize("method_name,default_value", [ - ('enable_strict_object_creation', True), - ('enable_strict_symbolic_ref_creation', True), - ('enable_ofs_delta', True), - ('enable_fsync_gitdir', False), - ('enable_strict_hash_verification', True), - ('enable_unsaved_index_safety', False), - ('enable_http_expect_continue', False), -]) +@pytest.mark.parametrize( + 'method_name,default_value', + [ + ('enable_strict_object_creation', True), + ('enable_strict_symbolic_ref_creation', True), + ('enable_ofs_delta', True), + ('enable_fsync_gitdir', False), + ('enable_strict_hash_verification', True), + ('enable_unsaved_index_safety', False), + ('enable_http_expect_continue', False), + ], +) def test_enable_methods(method_name: str, default_value: bool) -> None: """Test various enable methods""" assert hasattr(pygit2.settings, method_name) method = getattr(pygit2.settings, method_name) assert callable(method) - + method(True) method(False) method(default_value) -@pytest.mark.parametrize("priority", [1, 5, 10, 0, -1, -2]) +@pytest.mark.parametrize('priority', [1, 5, 10, 0, -1, -2]) def test_odb_priorities(priority: int) -> None: """Test setting ODB priorities""" assert hasattr(pygit2.settings, 'set_odb_packed_priority') assert hasattr(pygit2.settings, 'set_odb_loose_priority') assert callable(pygit2.settings.set_odb_packed_priority) assert callable(pygit2.settings.set_odb_loose_priority) - + pygit2.settings.set_odb_packed_priority(priority) pygit2.settings.set_odb_loose_priority(priority) - + pygit2.settings.set_odb_packed_priority(1) pygit2.settings.set_odb_loose_priority(2) @@ -271,7 +282,7 @@ def test_ssl_methods() -> None: """Test SSL-related methods""" assert callable(pygit2.settings.set_ssl_ciphers) assert callable(pygit2.settings.add_ssl_x509_cert) - + ssl_ciphers_supported = True try: pygit2.settings.set_ssl_ciphers('DEFAULT') @@ -280,8 +291,8 @@ def test_ssl_methods() -> None: if "tls backend doesn't support" not in msg: raise ssl_ciphers_supported = False - - test_cert = "-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----" + + test_cert = '-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----' x509_cert_supported = True try: pygit2.settings.add_ssl_x509_cert(test_cert) @@ -289,17 +300,17 @@ def test_ssl_methods() -> None: msg = str(e).lower() if ( "tls backend doesn't support" not in msg - and "invalid" not in msg - and "failed to add raw x509 certificate" not in msg + and 'invalid' not in msg + and 'failed to add raw x509 certificate' not in msg ): raise x509_cert_supported = False - + # At least verify the methods exist even if not supported assert not ssl_ciphers_supported or not x509_cert_supported or True -@pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific feature") +@pytest.mark.skipif(sys.platform != 'win32', reason='Windows-specific feature') def test_windows_sharemode() -> None: """Test get/set Windows share mode""" original = pygit2.settings.windows_sharemode @@ -309,4 +320,4 @@ def test_windows_sharemode() -> None: pygit2.settings.windows_sharemode = 2 assert pygit2.settings.windows_sharemode == 2 finally: - pygit2.settings.windows_sharemode = original \ No newline at end of file + pygit2.settings.windows_sharemode = original From 6007f9725f920cfde35f0ec3dc1189431dc850ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Tue, 12 Aug 2025 11:23:38 +0200 Subject: [PATCH 089/225] ruff check --fix --- pygit2/__init__.py | 36 ++++++++++++++++++------------------ pygit2/_pygit2.pyi | 2 -- pygit2/enums.py | 3 +-- pygit2/options.py | 13 ++++++------- pygit2/settings.py | 2 +- test/test_settings.py | 2 ++ 6 files changed, 28 insertions(+), 30 deletions(-) diff --git a/pygit2/__init__.py b/pygit2/__init__.py index f5250a604..d5edd2a10 100644 --- a/pygit2/__init__.py +++ b/pygit2/__init__.py @@ -295,8 +295,24 @@ ) from .blame import Blame, BlameHunk from .blob import BlobIO +from .callbacks import ( + CheckoutCallbacks, + Payload, + RemoteCallbacks, + StashApplyCallbacks, + get_credentials, + git_clone_options, + git_fetch_options, + git_proxy_options, +) +from .config import Config +from .credentials import * +from .errors import Passthrough, check_error +from .ffi import C, ffi +from .filter import Filter +from .index import Index, IndexEntry +from .legacyenums import * from .options import ( - option, GIT_OPT_ADD_SSL_X509_CERT, GIT_OPT_DISABLE_PACK_KEEP_FILE_CHECKS, GIT_OPT_ENABLE_CACHING, @@ -343,24 +359,8 @@ GIT_OPT_SET_USER_AGENT, GIT_OPT_SET_USER_AGENT_PRODUCT, GIT_OPT_SET_WINDOWS_SHAREMODE, + option, ) -from .callbacks import ( - CheckoutCallbacks, - Payload, - RemoteCallbacks, - StashApplyCallbacks, - get_credentials, - git_clone_options, - git_fetch_options, - git_proxy_options, -) -from .config import Config -from .credentials import * -from .errors import Passthrough, check_error -from .ffi import C, ffi -from .filter import Filter -from .index import Index, IndexEntry -from .legacyenums import * from .packbuilder import PackBuilder from .remotes import Remote from .repository import Repository diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index 0a2245be9..1172f965a 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -25,7 +25,6 @@ from .enums import ( ApplyLocation, BlobFilter, BranchType, - ConfigLevel, DeltaStatus, DiffFind, DiffFlag, @@ -35,7 +34,6 @@ from .enums import ( MergeAnalysis, MergePreference, ObjectType, - Option, ReferenceFilter, ReferenceType, ResetMode, diff --git a/pygit2/enums.py b/pygit2/enums.py index 71239ca91..d690e73ae 100644 --- a/pygit2/enums.py +++ b/pygit2/enums.py @@ -25,8 +25,7 @@ from enum import IntEnum, IntFlag -from . import _pygit2 -from . import options +from . import _pygit2, options from .ffi import C diff --git a/pygit2/options.py b/pygit2/options.py index 036b8b0ba..30bdd13cf 100644 --- a/pygit2/options.py +++ b/pygit2/options.py @@ -1,4 +1,3 @@ -from __future__ import annotations # Copyright 2010-2025 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify @@ -28,18 +27,18 @@ Libgit2 global options management using CFFI. """ -from typing import Any, Literal, overload, cast +from __future__ import annotations + +# Import only for type checking to avoid circular imports +from typing import TYPE_CHECKING, Any, Literal, cast, overload -from .ffi import C, ffi from .errors import check_error +from .ffi import C, ffi from .utils import to_bytes, to_str -# Import only for type checking to avoid circular imports -from typing import TYPE_CHECKING - if TYPE_CHECKING: + from ._libgit2.ffi import NULL_TYPE, ArrayC, char, char_pointer from .enums import ConfigLevel, ObjectType, Option - from ._libgit2.ffi import ArrayC, NULL_TYPE, char, char_pointer # Export GIT_OPT constants for backward compatibility GIT_OPT_GET_MWINDOW_SIZE: int = C.GIT_OPT_GET_MWINDOW_SIZE diff --git a/pygit2/settings.py b/pygit2/settings.py index a3d1e186d..feecea42c 100644 --- a/pygit2/settings.py +++ b/pygit2/settings.py @@ -32,9 +32,9 @@ import pygit2.enums -from .options import option from .enums import ConfigLevel, Option from .errors import GitError +from .options import option class SearchPathList: diff --git a/test/test_settings.py b/test/test_settings.py index f24ddfb3f..5d0dedb5e 100644 --- a/test/test_settings.py +++ b/test/test_settings.py @@ -26,7 +26,9 @@ """Test the Settings class.""" import sys + import pytest + import pygit2 from pygit2.enums import ConfigLevel, ObjectType From 13dc3016e1998d1fe822c19c2b6aab44ff0b63bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Tue, 12 Aug 2025 14:56:00 +0200 Subject: [PATCH 090/225] Drop (broken) support for GIT_OPT_ADD_SSL_X509_CERT --- pygit2/options.py | 36 +++++-------------------- pygit2/settings.py | 4 --- test/test_options.py | 63 +++++++------------------------------------ test/test_settings.py | 49 +++------------------------------ 4 files changed, 19 insertions(+), 133 deletions(-) diff --git a/pygit2/options.py b/pygit2/options.py index 30bdd13cf..d238a2999 100644 --- a/pygit2/options.py +++ b/pygit2/options.py @@ -307,7 +307,6 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) Set the maximum number of objects to include in a pack. """ - # Handle GET options with size_t output if option_type in ( C.GIT_OPT_GET_MWINDOW_SIZE, C.GIT_OPT_GET_MWINDOW_MAPPED_LIMIT, @@ -320,7 +319,6 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) check_error(err) return size_ptr[0] - # Handle SET options with size_t input elif option_type in ( C.GIT_OPT_SET_MWINDOW_SIZE, C.GIT_OPT_SET_MWINDOW_MAPPED_LIMIT, @@ -338,7 +336,6 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) check_error(err) return None - # Handle GET_SEARCH_PATH elif option_type == C.GIT_OPT_GET_SEARCH_PATH: check_args(option_type, arg1, arg2, 1) @@ -357,7 +354,6 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) return result - # Handle SET_SEARCH_PATH elif option_type == C.GIT_OPT_SET_SEARCH_PATH: check_args(option_type, arg1, arg2, 2) @@ -375,7 +371,6 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) check_error(err) return None - # Handle SET_CACHE_OBJECT_LIMIT elif option_type == C.GIT_OPT_SET_CACHE_OBJECT_LIMIT: check_args(option_type, arg1, arg2, 2) @@ -394,7 +389,6 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) check_error(err) return None - # Handle SET_CACHE_MAX_SIZE elif option_type == C.GIT_OPT_SET_CACHE_MAX_SIZE: check_args(option_type, arg1, arg2, 1) @@ -408,7 +402,6 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) check_error(err) return None - # Handle GET_CACHED_MEMORY elif option_type == C.GIT_OPT_GET_CACHED_MEMORY: check_args(option_type, arg1, arg2, 0) @@ -418,7 +411,6 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) check_error(err) return (current_ptr[0], allowed_ptr[0]) - # Handle SET_SSL_CERT_LOCATIONS elif option_type == C.GIT_OPT_SET_SSL_CERT_LOCATIONS: check_args(option_type, arg1, arg2, 2) @@ -465,7 +457,6 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) check_error(err) return None - # Handle GET_OWNER_VALIDATION elif option_type == C.GIT_OPT_GET_OWNER_VALIDATION: check_args(option_type, arg1, arg2, 0) @@ -474,7 +465,6 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) check_error(err) return bool(enabled_ptr[0]) - # Handle GET_TEMPLATE_PATH elif option_type == C.GIT_OPT_GET_TEMPLATE_PATH: check_args(option_type, arg1, arg2, 0) @@ -492,7 +482,6 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) return result - # Handle SET_TEMPLATE_PATH elif option_type == C.GIT_OPT_SET_TEMPLATE_PATH: check_args(option_type, arg1, arg2, 1) @@ -508,7 +497,6 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) check_error(err) return None - # Handle GET_USER_AGENT elif option_type == C.GIT_OPT_GET_USER_AGENT: check_args(option_type, arg1, arg2, 0) @@ -526,7 +514,6 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) return result - # Handle SET_USER_AGENT elif option_type == C.GIT_OPT_SET_USER_AGENT: check_args(option_type, arg1, arg2, 1) @@ -538,7 +525,6 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) check_error(err) return None - # Handle SET_SSL_CIPHERS elif option_type == C.GIT_OPT_SET_SSL_CIPHERS: check_args(option_type, arg1, arg2, 1) @@ -800,28 +786,18 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) check_error(err) return None - # Handle ADD_SSL_X509_CERT + # Not implemented - ADD_SSL_X509_CERT requires directly binding with OpenSSL + # as the API works accepts a X509* struct. Use GIT_OPT_SET_SSL_CERT_LOCATIONS + # instead. elif option_type == C.GIT_OPT_ADD_SSL_X509_CERT: - check_args(option_type, arg1, arg2, 1) - - cert = arg1 - if isinstance(cert, (str, bytes)): - cert_bytes = to_bytes(cert) - cert_cdata = ffi.new('char[]', cert_bytes) - cert_len = len(cert_bytes) - else: - raise TypeError('certificate must be a string or bytes') - - err = C.git_libgit2_opts(option_type, cert_cdata, ffi.cast('size_t', cert_len)) - check_error(err) - return None + raise NotImplementedError("Use GIT_OPT_SET_SSL_CERT_LOCATIONS instead") # Not implemented - SET_ALLOCATOR is not feasible from Python level # because it requires providing C function pointers for memory management # (malloc, free, etc.) that must handle raw memory at the C level, # which cannot be safely implemented in pure Python. - elif option_type in (C.GIT_OPT_SET_ALLOCATOR,): - return NotImplemented + elif option_type == C.GIT_OPT_SET_ALLOCATOR: + raise NotImplementedError("Setting a custom allocator not possible from Python") else: raise ValueError(f'Invalid option {option_type}') diff --git a/pygit2/settings.py b/pygit2/settings.py index feecea42c..91b5d3191 100644 --- a/pygit2/settings.py +++ b/pygit2/settings.py @@ -248,10 +248,6 @@ def set_ssl_ciphers(self, ciphers: str | bytes) -> None: """Set the SSL ciphers to use for HTTPS connections""" option(Option.SET_SSL_CIPHERS, ciphers) - def add_ssl_x509_cert(self, certificate: str | bytes) -> None: - """Add a trusted X.509 certificate for SSL/TLS connections""" - option(Option.ADD_SSL_X509_CERT, certificate) - def enable_strict_object_creation(self, value: bool = True) -> None: """Enable or disable strict object creation validation""" option(Option.ENABLE_STRICT_OBJECT_CREATION, value) diff --git a/test/test_options.py b/test/test_options.py index ed746824b..6a1c1ac08 100644 --- a/test/test_options.py +++ b/test/test_options.py @@ -23,6 +23,10 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. +import sys + +import pytest + import pygit2 from pygit2 import option from pygit2.enums import ConfigLevel, ObjectType, Option @@ -133,15 +137,12 @@ def test_owner_validation() -> None: def test_template_path() -> None: - # Get the initial template path original_path = option(Option.GET_TEMPLATE_PATH) - # Set a new template path test_path = '/tmp/test_templates' option(Option.SET_TEMPLATE_PATH, test_path) assert option(Option.GET_TEMPLATE_PATH) == test_path - # Reset to original path if original_path: option(Option.SET_TEMPLATE_PATH, original_path) else: @@ -149,15 +150,12 @@ def test_template_path() -> None: def test_user_agent() -> None: - # Get the initial user agent original_agent = option(Option.GET_USER_AGENT) - # Set a new user agent test_agent = 'test-agent/1.0' option(Option.SET_USER_AGENT, test_agent) assert option(Option.GET_USER_AGENT) == test_agent - # Reset to original agent if original_agent: option(Option.SET_USER_AGENT, original_agent) @@ -166,13 +164,9 @@ def test_pack_max_objects() -> None: __option(Option.GET_PACK_MAX_OBJECTS, Option.SET_PACK_MAX_OBJECTS, 100000) +@pytest.mark.skipif(sys.platform != 'win32', reason='Windows-specific feature') def test_windows_sharemode() -> None: - # This test might not work on non-Windows platforms - try: - __option(Option.GET_WINDOWS_SHAREMODE, Option.SET_WINDOWS_SHAREMODE, 1) - except Exception: - # May fail on non-Windows platforms - pass + __option(Option.GET_WINDOWS_SHAREMODE, Option.SET_WINDOWS_SHAREMODE, 1) def test_ssl_ciphers() -> None: @@ -180,76 +174,61 @@ def test_ssl_ciphers() -> None: try: option(Option.SET_SSL_CIPHERS, 'DEFAULT') except pygit2.GitError as e: - # May fail if TLS backend doesn't support custom ciphers - if "TLS backend doesn't support custom ciphers" not in str(e): - raise + if "TLS backend doesn't support custom ciphers" in str(e): + pytest.skip(str(e)) + raise def test_enable_http_expect_continue() -> None: - # Enable and disable HTTP expect continue option(Option.ENABLE_HTTP_EXPECT_CONTINUE, True) option(Option.ENABLE_HTTP_EXPECT_CONTINUE, False) def test_odb_priorities() -> None: - # Set ODB priorities option(Option.SET_ODB_PACKED_PRIORITY, 1) option(Option.SET_ODB_LOOSE_PRIORITY, 2) def test_extensions() -> None: - # Get initial extensions list original_extensions = option(Option.GET_EXTENSIONS) assert isinstance(original_extensions, list) - # Set extensions test_extensions = ['objectformat', 'worktreeconfig'] option(Option.SET_EXTENSIONS, test_extensions, len(test_extensions)) - # Verify they were set new_extensions = option(Option.GET_EXTENSIONS) assert isinstance(new_extensions, list) - # Check that our extensions are present # Note: libgit2 may add its own built-in extensions and sort them for ext in test_extensions: assert ext in new_extensions, f"Extension '{ext}' not found in {new_extensions}" - # Test with empty list option(Option.SET_EXTENSIONS, [], 0) empty_extensions = option(Option.GET_EXTENSIONS) assert isinstance(empty_extensions, list) - # Even with empty input, libgit2 may have built-in extensions - # Test with a custom extension custom_extensions = ['myextension', 'objectformat'] option(Option.SET_EXTENSIONS, custom_extensions, len(custom_extensions)) custom_result = option(Option.GET_EXTENSIONS) assert 'myextension' in custom_result assert 'objectformat' in custom_result - # Restore original extensions if original_extensions: option(Option.SET_EXTENSIONS, original_extensions, len(original_extensions)) else: - # Reset to empty list if there were no extensions option(Option.SET_EXTENSIONS, [], 0) - # Verify restoration final_extensions = option(Option.GET_EXTENSIONS) assert set(final_extensions) == set(original_extensions) def test_homedir() -> None: - # Get the initial home directory original_homedir = option(Option.GET_HOMEDIR) - # Set a new home directory test_homedir = '/tmp/test_home' option(Option.SET_HOMEDIR, test_homedir) assert option(Option.GET_HOMEDIR) == test_homedir - # Reset to original home directory if original_homedir: option(Option.SET_HOMEDIR, original_homedir) else: @@ -257,13 +236,11 @@ def test_homedir() -> None: def test_server_timeouts() -> None: - # Test connect timeout original_connect = option(Option.GET_SERVER_CONNECT_TIMEOUT) option(Option.SET_SERVER_CONNECT_TIMEOUT, 5000) assert option(Option.GET_SERVER_CONNECT_TIMEOUT) == 5000 option(Option.SET_SERVER_CONNECT_TIMEOUT, original_connect) - # Test server timeout original_timeout = option(Option.GET_SERVER_TIMEOUT) option(Option.SET_SERVER_TIMEOUT, 10000) assert option(Option.GET_SERVER_TIMEOUT) == 10000 @@ -271,37 +248,15 @@ def test_server_timeouts() -> None: def test_user_agent_product() -> None: - # Get the initial user agent product original_product = option(Option.GET_USER_AGENT_PRODUCT) - # Set a new user agent product test_product = 'test-product' option(Option.SET_USER_AGENT_PRODUCT, test_product) assert option(Option.GET_USER_AGENT_PRODUCT) == test_product - # Reset to original product if original_product: option(Option.SET_USER_AGENT_PRODUCT, original_product) -def test_add_ssl_x509_cert() -> None: - # Test adding an SSL certificate - # This is a minimal test certificate (not valid, but tests the API) - test_cert = '-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----' - - try: - option(Option.ADD_SSL_X509_CERT, test_cert) - except pygit2.GitError as e: - # May fail if TLS backend doesn't support adding raw certs - # or if the certificate format is invalid - msg = str(e).lower() - if ( - "tls backend doesn't support" not in msg - and 'invalid' not in msg - and 'failed to add raw x509 certificate' not in msg - ): - raise - - def test_mwindow_file_limit() -> None: __option(Option.GET_MWINDOW_FILE_LIMIT, Option.SET_MWINDOW_FILE_LIMIT, 100) diff --git a/test/test_settings.py b/test/test_settings.py index 5d0dedb5e..5c5211019 100644 --- a/test/test_settings.py +++ b/test/test_settings.py @@ -34,7 +34,6 @@ def test_mwindow_size() -> None: - """Test get/set mwindow size""" original = pygit2.settings.mwindow_size try: test_size = 200 * 1024 @@ -45,7 +44,6 @@ def test_mwindow_size() -> None: def test_mwindow_mapped_limit() -> None: - """Test get/set mwindow mapped limit""" original = pygit2.settings.mwindow_mapped_limit try: test_limit = 300 * 1024 @@ -56,7 +54,6 @@ def test_mwindow_mapped_limit() -> None: def test_cached_memory() -> None: - """Test get cached memory""" cached = pygit2.settings.cached_memory assert isinstance(cached, tuple) assert len(cached) == 2 @@ -65,8 +62,6 @@ def test_cached_memory() -> None: def test_enable_caching() -> None: - """Test enable/disable caching""" - # Verify the method exists and accepts boolean values without raising assert hasattr(pygit2.settings, 'enable_caching') assert callable(pygit2.settings.enable_caching) @@ -76,7 +71,6 @@ def test_enable_caching() -> None: def test_disable_pack_keep_file_checks() -> None: - """Test disable pack keep file checks""" assert hasattr(pygit2.settings, 'disable_pack_keep_file_checks') assert callable(pygit2.settings.disable_pack_keep_file_checks) @@ -87,7 +81,6 @@ def test_disable_pack_keep_file_checks() -> None: def test_cache_max_size() -> None: - """Test set cache max size""" original_max_size = pygit2.settings.cached_memory[1] try: pygit2.settings.cache_max_size(128 * 1024**2) @@ -111,7 +104,6 @@ def test_cache_max_size() -> None: def test_cache_object_limit( object_type: ObjectType, test_size: int, default_size: int ) -> None: - """Test set cache object limit""" assert callable(pygit2.settings.cache_object_limit) pygit2.settings.cache_object_limit(object_type, test_size) @@ -127,7 +119,6 @@ def test_cache_object_limit( ], ) def test_search_path(level: ConfigLevel, test_path: str) -> None: - """Test get/set search paths""" original = pygit2.settings.search_path[level] try: pygit2.settings.search_path[level] = test_path @@ -137,7 +128,6 @@ def test_search_path(level: ConfigLevel, test_path: str) -> None: def test_template_path() -> None: - """Test get/set template path""" original = pygit2.settings.template_path try: pygit2.settings.template_path = '/tmp/test_templates' @@ -148,7 +138,6 @@ def test_template_path() -> None: def test_user_agent() -> None: - """Test get/set user agent""" original = pygit2.settings.user_agent try: pygit2.settings.user_agent = 'test-agent/1.0' @@ -159,7 +148,6 @@ def test_user_agent() -> None: def test_user_agent_product() -> None: - """Test get/set user agent product""" original = pygit2.settings.user_agent_product try: pygit2.settings.user_agent_product = 'test-product' @@ -170,7 +158,6 @@ def test_user_agent_product() -> None: def test_pack_max_objects() -> None: - """Test get/set pack max objects""" original = pygit2.settings.pack_max_objects try: pygit2.settings.pack_max_objects = 100000 @@ -180,7 +167,6 @@ def test_pack_max_objects() -> None: def test_owner_validation() -> None: - """Test get/set owner validation""" original = pygit2.settings.owner_validation try: pygit2.settings.owner_validation = False @@ -192,7 +178,6 @@ def test_owner_validation() -> None: def test_mwindow_file_limit() -> None: - """Test get/set mwindow file limit""" original = pygit2.settings.mwindow_file_limit try: pygit2.settings.mwindow_file_limit = 100 @@ -202,7 +187,6 @@ def test_mwindow_file_limit() -> None: def test_homedir() -> None: - """Test get/set home directory""" original = pygit2.settings.homedir try: pygit2.settings.homedir = '/tmp/test_home' @@ -213,7 +197,6 @@ def test_homedir() -> None: def test_server_timeouts() -> None: - """Test get/set server timeouts""" original_connect = pygit2.settings.server_connect_timeout original_timeout = pygit2.settings.server_timeout try: @@ -228,7 +211,6 @@ def test_server_timeouts() -> None: def test_extensions() -> None: - """Test get/set extensions""" original = pygit2.settings.extensions try: test_extensions = ['objectformat', 'worktreeconfig'] @@ -255,7 +237,6 @@ def test_extensions() -> None: ], ) def test_enable_methods(method_name: str, default_value: bool) -> None: - """Test various enable methods""" assert hasattr(pygit2.settings, method_name) method = getattr(pygit2.settings, method_name) assert callable(method) @@ -280,41 +261,19 @@ def test_odb_priorities(priority: int) -> None: pygit2.settings.set_odb_loose_priority(2) -def test_ssl_methods() -> None: - """Test SSL-related methods""" +def test_ssl_ciphers() -> None: assert callable(pygit2.settings.set_ssl_ciphers) - assert callable(pygit2.settings.add_ssl_x509_cert) - ssl_ciphers_supported = True try: pygit2.settings.set_ssl_ciphers('DEFAULT') except pygit2.GitError as e: - msg = str(e).lower() - if "tls backend doesn't support" not in msg: - raise - ssl_ciphers_supported = False - - test_cert = '-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----' - x509_cert_supported = True - try: - pygit2.settings.add_ssl_x509_cert(test_cert) - except pygit2.GitError as e: - msg = str(e).lower() - if ( - "tls backend doesn't support" not in msg - and 'invalid' not in msg - and 'failed to add raw x509 certificate' not in msg - ): - raise - x509_cert_supported = False - - # At least verify the methods exist even if not supported - assert not ssl_ciphers_supported or not x509_cert_supported or True + if "TLS backend doesn't support" in str(e): + pytest.skip(str(e)) + raise @pytest.mark.skipif(sys.platform != 'win32', reason='Windows-specific feature') def test_windows_sharemode() -> None: - """Test get/set Windows share mode""" original = pygit2.settings.windows_sharemode try: pygit2.settings.windows_sharemode = 1 From 47df253972fb7d4b5020540b8c76bc91bb3f8963 Mon Sep 17 00:00:00 2001 From: kbaikov Date: Fri, 8 Aug 2025 22:01:43 +0200 Subject: [PATCH 091/225] Fix some disallow_untyped_defs --- pygit2/_build.py | 6 +++--- pygit2/blob.py | 2 +- pygit2/errors.py | 4 ++-- pygit2/refspec.py | 2 +- pygit2/utils.py | 4 ++-- setup.py | 10 +++++----- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/pygit2/_build.py b/pygit2/_build.py index 4232605bc..da4c34b28 100644 --- a/pygit2/_build.py +++ b/pygit2/_build.py @@ -40,7 +40,7 @@ # # Utility functions to get the paths required for building extensions # -def _get_libgit2_path(): +def _get_libgit2_path() -> Path: # LIBGIT2 environment variable takes precedence libgit2_path = os.getenv('LIBGIT2') if libgit2_path is not None: @@ -52,7 +52,7 @@ def _get_libgit2_path(): return Path('/usr/local') -def get_libgit2_paths(): +def get_libgit2_paths() -> tuple[Path, dict[str, list[str]]]: # Base path path = _get_libgit2_path() @@ -61,7 +61,7 @@ def get_libgit2_paths(): if libgit2_lib is None: library_dirs = [path / 'lib', path / 'lib64'] else: - library_dirs = [libgit2_lib] + library_dirs = [Path(libgit2_lib)] include_dirs = [path / 'include'] return ( diff --git a/pygit2/blob.py b/pygit2/blob.py index 1232fb2fa..1ee6a9eb7 100644 --- a/pygit2/blob.py +++ b/pygit2/blob.py @@ -84,7 +84,7 @@ def readinto(self, b, /): except KeyboardInterrupt: return 0 - def close(self): + def close(self) -> None: try: self._ready.wait() self._writer_closed.wait() diff --git a/pygit2/errors.py b/pygit2/errors.py index 72a4af396..3ddf71eb5 100644 --- a/pygit2/errors.py +++ b/pygit2/errors.py @@ -32,7 +32,7 @@ value_errors = set([C.GIT_EEXISTS, C.GIT_EINVALIDSPEC, C.GIT_EAMBIGUOUS]) -def check_error(err, io=False): +def check_error(err, io: bool = False) -> None: if err >= 0: return @@ -69,5 +69,5 @@ def check_error(err, io=False): # Indicate that we want libgit2 to pretend a function was not set class Passthrough(Exception): - def __init__(self): + def __init__(self) -> None: super().__init__('The function asked for pass-through') diff --git a/pygit2/refspec.py b/pygit2/refspec.py index cb900e75f..794e8d134 100644 --- a/pygit2/refspec.py +++ b/pygit2/refspec.py @@ -34,7 +34,7 @@ class Refspec: """The constructor is for internal use only.""" - def __init__(self, owner, ptr): + def __init__(self, owner, ptr) -> None: self._owner = owner self._refspec = ptr diff --git a/pygit2/utils.py b/pygit2/utils.py index b3d5d1416..22fde943d 100644 --- a/pygit2/utils.py +++ b/pygit2/utils.py @@ -80,7 +80,7 @@ def to_bytes( return s.encode(encoding, errors) # type: ignore[union-attr] -def to_str(s): +def to_str(s: str | bytes | os.PathLike[str] | os.PathLike[bytes]) -> str: if hasattr(s, '__fspath__'): s = os.fspath(s) @@ -110,7 +110,7 @@ def new_git_strarray() -> Generator['GitStrrayC', None, None]: C.git_strarray_dispose(strarray) -def strarray_to_strings(arr): +def strarray_to_strings(arr) -> list[str]: """ Return a list of strings from a git_strarray pointer. diff --git a/setup.py b/setup.py index ff904fa9e..2bd62f903 100644 --- a/setup.py +++ b/setup.py @@ -47,7 +47,7 @@ class sdist_files_from_git(sdist): - def get_file_list(self): + def get_file_list(self) -> None: popen = Popen( ['git', 'ls-files'], stdout=PIPE, stderr=PIPE, universal_newlines=True ) @@ -56,7 +56,7 @@ def get_file_list(self): print(stderrdata) sys.exit() - def exclude(line): + def exclude(line: str) -> bool: for prefix in ['.', 'appveyor.yml', 'docs/', 'misc/']: if line.startswith(prefix): return True @@ -97,11 +97,11 @@ def exclude(line): # On Windows, we install the git2.dll too. class BuildWithDLLs(build): - def _get_dlls(self): + def _get_dlls(self) -> list[tuple[Path, Path]]: # return a list of (FQ-in-name, relative-out-name) tuples. ret = [] bld_ext = self.distribution.get_command_obj('build_ext') - compiler_type = bld_ext.compiler.compiler_type + compiler_type = bld_ext.compiler.compiler_type # type: ignore[attr-defined] libgit2_dlls = [] if compiler_type == 'msvc': libgit2_dlls.append('git2.dll') @@ -121,7 +121,7 @@ def _get_dlls(self): log.debug(f'(looked in {look_dirs})') return ret - def run(self): + def run(self) -> None: build.run(self) for s, d in self._get_dlls(): self.copy_file(s, d) From 7a7895f06a7fee3e57b3be2561ec1c63737724d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Tue, 12 Aug 2025 19:40:06 +0200 Subject: [PATCH 092/225] Halve the number of quotes in NotImplementedError messages --- pygit2/options.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pygit2/options.py b/pygit2/options.py index d238a2999..06216aee3 100644 --- a/pygit2/options.py +++ b/pygit2/options.py @@ -790,14 +790,14 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) # as the API works accepts a X509* struct. Use GIT_OPT_SET_SSL_CERT_LOCATIONS # instead. elif option_type == C.GIT_OPT_ADD_SSL_X509_CERT: - raise NotImplementedError("Use GIT_OPT_SET_SSL_CERT_LOCATIONS instead") + raise NotImplementedError('Use GIT_OPT_SET_SSL_CERT_LOCATIONS instead') # Not implemented - SET_ALLOCATOR is not feasible from Python level # because it requires providing C function pointers for memory management # (malloc, free, etc.) that must handle raw memory at the C level, # which cannot be safely implemented in pure Python. elif option_type == C.GIT_OPT_SET_ALLOCATOR: - raise NotImplementedError("Setting a custom allocator not possible from Python") + raise NotImplementedError('Setting a custom allocator not possible from Python') else: raise ValueError(f'Invalid option {option_type}') From 5c8ad80e935fb3b3de2977a98ff5be1d52fd0a46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Wed, 13 Aug 2025 10:47:25 +0200 Subject: [PATCH 093/225] Update Changelog --- CHANGELOG.md | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 132e21a9c..c2b9cd0bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,62 @@ +# 1.18.2 (UNRELEASED) + +- Add support for almost all global options + [#1409](https://github.com/libgit2/pygit2/pull/1409) + +- Now it's possible to set `Submodule.url = url` + [#1395](https://github.com/libgit2/pygit2/pull/1395) + +- New `RemoteCallbacks.push_negotiation(...)` + [#1396](https://github.com/libgit2/pygit2/pull/1396) + +- New optional boolean argument `connect` in `Remote.ls_remotes(...)` + [#1396](https://github.com/libgit2/pygit2/pull/1396) + +- Now `Remote.ls_remotes(...)` returns a list of `RemoteHead` objects + [#1397](https://github.com/libgit2/pygit2/pull/1397) + +- Now `RemoteHead.loid` is Oid zero instead of None when not available locally + [#1397](https://github.com/libgit2/pygit2/pull/1397) + +- Documentation fixes + [#1388](https://github.com/libgit2/pygit2/pull/1388) + +- Typing improvements + [#1387](https://github.com/libgit2/pygit2/pull/1387) + [#1389](https://github.com/libgit2/pygit2/pull/1389) + [#1390](https://github.com/libgit2/pygit2/pull/1390) + [#1391](https://github.com/libgit2/pygit2/pull/1391) + [#1392](https://github.com/libgit2/pygit2/pull/1392) + [#1393](https://github.com/libgit2/pygit2/pull/1393) + [#1394](https://github.com/libgit2/pygit2/pull/1394) + [#1398](https://github.com/libgit2/pygit2/pull/1398) + [#1399](https://github.com/libgit2/pygit2/pull/1399) + [#1400](https://github.com/libgit2/pygit2/pull/1400) + [#1402](https://github.com/libgit2/pygit2/pull/1402) + [#1403](https://github.com/libgit2/pygit2/pull/1403) + [#1406](https://github.com/libgit2/pygit2/pull/1406) + +Deprecations: + +- Now `Remote.ls_remotes(...)` returns a list of `RemoteHead` objects + + # Before + for head in remote.ls_remotes(): + head['name'] + head['oid'] + head['loid'] + head['local'] + head['symref_target'] + + # Now + for head in remote.ls_remotes(): + head.name + head.oid + head.loid + head.local + head.symref_target + + # 1.18.1 (2025-07-26) - Update wheels to libgit2 1.9.1 and OpenSSL 3.3 From 2c9792a878b4627559a6c08e3c344ddf8bc4a03f Mon Sep 17 00:00:00 2001 From: Iliyas Jorio Date: Wed, 13 Aug 2025 12:47:01 +0200 Subject: [PATCH 094/225] Deprecate Remote.ls_remotes in favor of Remote.list_heads --- pygit2/remotes.py | 41 ++++++++++++++++++++++++++--------------- test/test_remote.py | 37 ++++++++++++++++++++++--------------- 2 files changed, 48 insertions(+), 30 deletions(-) diff --git a/pygit2/remotes.py b/pygit2/remotes.py index 52974646b..30feeb15b 100644 --- a/pygit2/remotes.py +++ b/pygit2/remotes.py @@ -55,7 +55,7 @@ class RemoteHead: """ Description of a reference advertised by a remote server, - given out on `Remote.ls_remotes` calls. + given out on `Remote.list_heads` calls. """ local: bool @@ -80,19 +80,6 @@ def __init__(self, c_struct: Any) -> None: self.name = maybe_string(c_struct.name) self.symref_target = maybe_string(c_struct.symref_target) - def __getitem__(self, item: str) -> Any: - """ - DEPRECATED: Backwards compatibility with legacy user code - that expects this object to be a dictionary with string keys. - """ - warnings.warn( - 'ls_remotes no longer returns a dict. ' - 'Update your code to read from fields instead ' - '(e.g. result["name"] --> result.name)', - DeprecationWarning, - ) - return getattr(self, item) - class PushUpdate: """ @@ -259,7 +246,7 @@ def fetch( return TransferProgress(C.git_remote_stats(self._remote)) - def ls_remotes( + def list_heads( self, callbacks: RemoteCallbacks | None = None, proxy: str | None | bool = None, @@ -294,6 +281,30 @@ def ls_remotes( return results + def ls_remotes( + self, + callbacks: RemoteCallbacks | None = None, + proxy: str | None | bool = None, + connect: bool = True, + ) -> list[dict[str, Any]]: + """ + Deprecated interface to list_heads + """ + warnings.warn('Use list_heads', DeprecationWarning) + + heads = self.list_heads(callbacks, proxy, connect) + + return [ + { + 'local': h.local, + 'oid': h.oid, + 'loid': h.loid, + 'name': h.name, + 'symref_target': h.symref_target, + } + for h in heads + ] + def prune(self, callbacks: RemoteCallbacks | None = None) -> None: """Perform a prune against this remote.""" with git_remote_callbacks(callbacks) as payload: diff --git a/test/test_remote.py b/test/test_remote.py index d13bb38b7..277d490a4 100644 --- a/test/test_remote.py +++ b/test/test_remote.py @@ -193,11 +193,11 @@ def test_remote_list(testrepo: Repository) -> None: @utils.requires_network -def test_ls_remotes(testrepo: Repository) -> None: +def test_list_heads(testrepo: Repository) -> None: assert 1 == len(testrepo.remotes) remote = testrepo.remotes[0] - refs = remote.ls_remotes() + refs = remote.list_heads() assert refs # Check that a known ref is returned. @@ -205,29 +205,36 @@ def test_ls_remotes(testrepo: Repository) -> None: @utils.requires_network -def test_ls_remotes_backwards_compatibility(testrepo: Repository) -> None: +def test_ls_remotes_deprecated(testrepo: Repository) -> None: assert 1 == len(testrepo.remotes) remote = testrepo.remotes[0] - refs = remote.ls_remotes() - ref = refs[0] - for field in ('name', 'oid', 'loid', 'local', 'symref_target'): - new_value = getattr(ref, field) - with pytest.warns(DeprecationWarning, match='no longer returns a dict'): - old_value = ref[field] - assert new_value == old_value + new_refs = remote.list_heads() + + with pytest.warns(DeprecationWarning, match='Use list_heads'): + old_refs = remote.ls_remotes() + + assert new_refs + assert old_refs + + for new, old in zip(new_refs, old_refs, strict=True): + assert new.name == old['name'] + assert new.oid == old['oid'] + assert new.loid == old['loid'] + assert new.local == old['local'] + assert new.symref_target == old['symref_target'] @utils.requires_network -def test_ls_remotes_without_implicit_connect(testrepo: Repository) -> None: +def test_list_heads_without_implicit_connect(testrepo: Repository) -> None: assert 1 == len(testrepo.remotes) remote = testrepo.remotes[0] with pytest.raises(pygit2.GitError, match='this remote has never connected'): - remote.ls_remotes(connect=False) + remote.list_heads(connect=False) remote.connect() - refs = remote.ls_remotes(connect=False) + refs = remote.list_heads(connect=False) assert refs # Check that a known ref is returned. @@ -328,7 +335,7 @@ def update_tips(self, name: str, old: pygit2.Oid, new: pygit2.Oid) -> None: @utils.requires_network -def test_ls_remotes_certificate_check() -> None: +def test_list_heads_certificate_check() -> None: url = 'https://github.com/pygit2/empty.git' class MyCallbacks(pygit2.RemoteCallbacks): @@ -350,7 +357,7 @@ def certificate_check( remote = git.remotes.create_anonymous(url) callbacks = MyCallbacks() - refs = remote.ls_remotes(callbacks=callbacks) + refs = remote.list_heads(callbacks=callbacks) # Sanity check that we indeed got some refs. assert len(refs) > 0 From d16378e01217bfef13a208f35e89f59898002acf Mon Sep 17 00:00:00 2001 From: Iliyas Jorio Date: Wed, 13 Aug 2025 17:21:23 +0200 Subject: [PATCH 095/225] Remote.ls_remotes returns backwards compatible value for loid --- pygit2/remotes.py | 2 +- test/test_remote.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/pygit2/remotes.py b/pygit2/remotes.py index 30feeb15b..0603a6f9d 100644 --- a/pygit2/remotes.py +++ b/pygit2/remotes.py @@ -298,7 +298,7 @@ def ls_remotes( { 'local': h.local, 'oid': h.oid, - 'loid': h.loid, + 'loid': h.loid if h.local else None, 'name': h.name, 'symref_target': h.symref_target, } diff --git a/test/test_remote.py b/test/test_remote.py index 277d490a4..d4c3bea68 100644 --- a/test/test_remote.py +++ b/test/test_remote.py @@ -220,9 +220,13 @@ def test_ls_remotes_deprecated(testrepo: Repository) -> None: for new, old in zip(new_refs, old_refs, strict=True): assert new.name == old['name'] assert new.oid == old['oid'] - assert new.loid == old['loid'] assert new.local == old['local'] assert new.symref_target == old['symref_target'] + if new.local: + assert new.loid == old['loid'] + else: + assert new.loid == pygit2.Oid(b'') + assert old['loid'] is None @utils.requires_network From f09359fd6164913ab303f92fa2877af7a393a44a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Wed, 13 Aug 2025 18:13:50 +0200 Subject: [PATCH 096/225] Don't build wheels for Python 3.14 --- CHANGELOG.md | 14 ++++++-------- pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2b9cd0bb..336f6f62a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,11 +12,9 @@ - New optional boolean argument `connect` in `Remote.ls_remotes(...)` [#1396](https://github.com/libgit2/pygit2/pull/1396) -- Now `Remote.ls_remotes(...)` returns a list of `RemoteHead` objects - [#1397](https://github.com/libgit2/pygit2/pull/1397) - -- Now `RemoteHead.loid` is Oid zero instead of None when not available locally +- New `Remote.list_heads(...)` returns a list of `RemoteHead` objects [#1397](https://github.com/libgit2/pygit2/pull/1397) + [#1410](https://github.com/libgit2/pygit2/pull/1410) - Documentation fixes [#1388](https://github.com/libgit2/pygit2/pull/1388) @@ -38,21 +36,21 @@ Deprecations: -- Now `Remote.ls_remotes(...)` returns a list of `RemoteHead` objects +- `Remote.ls_remotes(...)` is deprecated, use `Remote.list_heads(...)`: # Before for head in remote.ls_remotes(): head['name'] head['oid'] - head['loid'] + head['loid'] # None when local is False head['local'] head['symref_target'] # Now - for head in remote.ls_remotes(): + for head in remote.list_heads(): head.name head.oid - head.loid + head.loid # The zero oid when local is False head.local head.symref_target diff --git a/pyproject.toml b/pyproject.toml index a9a25a5c3..84a956116 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools", "wheel"] [tool.cibuildwheel] enable = ["pypy"] -skip = "*musllinux_aarch64 *musllinux_ppc64le" +skip = "*musllinux_aarch64 *musllinux_ppc64le cp314*" archs = ["native"] build-frontend = "default" From 68c871b29fd08621ed607a8d7789af6436d8e9cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Thu, 14 Aug 2025 11:41:20 +0200 Subject: [PATCH 097/225] Make mypy happy --- CHANGELOG.md | 2 ++ pygit2/options.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 336f6f62a..066c2de80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ [#1402](https://github.com/libgit2/pygit2/pull/1402) [#1403](https://github.com/libgit2/pygit2/pull/1403) [#1406](https://github.com/libgit2/pygit2/pull/1406) + [#1407](https://github.com/libgit2/pygit2/pull/1407) + [#1408](https://github.com/libgit2/pygit2/pull/1408) Deprecations: diff --git a/pygit2/options.py b/pygit2/options.py index 06216aee3..ad96253bc 100644 --- a/pygit2/options.py +++ b/pygit2/options.py @@ -307,6 +307,8 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) Set the maximum number of objects to include in a pack. """ + result: str | None | list[str] + if option_type in ( C.GIT_OPT_GET_MWINDOW_SIZE, C.GIT_OPT_GET_MWINDOW_MAPPED_LIMIT, From d88fa3dcbab21a7eb33ff354222c4ec8e5ff6ece Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Sat, 16 Aug 2025 13:34:29 +0200 Subject: [PATCH 098/225] Release 1.18.2 --- .mailmap | 2 ++ AUTHORS.md | 5 ++++- CHANGELOG.md | 2 +- docs/conf.py | 2 +- pygit2/_build.py | 2 +- 5 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.mailmap b/.mailmap index 6a85c073b..dc40e8c49 100644 --- a/.mailmap +++ b/.mailmap @@ -14,6 +14,7 @@ Jeremy Westwood Jose Plana Kaarel Kitsemets Karl Malmros <44969574+ktpa@users.noreply.github.com> +Konstantin Baikov Lukas Fleischer Martin Lenders Matthew Duggan @@ -21,6 +22,7 @@ Matthew Gamble Matthias Bartelmeß Mikhail Yushkovskiy Nabijacz Leweli +Nicolas Rybowski Óscar San José Petr Hosek Phil Schleihauf diff --git a/AUTHORS.md b/AUTHORS.md index 5ee1cfceb..0b9e86e70 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -4,6 +4,7 @@ Authors: Carlos Martín Nieto Nico von Geyso Iliyas Jorio + Benedikt Seidl Sviatoslav Sydorenko Matthias Bartelmeß Robert Coup @@ -16,10 +17,10 @@ Authors: Richo Healey Christian Boos Julien Miotte - Benedikt Seidl Nick Hynes Richard Möhn Xu Tao + Konstantin Baikov Matthew Duggan Matthew Gamble Jeremy Westwood @@ -28,6 +29,7 @@ Authors: Sriram Raghu Victor Garcia Yonggang Luo + Łukasz Langa Patrick Steinhardt Petr Hosek Tamir Bahar @@ -202,6 +204,7 @@ Authors: Maxwell G Michał Górny Na'aman Hirschfeld + Nicolas Rybowski Nicolás Sanguinetti Nikita Kartashov Nikolai Zujev diff --git a/CHANGELOG.md b/CHANGELOG.md index 066c2de80..9d3f33239 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# 1.18.2 (UNRELEASED) +# 1.18.2 (2025-08-16) - Add support for almost all global options [#1409](https://github.com/libgit2/pygit2/pull/1409) diff --git a/docs/conf.py b/docs/conf.py index 6978c4ea5..cf7980861 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,7 +23,7 @@ # author = '' # The full version, including alpha/beta/rc tags -release = '1.18.1' +release = '1.18.2' # -- General configuration --------------------------------------------------- diff --git a/pygit2/_build.py b/pygit2/_build.py index da4c34b28..2baa021b5 100644 --- a/pygit2/_build.py +++ b/pygit2/_build.py @@ -34,7 +34,7 @@ # # The version number of pygit2 # -__version__ = '1.18.1' +__version__ = '1.18.2' # From 3e7e10b8b1e620fd81b37ff4eca58f95e90061ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Wed, 20 Aug 2025 18:21:17 +0200 Subject: [PATCH 099/225] Wheels: update cibuildwheel --- .github/workflows/wheels.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index bd61ae63e..068410b9d 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -30,7 +30,7 @@ jobs: python-version: '3.13' - name: Install cibuildwheel - run: python -m pip install cibuildwheel==3.1.1 + run: python -m pip install cibuildwheel~=3.1.1 - name: Build wheels run: python -m cibuildwheel --output-dir wheelhouse @@ -56,7 +56,7 @@ jobs: platforms: linux/ppc64le - name: Install cibuildwheel - run: python -m pip install cibuildwheel==3.1.1 + run: python -m pip install cibuildwheel~=3.1.1 - name: Build wheels run: python -m cibuildwheel --output-dir wheelhouse From 3d129c77e47b23512de7453dab44df43f21d4038 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Wed, 20 Aug 2025 18:53:31 +0200 Subject: [PATCH 100/225] Build musllinux_aarch64 wheels --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 84a956116..80c9594ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools", "wheel"] [tool.cibuildwheel] enable = ["pypy"] -skip = "*musllinux_aarch64 *musllinux_ppc64le cp314*" +skip = "*musllinux_ppc64le cp314*" archs = ["native"] build-frontend = "default" From 2b231a3421d60ac7f9aa1be8f6b2602ed8753718 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Thu, 11 Sep 2025 10:47:18 +0200 Subject: [PATCH 101/225] CI: Update setup-python to v6 --- .github/workflows/lint.yml | 2 +- .github/workflows/tests.yml | 4 ++-- .github/workflows/wheels.yml | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 57db3c455..39e3d06cc 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -15,7 +15,7 @@ jobs: uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.13' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 232d4ceb8..c58a00343 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -26,7 +26,7 @@ jobs: uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} @@ -61,7 +61,7 @@ jobs: uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.13' diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 068410b9d..a3def8d14 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -25,7 +25,7 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: '3.13' @@ -47,7 +47,7 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: '3.13' From ff64e610593a39d0ccad55afe890be655a8e9052 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Fri, 12 Sep 2025 09:27:53 +0200 Subject: [PATCH 102/225] Build wheels for Python 3.14 --- docs/install.rst | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- setup.py | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/install.rst b/docs/install.rst index 9d8b21bb6..140adffa1 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -55,7 +55,7 @@ Supported versions of Python: Python requirements (these are specified in ``setup.py``): -- cffi 1.17.0 or later +- cffi 2.0 or later Libgit2 **v1.9.x**; binary wheels already include libgit2, so you only need to worry about this if you install the source package. diff --git a/pyproject.toml b/pyproject.toml index 80c9594ad..c17310534 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools", "wheel"] [tool.cibuildwheel] enable = ["pypy"] -skip = "*musllinux_ppc64le cp314*" +skip = "*musllinux_ppc64le" archs = ["native"] build-frontend = "default" diff --git a/requirements.txt b/requirements.txt index e06cd8ab1..df6e4d8c4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -cffi>=1.16.0 +cffi>=2.0 setuptools ; python_version >= "3.12" diff --git a/setup.py b/setup.py index 2bd62f903..e2c628291 100644 --- a/setup.py +++ b/setup.py @@ -154,8 +154,8 @@ def run(self) -> None: ext_modules=ext_modules, # Requirements python_requires='>=3.10', - setup_requires=['cffi>=1.17.0'], - install_requires=['cffi>=1.17.0'], + setup_requires=['cffi>=2.0'], + install_requires=['cffi>=2.0'], # URLs url='https://github.com/libgit2/pygit2', project_urls={ From d0a17437f594cca4e937cf889321c7feac7a0d9e Mon Sep 17 00:00:00 2001 From: Craig de Stigter Date: Sun, 5 Oct 2025 09:21:45 +1300 Subject: [PATCH 103/225] Add an implementation of reference transactions refs #1419 --- docs/index.rst | 1 + docs/references.rst | 15 ++ docs/transactions.rst | 120 ++++++++++++++ pygit2/__init__.py | 3 + pygit2/_libgit2/ffi.pyi | 5 + pygit2/_run.py | 1 + pygit2/decl/transaction.h | 8 + pygit2/decl/types.h | 2 + pygit2/repository.py | 18 +++ pygit2/transaction.py | 199 +++++++++++++++++++++++ test/test_transaction.py | 327 ++++++++++++++++++++++++++++++++++++++ 11 files changed, 699 insertions(+) create mode 100644 docs/transactions.rst create mode 100644 pygit2/decl/transaction.h create mode 100644 pygit2/transaction.py create mode 100644 test/test_transaction.py diff --git a/docs/index.rst b/docs/index.rst index d728af53c..838fd07af 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -74,6 +74,7 @@ Table of Contents oid packing references + transactions remotes repository revparse diff --git a/docs/references.rst b/docs/references.rst index 0d06c3c36..56387855c 100644 --- a/docs/references.rst +++ b/docs/references.rst @@ -88,6 +88,21 @@ Example:: .. autoclass:: pygit2.RefLogEntry :members: +Reference Transactions +======================= + +For atomic updates of multiple references, use transactions. See the +:doc:`transactions` documentation for details. + +Example:: + + # Update multiple refs atomically + with repo.transaction() as txn: + txn.lock_ref('refs/heads/master') + txn.lock_ref('refs/heads/develop') + txn.set_target('refs/heads/master', new_oid, message='Release') + txn.set_target('refs/heads/develop', dev_oid, message='Continue dev') + Notes ==================== diff --git a/docs/transactions.rst b/docs/transactions.rst new file mode 100644 index 000000000..4645320e0 --- /dev/null +++ b/docs/transactions.rst @@ -0,0 +1,120 @@ +********************************************************************** +Reference Transactions +********************************************************************** + +Reference transactions allow you to update multiple references atomically. +All reference updates within a transaction either succeed together or fail +together, ensuring repository consistency. + +Basic Usage +=========== + +Use the :meth:`Repository.transaction` method as a context manager. The +transaction commits automatically when the context exits successfully, or +rolls back if an exception is raised:: + + with repo.transaction() as txn: + txn.lock_ref('refs/heads/master') + txn.set_target('refs/heads/master', new_oid, message='Update master') + +Atomic Multi-Reference Updates +=============================== + +Transactions are useful when you need to update multiple references +atomically:: + + # Swap two branches atomically + with repo.transaction() as txn: + txn.lock_ref('refs/heads/branch-a') + txn.lock_ref('refs/heads/branch-b') + + # Get current targets + ref_a = repo.lookup_reference('refs/heads/branch-a') + ref_b = repo.lookup_reference('refs/heads/branch-b') + + # Swap them + txn.set_target('refs/heads/branch-a', ref_b.target, message='Swap') + txn.set_target('refs/heads/branch-b', ref_a.target, message='Swap') + +Automatic Rollback +================== + +If an exception occurs during the transaction, changes are automatically +rolled back:: + + try: + with repo.transaction() as txn: + txn.lock_ref('refs/heads/master') + txn.set_target('refs/heads/master', new_oid) + + # If this raises an exception, the ref update is rolled back + validate_commit(new_oid) + except ValidationError: + # Master still points to its original target + pass + +Manual Commit +============= + +While the context manager is recommended, you can manually manage +transactions:: + + from pygit2 import ReferenceTransaction + + txn = ReferenceTransaction(repo) + try: + txn.lock_ref('refs/heads/master') + txn.set_target('refs/heads/master', new_oid, message='Update') + txn.commit() + finally: + del txn # Ensure transaction is freed + +API Reference +============= + +Repository Methods +------------------ + +.. automethod:: pygit2.Repository.transaction + +The ReferenceTransaction Type +------------------------------ + +.. autoclass:: pygit2.ReferenceTransaction + :members: + :special-members: __enter__, __exit__ + +Usage Notes +=========== + +- Always lock a reference with :meth:`~ReferenceTransaction.lock_ref` before + modifying it +- Transactions operate on reference names, not Reference objects +- Symbolic references can be updated with + :meth:`~ReferenceTransaction.set_symbolic_target` +- References can be deleted with :meth:`~ReferenceTransaction.remove` +- The signature parameter defaults to the repository's configured identity + +Thread Safety +============= + +Transactions are thread-local and must be used from the thread that created +them. Attempting to use a transaction from a different thread raises +:exc:`RuntimeError`:: + + # This is safe - each thread has its own transaction + def thread1(): + with repo.transaction() as txn: + txn.lock_ref('refs/heads/branch1') + txn.set_target('refs/heads/branch1', oid1) + + def thread2(): + with repo.transaction() as txn: + txn.lock_ref('refs/heads/branch2') + txn.set_target('refs/heads/branch2', oid2) + + # Both threads can run concurrently without conflicts + +Different threads can hold transactions simultaneously as long as they don't +attempt to lock the same references. If two threads try to acquire locks in +different orders, libgit2 will detect potential deadlocks and raise an error. diff --git a/pygit2/__init__.py b/pygit2/__init__.py index d06a911ed..518f34361 100644 --- a/pygit2/__init__.py +++ b/pygit2/__init__.py @@ -366,6 +366,7 @@ from .repository import Repository from .settings import Settings from .submodules import Submodule +from .transaction import ReferenceTransaction from .utils import to_bytes, to_str # Features @@ -971,6 +972,8 @@ def clone_repository( 'Settings', 'submodules', 'Submodule', + 'transaction', + 'ReferenceTransaction', 'utils', 'to_bytes', 'to_str', diff --git a/pygit2/_libgit2/ffi.pyi b/pygit2/_libgit2/ffi.pyi index ea376f137..e73a7ad7c 100644 --- a/pygit2/_libgit2/ffi.pyi +++ b/pygit2/_libgit2/ffi.pyi @@ -239,12 +239,17 @@ class GitRemoteC: class GitReferenceC: pass +class GitTransactionC: + pass + def string(a: char_pointer) -> bytes: ... @overload def new(a: Literal['git_repository **']) -> _Pointer[GitRepositoryC]: ... @overload def new(a: Literal['git_remote **']) -> _Pointer[GitRemoteC]: ... @overload +def new(a: Literal['git_transaction **']) -> _Pointer[GitTransactionC]: ... +@overload def new(a: Literal['git_repository_init_options *']) -> GitRepositoryInitOptionsC: ... @overload def new(a: Literal['git_submodule_update_options *']) -> GitSubmoduleUpdateOptionsC: ... diff --git a/pygit2/_run.py b/pygit2/_run.py index 44f30344e..863565f6a 100644 --- a/pygit2/_run.py +++ b/pygit2/_run.py @@ -81,6 +81,7 @@ 'revert.h', 'stash.h', 'submodule.h', + 'transaction.h', 'options.h', 'callbacks.h', # Bridge from libgit2 to Python ] diff --git a/pygit2/decl/transaction.h b/pygit2/decl/transaction.h new file mode 100644 index 000000000..20ac98de0 --- /dev/null +++ b/pygit2/decl/transaction.h @@ -0,0 +1,8 @@ +int git_transaction_new(git_transaction **out, git_repository *repo); +int git_transaction_lock_ref(git_transaction *tx, const char *refname); +int git_transaction_set_target(git_transaction *tx, const char *refname, const git_oid *target, const git_signature *sig, const char *msg); +int git_transaction_set_symbolic_target(git_transaction *tx, const char *refname, const char *target, const git_signature *sig, const char *msg); +int git_transaction_set_reflog(git_transaction *tx, const char *refname, const git_reflog *reflog); +int git_transaction_remove(git_transaction *tx, const char *refname); +int git_transaction_commit(git_transaction *tx); +void git_transaction_free(git_transaction *tx); diff --git a/pygit2/decl/types.h b/pygit2/decl/types.h index 8bb8fd297..0bef96d3e 100644 --- a/pygit2/decl/types.h +++ b/pygit2/decl/types.h @@ -12,6 +12,8 @@ typedef struct git_submodule git_submodule; typedef struct git_transport git_transport; typedef struct git_tree git_tree; typedef struct git_packbuilder git_packbuilder; +typedef struct git_transaction git_transaction; +typedef struct git_reflog git_reflog; typedef int64_t git_off_t; typedef int64_t git_time_t; diff --git a/pygit2/repository.py b/pygit2/repository.py index 509b4d0f1..18caa5e7e 100644 --- a/pygit2/repository.py +++ b/pygit2/repository.py @@ -78,6 +78,7 @@ from .references import References from .remotes import RemoteCollection from .submodules import SubmoduleCollection +from .transaction import ReferenceTransaction from .utils import StrArray, to_bytes if TYPE_CHECKING: @@ -120,6 +121,7 @@ def _common_init(self) -> None: self.references = References(self) self.remotes = RemoteCollection(self) self.submodules = SubmoduleCollection(self) + self._active_transaction = None # Get the pointer as the contents of a buffer and store it for # later access @@ -359,6 +361,22 @@ def resolve_refish(self, refish: str) -> tuple[Commit, Reference]: return (commit, reference) # type: ignore + def transaction(self) -> ReferenceTransaction: + """Create a new reference transaction. + + Returns a context manager that commits all reference updates atomically + when the context exits successfully, or performs no updates if an exception + is raised. + + Example:: + + with repo.transaction() as txn: + txn.lock_ref('refs/heads/master') + txn.set_target('refs/heads/master', new_oid, message='Update') + """ + txn = ReferenceTransaction(self) + return txn + # # Checkout # diff --git a/pygit2/transaction.py b/pygit2/transaction.py new file mode 100644 index 000000000..358b40a98 --- /dev/null +++ b/pygit2/transaction.py @@ -0,0 +1,199 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file 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; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +from __future__ import annotations + +import threading +from typing import TYPE_CHECKING + +from .errors import check_error +from .ffi import C, ffi +from .utils import to_bytes + +if TYPE_CHECKING: + from ._pygit2 import Oid, Signature + from .repository import BaseRepository + + +class ReferenceTransaction: + """Context manager for transactional reference updates. + + A transaction allows multiple reference updates to be performed atomically. + All updates are applied when the transaction is committed, or none are applied + if the transaction is rolled back. + + Example: + with repo.transaction() as txn: + txn.lock_ref('refs/heads/master') + txn.set_target('refs/heads/master', new_oid, message='Update master') + # Changes committed automatically on context exit + """ + + def __init__(self, repository: BaseRepository) -> None: + self._repository = repository + self._transaction = ffi.new('git_transaction **') + self._tx = None + self._thread_id = threading.get_ident() + + err = C.git_transaction_new(self._transaction, repository._repo) + check_error(err) + self._tx = self._transaction[0] + + def _check_thread(self) -> None: + """Verify transaction is being used from the same thread that created it.""" + current_thread = threading.get_ident() + if current_thread != self._thread_id: + raise RuntimeError( + f'Transaction created in thread {self._thread_id} ' + f'but used in thread {current_thread}. ' + 'Transactions must be used from the thread that created them.' + ) + + def lock_ref(self, refname: str) -> None: + """Lock a reference in preparation for updating it. + + Args: + refname: Name of the reference to lock (e.g., 'refs/heads/master') + """ + self._check_thread() + if self._tx is None: + raise ValueError('Transaction already closed') + + c_refname = ffi.new('char[]', to_bytes(refname)) + err = C.git_transaction_lock_ref(self._tx, c_refname) + check_error(err) + + def set_target( + self, + refname: str, + target: Oid | str, + signature: Signature | None = None, + message: str | None = None, + ) -> None: + """Set the target of a direct reference. + + The reference must be locked first via lock_ref(). + + Args: + refname: Name of the reference to update + target: Target OID or hex string + signature: Signature for the reflog (None to use repo identity) + message: Message for the reflog + """ + self._check_thread() + if self._tx is None: + raise ValueError('Transaction already closed') + + from ._pygit2 import Oid + + c_refname = ffi.new('char[]', to_bytes(refname)) + + # Convert target to OID + if isinstance(target, str): + target = Oid(hex=target) + + c_oid = ffi.new('git_oid *') + ffi.buffer(c_oid)[:] = target.raw + + c_sig = signature._pointer if signature else ffi.NULL + c_msg = ffi.new('char[]', to_bytes(message)) if message else ffi.NULL + + err = C.git_transaction_set_target(self._tx, c_refname, c_oid, c_sig, c_msg) + check_error(err) + + def set_symbolic_target( + self, + refname: str, + target: str, + signature: Signature | None = None, + message: str | None = None, + ) -> None: + """Set the target of a symbolic reference. + + The reference must be locked first via lock_ref(). + + Args: + refname: Name of the reference to update + target: Target reference name (e.g., 'refs/heads/master') + signature: Signature for the reflog (None to use repo identity) + message: Message for the reflog + """ + self._check_thread() + if self._tx is None: + raise ValueError('Transaction already closed') + + c_refname = ffi.new('char[]', to_bytes(refname)) + c_target = ffi.new('char[]', to_bytes(target)) + c_sig = signature._pointer if signature else ffi.NULL + c_msg = ffi.new('char[]', to_bytes(message)) if message else ffi.NULL + + err = C.git_transaction_set_symbolic_target( + self._tx, c_refname, c_target, c_sig, c_msg + ) + check_error(err) + + def remove(self, refname: str) -> None: + """Remove a reference. + + The reference must be locked first via lock_ref(). + + Args: + refname: Name of the reference to remove + """ + self._check_thread() + if self._tx is None: + raise ValueError('Transaction already closed') + + c_refname = ffi.new('char[]', to_bytes(refname)) + err = C.git_transaction_remove(self._tx, c_refname) + check_error(err) + + def commit(self) -> None: + """Commit the transaction, applying all queued updates.""" + self._check_thread() + if self._tx is None: + raise ValueError('Transaction already closed') + + err = C.git_transaction_commit(self._tx) + check_error(err) + + def __enter__(self) -> ReferenceTransaction: + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + self._check_thread() + # Only commit if no exception occurred + if exc_type is None and self._tx is not None: + self.commit() + + # Always free the transaction + if self._tx is not None: + C.git_transaction_free(self._tx) + self._tx = None + + def __del__(self) -> None: + if self._tx is not None: + C.git_transaction_free(self._tx) + self._tx = None diff --git a/test/test_transaction.py b/test/test_transaction.py new file mode 100644 index 000000000..5a6e97ed5 --- /dev/null +++ b/test/test_transaction.py @@ -0,0 +1,327 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file 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; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +import threading + +import pytest + +from pygit2 import GitError, Oid, Repository +from pygit2.transaction import ReferenceTransaction + + +def test_transaction_context_manager(testrepo: Repository) -> None: + """Test basic transaction with context manager.""" + master_ref = testrepo.lookup_reference('refs/heads/master') + assert str(master_ref.target) == '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98' + + # Create a transaction and update a ref + new_target = Oid(hex='5ebeeebb320790caf276b9fc8b24546d63316533') + + with testrepo.transaction() as txn: + txn.lock_ref('refs/heads/master') + txn.set_target('refs/heads/master', new_target, message='Test update') + + # Verify the update was applied + master_ref = testrepo.lookup_reference('refs/heads/master') + assert master_ref.target == new_target + + +def test_transaction_rollback_on_exception(testrepo: Repository) -> None: + """Test that transaction rolls back when exception is raised.""" + master_ref = testrepo.lookup_reference('refs/heads/master') + original_target = master_ref.target + + new_target = Oid(hex='5ebeeebb320790caf276b9fc8b24546d63316533') + + # Transaction should not commit if exception is raised + with pytest.raises(RuntimeError): + with testrepo.transaction() as txn: + txn.lock_ref('refs/heads/master') + txn.set_target('refs/heads/master', new_target, message='Test update') + raise RuntimeError('Abort transaction') + + # Verify the update was NOT applied + master_ref = testrepo.lookup_reference('refs/heads/master') + assert master_ref.target == original_target + + +def test_transaction_multiple_refs(testrepo: Repository) -> None: + """Test updating multiple refs in a single transaction.""" + master_ref = testrepo.lookup_reference('refs/heads/master') + i18n_ref = testrepo.lookup_reference('refs/heads/i18n') + + new_master = Oid(hex='5ebeeebb320790caf276b9fc8b24546d63316533') + new_i18n = Oid(hex='2be5719152d4f82c7302b1c0932d8e5f0a4a0e98') + + with testrepo.transaction() as txn: + txn.lock_ref('refs/heads/master') + txn.lock_ref('refs/heads/i18n') + txn.set_target('refs/heads/master', new_master, message='Update master') + txn.set_target('refs/heads/i18n', new_i18n, message='Update i18n') + + # Verify both updates were applied + master_ref = testrepo.lookup_reference('refs/heads/master') + i18n_ref = testrepo.lookup_reference('refs/heads/i18n') + assert master_ref.target == new_master + assert i18n_ref.target == new_i18n + + +def test_transaction_symbolic_ref(testrepo: Repository) -> None: + """Test updating symbolic reference in transaction.""" + with testrepo.transaction() as txn: + txn.lock_ref('HEAD') + txn.set_symbolic_target('HEAD', 'refs/heads/i18n', message='Switch HEAD') + + head = testrepo.lookup_reference('HEAD') + assert head.target == 'refs/heads/i18n' + + # Restore HEAD to master + with testrepo.transaction() as txn: + txn.lock_ref('HEAD') + txn.set_symbolic_target('HEAD', 'refs/heads/master', message='Restore HEAD') + + +def test_transaction_remove_ref(testrepo: Repository) -> None: + """Test removing a reference in a transaction.""" + # Create a test ref + test_ref_name = 'refs/heads/test-transaction-delete' + target = Oid(hex='5ebeeebb320790caf276b9fc8b24546d63316533') + testrepo.create_reference(test_ref_name, target) + + # Verify it exists + assert test_ref_name in testrepo.references + + # Remove it in a transaction + with testrepo.transaction() as txn: + txn.lock_ref(test_ref_name) + txn.remove(test_ref_name) + + # Verify it's gone + assert test_ref_name not in testrepo.references + + +def test_transaction_error_without_lock(testrepo: Repository) -> None: + """Test that setting target without lock raises error.""" + new_target = Oid(hex='5ebeeebb320790caf276b9fc8b24546d63316533') + + with pytest.raises(KeyError, match='not locked'): + with testrepo.transaction() as txn: + # Try to set target without locking first + txn.set_target('refs/heads/master', new_target, message='Should fail') + + +def test_transaction_isolated_across_threads(testrepo: Repository) -> None: + """Test that transactions from different threads are isolated.""" + # Create two test refs + ref1_name = 'refs/heads/thread-test-1' + ref2_name = 'refs/heads/thread-test-2' + target1 = Oid(hex='5ebeeebb320790caf276b9fc8b24546d63316533') + target2 = Oid(hex='2be5719152d4f82c7302b1c0932d8e5f0a4a0e98') + testrepo.create_reference(ref1_name, target1) + testrepo.create_reference(ref2_name, target2) + + results = [] + errors = [] + thread1_ref1_locked = threading.Event() + thread2_ref2_locked = threading.Event() + + def update_ref1() -> None: + try: + with testrepo.transaction() as txn: + txn.lock_ref(ref1_name) + thread1_ref1_locked.set() + thread2_ref2_locked.wait(timeout=5) + txn.set_target(ref1_name, target2, message='Thread 1 update') + results.append('thread1_success') + except Exception as e: + errors.append(('thread1', str(e))) + + def update_ref2() -> None: + try: + with testrepo.transaction() as txn: + txn.lock_ref(ref2_name) + thread2_ref2_locked.set() + thread1_ref1_locked.wait(timeout=5) + txn.set_target(ref2_name, target1, message='Thread 2 update') + results.append('thread2_success') + except Exception as e: + errors.append(('thread2', str(e))) + + thread1 = threading.Thread(target=update_ref1) + thread2 = threading.Thread(target=update_ref2) + + thread1.start() + thread2.start() + thread1.join() + thread2.join() + + # Both threads should succeed - transactions are isolated + assert len(errors) == 0, f'Errors: {errors}' + assert 'thread1_success' in results + assert 'thread2_success' in results + + # Verify both updates were applied + ref1 = testrepo.lookup_reference(ref1_name) + ref2 = testrepo.lookup_reference(ref2_name) + assert str(ref1.target) == '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98' + assert str(ref2.target) == '5ebeeebb320790caf276b9fc8b24546d63316533' + + +def test_transaction_deadlock_prevention(testrepo: Repository) -> None: + """Test that acquiring locks in different order raises error instead of deadlock.""" + # Create two test refs + ref1_name = 'refs/heads/deadlock-test-1' + ref2_name = 'refs/heads/deadlock-test-2' + target = Oid(hex='5ebeeebb320790caf276b9fc8b24546d63316533') + testrepo.create_reference(ref1_name, target) + testrepo.create_reference(ref2_name, target) + + thread1_ref1_locked = threading.Event() + thread2_ref2_locked = threading.Event() + errors = [] + successes = [] + + def thread1_task() -> None: + try: + with testrepo.transaction() as txn: + txn.lock_ref(ref1_name) + thread1_ref1_locked.set() + thread2_ref2_locked.wait(timeout=5) + # this would cause a deadlock, so will throw (GitError) + txn.lock_ref(ref2_name) + # shouldn't get here + successes.append('thread1') + except Exception as e: + errors.append(('thread1', type(e).__name__, str(e))) + + def thread2_task() -> None: + try: + with testrepo.transaction() as txn: + txn.lock_ref(ref2_name) + thread2_ref2_locked.set() + thread1_ref1_locked.wait(timeout=5) + # this would cause a deadlock, so will throw (GitError) + txn.lock_ref(ref2_name) + # shouldn't get here + successes.append('thread2') + except Exception as e: + errors.append(('thread2', type(e).__name__, str(e))) + + thread1 = threading.Thread(target=thread1_task) + thread2 = threading.Thread(target=thread2_task) + + thread1.start() + thread2.start() + thread1.join(timeout=5) + thread2.join(timeout=5) + + # At least one thread should fail with an error (not deadlock) + # If both threads are still alive, we have a deadlock + assert not thread1.is_alive(), 'Thread 1 deadlocked' + assert not thread2.is_alive(), 'Thread 2 deadlocked' + + # Both can't succeed. + # libgit2 doesn't *wait* for locks, so it's possible for neither to succeed + # if they both try to take the second lock at basically the same time. + # The other possibility is that one thread throws, exits its transaction, + # and the other thread is able to acquire the second lock. + assert len(successes) <= 1 and len(errors) >= 1, ( + f'Successes: {successes}; errors: {errors}' + ) + + +def test_transaction_commit_from_wrong_thread(testrepo: Repository) -> None: + """Test that committing a transaction from wrong thread raises error.""" + txn: ReferenceTransaction | None = None + + def create_transaction() -> None: + nonlocal txn + txn = testrepo.transaction().__enter__() + ref_name = 'refs/heads/wrong-thread-test' + target = Oid(hex='5ebeeebb320790caf276b9fc8b24546d63316533') + testrepo.create_reference(ref_name, target) + txn.lock_ref(ref_name) + + # Create transaction in thread 1 + thread = threading.Thread(target=create_transaction) + thread.start() + thread.join() + + assert txn is not None + with pytest.raises(RuntimeError): + # Try to commit from main thread (different from creator) doesn't cause libgit2 to crash, + # it raises an exception instead + txn.commit() + + +def test_transaction_nested_same_thread(testrepo: Repository) -> None: + """Test that two concurrent transactions from same thread work with different refs.""" + # Create test refs + ref1_name = 'refs/heads/nested-test-1' + ref2_name = 'refs/heads/nested-test-2' + target1 = Oid(hex='5ebeeebb320790caf276b9fc8b24546d63316533') + target2 = Oid(hex='2be5719152d4f82c7302b1c0932d8e5f0a4a0e98') + testrepo.create_reference(ref1_name, target1) + testrepo.create_reference(ref2_name, target2) + + # Nested transactions should work as long as they don't conflict + with testrepo.transaction() as txn1: + txn1.lock_ref(ref1_name) + + with testrepo.transaction() as txn2: + txn2.lock_ref(ref2_name) + txn2.set_target(ref2_name, target1, message='Inner transaction') + + # Inner transaction committed, now update outer + txn1.set_target(ref1_name, target2, message='Outer transaction') + + # Both updates should have been applied + ref1 = testrepo.lookup_reference(ref1_name) + ref2 = testrepo.lookup_reference(ref2_name) + assert str(ref1.target) == '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98' + assert str(ref2.target) == '5ebeeebb320790caf276b9fc8b24546d63316533' + + +def test_transaction_nested_same_ref_conflict(testrepo: Repository) -> None: + """Test that nested transactions fail when trying to lock the same ref.""" + ref_name = 'refs/heads/nested-conflict-test' + target = Oid(hex='5ebeeebb320790caf276b9fc8b24546d63316533') + new_target = Oid(hex='2be5719152d4f82c7302b1c0932d8e5f0a4a0e98') + testrepo.create_reference(ref_name, target) + + with testrepo.transaction() as txn1: + txn1.lock_ref(ref_name) + + # Inner transaction should fail to lock the same ref + with pytest.raises(GitError): + with testrepo.transaction() as txn2: + txn2.lock_ref(ref_name) + + # Outer transaction should still be able to complete + txn1.set_target(ref_name, new_target, message='Outer transaction') + + # Outer transaction's update should have been applied + ref = testrepo.lookup_reference(ref_name) + assert ref.target == new_target From 5f655b7e6aa317494e5bb6d5aa823561bf1609d1 Mon Sep 17 00:00:00 2001 From: Brendan <2bndy5@gmail.com> Date: Wed, 8 Oct 2025 04:32:15 -0700 Subject: [PATCH 104/225] feat: enable future-compatible deployments Free-threaded builds are exempt from abi3 builds --- .gitignore | 1 + setup.py | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/.gitignore b/.gitignore index 12c3a9701..a57da3644 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ /build/ /ci/ /dist/ +/wheelhouse/ /docs/_build/ /MANIFEST /venv* diff --git a/setup.py b/setup.py index e2c628291..7e6d70346 100644 --- a/setup.py +++ b/setup.py @@ -28,6 +28,7 @@ # Import setuptools before distutils to avoid user warning import os import sys +import sysconfig from distutils import log # type: ignore[attr-defined] from distutils.command.build import build from distutils.command.sdist import sdist @@ -152,6 +153,16 @@ def run(self) -> None: cmdclass=cmdclass, cffi_modules=['pygit2/_run.py:ffi'], ext_modules=ext_modules, + options={ + 'bdist_wheel': { + # for ABI3 (future-compatible) wheels. + # NOTE: free-threaded CPython builds do not support the limited API yet. + # See https://github.com/python/cpython/issues/111506 + 'py_limited_api': False + if sysconfig.get_config_var('Py_GIL_DISABLED') + else 'cp310' # should correspond to `python_requires` below + } + }, # Requirements python_requires='>=3.10', setup_requires=['cffi>=2.0'], From 2a5c87474dc468eb1ef2625a5ab896f672329804 Mon Sep 17 00:00:00 2001 From: Brendan <2bndy5@gmail.com> Date: Thu, 9 Oct 2025 16:35:54 -0700 Subject: [PATCH 105/225] build(dev): enable dependabot for automatic updates Only for GitHub actions in CI workflows. Managing Python dependencies in this project seems to be manual driven. It can be enabled (un-commented) later, if desired. I just didn't want to cause PR spam. To reduce perceivable spam, I set the "auto-check for updates" schedule to monthly. Note, dependabot will automatically close or update any outdated PRs that it opened. --- .github/dependabot.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..faf726ef8 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,25 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: "monthly" + commit-message: + prefix: "chore(CI):" + groups: + actions: + patterns: + - "*" + # - package-ecosystem: pip + # directory: .github/ + # schedule: + # interval: "monthly" + # groups: + # pip: + # patterns: + # - "*" From 064593660ae6d2191b01c653130ee020a5cb94ee Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Oct 2025 05:21:29 +0000 Subject: [PATCH 106/225] chore(CI): bump the actions group with 2 updates Bumps the actions group with 2 updates: [actions/checkout](https://github.com/actions/checkout) and [actions/download-artifact](https://github.com/actions/download-artifact). Updates `actions/checkout` from 4 to 5 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4...v5) Updates `actions/download-artifact` from 4 to 5 - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions - dependency-name: actions/download-artifact dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions ... Signed-off-by: dependabot[bot] --- .github/workflows/codespell.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/tests.yml | 6 +++--- .github/workflows/wheels.yml | 6 +++--- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml index 9b5e5a6bc..863f4edb0 100644 --- a/.github/workflows/codespell.yml +++ b/.github/workflows/codespell.yml @@ -16,7 +16,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Annotate locations with typos uses: codespell-project/codespell-problem-matcher@v1 - name: Codespell diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 39e3d06cc..81ae37f20 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,7 +12,7 @@ jobs: steps: - name: Checkout pygit2 - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Python uses: actions/setup-python@v6 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c58a00343..91851e3be 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -23,7 +23,7 @@ jobs: steps: - name: Checkout pygit2 - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Python uses: actions/setup-python@v6 @@ -40,7 +40,7 @@ jobs: if: github.ref == 'refs/heads/master' steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Build & test uses: uraimo/run-on-arch-action@v3 @@ -58,7 +58,7 @@ jobs: runs-on: macos-latest steps: - name: Checkout pygit2 - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Python uses: actions/setup-python@v6 diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index a3def8d14..510f6af79 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -23,7 +23,7 @@ jobs: os: macos-13 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: actions/setup-python@v6 with: @@ -45,7 +45,7 @@ jobs: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: actions/setup-python@v6 with: @@ -75,7 +75,7 @@ jobs: runs-on: ubuntu-24.04 steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v5 with: path: dist pattern: wheels-* From 098cbe3003e3e69be203fde0b1ca0c287c97db32 Mon Sep 17 00:00:00 2001 From: Brendan <2bndy5@gmail.com> Date: Thu, 9 Oct 2025 01:07:04 -0700 Subject: [PATCH 107/225] feat: build windows distributions with cibuildwheel in GH CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Should avoid maintaining appveyor workflow (and free up appveyor account limitations 😉). This also introduces windows aarch64 distributions 🎉 Lastly, I updated other spots in the GH actions workflow: - upgrade `actions/checkout` to v5 (only breaking changes are internal like upgrade node version used for building their dist) - turn off credential persistence in `actions/checkout` action (only useful if the workflow uses `git push` or similar) - upgrade `actions/download-artifact` to v5 (only breaking changes are internal because this already uses `merge-multiple: true`) - add job to check built distributions for non-tagged commits to default branch. This is meant to highlight any problems with dists' metadata before tagging a release. - add job to build sdist (for sake of completeness) - update README.md badges --- .github/workflows/wheels.yml | 52 ++++++++++++++++++++++++++++++++++++ README.md | 20 ++++++++------ build.ps1 | 21 +++++++++++++++ pyproject.toml | 29 ++++++++++++++++++++ 4 files changed, 114 insertions(+), 8 deletions(-) create mode 100644 build.ps1 diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 510f6af79..133038896 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -21,9 +21,19 @@ jobs: os: ubuntu-24.04-arm - name: macos os: macos-13 + - name: windows-x64 + os: windows-latest + - name: windows-x86 + os: windows-latest + - name: windows-arm64 + # https://github.com/actions/partner-runner-images#available-images + os: windows-11-arm steps: - uses: actions/checkout@v5 + with: + # avoid leaking credentials in uploaded artifacts + persist-credentials: false - uses: actions/setup-python@v6 with: @@ -33,6 +43,8 @@ jobs: run: python -m pip install cibuildwheel~=3.1.1 - name: Build wheels + env: + CIBW_ARCHS_WINDOWS: ${{ matrix.name == 'windows-x86' && 'auto32' || 'native' }} run: python -m cibuildwheel --output-dir wheelhouse - uses: actions/upload-artifact@v4 @@ -46,6 +58,9 @@ jobs: steps: - uses: actions/checkout@v5 + with: + # avoid leaking credentials in uploaded artifacts + persist-credentials: false - uses: actions/setup-python@v6 with: @@ -69,6 +84,43 @@ jobs: name: wheels-linux-ppc path: ./wheelhouse/*.whl + sdist: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + # avoid leaking credentials in uploaded artifacts + persist-credentials: false + + - uses: actions/setup-python@v6 + with: + python-version: '3.13' + + - name: Build sdist + run: pipx run build --sdist --outdir dist + + - uses: actions/upload-artifact@v4 + with: + name: wheels-sdist + path: dist/* + + twine-check: + name: Twine check + # It is good to do this check on non-tagged commits. + # Note, pypa/gh-action-pypi-publish (see job below) does this automatically. + if: ${{ !startsWith(github.ref, 'refs/tags/v') }} + needs: [build_wheels, build_wheels_ppc, sdist] + runs-on: ubuntu-latest + + steps: + - uses: actions/download-artifact@v5 + with: + path: dist + pattern: wheels-* + merge-multiple: true + - name: check distribution files + run: pipx run twine check dist/* + pypi: if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') needs: [build_wheels, build_wheels_ppc] diff --git a/README.md b/README.md index 0182486fe..5286c52a6 100644 --- a/README.md +++ b/README.md @@ -3,18 +3,22 @@ Bindings to the libgit2 shared library, implements Git plumbing. Supports Python 3.10 to 3.13 and PyPy3 7.3+ -[![image](https://github.com/libgit2/pygit2/actions/workflows/tests.yml/badge.svg)](https://github.com/libgit2/pygit2/actions/workflows/tests.yml) +[![test-ci-badge][test-ci-badge]][test-ci-link] +[![deploy-ci-badge][deploy-ci-badge]][deploy-ci-link] -[![image](https://ci.appveyor.com/api/projects/status/edmwc0dctk5nacx0/branch/master?svg=true)](https://ci.appveyor.com/project/jdavid/pygit2/branch/master) +[deploy-ci-badge]: https://github.com/libgit2/pygit2/actions/workflows/wheels.yml/badge.svg +[deploy-ci-link]: https://github.com/libgit2/pygit2/actions/workflows/wheels.yml +[test-ci-badge]: https://github.com/libgit2/pygit2/actions/workflows/tests.yml/badge.svg +[test-ci-link]: https://github.com/libgit2/pygit2/actions/workflows/tests.yml ## Links -- Documentation - -- Install - -- Download - -- Source code and issue tracker - -- Changelog - -- Authors - +- Documentation - +- Install - +- Download - +- Source code and issue tracker - +- Changelog - +- Authors - ## Sponsors diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 000000000..204a201b8 --- /dev/null +++ b/build.ps1 @@ -0,0 +1,21 @@ +if (!(Test-Path -Path "build")) { + # in case the pygit2 package build/ workspace has not been created by cibuildwheel yet + mkdir build +} +if (Test-Path -Path "$env:LIBGIT2_SRC") { + Set-Location "$env:LIBGIT2_SRC" + # for local runs, reuse build/libgit_src if it exists + if (Test-Path -Path build) { + # purge previous build env (likely for a different arch type) + Remove-Item -Recurse -Force build + } + # ensure we are checked out to the right version + git fetch --depth=1 --tags + git checkout "v$env:LIBGIT2_VERSION" +} else { + # from a fresh run (like in CI) + git clone --depth=1 -b "v$env:LIBGIT2_VERSION" https://github.com/libgit2/libgit2.git $env:LIBGIT2_SRC + Set-Location "$env:LIBGIT2_SRC" +} +cmake -B build -S . -DBUILD_TESTS=OFF +cmake --build build/ --config=Release --target install diff --git a/pyproject.toml b/pyproject.toml index c17310534..e688d1783 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,35 @@ archs = ["universal2"] environment = {LIBGIT2_VERSION="1.9.1", LIBSSH2_VERSION="1.11.1", OPENSSL_VERSION="3.3.3", LIBGIT2="/Users/runner/work/pygit2/pygit2/ci"} repair-wheel-command = "DYLD_LIBRARY_PATH=/Users/runner/work/pygit2/pygit2/ci/lib delocate-wheel --require-archs {delocate_archs} -w {dest_dir} {wheel}" +[tool.cibuildwheel.windows] +environment.LIBGIT2_SRC = "build/libgit2_src" +environment.LIBGIT2_VERSION = "1.9.1" +before-all = "powershell -File build.ps1" + +[[tool.cibuildwheel.overrides]] +select="*-win_amd64" +inherit.environment="append" +environment.CMAKE_GENERATOR = "Visual Studio 17 2022" +environment.CMAKE_GENERATOR_PLATFORM = "x64" +environment.CMAKE_INSTALL_PREFIX = "C:/libgit2_install_x86_64" +environment.LIBGIT2 = "C:/libgit2_install_x86_64" + +[[tool.cibuildwheel.overrides]] +select="*-win32" +inherit.environment="append" +environment.CMAKE_GENERATOR = "Visual Studio 17 2022" +environment.CMAKE_GENERATOR_PLATFORM = "Win32" +environment.CMAKE_INSTALL_PREFIX = "C:/libgit2_install_x86" +environment.LIBGIT2 = "C:/libgit2_install_x86" + +[[tool.cibuildwheel.overrides]] +select="*-win_arm64" +inherit.environment="append" +environment.CMAKE_GENERATOR = "Visual Studio 17 2022" +environment.CMAKE_GENERATOR_PLATFORM = "ARM64" +environment.CMAKE_INSTALL_PREFIX = "C:/libgit2_install_arm64" +environment.LIBGIT2 = "C:/libgit2_install_arm64" + [tool.ruff] extend-exclude = [ ".cache", ".coverage", "build", "site-packages", "venv*"] target-version = "py310" # oldest supported Python version From f711b4efe9354855d862cfd1e318b8111ba7e434 Mon Sep 17 00:00:00 2001 From: Brendan <2bndy5@gmail.com> Date: Thu, 9 Oct 2025 15:14:58 -0700 Subject: [PATCH 108/225] chore: update gitignore with common paths related to Python and C projects Fixes #1381 Fear not: I have ensured that all previous entries remain as they were (just in different order with contextual comments) --- .gitignore | 252 ++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 239 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index a57da3644..a9106ffba 100644 --- a/.gitignore +++ b/.gitignore @@ -1,19 +1,245 @@ -/.cache/ -/.coverage -/.eggs/ -/.envrc -/.tox/ -/build/ -/ci/ +# Created by https://www.toptal.com/developers/gitignore/api/python,c +# Edit at https://www.toptal.com/developers/gitignore?templates=python,c + +### C ### +# Prerequisites +*.d + +# Object files +*.o +*.ko +*.obj +*.elf + +# Linker output +*.ilk +*.map +*.exp + +# Precompiled Headers +*.gch +*.pch + +# Libraries +*.lib +*.a +*.la +*.lo + +# Shared objects (inc. Windows DLLs) +*.dll +*.so +*.so.* +*.dylib + +# Executables +*.exe +*.out +*.app +*.i*86 +*.x86_64 +*.hex + +# Debug files +*.dSYM/ +*.su +*.idb +*.pdb + +# Kernel Module Compile Results +*.mod* +*.cmd +.tmp_versions/ +modules.order +Module.symvers +Mkfile.old +dkms.conf + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions + +# Distribution / packaging +.Python +build/ +develop-eggs/ /dist/ -/wheelhouse/ -/docs/_build/ +downloads/ +eggs/ +/.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +wheelhouse/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg /MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +/.coverage +.coverage.* +/.cache/ +nosetests.xml +coverage.xml +lcov.info +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +### Python Patch ### +# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml + +# ruff +.ruff_cache/ + +# LSP config files +pyrightconfig.json + +# End of https://www.toptal.com/developers/gitignore/api/python,c + +# PyCharm (IntelliJ JetBrains) +.idea/ +*.iml +*.iws +*.ipr +.idea_modules/ + +# for VSCode +.vscode/ + +# for Eclipse +.settings/ + +# custom ignore paths +/.envrc /venv* -__pycache__/ -*.egg-info -*.pyc -*.so +/ci/ *.swp /pygit2/_libgit2.c /pygit2/_libgit2.o From e5620b969e92bc16f8b82acfab4f23614e51026e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Fri, 10 Oct 2025 12:10:08 +0200 Subject: [PATCH 109/225] Drop AppVeyor --- appveyor.yml | 68 -------------------------------------------- docs/development.rst | 4 +-- setup.py | 2 +- test/conftest.py | 3 +- 4 files changed, 5 insertions(+), 72 deletions(-) delete mode 100644 appveyor.yml diff --git a/appveyor.yml b/appveyor.yml deleted file mode 100644 index e62c62151..000000000 --- a/appveyor.yml +++ /dev/null @@ -1,68 +0,0 @@ -version: 1.18.{build} -image: Visual Studio 2019 -configuration: Release -environment: - global: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: - secure: 7YD82RnQJ9rnJE/josiQ/V6VWh+tlhmJpWVM/u5jGdl8XqyhsLEKF5MNMYd4ZYxA/MGaYBCQ525d4m9RSDk9RB+uIFMZJLnl1eOjHQVyJ+ZZmJb65tqd/fR5hybhWtVhn+0wANiI4uqrojFFVy1HjfBYSrvyk+7LLDxfSVTqkhMEhbZbWBpGP/3VET1gPy+qdlWcL7quwhSBPSbKpyMi/cqvp5/yFLAM615RRABgQUDpRyXxtBTReRgWSxi9kUXXqR18ZvQlvMLnAsEnGFRenA== - matrix: - - GENERATOR: 'Visual Studio 14' - PYTHON: 'C:\Python310\python.exe' - - GENERATOR: 'Visual Studio 14 Win64' - PYTHON: 'C:\Python310-x64\python.exe' - - GENERATOR: 'Visual Studio 14' - PYTHON: 'C:\Python311\python.exe' - - GENERATOR: 'Visual Studio 14 Win64' - PYTHON: 'C:\Python311-x64\python.exe' - - GENERATOR: 'Visual Studio 14' - PYTHON: 'C:\Python312\python.exe' - - GENERATOR: 'Visual Studio 14 Win64' - PYTHON: 'C:\Python312-x64\python.exe' - - GENERATOR: 'Visual Studio 14' - PYTHON: 'C:\Python313\python.exe' - - GENERATOR: 'Visual Studio 14 Win64' - PYTHON: 'C:\Python313-x64\python.exe' - -matrix: - fast_finish: true - -init: -- cmd: | - "%PYTHON%" -m pip install -U pip wheel - -build_script: -# Clone, build and install libgit2 -- cmd: | - set LIBGIT2=%APPVEYOR_BUILD_FOLDER%\venv - git clone --depth=1 -b v1.9.1 https://github.com/libgit2/libgit2.git libgit2 - cd libgit2 - cmake . -DBUILD_TESTS=OFF -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="%LIBGIT2%" -G "%GENERATOR%" - cmake --build . --target install - cd .. - -# Build and install pygit2 -# Rename pygit2 folder, so when testing it picks the installed one -- cmd: | - "%PYTHON%" -m pip install -r requirements-test.txt - "%PYTHON%" -m pip wheel --wheel-dir=dist . - "%PYTHON%" -m pip install --no-index --find-links=dist pygit2 - mv pygit2 pygit2.bak - -test_script: -- ps: | - &$env:PYTHON -m pytest test --junitxml=testresults.xml - - if ($LastExitCode -ne 0) { $host.SetShouldExit($LastExitCode) } - - # upload results to AppVeyor - $wc = New-Object 'System.Net.WebClient' - $wc.UploadFile("https://ci.appveyor.com/api/testresults/junit/$($env:APPVEYOR_JOB_ID)", (Resolve-Path ".\testresults.xml")) - -artifacts: -- path: dist\pygit2-*.whl - -deploy_script: -- ps: if ($env:APPVEYOR_REPO_TAG -eq $TRUE) { pip install twine; twine upload dist/pygit2-*.whl } - -deploy: on diff --git a/docs/development.rst b/docs/development.rst index b9422bf3d..79d7e6bf3 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -5,8 +5,8 @@ The development version .. image:: https://github.com/libgit2/pygit2/actions/workflows/tests.yml/badge.svg :target: https://github.com/libgit2/pygit2/actions/workflows/tests.yml -.. image:: https://ci.appveyor.com/api/projects/status/edmwc0dctk5nacx0/branch/master?svg=true - :target: https://ci.appveyor.com/project/jdavid/pygit2/branch/master +.. image:: https://github.com/libgit2/pygit2/actions/workflows/wheels.yml/badge.svg + :target: https://github.com/libgit2/pygit2/actions/workflows/wheels.yml .. contents:: Contents :local: diff --git a/setup.py b/setup.py index 7e6d70346..ef0989ab6 100644 --- a/setup.py +++ b/setup.py @@ -58,7 +58,7 @@ def get_file_list(self) -> None: sys.exit() def exclude(line: str) -> bool: - for prefix in ['.', 'appveyor.yml', 'docs/', 'misc/']: + for prefix in ['.', 'docs/', 'misc/']: if line.startswith(prefix): return True return False diff --git a/test/conftest.py b/test/conftest.py index 6b8a2737d..7b0d4149b 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -22,7 +22,8 @@ def global_git_config() -> None: for level in levels: pygit2.settings.search_path[level] = '' - # Fix tests running in AppVeyor + # Fix tests running in Windows + # XXX Still needed now we have moved to GitHub CI? if platform.system() == 'Windows': pygit2.option(pygit2.enums.Option.SET_OWNER_VALIDATION, False) From 7398c43fec165a77212f74b6edfd87e5b2f68a48 Mon Sep 17 00:00:00 2001 From: Brendan <2bndy5@gmail.com> Date: Sat, 11 Oct 2025 22:48:57 -0700 Subject: [PATCH 110/225] feat: revert future-compatible deployments This reverts commit 5f655b7e6aa317494e5bb6d5aa823561bf1609d1. There's much more needed in pygit2 C sources (`src/*`) to comply with CPython's Limited API. Unfortunately, this feature is not entirely dependent on `cffi` (which only handles libgit2 compilation). This can be re-addressed at a later time, when more time is available. ref https://github.com/libgit2/pygit2/issues/1418#issuecomment-3393952952 reopens #1046 --- setup.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/setup.py b/setup.py index ef0989ab6..f8b934727 100644 --- a/setup.py +++ b/setup.py @@ -28,7 +28,6 @@ # Import setuptools before distutils to avoid user warning import os import sys -import sysconfig from distutils import log # type: ignore[attr-defined] from distutils.command.build import build from distutils.command.sdist import sdist @@ -153,16 +152,6 @@ def run(self) -> None: cmdclass=cmdclass, cffi_modules=['pygit2/_run.py:ffi'], ext_modules=ext_modules, - options={ - 'bdist_wheel': { - # for ABI3 (future-compatible) wheels. - # NOTE: free-threaded CPython builds do not support the limited API yet. - # See https://github.com/python/cpython/issues/111506 - 'py_limited_api': False - if sysconfig.get_config_var('Py_GIL_DISABLED') - else 'cp310' # should correspond to `python_requires` below - } - }, # Requirements python_requires='>=3.10', setup_requires=['cffi>=2.0'], From 559f3160d7b8b57cf9ac86a97c60c0c677725015 Mon Sep 17 00:00:00 2001 From: Brendan <2bndy5@gmail.com> Date: Sat, 11 Oct 2025 23:44:12 -0700 Subject: [PATCH 111/225] fix: disable GIL for free-threaded builds --- src/pygit2.c | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/pygit2.c b/src/pygit2.c index c2bac5e5e..2649efc03 100644 --- a/src/pygit2.c +++ b/src/pygit2.c @@ -465,6 +465,10 @@ PyInit__pygit2(void) if (m == NULL) return NULL; +#ifdef Py_GIL_DISABLED + PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED); +#endif + /* libgit2 version info */ ADD_CONSTANT_INT(m, LIBGIT2_VER_MAJOR) ADD_CONSTANT_INT(m, LIBGIT2_VER_MINOR) From 706e3576c3ab915d195bd438006d8370822820be Mon Sep 17 00:00:00 2001 From: WANG Xuerui Date: Mon, 13 Oct 2025 15:08:08 +0800 Subject: [PATCH 112/225] ci: do not build unused openssl components We build OpenSSL only for the library, so save some time and computing power by disabling unused apps, docs, and tests components for the CI OpenSSL build. Signed-off-by: WANG Xuerui --- build.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sh b/build.sh index c710a076c..3c9fb68a7 100644 --- a/build.sh +++ b/build.sh @@ -134,7 +134,7 @@ if [ -n "$OPENSSL_VERSION" ]; then # Linux tar xf $FILENAME.tar.gz cd $FILENAME - ./Configure shared --prefix=$PREFIX --libdir=$PREFIX/lib + ./Configure shared no-apps no-docs no-tests --prefix=$PREFIX --libdir=$PREFIX/lib make make install OPENSSL_PREFIX=$(pwd) From 4956dd5e9d907dbdc8fab1af2d1c2944e0c98e78 Mon Sep 17 00:00:00 2001 From: Brendan <2bndy5@gmail.com> Date: Fri, 10 Oct 2025 05:44:17 -0700 Subject: [PATCH 113/225] test: run unit tests for each built wheel --- pyproject.toml | 3 +++ test/__init__.py | 5 +++-- test/conftest.py | 6 ------ 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e688d1783..71c29e7c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,9 @@ dependency-versions = "pinned" environment = {LIBGIT2_VERSION="1.9.1", LIBSSH2_VERSION="1.11.1", OPENSSL_VERSION="3.3.3", LIBGIT2="/project/ci"} before-all = "sh build.sh" +test-command = "pytest" +test-sources = ["test"] +before-test = "pip install -r {project}/requirements-test.txt" [tool.cibuildwheel.linux] repair-wheel-command = "LD_LIBRARY_PATH=/project/ci/lib64 auditwheel repair -w {dest_dir} {wheel}" diff --git a/test/__init__.py b/test/__init__.py index 7fb15c4fc..53a1b2306 100644 --- a/test/__init__.py +++ b/test/__init__.py @@ -30,5 +30,6 @@ import sys cwd = os.getcwd() -sys.path.remove(cwd) -sys.path.append(cwd) +if cwd in sys.path: + sys.path.remove(cwd) + sys.path.append(cwd) diff --git a/test/conftest.py b/test/conftest.py index 7b0d4149b..69d3e6389 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,4 +1,3 @@ -import platform from collections.abc import Generator from pathlib import Path @@ -22,11 +21,6 @@ def global_git_config() -> None: for level in levels: pygit2.settings.search_path[level] = '' - # Fix tests running in Windows - # XXX Still needed now we have moved to GitHub CI? - if platform.system() == 'Windows': - pygit2.option(pygit2.enums.Option.SET_OWNER_VALIDATION, False) - @pytest.fixture def pygit2_empty_key() -> tuple[Path, str, str]: From dbdd36f97150d11ece39a6a438a9b6ea95460ef6 Mon Sep 17 00:00:00 2001 From: Brendan <2bndy5@gmail.com> Date: Mon, 13 Oct 2025 03:37:23 -0700 Subject: [PATCH 114/225] only compare certain `Signature` attributes in an assertion --- test/test_refs.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/test_refs.py b/test/test_refs.py index 9ad830b78..276a0cdb2 100644 --- a/test/test_refs.py +++ b/test/test_refs.py @@ -595,7 +595,10 @@ def test_set_target_with_message(testrepo: Repository) -> None: assert reference.raw_target == b'refs/heads/i18n' first = list(reference.log())[0] assert first.message == msg - assert first.committer == sig + # Signature.time and Signature.encoding may not be equal. + # Here we only care that the name and email are correctly set. + assert first.committer.name == sig.name + assert first.committer.email == sig.email def test_delete(testrepo: Repository) -> None: From d16e2f36be89bf558c1a2e5d3d29a63b87cb71f1 Mon Sep 17 00:00:00 2001 From: Brendan <2bndy5@gmail.com> Date: Mon, 13 Oct 2025 03:43:31 -0700 Subject: [PATCH 115/225] skip tests when using qemu to biuld wheel only affects ppc64le linux platform --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 71c29e7c5..1a5a95a65 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,8 @@ before-all = "sh build.sh" test-command = "pytest" test-sources = ["test"] before-test = "pip install -r {project}/requirements-test.txt" +# Will avoid testing on emulated architectures (specifically ppc64le) +test-skip = "*-*linux_ppc64le" [tool.cibuildwheel.linux] repair-wheel-command = "LD_LIBRARY_PATH=/project/ci/lib64 auditwheel repair -w {dest_dir} {wheel}" From 8fb06faf390c8dff13228eec1802553b0117cd4c Mon Sep 17 00:00:00 2001 From: Brendan <2bndy5@gmail.com> Date: Tue, 14 Oct 2025 03:58:40 -0700 Subject: [PATCH 116/225] test: refactor CI per request from https://github.com/libgit2/pygit2/pull/1432#issuecomment-3400976809 closes #1366 ### Changes - remove all test jobs from test.yml workflow except the one that runs on s390x platform. - rename the test.yml Display name accordingly: "Tests (s390x)" - skip building ppc64le wheels when not triggered on master branch. - reconfigure wheels.yml workflow triggers. The wheels.yml CI now runs for 1. any push to master branch 2. any change in a PR that targets master branch (excluding when PR changes only affect docs/ path) 3. any tag (starting with "v") is pushed - added `skip-existing` param in case deployment to PyPI suffers a network error and the CI just needs to be re-run. - cherry pick changes from PR #1366 (about CI triggers for lint and spell-check CI workflows) - auto-cancel wheels CI if new run triggered on non-default branch - use pytest.ini in cibuildwheel isolated env - added step to job that deploys wheels to PyPI. This new step creates a GitHub Release for the tag that was pushed. Uses release notes extracted from CHANGELOG.md (during sdist job). --- .github/workflows/codespell.yml | 2 + .github/workflows/lint.yml | 2 + .github/workflows/parse_release_notes.py | 94 ++++++++++++++++++++++++ .github/workflows/tests.yml | 48 +----------- .github/workflows/wheels.yml | 50 +++++++++++-- pyproject.toml | 2 +- 6 files changed, 147 insertions(+), 51 deletions(-) create mode 100644 .github/workflows/parse_release_notes.py diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml index 863f4edb0..fcf5693ca 100644 --- a/.github/workflows/codespell.yml +++ b/.github/workflows/codespell.yml @@ -4,7 +4,9 @@ name: Codespell on: pull_request: + branches: [master] push: + branches: [master] permissions: contents: read diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 81ae37f20..89921a979 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2,7 +2,9 @@ name: Lints on: pull_request: + branches: [master] push: + branches: [master] paths-ignore: - '**.rst' diff --git a/.github/workflows/parse_release_notes.py b/.github/workflows/parse_release_notes.py new file mode 100644 index 000000000..a2b25cda3 --- /dev/null +++ b/.github/workflows/parse_release_notes.py @@ -0,0 +1,94 @@ +"""Parse the latest release notes from CHANGELOG.md. + +If running in GitHub Actions, set the `release_title` output +variable for use in subsequent step(s). + +If running in CI, write the release notes to ReleaseNotes.md +for upload as an artifact. + +Otherwise, print the release title and notes to stdout. +""" + +import re +import subprocess +from os import environ +from pathlib import Path + + +class ChangesEntry: + def __init__(self, version: str, notes: str) -> None: + self.version = version + title = notes.splitlines()[0] + self.title = f'{version} {title}' + self.notes = notes[len(title) :].strip() + + +H1 = re.compile(r'^# (\d+\.\d+\.\d+)', re.MULTILINE) + + +def parse_changelog() -> list[ChangesEntry]: + changelog = Path('CHANGELOG.md').read_text(encoding='utf-8') + parsed = H1.split(changelog) # may result in a blank line at index 0 + if not parsed[0]: # leading entry is a blank line due to re.split() implementation + parsed = parsed[1:] + assert len(parsed) % 2 == 0, ( + 'Malformed CHANGELOG.md; Entries expected to start with "# x.y.x"' + ) + + changes: list[ChangesEntry] = [] + for i in range(0, len(parsed), 2): + version = parsed[i] + notes = parsed[i + 1].strip() + changes.append(ChangesEntry(version, notes)) + return changes + + +def get_version_tag() -> str | None: + if 'GITHUB_REF' in environ: # for use in GitHub Actions + git_ref = environ['GITHUB_REF'] + else: # for local use + git_out = subprocess.run( + ['git', 'rev-parse', '--symbolic-full-name', 'HEAD'], + capture_output=True, + text=True, + check=True, + ) + git_ref = git_out.stdout.strip() + version: str | None = None + if git_ref and git_ref.startswith('refs/tags/'): + version = git_ref[len('refs/tags/') :].lstrip('v') + else: + print( + f"Using latest CHANGELOG.md entry because the git ref '{git_ref}' is not a tag." + ) + return version + + +def get_entry(changes: list[ChangesEntry], version: str | None) -> ChangesEntry: + latest = changes[0] + if version is not None: + for entry in changes: + if entry.version == version: + latest = entry + break + else: + raise ValueError(f'No changelog entry found for version {version}') + return latest + + +def main() -> None: + changes = parse_changelog() + version = get_version_tag() + latest = get_entry(changes=changes, version=version) + if 'GITHUB_OUTPUT' in environ: + with Path(environ['GITHUB_OUTPUT']).open('a') as gh_out: + print(f'release_title={latest.title}', file=gh_out) + if environ.get('CI', 'false') == 'true': + Path('ReleaseNotes.md').write_text(latest.notes, encoding='utf-8') + else: + print('Release notes:') + print(f'# {latest.title}\n{latest.notes}') + + +if __name__ == '__main__': + main() diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 91851e3be..663034327 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,40 +1,14 @@ -name: Tests +name: Tests (s390x) on: pull_request: + branches: [master] push: + branches: [master] paths-ignore: - '**.rst' jobs: - linux: - runs-on: ${{ matrix.os }} - strategy: - matrix: - include: - - os: ubuntu-24.04 - python-version: '3.10' - - os: ubuntu-24.04 - python-version: '3.13' - - os: ubuntu-24.04 - python-version: 'pypy3.10' - - os: ubuntu-24.04-arm - python-version: '3.13' - - steps: - - name: Checkout pygit2 - uses: actions/checkout@v5 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: ${{ matrix.python-version }} - - - name: Linux - run: | - sudo apt install tinyproxy - LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.1 /bin/sh build.sh test - linux-s390x: runs-on: ubuntu-24.04 if: github.ref == 'refs/heads/master' @@ -53,19 +27,3 @@ jobs: run: | LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.1 /bin/sh build.sh test continue-on-error: true # Tests are expected to fail, see issue #812 - - macos-arm64: - runs-on: macos-latest - steps: - - name: Checkout pygit2 - uses: actions/checkout@v5 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: '3.13' - - - name: macOS - run: | - export OPENSSL_PREFIX=`brew --prefix openssl@3` - LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.1 /bin/sh build.sh test diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 133038896..3cfb8d461 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -1,12 +1,18 @@ name: Wheels +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref_name != 'master' }} + on: push: - branches: - - master - - wheels-* + branches: [master] tags: - - 'v*' + - 'v*' + pull_request: + branches: [master] + paths-ignore: + - 'docs/**' jobs: build_wheels: @@ -54,6 +60,7 @@ jobs: build_wheels_ppc: name: Wheels for linux-ppc + if: github.ref == 'refs/heads/master' runs-on: ubuntu-24.04 steps: @@ -86,6 +93,8 @@ jobs: sdist: runs-on: ubuntu-latest + outputs: + release_title: ${{ steps.parse_changelog.outputs.release_title }} steps: - uses: actions/checkout@v5 with: @@ -104,6 +113,17 @@ jobs: name: wheels-sdist path: dist/* + - name: parse CHANGELOG for release notes + id: parse_changelog + run: python .github/workflows/parse_release_notes.py + + - name: Upload Release Notes + uses: actions/upload-artifact@v4 + with: + name: release-notes + path: ReleaseNotes.md + + twine-check: name: Twine check # It is good to do this check on non-tagged commits. @@ -123,7 +143,9 @@ jobs: pypi: if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') - needs: [build_wheels, build_wheels_ppc] + needs: [build_wheels, build_wheels_ppc, sdist] + permissions: + contents: write # to create GitHub Release runs-on: ubuntu-24.04 steps: @@ -140,3 +162,21 @@ jobs: with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} + skip-existing: true + + - uses: actions/download-artifact@v5 + with: + name: release-notes + - name: Create GitHub Release + env: + GITHUB_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + REPO: ${{ github.repository }} + TITLE: ${{ needs.sdist.outputs.release_title }} + # https://cli.github.com/manual/gh_release_create + run: >- + gh release create ${TAG} + --verify-tag + --repo ${REPO} + --title ${TITLE} + --notes-file ReleaseNotes.md diff --git a/pyproject.toml b/pyproject.toml index 1a5a95a65..2db7c9576 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ environment = {LIBGIT2_VERSION="1.9.1", LIBSSH2_VERSION="1.11.1", OPENSSL_VERSIO before-all = "sh build.sh" test-command = "pytest" -test-sources = ["test"] +test-sources = ["test", "pytest.ini"] before-test = "pip install -r {project}/requirements-test.txt" # Will avoid testing on emulated architectures (specifically ppc64le) test-skip = "*-*linux_ppc64le" From 9fe9f2200424ad47e13e16c4d73917881ef141d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Sun, 19 Oct 2025 12:46:28 +0200 Subject: [PATCH 117/225] Update Changelog --- CHANGELOG.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d3f33239..94f55afa9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,30 @@ +# 1.19.0 (UNRELEASED) + +- Add support for Python 3.14 + +- Support threaded builds + [#1430](https://github.com/libgit2/pygit2/pull/1430) + +- Add Linux wheels for musl on AArch64 + +- Add Windows wheels for AArch64 + [#1423](https://github.com/libgit2/pygit2/pull/1423) + +- New `Repository.transaction()` context manager, returns new `ReferenceTransaction` + [#1420](https://github.com/libgit2/pygit2/pull/1420) + +- CI: Move Windows wheels to cibuildwheel and GitHub + [#1423](https://github.com/libgit2/pygit2/pull/1423) + +- CI: add GitHub releases and other improvements + [#1433](https://github.com/libgit2/pygit2/pull/1433) + [#1432](https://github.com/libgit2/pygit2/pull/1432) + [#1425](https://github.com/libgit2/pygit2/pull/1425) + [#1431](https://github.com/libgit2/pygit2/pull/1431) + +- Updated .gitignore + [#1424](https://github.com/libgit2/pygit2/pull/1424) + # 1.18.2 (2025-08-16) - Add support for almost all global options From 2ea43ed59275e2b458b39b62499a2c1df5b6035e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Sun, 19 Oct 2025 13:40:06 +0200 Subject: [PATCH 118/225] Remove deprecated IndexEntry.hex and deprecate IndexEntry.oid --- CHANGELOG.md | 66 ++++++++++++++++++++++++++++++------------------- pygit2/index.py | 8 +----- 2 files changed, 42 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94f55afa9..3f504a23f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,14 @@ - Updated .gitignore [#1424](https://github.com/libgit2/pygit2/pull/1424) +Breaking changes: + +- Remove deprecated `IndexEntry.hex`, use `str(entry.id)` instead of `entry.hex` + +Deprecations: + +- Deprecate `IndexEntry.oid`, use `entry.id` instead of `entry.oid` + # 1.18.2 (2025-08-16) - Add support for almost all global options @@ -325,31 +333,39 @@ Deprecations: # 1.14.0 (2024-01-26) -- Drop support for Python 3.8 -- Add Linux wheels for musl on x86\_64 - [#1266](https://github.com/libgit2/pygit2/pull/1266) -- New `Repository.submodules` namespace - [#1250](https://github.com/libgit2/pygit2/pull/1250) -- New `Repository.listall_mergeheads()`, `Repository.message`, - `Repository.raw_message` and `Repository.remove_message()` - [#1261](https://github.com/libgit2/pygit2/pull/1261) -- New `pygit2.enums` supersedes the `GIT_` constants - [#1251](https://github.com/libgit2/pygit2/pull/1251) -- Now `Repository.status()`, `Repository.status_file()`, - `Repository.merge_analysis()`, `DiffFile.flags`, `DiffFile.mode`, - `DiffDelta.flags` and `DiffDelta.status` return enums - [#1263](https://github.com/libgit2/pygit2/pull/1263) -- Now repository\'s `merge()`, `merge_commits()` and `merge_trees()` - take enums/flags for their `favor`, `flags` and `file_flags` arguments. - [#1271](https://github.com/libgit2/pygit2/pull/1271) - [#1272](https://github.com/libgit2/pygit2/pull/1272) -- Fix crash in filter cleanup - [#1259](https://github.com/libgit2/pygit2/pull/1259) -- Documentation fixes - [#1255](https://github.com/libgit2/pygit2/pull/1255) - [#1258](https://github.com/libgit2/pygit2/pull/1258) - [#1268](https://github.com/libgit2/pygit2/pull/1268) - [#1270](https://github.com/libgit2/pygit2/pull/1270) +- Drop support for Python 3.8 + +- Add Linux wheels for musl on x86\_64 + [#1266](https://github.com/libgit2/pygit2/pull/1266) + +- New `Repository.submodules` namespace + [#1250](https://github.com/libgit2/pygit2/pull/1250) + +- New `Repository.listall_mergeheads()`, `Repository.message`, + `Repository.raw_message` and `Repository.remove_message()` + [#1261](https://github.com/libgit2/pygit2/pull/1261) + +- New `pygit2.enums` supersedes the `GIT_` constants + [#1251](https://github.com/libgit2/pygit2/pull/1251) + +- Now `Repository.status()`, `Repository.status_file()`, + `Repository.merge_analysis()`, `DiffFile.flags`, `DiffFile.mode`, + `DiffDelta.flags` and `DiffDelta.status` return enums + [#1263](https://github.com/libgit2/pygit2/pull/1263) + +- Now repository\'s `merge()`, `merge_commits()` and `merge_trees()` + take enums/flags for their `favor`, `flags` and `file_flags` arguments. + [#1271](https://github.com/libgit2/pygit2/pull/1271) + [#1272](https://github.com/libgit2/pygit2/pull/1272) + +- Fix crash in filter cleanup + [#1259](https://github.com/libgit2/pygit2/pull/1259) + +- Documentation fixes + [#1255](https://github.com/libgit2/pygit2/pull/1255) + [#1258](https://github.com/libgit2/pygit2/pull/1258) + [#1268](https://github.com/libgit2/pygit2/pull/1268) + [#1270](https://github.com/libgit2/pygit2/pull/1270) Breaking changes: diff --git a/pygit2/index.py b/pygit2/index.py index 928a62223..6ce8a39c6 100644 --- a/pygit2/index.py +++ b/pygit2/index.py @@ -446,15 +446,9 @@ def __init__( @property def oid(self): - # For backwards compatibility + warnings.warn('Use entry.id', DeprecationWarning) return self.id - @property - def hex(self): - """The id of the referenced object as a hex string""" - warnings.warn('Use str(entry.id)', DeprecationWarning) - return str(self.id) - def __str__(self): return f'' From 03b6afcfd2c802f2da7d81a417ae9c6484ab509d Mon Sep 17 00:00:00 2001 From: Brendan <2bndy5@gmail.com> Date: Thu, 9 Oct 2025 18:04:00 -0700 Subject: [PATCH 119/225] docs: update install docs * resolves #1308 * resolves #1383 ### Changes * added docs/requirements.txt, so contributors can check changes locally before committing. * remove `$` from code snippets for better (platform-agnostic) copy-n-paste. I replaced this with a caption if any code snippets implicitly rely on bash. * update the version of libgit2 used * update table of version compatibility (assumes the next version is v1.19) * added implied requirements about building from source * cross linked "build from source" section for better doc navigation * fix link to libgit2 docs about building/installing * converted code snippet about building libgit2 on Windows to powershell syntax. I also added a caption to explain minimum CMake version used. --- docs/install.rst | 127 ++++++++++++++++++++++++------------------ docs/requirements.txt | 2 + 2 files changed, 74 insertions(+), 55 deletions(-) create mode 100644 docs/requirements.txt diff --git a/docs/install.rst b/docs/install.rst index 140adffa1..0f4818f8a 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -2,10 +2,6 @@ Installation ********************************************************************** -.. |lq| unicode:: U+00AB -.. |rq| unicode:: U+00BB - - .. contents:: Contents :local: @@ -17,8 +13,8 @@ Install pygit2: .. code-block:: sh - $ pip install -U pip - $ pip install pygit2 + pip install -U pip + pip install pygit2 The line above will install binary wheels if available in your platform. @@ -33,12 +29,12 @@ If you get the error:: fatal error: git2.h: No such file or directory It means that pip did not find a binary wheel for your platform, so it tried to -build from source, but it failed because it could not find the libgit2 headers. +build from source. It failed to build because it could not find the libgit2 headers. Then: - Verify pip is updated - Verify there is a binary wheel of pygit2 for your platform -- Otherwise install from the source distribution +- Otherwise `install from the source distribution`_ Caveats: @@ -58,12 +54,12 @@ Python requirements (these are specified in ``setup.py``): - cffi 2.0 or later Libgit2 **v1.9.x**; binary wheels already include libgit2, so you only need to -worry about this if you install the source package. +worry about this if you `install from the source distribution`_. Optional libgit2 dependencies to support ssh and https: - https: WinHTTP (Windows), SecureTransport (OS X) or OpenSSL. -- ssh: libssh2 1.9.0 or later, pkg-config +- ssh: libssh2 1.9.1 or later, pkg-config To run the tests: @@ -72,8 +68,9 @@ To run the tests: Version numbers =============== -The version number of pygit2 is composed of three numbers separated by dots -|lq| *major.medium.minor* |rq|: +The version number of pygit2 is composed of three numbers separated by dots:: + + .. - *major* will always be 1 (until we release 2.0 in a far undefined future) - *medium* will increase whenever we make breaking changes, or upgrade to new @@ -86,6 +83,8 @@ of Python and the required libgit2 version. +-------------+----------------+------------+ | pygit2 | Python | libgit2 | +-------------+----------------+------------+ +| 1.19 | 3.10 - 3.14(t) | 1.9 | ++-------------+----------------+------------+ | 1.17 - 1.18 | 3.10 - 3.13 | 1.9 | +-------------+----------------+------------+ | 1.16 | 3.10 - 3.13 | 1.8 | @@ -139,33 +138,41 @@ lockstep with libgit2, e.g. pygit2 0.28.x worked with libgit2 0.28.x Advanced =========================== +.. _install from the source distribution: + Install libgit2 from source --------------------------- +Installing from source requires + +* a C compiler (such as gcc) +* the CPython API headers (typically in an ``apt`` package named ``python3-dev``) + To install the latest version of libgit2 system wide, in the ``/usr/local`` directory, do: .. code-block:: sh + :caption: On Linux using bash - $ wget https://github.com/libgit2/libgit2/archive/refs/tags/v1.9.0.tar.gz -O libgit2-1.9.0.tar.gz - $ tar xzf libgit2-1.9.0.tar.gz - $ cd libgit2-1.9.0/ - $ cmake . - $ make - $ sudo make install + wget https://github.com/libgit2/libgit2/archive/refs/tags/v1.9.1.tar.gz -O libgit2-1.9.1.tar.gz + tar -xzf libgit2-1.9.1.tar.gz + cd libgit2-1.9.1/ + cmake . + make + sudo make install .. seealso:: For detailed instructions on building libgit2 check - https://libgit2.github.com/docs/guides/build-and-link/ + https://libgit2.org/docs/guides/build-and-link/ Now install pygit2, and then verify it is correctly installed: .. code-block:: sh - $ pip install pygit2 - ... - $ python -c 'import pygit2' + pip install pygit2 + # ... + python -c 'import pygit2' Troubleshooting @@ -174,9 +181,9 @@ Troubleshooting The verification step may fail if the dynamic linker does not find the libgit2 library: -.. code-block:: sh +.. code-block:: text - $ python -c 'import pygit2' + python -c 'import pygit2' Traceback (most recent call last): File "", line 1, in File "pygit2/__init__.py", line 29, in @@ -188,9 +195,10 @@ the ``/usr/local/lib`` directory, but the linker does not look for it there. To fix this call ``ldconfig``: .. code-block:: sh + :caption: On Linux using bash - $ sudo ldconfig - $ python -c 'import pygit2' + sudo ldconfig + python -c 'import pygit2' If it still does not work, please open an issue at https://github.com/libgit2/pygit2/issues @@ -222,29 +230,32 @@ Create the virtualenv, activate it, and set the ``LIBGIT2`` environment variable: .. code-block:: sh + :caption: On Linux using bash - $ virtualenv venv - $ source venv/bin/activate - $ export LIBGIT2=$VIRTUAL_ENV + virtualenv venv + source venv/bin/activate + export LIBGIT2=$VIRTUAL_ENV Install libgit2 (see we define the installation prefix): .. code-block:: sh + :caption: On Linux using bash - $ wget https://github.com/libgit2/libgit2/archive/refs/tags/v1.9.0.tar.gz -O libgit2-1.9.0.tar.gz - $ tar xzf libgit2-1.9.0.tar.gz - $ cd libgit2-1.9.0/ - $ cmake . -DCMAKE_INSTALL_PREFIX=$LIBGIT2 - $ cmake --build . --target install + wget https://github.com/libgit2/libgit2/archive/refs/tags/v1.9.1.tar.gz -O libgit2-1.9.1.tar.gz + tar xzf libgit2-1.9.1.tar.gz + cd libgit2-1.9.1/ + cmake . -DCMAKE_INSTALL_PREFIX=$LIBGIT2 + cmake --build . --target install Install pygit2: .. code-block:: sh + :caption: On Linux using bash - $ export LDFLAGS="-Wl,-rpath,'$LIBGIT2/lib',--enable-new-dtags $LDFLAGS" + export LDFLAGS="-Wl,-rpath,'$LIBGIT2/lib',--enable-new-dtags $LDFLAGS" # on OSX: export LDFLAGS="-Wl,-rpath,'$LIBGIT2/lib' $LDFLAGS" - $ pip install pygit2 - $ python -c 'import pygit2' + pip install pygit2 + python -c 'import pygit2' The run-path @@ -258,9 +269,10 @@ this time. So you need to either set ``LD_LIBRARY_PATH`` before using pygit2, like: .. code-block:: sh + :caption: On Linux using bash - $ export LD_LIBRARY_PATH=$LIBGIT2/lib - $ python -c 'import pygit2' + export LD_LIBRARY_PATH=$LIBGIT2/lib + python -c 'import pygit2' Or, like we have done in the instructions above, use the `rpath `_, it hard-codes extra search paths within @@ -268,33 +280,38 @@ the pygit2 extension modules, so you don't need to set ``LD_LIBRARY_PATH`` every time. Verify yourself if curious: .. code-block:: sh + :caption: On Linux using bash - $ readelf --dynamic lib/python2.7/site-packages/pygit2-0.27.0-py2.7-linux-x86_64.egg/pygit2/_pygit2.so | grep PATH + readelf --dynamic lib/python2.7/site-packages/pygit2-0.27.0-py2.7-linux-x86_64.egg/pygit2/_pygit2.so | grep PATH 0x000000000000001d (RUNPATH) Library runpath: [/tmp/venv/lib] Installing on Windows =================================== -`pygit2` for Windows is packaged into wheels and can be easily installed with -`pip`: +``pygit2`` for Windows is packaged into wheels and can be easily installed with +``pip``: .. code-block:: console pip install pygit2 -For development it is also possible to build `pygit2` with `libgit2` from -sources. `libgit2` location is specified by the ``LIBGIT2`` environment -variable. The following recipe shows you how to do it from a bash shell: +For development it is also possible to build ``pygit2`` with ``libgit2`` from +sources. ``libgit2`` location is specified by the ``LIBGIT2`` environment +variable. The following recipe shows you how to do it: -.. code-block:: sh +.. code-block:: pwsh + :caption: On Windows using PowerShell (and CMake v3.21 or newer) + + git clone --depth=1 -b v1.9.1 https://github.com/libgit2/libgit2.git + $env:CMAKE_INSTALL_PREFIX = "C:/Dev/libgit2" + $env:CMAKE_GENERATOR = "Visual Studio 17 2022" + $env:CMAKE_GENERATOR_PLATFORM = "x64" # or "Win32" or "ARM64" + cmake -B libgit2/build -S libgit2 + cmake --build libgit2/build --config release --target install - $ export LIBGIT2=C:/Dev/libgit2 - $ git clone --depth=1 -b v1.9.0 https://github.com/libgit2/libgit2.git - $ cd libgit2 - $ cmake . -DCMAKE_INSTALL_PREFIX=$LIBGIT2 -G "Visual Studio 14 Win64" - $ cmake --build . --config release --target install - $ ctest -v + # let pip know where to find libgit2 when building pygit2 + $env:LIBGIT2 = "$env:CMAKE_INSTALL_PREFIX" At this point, you're ready to execute the generic `pygit2` installation steps described at the start of this page. @@ -321,8 +338,8 @@ XCode and Homebrew are already installed. .. code-block:: sh - $ brew update - $ brew install libgit2 - $ pip3 install pygit2 + brew update + brew install libgit2 + pip3 install pygit2 To build from a non-Homebrew libgit2 follow the guide in `libgit2 within a virtual environment`_. diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 000000000..cbf1e3658 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,2 @@ +sphinx +sphinx-rtd-theme From db088ecb22eb25b1e86f6edae2330e2e2d76d43a Mon Sep 17 00:00:00 2001 From: Brendan <2bndy5@gmail.com> Date: Tue, 14 Oct 2025 23:11:18 -0700 Subject: [PATCH 120/225] fix: replace `PyList_GET_ITEM()` resolves #1434 Instead use [`PyList_GetItemRef()`] for python >= 3.13. Internally, this function is conditionally defined for python < 3.13. [`PyList_GetItemRef()`] is part of the Stable ABI but also propagate errors. Whereas the previous [`PyList_GET_ITEM()`] did not do any error checking and was not part of the Stable ABI. [`PyList_GetItemRef()`]: https://docs.python.org/3/c-api/list.html#c.PyList_GetItemRef [`PyList_GET_ITEM()`]: https://docs.python.org/3/c-api/list.html#c.PyList_GET_ITEM --- src/repository.c | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/src/repository.c b/src/repository.c index 4be614bf7..1e57d8f58 100644 --- a/src/repository.c +++ b/src/repository.c @@ -46,6 +46,17 @@ #include #include +// TODO: remove this function when Python 3.13 becomes the minimum supported version +#if PY_MAJOR_VERSION == 3 && PY_MINOR_VERSION < 13 +static inline PyObject * +PyList_GetItemRef(PyObject *op, Py_ssize_t index) +{ + PyObject *item = PyList_GetItem(op, index); + Py_XINCREF(item); + return item; +} +#endif + extern PyObject *GitError; extern PyTypeObject IndexType; @@ -599,8 +610,11 @@ merge_base_xxx(Repository *self, PyObject *args, git_merge_base_xxx_t git_merge_ } for (; i < commit_oid_count; i++) { - py_commit_oid = PyList_GET_ITEM(py_commit_oids, i); + py_commit_oid = PyList_GetItemRef(py_commit_oids, i); + if (py_commit_oid == NULL) + goto out; err = py_oid_to_git_oid_expand(self->repo, py_commit_oid, &commit_oids[i]); + Py_DECREF(py_commit_oid); if (err < 0) goto out; } @@ -1052,8 +1066,11 @@ Repository_create_commit(Repository *self, PyObject *args) goto out; } for (; i < parent_count; i++) { - py_parent = PyList_GET_ITEM(py_parents, i); + py_parent = PyList_GetItemRef(py_parents, i); + if (py_parent == NULL) + goto out; len = py_oid_to_git_oid(py_parent, &oid); + Py_DECREF(py_parent); if (len == 0) goto out; err = git_commit_lookup_prefix(&parents[i], self->repo, &oid, len); @@ -1135,8 +1152,11 @@ Repository_create_commit_string(Repository *self, PyObject *args) goto out; } for (; i < parent_count; i++) { - py_parent = PyList_GET_ITEM(py_parents, i); + py_parent = PyList_GetItemRef(py_parents, i); + if (py_parent == NULL) + goto out; len = py_oid_to_git_oid(py_parent, &oid); + Py_DECREF(py_parent); if (len == 0) goto out; err = git_commit_lookup_prefix(&parents[i], self->repo, &oid, len); From 2a9058011642ae8e5a3a6d9acd12e074a0ca1f80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Tue, 21 Oct 2025 09:03:57 +0200 Subject: [PATCH 121/225] Drop support for Python 3.10 --- CHANGELOG.md | 12 +++++------- README.md | 2 +- docs/index.rst | 2 +- docs/install.rst | 8 ++++++-- pyproject.toml | 2 +- setup.py | 4 ++-- 6 files changed, 16 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f504a23f..8fdc5d496 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,21 +1,19 @@ # 1.19.0 (UNRELEASED) -- Add support for Python 3.14 +- Add support for Python 3.14 and drop 3.10 -- Support threaded builds +- Support threaded builds (experimental) [#1430](https://github.com/libgit2/pygit2/pull/1430) -- Add Linux wheels for musl on AArch64 +- Add Linux musl wheels for AArch64 -- Add Windows wheels for AArch64 +- Add Windows wheels for AArch64; + CI: build Windows wheels with cibuildwheel on GitHub [#1423](https://github.com/libgit2/pygit2/pull/1423) - New `Repository.transaction()` context manager, returns new `ReferenceTransaction` [#1420](https://github.com/libgit2/pygit2/pull/1420) -- CI: Move Windows wheels to cibuildwheel and GitHub - [#1423](https://github.com/libgit2/pygit2/pull/1423) - - CI: add GitHub releases and other improvements [#1433](https://github.com/libgit2/pygit2/pull/1433) [#1432](https://github.com/libgit2/pygit2/pull/1432) diff --git a/README.md b/README.md index 5286c52a6..3d0891e92 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # pygit2 - libgit2 bindings in Python Bindings to the libgit2 shared library, implements Git plumbing. -Supports Python 3.10 to 3.13 and PyPy3 7.3+ +Supports Python 3.11 to 3.14 and PyPy3 7.3+ [![test-ci-badge][test-ci-badge]][test-ci-link] [![deploy-ci-badge][deploy-ci-badge]][deploy-ci-link] diff --git a/docs/index.rst b/docs/index.rst index 838fd07af..bc922c548 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -3,7 +3,7 @@ pygit2 - libgit2 bindings in Python ###################################################################### Bindings to the libgit2 shared library, implements Git plumbing. -Supports Python 3.10 to 3.13 and PyPy3 7.3+ +Supports Python 3.11 to 3.14 and PyPy3 7.3+ Links ===================================== diff --git a/docs/install.rst b/docs/install.rst index 0f4818f8a..843149e61 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -46,7 +46,7 @@ Requirements Supported versions of Python: -- Python 3.10 to 3.13 +- Python 3.11 to 3.14 - PyPy3 7.3+ Python requirements (these are specified in ``setup.py``): @@ -83,7 +83,7 @@ of Python and the required libgit2 version. +-------------+----------------+------------+ | pygit2 | Python | libgit2 | +-------------+----------------+------------+ -| 1.19 | 3.10 - 3.14(t) | 1.9 | +| 1.19 | 3.11 - 3.14(t) | 1.9 | +-------------+----------------+------------+ | 1.17 - 1.18 | 3.10 - 3.13 | 1.9 | +-------------+----------------+------------+ @@ -127,6 +127,10 @@ of Python and the required libgit2 version. the release notes for incompatible changes before upgrading to a new release. +.. warning:: + + Threaded builds are experimental, do not use them in production. + History: the 0.x series ----------------------- diff --git a/pyproject.toml b/pyproject.toml index 2db7c9576..09fbaaf7d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,7 +60,7 @@ environment.LIBGIT2 = "C:/libgit2_install_arm64" [tool.ruff] extend-exclude = [ ".cache", ".coverage", "build", "site-packages", "venv*"] -target-version = "py310" # oldest supported Python version +target-version = "py311" # oldest supported Python version [tool.ruff.lint] select = ["E4", "E7", "E9", "F", "I", "UP035", "UP007"] diff --git a/setup.py b/setup.py index f8b934727..d92087cd1 100644 --- a/setup.py +++ b/setup.py @@ -77,10 +77,10 @@ def exclude(line: str) -> bool: 'Intended Audience :: Developers', 'Programming Language :: Python', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', 'Programming Language :: Python :: 3.13', + 'Programming Language :: Python :: 3.14', 'Programming Language :: Python :: Implementation :: PyPy', 'Programming Language :: Python :: Implementation :: CPython', 'Topic :: Software Development :: Version Control', @@ -153,7 +153,7 @@ def run(self) -> None: cffi_modules=['pygit2/_run.py:ffi'], ext_modules=ext_modules, # Requirements - python_requires='>=3.10', + python_requires='>=3.11', setup_requires=['cffi>=2.0'], install_requires=['cffi>=2.0'], # URLs From 45423e992d7b24a4224b1e150efe1650281980d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Thu, 23 Oct 2025 10:52:11 +0200 Subject: [PATCH 122/225] Release 1.19.0 --- .mailmap | 2 ++ AUTHORS.md | 7 ++++--- CHANGELOG.md | 6 ++++-- docs/conf.py | 2 +- pygit2/_build.py | 2 +- 5 files changed, 12 insertions(+), 7 deletions(-) diff --git a/.mailmap b/.mailmap index dc40e8c49..9ee281b19 100644 --- a/.mailmap +++ b/.mailmap @@ -3,6 +3,7 @@ Alexander Linne Anatoly Techtonik Bob Carroll Brandon Milton +Brendan Doherty <2bndy5@gmail.com> CJ Steiner <47841949+clintonsteiner@users.noreply.github.com> Carlos Martín Nieto Christian Boos @@ -26,6 +27,7 @@ Nicolas Rybowski Óscar San José Petr Hosek Phil Schleihauf +Raphael Medaer Richo Healey Robert Hölzl Saugat Pachhai diff --git a/AUTHORS.md b/AUTHORS.md index 0b9e86e70..fffba38ed 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -15,6 +15,7 @@ Authors: Daniel Rodríguez Troitiño Peter Rowlands Richo Healey + Brendan Doherty Christian Boos Julien Miotte Nick Hynes @@ -50,7 +51,7 @@ Authors: Mathieu Parent Michał Kępień Nicolas Dandrimont - Raphael Medaer (Escaux) + Raphael Medaer Yaroslav Halchenko Anatoly Techtonik Andrew Olsen @@ -64,6 +65,7 @@ Authors: Santiago Perez De Rosso Sebastian Thiel Thom Wiggers + WANG Xuerui William Manley Alexander Linne Alok Singhal @@ -89,7 +91,6 @@ Authors: Sukhman Bhuller Thomas Kluyver Tyler Cipriani - WANG Xuerui Alex Chamberlain Alexander Bayandin Amit Bakshi @@ -98,6 +99,7 @@ Authors: Ben Davis CJ Steiner Colin Watson + Craig de Stigter Dan Yeaw Dustin Raimondi Eric Schrijver @@ -156,7 +158,6 @@ Authors: Chris Rebert Christopher Hunt Claudio Jolowicz - Craig de Stigter Cristian Hotea Cyril Jouve Dan Cecile diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fdc5d496..941588daa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,10 @@ -# 1.19.0 (UNRELEASED) +# 1.19.0 (2025-10-23) - Add support for Python 3.14 and drop 3.10 - Support threaded builds (experimental) [#1430](https://github.com/libgit2/pygit2/pull/1430) + [#1435](https://github.com/libgit2/pygit2/pull/1435) - Add Linux musl wheels for AArch64 @@ -20,7 +21,8 @@ [#1425](https://github.com/libgit2/pygit2/pull/1425) [#1431](https://github.com/libgit2/pygit2/pull/1431) -- Updated .gitignore +- Documentation improvements and other changes + [#1426](https://github.com/libgit2/pygit2/pull/1426) [#1424](https://github.com/libgit2/pygit2/pull/1424) Breaking changes: diff --git a/docs/conf.py b/docs/conf.py index cf7980861..7f6bdfd4e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,7 +23,7 @@ # author = '' # The full version, including alpha/beta/rc tags -release = '1.18.2' +release = '1.19.0' # -- General configuration --------------------------------------------------- diff --git a/pygit2/_build.py b/pygit2/_build.py index 2baa021b5..47977aac9 100644 --- a/pygit2/_build.py +++ b/pygit2/_build.py @@ -34,7 +34,7 @@ # # The version number of pygit2 # -__version__ = '1.18.2' +__version__ = '1.19.0' # From a85f6fb274b237cb76d686b57f6865a90a3b3ef8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Thu, 23 Oct 2025 13:50:22 +0200 Subject: [PATCH 123/225] CI: build ppc wheels always --- .github/workflows/wheels.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 3cfb8d461..59240dd5c 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -60,7 +60,6 @@ jobs: build_wheels_ppc: name: Wheels for linux-ppc - if: github.ref == 'refs/heads/master' runs-on: ubuntu-24.04 steps: From e051a146b8bb01666564ee69e6075ce5224d610b Mon Sep 17 00:00:00 2001 From: Iliyas Jorio Date: Tue, 19 Aug 2025 13:41:00 +0200 Subject: [PATCH 124/225] git_patch_from_diff may return a NULL patch without an error --- pygit2/_pygit2.pyi | 4 ++-- src/diff.c | 5 +++++ test/test_diff.py | 31 +++++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index 944f18ce2..0dda06364 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -432,8 +432,8 @@ class Diff: def from_c(diff, repo) -> Diff: ... @staticmethod def parse_diff(git_diff: str | bytes) -> Diff: ... - def __getitem__(self, index: int) -> Patch: ... # Diff_getitem - def __iter__(self) -> Iterator[Patch]: ... # -> DiffIter + def __getitem__(self, index: int) -> Patch | None: ... # Diff_getitem + def __iter__(self) -> Iterator[Patch | None]: ... # -> DiffIter def __len__(self) -> int: ... class DiffDelta: diff --git a/src/diff.c b/src/diff.c index 0bc7c6136..11e0f9972 100644 --- a/src/diff.c +++ b/src/diff.c @@ -533,6 +533,11 @@ diff_get_patch_byindex(git_diff *diff, size_t idx) if (err < 0) return Error_set(err); + /* libgit2 may decide not to create a patch if the file is + "unchanged or binary", but this isn't an error case */ + if (patch == NULL) + Py_RETURN_NONE; + return (PyObject*) wrap_patch(patch, NULL, NULL); } diff --git a/test/test_diff.py b/test/test_diff.py index 838d4dd08..4cd676966 100644 --- a/test/test_diff.py +++ b/test/test_diff.py @@ -28,6 +28,7 @@ import textwrap from collections.abc import Iterator from itertools import chain +from pathlib import Path import pytest @@ -459,3 +460,33 @@ def test_diff_blobs(emptyrepo: Repository) -> None: assert diff_one_context_line.text == PATCH_BLOBS_ONE_CONTEXT_LINE diff_all_together = repo.diff(blob1, blob2, context_lines=1, interhunk_lines=1) assert diff_all_together.text == PATCH_BLOBS_DEFAULT + + +def test_diff_unchanged_file_no_patch(testrepo) -> None: + repo = testrepo + + # Convert hello.txt line endings to CRLF + path = Path(repo.workdir) / 'hello.txt' + data = path.read_bytes() + data = data.replace(b'\n', b'\r\n') + path.write_bytes(data) + + # Enable CRLF filter + repo.config['core.autocrlf'] = 'input' + + diff = repo.diff() + assert len(diff) == 1 + + # Get patch #0 in the same diff several times. + # git_patch_from_diff eventually decides that the file is "unchanged"; + # it returns a NULL patch in this case. + # https://libgit2.org/docs/reference/main/patch/git_patch_from_diff + for i in range(10): # loop typically exits in the third iteration + patch = diff[0] + if patch is None: # libgit2 decides the file is unchanged + break + assert patch.delta.new_file.path == path.name + assert patch.text == '' # no content change (just line endings) + else: + # Didn't find the edge case that this test is supposed to exercise. + assert False, 'libgit2 rebuilt a new patch every time' From e5b21e05ea80157f496e47e2107116aabbbe7ead Mon Sep 17 00:00:00 2001 From: Iliyas Jorio Date: Wed, 29 Oct 2025 19:43:26 +0100 Subject: [PATCH 125/225] Fix mypy warnings in tests that assume Diff.__iter__ can't yield None --- test/test_diff.py | 60 +++++++++++++++++++++++++---------------- test/test_repository.py | 4 ++- test/utils.py | 14 +++++++++- 3 files changed, 53 insertions(+), 25 deletions(-) diff --git a/test/test_diff.py b/test/test_diff.py index 4cd676966..cdd596884 100644 --- a/test/test_diff.py +++ b/test/test_diff.py @@ -36,6 +36,8 @@ from pygit2 import Diff, Repository from pygit2.enums import DeltaStatus, DiffFlag, DiffOption, DiffStatsFormat, FileMode +from .utils import diff_safeiter + COMMIT_SHA1_1 = '5fe808e8953c12735680c257f56600cb0de44b10' COMMIT_SHA1_2 = 'c2792cfa289ae6321ecf2cd5806c2194b0fd070c' COMMIT_SHA1_3 = '2cdae28389c059815e951d0bb9eed6533f61a46b' @@ -177,11 +179,11 @@ def test_diff_empty_index(dirtyrepo: Repository) -> None: head = repo[repo.lookup_reference('HEAD').resolve().target] diff = head.tree.diff_to_index(repo.index) - files = [patch.delta.new_file.path for patch in diff] + files = [patch.delta.new_file.path for patch in diff_safeiter(diff)] assert DIFF_HEAD_TO_INDEX_EXPECTED == files diff = repo.diff('HEAD', cached=True) - files = [patch.delta.new_file.path for patch in diff] + files = [patch.delta.new_file.path for patch in diff_safeiter(diff)] assert DIFF_HEAD_TO_INDEX_EXPECTED == files @@ -190,17 +192,17 @@ def test_workdir_to_tree(dirtyrepo: Repository) -> None: head = repo[repo.lookup_reference('HEAD').resolve().target] diff = head.tree.diff_to_workdir() - files = [patch.delta.new_file.path for patch in diff] + files = [patch.delta.new_file.path for patch in diff_safeiter(diff)] assert DIFF_HEAD_TO_WORKDIR_EXPECTED == files diff = repo.diff('HEAD') - files = [patch.delta.new_file.path for patch in diff] + files = [patch.delta.new_file.path for patch in diff_safeiter(diff)] assert DIFF_HEAD_TO_WORKDIR_EXPECTED == files def test_index_to_workdir(dirtyrepo: Repository) -> None: diff = dirtyrepo.diff() - files = [patch.delta.new_file.path for patch in diff] + files = [patch.delta.new_file.path for patch in diff_safeiter(diff)] assert DIFF_INDEX_TO_WORK_EXPECTED == files @@ -218,15 +220,15 @@ def test_diff_empty_index_bare(barerepo: Repository) -> None: head = repo[repo.lookup_reference('HEAD').resolve().target] diff = barerepo.index.diff_to_tree(head.tree) - files = [patch.delta.new_file.path.split('/')[0] for patch in diff] + files = [patch.delta.new_file.path.split('/')[0] for patch in diff_safeiter(diff)] assert [x.name for x in head.tree] == files diff = head.tree.diff_to_index(repo.index) - files = [patch.delta.new_file.path.split('/')[0] for patch in diff] + files = [patch.delta.new_file.path.split('/')[0] for patch in diff_safeiter(diff)] assert [x.name for x in head.tree] == files diff = repo.diff('HEAD', cached=True) - files = [patch.delta.new_file.path.split('/')[0] for patch in diff] + files = [patch.delta.new_file.path.split('/')[0] for patch in diff_safeiter(diff)] assert [x.name for x in head.tree] == files @@ -236,9 +238,10 @@ def test_diff_tree(barerepo: Repository) -> None: def _test(diff: Diff) -> None: assert diff is not None - assert 2 == sum(map(lambda x: len(x.hunks), diff)) + assert 2 == sum(map(lambda x: len(x.hunks), diff_safeiter(diff))) patch = diff[0] + assert patch is not None hunk = patch.hunks[0] assert hunk.old_start == 1 assert hunk.old_lines == 1 @@ -268,16 +271,16 @@ def test_diff_empty_tree(barerepo: Repository) -> None: diff = commit_a.tree.diff_to_tree() def get_context_for_lines(diff: Diff) -> Iterator[str]: - hunks = chain.from_iterable(map(lambda x: x.hunks, diff)) + hunks = chain.from_iterable(map(lambda x: x.hunks, diff_safeiter(diff))) lines = chain.from_iterable(map(lambda x: x.lines, hunks)) return map(lambda x: x.origin, lines) - entries = [p.delta.new_file.path for p in diff] + entries = [p.delta.new_file.path for p in diff_safeiter(diff)] assert all(commit_a.tree[x] for x in entries) assert all('-' == x for x in get_context_for_lines(diff)) diff_swaped = commit_a.tree.diff_to_tree(swap=True) - entries = [p.delta.new_file.path for p in diff_swaped] + entries = [p.delta.new_file.path for p in diff_safeiter(diff_swaped)] assert all(commit_a.tree[x] for x in entries) assert all('+' == x for x in get_context_for_lines(diff_swaped)) @@ -294,11 +297,15 @@ def test_diff_tree_opts(barerepo: Repository) -> None: for flag in [DiffOption.IGNORE_WHITESPACE, DiffOption.IGNORE_WHITESPACE_EOL]: diff = commit_c.tree.diff_to_tree(commit_d.tree, flag) assert diff is not None - assert 0 == len(diff[0].hunks) + patch = diff[0] + assert patch is not None + assert 0 == len(patch.hunks) diff = commit_c.tree.diff_to_tree(commit_d.tree) assert diff is not None - assert 1 == len(diff[0].hunks) + patch = diff[0] + assert patch is not None + assert 1 == len(patch.hunks) def test_diff_merge(barerepo: Repository) -> None: @@ -311,13 +318,14 @@ def test_diff_merge(barerepo: Repository) -> None: diff_c = commit_b.tree.diff_to_tree(commit_c.tree) assert diff_c is not None - assert 'b' not in [patch.delta.new_file.path for patch in diff_b] - assert 'b' in [patch.delta.new_file.path for patch in diff_c] + assert 'b' not in [patch.delta.new_file.path for patch in diff_safeiter(diff_b)] + assert 'b' in [patch.delta.new_file.path for patch in diff_safeiter(diff_c)] diff_b.merge(diff_c) - assert 'b' in [patch.delta.new_file.path for patch in diff_b] + assert 'b' in [patch.delta.new_file.path for patch in diff_safeiter(diff_b)] patch = diff_b[0] + assert patch is not None hunk = patch.hunks[0] assert hunk.old_start == 1 assert hunk.old_lines == 1 @@ -341,6 +349,7 @@ def test_diff_ids(barerepo: Repository) -> None: commit_a = barerepo[COMMIT_SHA1_1] commit_b = barerepo[COMMIT_SHA1_2] patch = commit_a.tree.diff_to_tree(commit_b.tree)[0] + assert patch is not None delta = patch.delta assert delta.old_file.id == '7f129fd57e31e935c6d60a0c794efe4e6927664b' assert delta.new_file.id == 'af431f20fc541ed6d5afede3e2dc7160f6f01f16' @@ -358,6 +367,7 @@ def test_hunk_content(barerepo: Repository) -> None: commit_a = barerepo[COMMIT_SHA1_1] commit_b = barerepo[COMMIT_SHA1_2] patch = commit_a.tree.diff_to_tree(commit_b.tree)[0] + assert patch is not None hunk = patch.hunks[0] lines = (f'{x.origin} {x.content}' for x in hunk.lines) assert HUNK_EXPECTED == ''.join(lines) @@ -372,11 +382,15 @@ def test_find_similar(barerepo: Repository) -> None: # ~ Must pass INCLUDE_UNMODIFIED if you expect to emulate # ~ --find-copies-harder during rename transformion... diff = commit_a.tree.diff_to_tree(commit_b.tree, DiffOption.INCLUDE_UNMODIFIED) - assert all(x.delta.status != DeltaStatus.RENAMED for x in diff) - assert all(x.delta.status_char() != 'R' for x in diff) + assert all( + patch.delta.status != DeltaStatus.RENAMED for patch in diff_safeiter(diff) + ) + assert all(patch.delta.status_char() != 'R' for patch in diff_safeiter(diff)) diff.find_similar() - assert any(x.delta.status == DeltaStatus.RENAMED for x in diff) - assert any(x.delta.status_char() == 'R' for x in diff) + assert any( + patch.delta.status == DeltaStatus.RENAMED for patch in diff_safeiter(diff) + ) + assert any(patch.delta.status_char() == 'R' for patch in diff_safeiter(diff)) def test_diff_stats(barerepo: Repository) -> None: @@ -399,7 +413,7 @@ def test_deltas(barerepo: Repository) -> None: commit_b = barerepo[COMMIT_SHA1_2] diff = commit_a.tree.diff_to_tree(commit_b.tree) deltas = list(diff.deltas) - patches = list(diff) + patches = list(diff_safeiter(diff)) assert len(deltas) == len(patches) for i, delta in enumerate(deltas): patch_delta = patches[i].delta @@ -462,7 +476,7 @@ def test_diff_blobs(emptyrepo: Repository) -> None: assert diff_all_together.text == PATCH_BLOBS_DEFAULT -def test_diff_unchanged_file_no_patch(testrepo) -> None: +def test_diff_unchanged_file_no_patch(testrepo: Repository) -> None: repo = testrepo # Convert hello.txt line endings to CRLF diff --git a/test/test_repository.py b/test/test_repository.py index 87bd4fbdd..68913ff5c 100644 --- a/test/test_repository.py +++ b/test/test_repository.py @@ -456,7 +456,9 @@ def stash_pathspecs(paths: list[str]) -> bool: ) stash_commit = testrepo[stash_id].peel(pygit2.Commit) stash_diff = testrepo.diff(stash_commit.parents[0], stash_commit) - stash_files = set(patch.delta.new_file.path for patch in stash_diff) + stash_files = set( + patch.delta.new_file.path for patch in utils.diff_safeiter(stash_diff) + ) return stash_files == set(paths) # Stash a modified file diff --git a/test/utils.py b/test/utils.py index a3b14cfdb..48dc7c329 100644 --- a/test/utils.py +++ b/test/utils.py @@ -30,7 +30,7 @@ import stat import sys import zipfile -from collections.abc import Callable +from collections.abc import Callable, Iterator from pathlib import Path from types import TracebackType from typing import Any, Optional, ParamSpec, TypeVar @@ -103,6 +103,18 @@ def rmtree(path: str | Path) -> None: shutil.rmtree(path, onerror=force_rm_handle) +def diff_safeiter(diff: pygit2.Diff) -> Iterator[pygit2.Patch]: + """ + In rare cases, Diff.__iter__ may yield None (see diff_get_patch_byindex). + To make mypy happy, use this iterator instead of Diff.__iter__ to ensure + that all patches in a Diff are valid Patch objects, not None. + """ + for patch in diff: + if patch is None: + raise TypeError('patch is None') + yield patch + + class TemporaryRepository: def __init__(self, name: str, tmp_path: Path) -> None: self.name = name From d138ee5e315234f4a817dbaf057394fafe973bf1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 1 Nov 2025 15:24:15 +0000 Subject: [PATCH 126/225] chore(CI): bump the actions group with 2 updates Bumps the actions group with 2 updates: [actions/upload-artifact](https://github.com/actions/upload-artifact) and [actions/download-artifact](https://github.com/actions/download-artifact). Updates `actions/upload-artifact` from 4 to 5 - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4...v5) Updates `actions/download-artifact` from 5 to 6 - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions - dependency-name: actions/download-artifact dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions ... Signed-off-by: dependabot[bot] --- .github/workflows/wheels.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 59240dd5c..adef34c7a 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -53,7 +53,7 @@ jobs: CIBW_ARCHS_WINDOWS: ${{ matrix.name == 'windows-x86' && 'auto32' || 'native' }} run: python -m cibuildwheel --output-dir wheelhouse - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v5 with: name: wheels-${{ matrix.name }} path: ./wheelhouse/*.whl @@ -85,7 +85,7 @@ jobs: CIBW_ARCHS: ppc64le CIBW_ENVIRONMENT: LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.1 LIBGIT2=/project/ci - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v5 with: name: wheels-linux-ppc path: ./wheelhouse/*.whl @@ -107,7 +107,7 @@ jobs: - name: Build sdist run: pipx run build --sdist --outdir dist - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v5 with: name: wheels-sdist path: dist/* @@ -117,7 +117,7 @@ jobs: run: python .github/workflows/parse_release_notes.py - name: Upload Release Notes - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: release-notes path: ReleaseNotes.md @@ -132,7 +132,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/download-artifact@v5 + - uses: actions/download-artifact@v6 with: path: dist pattern: wheels-* @@ -148,7 +148,7 @@ jobs: runs-on: ubuntu-24.04 steps: - - uses: actions/download-artifact@v5 + - uses: actions/download-artifact@v6 with: path: dist pattern: wheels-* @@ -163,7 +163,7 @@ jobs: password: ${{ secrets.PYPI_API_TOKEN }} skip-existing: true - - uses: actions/download-artifact@v5 + - uses: actions/download-artifact@v6 with: name: release-notes - name: Create GitHub Release From a4ec27e2f6921d7a63791383c110d60b745e211d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Sun, 30 Nov 2025 19:00:36 +0100 Subject: [PATCH 127/225] Update cibuildwheel --- .github/workflows/wheels.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index adef34c7a..13bfaf564 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -46,7 +46,7 @@ jobs: python-version: '3.13' - name: Install cibuildwheel - run: python -m pip install cibuildwheel~=3.1.1 + run: python -m pip install cibuildwheel~=3.3 - name: Build wheels env: @@ -77,7 +77,7 @@ jobs: platforms: linux/ppc64le - name: Install cibuildwheel - run: python -m pip install cibuildwheel~=3.1.1 + run: python -m pip install cibuildwheel~=3.3 - name: Build wheels run: python -m cibuildwheel --output-dir wheelhouse From ec6e6ea6146c932a61b815cb484dcb1e00c5257b Mon Sep 17 00:00:00 2001 From: Brendan <2bndy5@gmail.com> Date: Sun, 30 Nov 2025 20:36:48 -0800 Subject: [PATCH 128/225] refactor: organize dev-time dependencies --- .github/dependabot.yml | 16 ++++++++-------- .github/workflows/wheels.yml | 2 +- build.sh | 2 +- requirements-test.txt | 2 -- requirements-typing.txt | 2 ++ requirements-wheel.txt | 1 + 6 files changed, 13 insertions(+), 12 deletions(-) create mode 100644 requirements-typing.txt create mode 100644 requirements-wheel.txt diff --git a/.github/dependabot.yml b/.github/dependabot.yml index faf726ef8..a3d627a7c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -15,11 +15,11 @@ updates: actions: patterns: - "*" - # - package-ecosystem: pip - # directory: .github/ - # schedule: - # interval: "monthly" - # groups: - # pip: - # patterns: - # - "*" + - package-ecosystem: pip + directory: .github/ + schedule: + interval: "monthly" + groups: + pip: + patterns: + - "*" diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 13bfaf564..a951b3e79 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -77,7 +77,7 @@ jobs: platforms: linux/ppc64le - name: Install cibuildwheel - run: python -m pip install cibuildwheel~=3.3 + run: python -m pip install -r requirements-wheel.txt - name: Build wheels run: python -m cibuildwheel --output-dir wheelhouse diff --git a/build.sh b/build.sh index 3c9fb68a7..6ac40d4bf 100644 --- a/build.sh +++ b/build.sh @@ -268,7 +268,7 @@ if [ "$1" = "mypy" ]; then if [ -n "$WHEELDIR" ]; then $PREFIX/bin/pip install $WHEELDIR/pygit2*-$PYTHON_TAG-*.whl fi - $PREFIX/bin/pip install -r requirements-test.txt + $PREFIX/bin/pip install -r requirements-test.txt -r requirements-typing.txt $PREFIX/bin/mypy pygit2 test fi diff --git a/requirements-test.txt b/requirements-test.txt index 15e9ecf87..9955deccd 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,4 +1,2 @@ pytest pytest-cov -mypy -types-cffi diff --git a/requirements-typing.txt b/requirements-typing.txt new file mode 100644 index 000000000..c16ede753 --- /dev/null +++ b/requirements-typing.txt @@ -0,0 +1,2 @@ +mypy +types-cffi diff --git a/requirements-wheel.txt b/requirements-wheel.txt new file mode 100644 index 000000000..e14d9f0a7 --- /dev/null +++ b/requirements-wheel.txt @@ -0,0 +1 @@ +cibuildwheel ~= 3.3 From 6d8ca7557573fc34784d5621c2eacc970190ff3f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Dec 2025 09:56:27 +0000 Subject: [PATCH 129/225] chore(CI): bump actions/checkout from 5 to 6 in the actions group Bumps the actions group with 1 update: [actions/checkout](https://github.com/actions/checkout). Updates `actions/checkout` from 5 to 6 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions ... Signed-off-by: dependabot[bot] --- .github/workflows/codespell.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/tests.yml | 2 +- .github/workflows/wheels.yml | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml index fcf5693ca..58f0813a3 100644 --- a/.github/workflows/codespell.yml +++ b/.github/workflows/codespell.yml @@ -18,7 +18,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Annotate locations with typos uses: codespell-project/codespell-problem-matcher@v1 - name: Codespell diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 89921a979..e771e27e7 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -14,7 +14,7 @@ jobs: steps: - name: Checkout pygit2 - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 663034327..e8382bce8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,7 +14,7 @@ jobs: if: github.ref == 'refs/heads/master' steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Build & test uses: uraimo/run-on-arch-action@v3 diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index a951b3e79..8445527d5 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -36,7 +36,7 @@ jobs: os: windows-11-arm steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: # avoid leaking credentials in uploaded artifacts persist-credentials: false @@ -63,7 +63,7 @@ jobs: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: # avoid leaking credentials in uploaded artifacts persist-credentials: false @@ -95,7 +95,7 @@ jobs: outputs: release_title: ${{ steps.parse_changelog.outputs.release_title }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: # avoid leaking credentials in uploaded artifacts persist-credentials: false From 890730c1394c4b36b2c07f8679cd4c39e612fc75 Mon Sep 17 00:00:00 2001 From: Brendan <2bndy5@gmail.com> Date: Tue, 2 Dec 2025 09:23:05 -0800 Subject: [PATCH 130/225] build(dev): build separate wheels for Mac OS architectures The `macos-13` (GitHub-hosted) runner will be unavailable by Dec 4 2025. See the [GitHub notice]. Apparently, Apple has deprecated support for Intel-based x86_64 architecture. This means building a universal wheel will no longer be useful. This patch builds a separate wheel for x86_64 and arm64 architectures. Doing this also allows `cibuildwheel` to properly test each architecture individually. Whereas before, there were warnings about tests not getting run for arm64 on a x86_64 runner. > [!WARNING] > Beware, the github-hosted `macos-15-intel` runner is provided to sunset x86_64 MacOS runners. > The `macos-15-intel` runner will become unavailable in August 2027. > Afterward, the CI will only be able to compile arm64 wheels for MacOS. [GitHub notice]: https://github.com/actions/runner-images/issues/13046 --- .github/workflows/wheels.yml | 6 ++++-- pyproject.toml | 1 - 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 8445527d5..ab72ca703 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -25,8 +25,10 @@ jobs: os: ubuntu-24.04 - name: linux-arm os: ubuntu-24.04-arm - - name: macos - os: macos-13 + - name: macos-intel + os: macos-15-intel + - name: macos-arm + os: macos-15 - name: windows-x64 os: windows-latest - name: windows-x86 diff --git a/pyproject.toml b/pyproject.toml index 09fbaaf7d..7dd52578f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,6 @@ select = "*-musllinux*" repair-wheel-command = "LD_LIBRARY_PATH=/project/ci/lib auditwheel repair -w {dest_dir} {wheel}" [tool.cibuildwheel.macos] -archs = ["universal2"] environment = {LIBGIT2_VERSION="1.9.1", LIBSSH2_VERSION="1.11.1", OPENSSL_VERSION="3.3.3", LIBGIT2="/Users/runner/work/pygit2/pygit2/ci"} repair-wheel-command = "DYLD_LIBRARY_PATH=/Users/runner/work/pygit2/pygit2/ci/lib delocate-wheel --require-archs {delocate_archs} -w {dest_dir} {wheel}" From 48e9fec344530a52211d9f787766c6362ae0428b Mon Sep 17 00:00:00 2001 From: Brendan <2bndy5@gmail.com> Date: Wed, 3 Dec 2025 21:05:31 -0800 Subject: [PATCH 131/225] test: skip tests for pypy wheel on MacOS arm64 --- .github/workflows/wheels.yml | 2 ++ pyproject.toml | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index ab72ca703..ca81c6fe3 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -19,6 +19,8 @@ jobs: name: Wheels for ${{ matrix.name }} runs-on: ${{ matrix.os }} strategy: + # let other jobs in matrix complete if one fails + fail-fast: false matrix: include: - name: linux-amd diff --git a/pyproject.toml b/pyproject.toml index 7dd52578f..83425468a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,9 @@ test-command = "pytest" test-sources = ["test", "pytest.ini"] before-test = "pip install -r {project}/requirements-test.txt" # Will avoid testing on emulated architectures (specifically ppc64le) -test-skip = "*-*linux_ppc64le" +# Also, skip testing on pypy on macOS arm64 due issue with bootstrapping git config paths +# see https://github.com/libgit2/pygit2/issues/1442 +test-skip = "*-*linux_ppc64le pp*-macosx_arm64" [tool.cibuildwheel.linux] repair-wheel-command = "LD_LIBRARY_PATH=/project/ci/lib64 auditwheel repair -w {dest_dir} {wheel}" From 040a65a7622b940ad8706ad9c7422ba27523e917 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Wed, 24 Dec 2025 16:42:48 +0100 Subject: [PATCH 132/225] Update changelog --- CHANGELOG.md | 14 ++++++++++++++ pyproject.toml | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 941588daa..c888846d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +# 1.19.1 (UNRELEASED) + +- Fix diff getitem/iter for unachanged or binary files, return `None` + [#1412](https://github.com/libgit2/pygit2/pull/1412) + +- CI: separate macOS wheels (arm and intel instead of universal) + [#1441](https://github.com/libgit2/pygit2/pull/1441) + +- CI: pypy wheels on macOS + +- CI: fix tests with pypy + [#1437](https://github.com/libgit2/pygit2/pull/1437) + + # 1.19.0 (2025-10-23) - Add support for Python 3.14 and drop 3.10 diff --git a/pyproject.toml b/pyproject.toml index 83425468a..eef210bc4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ test-command = "pytest" test-sources = ["test", "pytest.ini"] before-test = "pip install -r {project}/requirements-test.txt" # Will avoid testing on emulated architectures (specifically ppc64le) -# Also, skip testing on pypy on macOS arm64 due issue with bootstrapping git config paths +# Also, skip testing pypy on macOS arm64 due issue with bootstrapping git config paths # see https://github.com/libgit2/pygit2/issues/1442 test-skip = "*-*linux_ppc64le pp*-macosx_arm64" From 60248b5ab897a9e7bdadb5f1e353477ec9bd49c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Fri, 26 Dec 2025 09:56:33 +0100 Subject: [PATCH 133/225] Update libgit2 and openssl --- .github/workflows/lint.yml | 2 +- .github/workflows/tests.yml | 2 +- .github/workflows/wheels.yml | 2 +- Makefile | 2 +- build.sh | 8 ++++---- docs/install.rst | 16 ++++++++-------- pyproject.toml | 6 +++--- 7 files changed, 19 insertions(+), 19 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e771e27e7..0b5290c9e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -31,4 +31,4 @@ jobs: run: ruff check - name: Check typing with mypy - run: LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.1 /bin/sh build.sh mypy + run: LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.2 /bin/sh build.sh mypy diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e8382bce8..1c02cb843 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -25,5 +25,5 @@ jobs: apt-get update -q -y apt-get install -q -y cmake libssl-dev python3-dev python3-venv wget run: | - LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.1 /bin/sh build.sh test + LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.2 /bin/sh build.sh test continue-on-error: true # Tests are expected to fail, see issue #812 diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index ca81c6fe3..b6716c66d 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -87,7 +87,7 @@ jobs: run: python -m cibuildwheel --output-dir wheelhouse env: CIBW_ARCHS: ppc64le - CIBW_ENVIRONMENT: LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.1 LIBGIT2=/project/ci + CIBW_ENVIRONMENT: LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.2 LIBGIT2=/project/ci - uses: actions/upload-artifact@v5 with: diff --git a/Makefile b/Makefile index 9cfe1226f..80107aae7 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ .PHONY: build html build: - OPENSSL_VERSION=3.3.3 LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.1 sh build.sh + OPENSSL_VERSION=3.5.4 LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.2 sh build.sh html: build make -C docs html diff --git a/build.sh b/build.sh index 6ac40d4bf..cf9e5959c 100644 --- a/build.sh +++ b/build.sh @@ -22,14 +22,14 @@ # # sh build.sh # -# Build libgit2 1.9.0 (will use libssh2 if available), then build pygit2 +# Build libgit2 1.9.2 (will use libssh2 if available), then build pygit2 # inplace: # -# LIBGIT2_VERSION=1.9.0 sh build.sh +# LIBGIT2_VERSION=1.9.2 sh build.sh # -# Build libssh2 1.11.1 and libgit2 1.9.0, then build pygit2 inplace: +# Build libssh2 1.11.1 and libgit2 1.9.2, then build pygit2 inplace: # -# LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.0 sh build.sh +# LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.2 sh build.sh # # Build inplace and run the tests: # diff --git a/docs/install.rst b/docs/install.rst index 843149e61..1ba7efc9d 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -59,7 +59,7 @@ worry about this if you `install from the source distribution`_. Optional libgit2 dependencies to support ssh and https: - https: WinHTTP (Windows), SecureTransport (OS X) or OpenSSL. -- ssh: libssh2 1.9.1 or later, pkg-config +- ssh: libssh2 1.10.0 or later, pkg-config To run the tests: @@ -158,9 +158,9 @@ directory, do: .. code-block:: sh :caption: On Linux using bash - wget https://github.com/libgit2/libgit2/archive/refs/tags/v1.9.1.tar.gz -O libgit2-1.9.1.tar.gz - tar -xzf libgit2-1.9.1.tar.gz - cd libgit2-1.9.1/ + wget https://github.com/libgit2/libgit2/archive/refs/tags/v1.9.2.tar.gz -O libgit2-1.9.2.tar.gz + tar -xzf libgit2-1.9.2.tar.gz + cd libgit2-1.9.2/ cmake . make sudo make install @@ -245,9 +245,9 @@ Install libgit2 (see we define the installation prefix): .. code-block:: sh :caption: On Linux using bash - wget https://github.com/libgit2/libgit2/archive/refs/tags/v1.9.1.tar.gz -O libgit2-1.9.1.tar.gz - tar xzf libgit2-1.9.1.tar.gz - cd libgit2-1.9.1/ + wget https://github.com/libgit2/libgit2/archive/refs/tags/v1.9.2.tar.gz -O libgit2-1.9.2.tar.gz + tar xzf libgit2-1.9.2.tar.gz + cd libgit2-1.9.2/ cmake . -DCMAKE_INSTALL_PREFIX=$LIBGIT2 cmake --build . --target install @@ -307,7 +307,7 @@ variable. The following recipe shows you how to do it: .. code-block:: pwsh :caption: On Windows using PowerShell (and CMake v3.21 or newer) - git clone --depth=1 -b v1.9.1 https://github.com/libgit2/libgit2.git + git clone --depth=1 -b v1.9.2 https://github.com/libgit2/libgit2.git $env:CMAKE_INSTALL_PREFIX = "C:/Dev/libgit2" $env:CMAKE_GENERATOR = "Visual Studio 17 2022" $env:CMAKE_GENERATOR_PLATFORM = "x64" # or "Win32" or "ARM64" diff --git a/pyproject.toml b/pyproject.toml index eef210bc4..9d8a4d22a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ skip = "*musllinux_ppc64le" archs = ["native"] build-frontend = "default" dependency-versions = "pinned" -environment = {LIBGIT2_VERSION="1.9.1", LIBSSH2_VERSION="1.11.1", OPENSSL_VERSION="3.3.3", LIBGIT2="/project/ci"} +environment = {LIBGIT2_VERSION="1.9.2", LIBSSH2_VERSION="1.11.1", OPENSSL_VERSION="3.5.4", LIBGIT2="/project/ci"} before-all = "sh build.sh" test-command = "pytest" @@ -27,12 +27,12 @@ select = "*-musllinux*" repair-wheel-command = "LD_LIBRARY_PATH=/project/ci/lib auditwheel repair -w {dest_dir} {wheel}" [tool.cibuildwheel.macos] -environment = {LIBGIT2_VERSION="1.9.1", LIBSSH2_VERSION="1.11.1", OPENSSL_VERSION="3.3.3", LIBGIT2="/Users/runner/work/pygit2/pygit2/ci"} +environment = {LIBGIT2_VERSION="1.9.2", LIBSSH2_VERSION="1.11.1", OPENSSL_VERSION="3.5.4", LIBGIT2="/Users/runner/work/pygit2/pygit2/ci"} repair-wheel-command = "DYLD_LIBRARY_PATH=/Users/runner/work/pygit2/pygit2/ci/lib delocate-wheel --require-archs {delocate_archs} -w {dest_dir} {wheel}" [tool.cibuildwheel.windows] environment.LIBGIT2_SRC = "build/libgit2_src" -environment.LIBGIT2_VERSION = "1.9.1" +environment.LIBGIT2_VERSION = "1.9.2" before-all = "powershell -File build.ps1" [[tool.cibuildwheel.overrides]] From 3ee2acc96aa1db29508005aba0dcfa5e03423922 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Fri, 26 Dec 2025 10:40:16 +0100 Subject: [PATCH 134/225] CI: fixing build of OpenSSL 3.5 --- build.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/build.sh b/build.sh index cf9e5959c..d95127046 100644 --- a/build.sh +++ b/build.sh @@ -62,6 +62,8 @@ if [ "$CIBUILDWHEEL" = "1" ]; then apt-get install wget -y if [ -z "$OPENSSL_VERSION" ]; then apt-get install libssl-dev -y + else + apt-get install libtime-piece-perl -y fi elif [ -f /usr/bin/yum ]; then yum install wget zlib-devel -y @@ -70,11 +72,14 @@ if [ "$CIBUILDWHEEL" = "1" ]; then else yum install perl-IPC-Cmd -y yum install perl-Pod-Html -y + yum install perl-Time-Piece -y fi elif [ -f /sbin/apk ]; then apk add wget if [ -z "$OPENSSL_VERSION" ]; then apk add openssl-dev + else + apk add perl-time-piece fi fi rm -rf ci From d610f770a7164722e59e587396ffa1a92bccf5bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Fri, 26 Dec 2025 11:39:46 +0100 Subject: [PATCH 135/225] CI: fixing build on Alpine --- build.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.sh b/build.sh index d95127046..c2d654d8a 100644 --- a/build.sh +++ b/build.sh @@ -77,9 +77,9 @@ if [ "$CIBUILDWHEEL" = "1" ]; then elif [ -f /sbin/apk ]; then apk add wget if [ -z "$OPENSSL_VERSION" ]; then - apk add openssl-dev + apk add --no-cache openssl-dev else - apk add perl-time-piece + apk add --no-cache perl fi fi rm -rf ci From 7ee1ae57fef6327ab47b2c7049456501fcee6f74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Mon, 29 Dec 2025 10:21:01 +0100 Subject: [PATCH 136/225] Release 1.19.1 --- AUTHORS.md | 2 +- CHANGELOG.md | 12 ++++++------ docs/conf.py | 2 +- pygit2/_build.py | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/AUTHORS.md b/AUTHORS.md index fffba38ed..6c4b13628 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -13,9 +13,9 @@ Authors: Dave Borowitz Brandon Milton Daniel Rodríguez Troitiño + Brendan Doherty Peter Rowlands Richo Healey - Brendan Doherty Christian Boos Julien Miotte Nick Hynes diff --git a/CHANGELOG.md b/CHANGELOG.md index c888846d2..bf172a90e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,14 @@ -# 1.19.1 (UNRELEASED) +# 1.19.1 (2025-12-29) -- Fix diff getitem/iter for unachanged or binary files, return `None` +- Update wheels to libgit2 1.9.2 and OpenSSL 3.5 + +- Fix: now diff's getitem/iter returns `None` for unchanged or binary files [#1412](https://github.com/libgit2/pygit2/pull/1412) -- CI: separate macOS wheels (arm and intel instead of universal) +- CI (macOS): arm, intel and pypy wheels (instead of universal) [#1441](https://github.com/libgit2/pygit2/pull/1441) -- CI: pypy wheels on macOS - -- CI: fix tests with pypy +- CI (pypy): fix tests [#1437](https://github.com/libgit2/pygit2/pull/1437) diff --git a/docs/conf.py b/docs/conf.py index 7f6bdfd4e..c5cb68fac 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,7 +23,7 @@ # author = '' # The full version, including alpha/beta/rc tags -release = '1.19.0' +release = '1.19.1' # -- General configuration --------------------------------------------------- diff --git a/pygit2/_build.py b/pygit2/_build.py index 47977aac9..985fcd87d 100644 --- a/pygit2/_build.py +++ b/pygit2/_build.py @@ -34,7 +34,7 @@ # # The version number of pygit2 # -__version__ = '1.19.0' +__version__ = '1.19.1' # From 0f87129a89c8d44c817bd67c67c8e6e7dc9fcd16 Mon Sep 17 00:00:00 2001 From: Iliyas Jorio Date: Sun, 28 Dec 2025 19:23:38 +0100 Subject: [PATCH 137/225] Add FilterList, Repository.load_filter_list --- pygit2/_libgit2/ffi.pyi | 6 ++++++ pygit2/_run.py | 1 + pygit2/decl/filter.h | 26 ++++++++++++++++++++++++++ pygit2/decl/types.h | 2 ++ pygit2/filter.py | 28 ++++++++++++++++++++++++++++ pygit2/repository.py | 27 +++++++++++++++++++++++++++ test/test_filter.py | 16 ++++++++++++++++ 7 files changed, 106 insertions(+) create mode 100644 pygit2/decl/filter.h diff --git a/pygit2/_libgit2/ffi.pyi b/pygit2/_libgit2/ffi.pyi index e73a7ad7c..5d74944c5 100644 --- a/pygit2/_libgit2/ffi.pyi +++ b/pygit2/_libgit2/ffi.pyi @@ -177,6 +177,10 @@ class GitDescribeOptionsC: class GitDescribeResultC: pass +class GitFilterListC: + # opaque struct + pass + class GitIndexC: pass @@ -318,6 +322,8 @@ def new(a: Literal['git_signature *']) -> GitSignatureC: ... @overload def new(a: Literal['git_signature **']) -> _Pointer[GitSignatureC]: ... @overload +def new(a: Literal['git_filter_list **']) -> _Pointer[GitFilterListC]: ... +@overload def new(a: Literal['int *']) -> int_c: ... @overload def new(a: Literal['int64_t *']) -> int64_t: ... diff --git a/pygit2/_run.py b/pygit2/_run.py index 863565f6a..adcc367b5 100644 --- a/pygit2/_run.py +++ b/pygit2/_run.py @@ -77,6 +77,7 @@ 'net.h', 'refspec.h', 'repository.h', + 'filter.h', 'commit.h', 'revert.h', 'stash.h', diff --git a/pygit2/decl/filter.h b/pygit2/decl/filter.h new file mode 100644 index 000000000..2d31b93de --- /dev/null +++ b/pygit2/decl/filter.h @@ -0,0 +1,26 @@ +typedef enum { + GIT_FILTER_TO_WORKTREE = ..., + GIT_FILTER_TO_ODB = ..., +} git_filter_mode_t; + +typedef enum { + GIT_FILTER_DEFAULT = ..., + GIT_FILTER_ALLOW_UNSAFE = ..., + GIT_FILTER_NO_SYSTEM_ATTRIBUTES = ..., + GIT_FILTER_ATTRIBUTES_FROM_HEAD = ..., + GIT_FILTER_ATTRIBUTES_FROM_COMMIT = ..., +} git_filter_flag_t; + +int git_filter_list_load( + git_filter_list **filters, + git_repository *repo, + git_blob *blob, + const char *path, + git_filter_mode_t mode, + uint32_t flags); + +int git_filter_list_contains( + git_filter_list *filters, + const char *name); + +void git_filter_list_free(git_filter_list *filters); diff --git a/pygit2/decl/types.h b/pygit2/decl/types.h index 0bef96d3e..64d7ce0a4 100644 --- a/pygit2/decl/types.h +++ b/pygit2/decl/types.h @@ -1,6 +1,8 @@ +typedef struct git_blob git_blob; typedef struct git_commit git_commit; typedef struct git_annotated_commit git_annotated_commit; typedef struct git_config git_config; +typedef struct git_filter_list git_filter_list; typedef struct git_index git_index; typedef struct git_index_conflict_iterator git_index_conflict_iterator; typedef struct git_object git_object; diff --git a/pygit2/filter.py b/pygit2/filter.py index 5c89a0d19..e80323215 100644 --- a/pygit2/filter.py +++ b/pygit2/filter.py @@ -24,8 +24,14 @@ # Boston, MA 02110-1301, USA. from collections.abc import Callable +from typing import TYPE_CHECKING from ._pygit2 import FilterSource +from .ffi import C, ffi +from .utils import to_bytes + +if TYPE_CHECKING: + from ._libgit2.ffi import GitFilterListC class Filter: @@ -107,3 +113,25 @@ def close(self, write_next: Callable[[bytes], None]) -> None: Any remaining filtered output data must be written to `write_next` before returning. """ + + +class FilterList: + _pointer: GitFilterListC + + @classmethod + def _from_c(cls, ptr: GitFilterListC): + if ptr == ffi.NULL: + return None + fl = cls.__new__(cls) + fl._pointer = ptr + return fl + + def __contains__(self, name: str) -> bool: + if not isinstance(name, str): + raise TypeError('argument must be str') + c_name = to_bytes(name) + result = C.git_filter_list_contains(self._pointer, c_name) + return bool(result) + + def __del__(self): + C.git_filter_list_free(self._pointer) diff --git a/pygit2/repository.py b/pygit2/repository.py index 18caa5e7e..2605eee36 100644 --- a/pygit2/repository.py +++ b/pygit2/repository.py @@ -64,6 +64,7 @@ DescribeStrategy, DiffOption, FileMode, + FilterMode, MergeFavor, MergeFileFlag, MergeFlag, @@ -73,6 +74,7 @@ ) from .errors import check_error from .ffi import C, ffi +from .filter import FilterList from .index import Index, IndexEntry, MergeFileResult from .packbuilder import PackBuilder from .references import References @@ -235,6 +237,31 @@ def hashfile( oid = Oid(raw=bytes(ffi.buffer(c_oid.id)[:])) return oid + def load_filter_list( + self, path: str, mode: FilterMode = FilterMode.TO_ODB + ) -> FilterList | None: + """ + Load the filter list for a given path. + May return None if there are no filters to apply to this path. + + Parameters: + + path + Relative path of the file to be filtered + + mode + Filtering direction: ODB to worktree (SMUDGE), or worktree to ODB + (CLEAN). + """ + c_filters = ffi.new('git_filter_list **') + c_path = to_bytes(path) + c_mode = int(mode) + + err = C.git_filter_list_load(c_filters, self._repo, ffi.NULL, c_path, c_mode, 0) + check_error(err) + fl = FilterList._from_c(c_filters[0]) + return fl + def __iter__(self) -> Iterator[Oid]: return iter(self.odb) diff --git a/test/test_filter.py b/test/test_filter.py index 26a45095f..5cf66a0f8 100644 --- a/test/test_filter.py +++ b/test/test_filter.py @@ -136,3 +136,19 @@ def test_filter_cleanup(dirtyrepo: Repository, rot13_filter: Filter) -> None: # Indirectly test that pygit2_filter_cleanup has the GIL # before calling pygit2_filter_payload_free. dirtyrepo.diff() + + +def test_filterlist_none(testrepo: Repository) -> None: + fl = testrepo.load_filter_list('hello.txt') + assert fl is None + + +def test_filterlist_crlf(testrepo: Repository) -> None: + testrepo.config['core.autocrlf'] = True + + fl = testrepo.load_filter_list('hello.txt') + assert fl is not None + assert 'crlf' in fl + + with pytest.raises(TypeError): + 1234 in fl # type: ignore From ac25a4488981dcf1793d894bbbee34dc26d8db18 Mon Sep 17 00:00:00 2001 From: Iliyas Jorio Date: Sun, 28 Dec 2025 19:27:56 +0100 Subject: [PATCH 138/225] Add FilterList.__len__ --- pygit2/_run.py | 1 + pygit2/decl/filter.h | 3 +++ pygit2/filter.py | 3 +++ test/test_filter.py | 1 + 4 files changed, 8 insertions(+) diff --git a/pygit2/_run.py b/pygit2/_run.py index adcc367b5..85d31f69d 100644 --- a/pygit2/_run.py +++ b/pygit2/_run.py @@ -97,6 +97,7 @@ C_PREAMBLE = """\ #include #include +#include """ # ffi diff --git a/pygit2/decl/filter.h b/pygit2/decl/filter.h index 2d31b93de..8928af67b 100644 --- a/pygit2/decl/filter.h +++ b/pygit2/decl/filter.h @@ -23,4 +23,7 @@ int git_filter_list_contains( git_filter_list *filters, const char *name); +size_t git_filter_list_length( + const git_filter_list *fl); + void git_filter_list_free(git_filter_list *filters); diff --git a/pygit2/filter.py b/pygit2/filter.py index e80323215..7a3ed95ea 100644 --- a/pygit2/filter.py +++ b/pygit2/filter.py @@ -133,5 +133,8 @@ def __contains__(self, name: str) -> bool: result = C.git_filter_list_contains(self._pointer, c_name) return bool(result) + def __len__(self) -> int: + return C.git_filter_list_length(self._pointer) + def __del__(self): C.git_filter_list_free(self._pointer) diff --git a/test/test_filter.py b/test/test_filter.py index 5cf66a0f8..9a06563fb 100644 --- a/test/test_filter.py +++ b/test/test_filter.py @@ -148,6 +148,7 @@ def test_filterlist_crlf(testrepo: Repository) -> None: fl = testrepo.load_filter_list('hello.txt') assert fl is not None + assert len(fl) == 1 assert 'crlf' in fl with pytest.raises(TypeError): From fe86852ac32b3fa164f17a7195a78ca2ff3c3c06 Mon Sep 17 00:00:00 2001 From: Iliyas Jorio Date: Sun, 28 Dec 2025 19:58:55 +0100 Subject: [PATCH 139/225] Prevent unregistering a filter that's still in use in a FilterList --- pygit2/__init__.py | 23 +++++++++++++++++-- pygit2/_pygit2.pyi | 1 - pygit2/decl/filter.h | 3 +++ pygit2/filter.py | 21 ++++++++++++++++++ src/pygit2.c | 26 ---------------------- test/test_filter.py | 53 ++++++++++++++++++++++++++++++++++---------- 6 files changed, 86 insertions(+), 41 deletions(-) diff --git a/pygit2/__init__.py b/pygit2/__init__.py index 518f34361..52ff96382 100644 --- a/pygit2/__init__.py +++ b/pygit2/__init__.py @@ -286,7 +286,6 @@ _cache_enums, discover_repository, filter_register, - filter_unregister, hash, hashfile, init_file_backend, @@ -545,6 +544,27 @@ def clone_repository( return Repository._from_c(crepo[0], owned=True) +def filter_unregister(name: str) -> None: + """ + Unregister the given filter. + + Note that the filter registry is not thread safe. Any registering or + deregistering of filters should be done outside of any possible usage + of the filters. + + In particular, any FilterLists that use the filter must have been garbage + collected before you can unregister the filter. + """ + from .filter import FilterList + + if FilterList._is_filter_in_use(name): + raise RuntimeError(f"filter still in use: '{name}'") + + c_name = to_bytes(name) + err = C.git_filter_unregister(c_name) + check_error(err) + + tree_entry_key = functools.cmp_to_key(tree_entry_cmp) settings = Settings() @@ -610,7 +630,6 @@ def clone_repository( # Low Level API (not present in .pyi) 'FilterSource', 'filter_register', - 'filter_unregister', 'GIT_APPLY_LOCATION_BOTH', 'GIT_APPLY_LOCATION_INDEX', 'GIT_APPLY_LOCATION_WORKDIR', diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index 0dda06364..a30a911a1 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -857,6 +857,5 @@ def reference_is_valid_name(refname: str) -> bool: ... def tree_entry_cmp(a: Object, b: Object) -> int: ... def _cache_enums() -> None: ... def filter_register(name: str, filter: type[Filter]) -> None: ... -def filter_unregister(name: str) -> None: ... _OidArg = str | Oid diff --git a/pygit2/decl/filter.h b/pygit2/decl/filter.h index 8928af67b..349e1cc18 100644 --- a/pygit2/decl/filter.h +++ b/pygit2/decl/filter.h @@ -11,6 +11,9 @@ typedef enum { GIT_FILTER_ATTRIBUTES_FROM_COMMIT = ..., } git_filter_flag_t; +int git_filter_unregister( + const char *name); + int git_filter_list_load( git_filter_list **filters, git_repository *repo, diff --git a/pygit2/filter.py b/pygit2/filter.py index 7a3ed95ea..212f965c2 100644 --- a/pygit2/filter.py +++ b/pygit2/filter.py @@ -23,6 +23,9 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. +from __future__ import annotations + +import weakref from collections.abc import Callable from typing import TYPE_CHECKING @@ -116,16 +119,34 @@ def close(self, write_next: Callable[[bytes], None]) -> None: class FilterList: + _all_filter_lists: set[weakref.ReferenceType[FilterList]] = set() + _pointer: GitFilterListC @classmethod def _from_c(cls, ptr: GitFilterListC): if ptr == ffi.NULL: return None + fl = cls.__new__(cls) fl._pointer = ptr + + # Keep track of this FilterList until it's garbage collected. This lets + # `filter_unregister` ensure the user isn't trying to delete a filter + # that's still in use. + ref = weakref.ref(fl, FilterList._all_filter_lists.remove) + FilterList._all_filter_lists.add(ref) + return fl + @classmethod + def _is_filter_in_use(cls, name: str) -> bool: + for ref in cls._all_filter_lists: + fl = ref() + if fl is not None and name in fl: + return True + return False + def __contains__(self, name: str) -> bool: if not isinstance(name, str): raise TypeError('argument must be str') diff --git a/src/pygit2.c b/src/pygit2.c index 2649efc03..215d2a2f7 100644 --- a/src/pygit2.c +++ b/src/pygit2.c @@ -345,31 +345,6 @@ filter_register(PyObject *self, PyObject *args, PyObject *kwds) return result; } -PyDoc_STRVAR(filter_unregister__doc__, - "filter_unregister(name: str) -> None\n" - "\n" - "Unregister the given filter.\n" - "\n" - "Note that the filter registry is not thread safe. Any registering or\n" - "deregistering of filters should be done outside of any possible usage\n" - "of the filters.\n"); - -PyObject * -filter_unregister(PyObject *self, PyObject *args) -{ - const char *name; - Py_ssize_t size; - int err; - - if (!PyArg_ParseTuple(args, "s#", &name, &size)) - return NULL; - if ((err = git_filter_unregister(name)) < 0) - return Error_set(err); - - Py_RETURN_NONE; -} - - static void forget_enums(void) { @@ -441,7 +416,6 @@ PyMethodDef module_methods[] = { {"reference_is_valid_name", reference_is_valid_name, METH_O, reference_is_valid_name__doc__}, {"tree_entry_cmp", tree_entry_cmp, METH_VARARGS, tree_entry_cmp__doc__}, {"filter_register", (PyCFunction)filter_register, METH_VARARGS | METH_KEYWORDS, filter_register__doc__}, - {"filter_unregister", filter_unregister, METH_VARARGS, filter_unregister__doc__}, {"_cache_enums", _cache_enums, METH_NOARGS, _cache_enums__doc__}, {NULL} }; diff --git a/test/test_filter.py b/test/test_filter.py index 9a06563fb..4963ff89e 100644 --- a/test/test_filter.py +++ b/test/test_filter.py @@ -1,4 +1,5 @@ import codecs +import gc from collections.abc import Callable, Generator from io import BytesIO @@ -56,32 +57,34 @@ class _UnmatchedFilter(_Rot13Filter): attributes = 'filter=rot13' +def _filter_fixture(name: str, filter: type[Filter]) -> Generator[None, None, None]: + pygit2.filter_register(name, filter) + yield + + # Collect any FilterLists that may use this filter before unregistering it + gc.collect() + + pygit2.filter_unregister(name) + + @pytest.fixture def rot13_filter() -> Generator[None, None, None]: - pygit2.filter_register('rot13', _Rot13Filter) - yield - pygit2.filter_unregister('rot13') + yield from _filter_fixture('rot13', _Rot13Filter) @pytest.fixture def passthrough_filter() -> Generator[None, None, None]: - pygit2.filter_register('passthrough-rot13', _PassthroughFilter) - yield - pygit2.filter_unregister('passthrough-rot13') + yield from _filter_fixture('passthrough-rot13', _PassthroughFilter) @pytest.fixture def buffered_filter() -> Generator[None, None, None]: - pygit2.filter_register('buffered-rot13', _BufferedFilter) - yield - pygit2.filter_unregister('buffered-rot13') + yield from _filter_fixture('buffered-rot13', _BufferedFilter) @pytest.fixture def unmatched_filter() -> Generator[None, None, None]: - pygit2.filter_register('unmatched-rot13', _UnmatchedFilter) - yield - pygit2.filter_unregister('unmatched-rot13') + yield from _filter_fixture('unmatched-rot13', _UnmatchedFilter) def test_filter(testrepo: Repository, rot13_filter: Filter) -> None: @@ -153,3 +156,29 @@ def test_filterlist_crlf(testrepo: Repository) -> None: with pytest.raises(TypeError): 1234 in fl # type: ignore + + +def test_filterlist_rot13(testrepo: Repository, rot13_filter: Filter) -> None: + fl = testrepo.load_filter_list('hello.txt') + assert fl is not None + assert 'rot13' in fl + + +def test_filterlist_dangerous_unregister(testrepo: Repository) -> None: + pygit2.filter_register('rot13', _Rot13Filter) + + fl = testrepo.load_filter_list('hello.txt') + assert fl is not None + assert len(fl) == 1 + assert 'rot13' in fl + + # Unregistering a filter that's still in use in a FilterList is dangerous! + # Our built-in check (that raises RuntimeError) may avert a segfault. + with pytest.raises(RuntimeError): + pygit2.filter_unregister('rot13') + + # Delete any FilterLists that use the filter, and only then is it safe + # to unregister the filter. + del fl + gc.collect() + pygit2.filter_unregister('rot13') From 47cc5a0e36590391e7207ffc22f3d6fdbfc98444 Mon Sep 17 00:00:00 2001 From: Iliyas Jorio Date: Mon, 29 Dec 2025 14:23:46 +0100 Subject: [PATCH 140/225] Add FilterList.apply_to_buffer, apply_to_file, apply_to_blob --- pygit2/_libgit2/ffi.pyi | 6 ++++ pygit2/decl/filter.h | 17 +++++++++++ pygit2/filter.py | 48 ++++++++++++++++++++++++++++++- test/test_filter.py | 62 ++++++++++++++++++++++++++++++++++++----- 4 files changed, 125 insertions(+), 8 deletions(-) diff --git a/pygit2/_libgit2/ffi.pyi b/pygit2/_libgit2/ffi.pyi index 5d74944c5..a0710caec 100644 --- a/pygit2/_libgit2/ffi.pyi +++ b/pygit2/_libgit2/ffi.pyi @@ -123,6 +123,10 @@ class GitBlameC: # incomplete pass +class GitBlobC: + # incomplete + pass + class GitMergeOptionsC: file_favor: int flags: int @@ -268,6 +272,8 @@ def new(a: Literal['git_oid *']) -> GitOidC: ... @overload def new(a: Literal['git_blame **']) -> _Pointer[GitBlameC]: ... @overload +def new(a: Literal['git_blob **']) -> _Pointer[GitBlobC]: ... +@overload def new(a: Literal['git_clone_options *']) -> GitCloneOptionsC: ... @overload def new(a: Literal['git_merge_options *']) -> GitMergeOptionsC: ... diff --git a/pygit2/decl/filter.h b/pygit2/decl/filter.h index 349e1cc18..51734687a 100644 --- a/pygit2/decl/filter.h +++ b/pygit2/decl/filter.h @@ -26,6 +26,23 @@ int git_filter_list_contains( git_filter_list *filters, const char *name); +int git_filter_list_apply_to_buffer( + git_buf *out, + git_filter_list *filters, + const char* in, + size_t in_len); + +int git_filter_list_apply_to_file( + git_buf *out, + git_filter_list *filters, + git_repository *repo, + const char *path); + +int git_filter_list_apply_to_blob( + git_buf *out, + git_filter_list *filters, + git_blob *blob); + size_t git_filter_list_length( const git_filter_list *fl); diff --git a/pygit2/filter.py b/pygit2/filter.py index 212f965c2..6fc953e3e 100644 --- a/pygit2/filter.py +++ b/pygit2/filter.py @@ -29,12 +29,14 @@ from collections.abc import Callable from typing import TYPE_CHECKING -from ._pygit2 import FilterSource +from ._pygit2 import Blob, FilterSource +from .errors import check_error from .ffi import C, ffi from .utils import to_bytes if TYPE_CHECKING: from ._libgit2.ffi import GitFilterListC + from .repository import BaseRepository class Filter: @@ -157,5 +159,49 @@ def __contains__(self, name: str) -> bool: def __len__(self) -> int: return C.git_filter_list_length(self._pointer) + def apply_to_buffer(self, data: bytes) -> bytes: + """ + Apply a filter list to a data buffer. + Return the filtered contents. + """ + buf = ffi.new('git_buf *') + err = C.git_filter_list_apply_to_buffer(buf, self._pointer, data, len(data)) + check_error(err) + try: + return ffi.string(buf.ptr) + finally: + C.git_buf_dispose(buf) + + def apply_to_file(self, repo: BaseRepository, path: str) -> bytes: + """ + Apply a filter list to the contents of a file on disk. + Return the filtered contents. + """ + buf = ffi.new('git_buf *') + c_path = to_bytes(path) + err = C.git_filter_list_apply_to_file(buf, self._pointer, repo._repo, c_path) + check_error(err) + try: + return ffi.string(buf.ptr) + finally: + C.git_buf_dispose(buf) + + def apply_to_blob(self, blob: Blob) -> bytes: + """ + Apply a filter list to a data buffer. + Return the filtered contents. + """ + buf = ffi.new('git_buf *') + + c_blob = ffi.new('git_blob **') + ffi.buffer(c_blob)[:] = blob._pointer[:] + + err = C.git_filter_list_apply_to_blob(buf, self._pointer, c_blob[0]) + check_error(err) + try: + return ffi.string(buf.ptr) + finally: + C.git_buf_dispose(buf) + def __del__(self): C.git_filter_list_free(self._pointer) diff --git a/test/test_filter.py b/test/test_filter.py index 4963ff89e..c4338489c 100644 --- a/test/test_filter.py +++ b/test/test_filter.py @@ -7,7 +7,7 @@ import pygit2 from pygit2 import Blob, Filter, FilterSource, Repository -from pygit2.enums import BlobFilter +from pygit2.enums import BlobFilter, FilterMode from pygit2.errors import Passthrough @@ -146,22 +146,31 @@ def test_filterlist_none(testrepo: Repository) -> None: assert fl is None -def test_filterlist_crlf(testrepo: Repository) -> None: +def test_filterlist_apply_to_buffer_crlf_clean(testrepo: Repository) -> None: testrepo.config['core.autocrlf'] = True - fl = testrepo.load_filter_list('hello.txt') + fl = testrepo.load_filter_list('whatever.txt', mode=FilterMode.CLEAN) assert fl is not None assert len(fl) == 1 assert 'crlf' in fl - + assert 'bogus_filter_name' not in fl with pytest.raises(TypeError): 1234 in fl # type: ignore + filtered = fl.apply_to_buffer(b'hello\r\nworld\r\n') + assert filtered == b'hello\nworld\n' -def test_filterlist_rot13(testrepo: Repository, rot13_filter: Filter) -> None: - fl = testrepo.load_filter_list('hello.txt') + +def test_filterlist_apply_to_buffer_crlf_smudge(testrepo: Repository) -> None: + testrepo.config['core.autocrlf'] = True + + fl = testrepo.load_filter_list('whatever.txt', mode=FilterMode.SMUDGE) assert fl is not None - assert 'rot13' in fl + assert len(fl) == 1 + assert 'crlf' in fl + + filtered = fl.apply_to_buffer(b'hello\nworld\n') + assert filtered == b'hello\r\nworld\r\n' def test_filterlist_dangerous_unregister(testrepo: Repository) -> None: @@ -182,3 +191,42 @@ def test_filterlist_dangerous_unregister(testrepo: Repository) -> None: del fl gc.collect() pygit2.filter_unregister('rot13') + + +def test_filterlist_apply_to_file(testrepo: Repository, rot13_filter: Filter) -> None: + fl = testrepo.load_filter_list('bye.txt') + assert fl is not None + assert len(fl) == 1 + assert 'rot13' in fl + + filtered = fl.apply_to_file(testrepo, 'bye.txt') + assert filtered == b'olr jbeyq\n' + + +def test_filterlist_apply_to_blob(testrepo: Repository, rot13_filter: Filter) -> None: + fl = testrepo.load_filter_list('whatever.txt') + assert fl is not None + assert len(fl) == 1 + assert 'rot13' in fl + + blob_oid = testrepo.create_blob(b'bye world\n') + blob = testrepo[blob_oid] + assert isinstance(blob, Blob) + + filtered = fl.apply_to_blob(blob) + assert filtered == b'olr jbeyq\n' + + +def test_filterlist_apply_to_buffer_multiple( + testrepo: Repository, rot13_filter: Filter +) -> None: + testrepo.config['core.autocrlf'] = True + + fl = testrepo.load_filter_list('whatever.txt') + assert fl is not None + assert len(fl) == 2 + assert 'crlf' in fl + assert 'rot13' in fl + + filtered = fl.apply_to_buffer(b'bye\r\nworld\r\n') + assert filtered == b'olr\njbeyq\n' From 5735462ff74688bddef4fee5cdf4841bda8f8def Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 1 Jan 2026 15:21:20 +0000 Subject: [PATCH 141/225] chore(CI): bump the actions group with 2 updates Bumps the actions group with 2 updates: [actions/upload-artifact](https://github.com/actions/upload-artifact) and [actions/download-artifact](https://github.com/actions/download-artifact). Updates `actions/upload-artifact` from 5 to 6 - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v5...v6) Updates `actions/download-artifact` from 6 to 7 - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions - dependency-name: actions/download-artifact dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions ... Signed-off-by: dependabot[bot] --- .github/workflows/wheels.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index b6716c66d..f2327d200 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -57,7 +57,7 @@ jobs: CIBW_ARCHS_WINDOWS: ${{ matrix.name == 'windows-x86' && 'auto32' || 'native' }} run: python -m cibuildwheel --output-dir wheelhouse - - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@v6 with: name: wheels-${{ matrix.name }} path: ./wheelhouse/*.whl @@ -89,7 +89,7 @@ jobs: CIBW_ARCHS: ppc64le CIBW_ENVIRONMENT: LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.2 LIBGIT2=/project/ci - - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@v6 with: name: wheels-linux-ppc path: ./wheelhouse/*.whl @@ -111,7 +111,7 @@ jobs: - name: Build sdist run: pipx run build --sdist --outdir dist - - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@v6 with: name: wheels-sdist path: dist/* @@ -121,7 +121,7 @@ jobs: run: python .github/workflows/parse_release_notes.py - name: Upload Release Notes - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: release-notes path: ReleaseNotes.md @@ -136,7 +136,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/download-artifact@v6 + - uses: actions/download-artifact@v7 with: path: dist pattern: wheels-* @@ -152,7 +152,7 @@ jobs: runs-on: ubuntu-24.04 steps: - - uses: actions/download-artifact@v6 + - uses: actions/download-artifact@v7 with: path: dist pattern: wheels-* @@ -167,7 +167,7 @@ jobs: password: ${{ secrets.PYPI_API_TOKEN }} skip-existing: true - - uses: actions/download-artifact@v6 + - uses: actions/download-artifact@v7 with: name: release-notes - name: Create GitHub Release From 6ce62a25bbd2b1532d7a1c2c92d4c31763c53582 Mon Sep 17 00:00:00 2001 From: Alexander Shadchin Date: Tue, 6 Jan 2026 13:19:46 +0300 Subject: [PATCH 142/225] Avoiding conflict with numpy for static build --- src/pygit2.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pygit2.c b/src/pygit2.c index 215d2a2f7..96cec5f19 100644 --- a/src/pygit2.c +++ b/src/pygit2.c @@ -408,7 +408,7 @@ free_module(void *self) } -PyMethodDef module_methods[] = { +static PyMethodDef module_methods[] = { {"discover_repository", discover_repository, METH_VARARGS, discover_repository__doc__}, {"hash", hash, METH_VARARGS, hash__doc__}, {"hashfile", hashfile, METH_VARARGS, hashfile__doc__}, From e91a0073a5c9b4ff62153875cbcb2f6b9bee200c Mon Sep 17 00:00:00 2001 From: Brendan <2bndy5@gmail.com> Date: Mon, 9 Feb 2026 19:39:29 -0800 Subject: [PATCH 143/225] CI: quote release title to account for spaces in CLI arg I noticed the gh-cli had not worked as intended to create a GitHub release. Turns out the space in the title was interpreted as a CLI positional argument. Using quotes should fix that. --- .github/workflows/wheels.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index f2327d200..1c4acf4d2 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -181,5 +181,5 @@ jobs: gh release create ${TAG} --verify-tag --repo ${REPO} - --title ${TITLE} + --title "${TITLE}" --notes-file ReleaseNotes.md From a6b354fb83ca1754b9527eeadcd7ffb7e2bb1f8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Sun, 1 Mar 2026 12:11:20 +0100 Subject: [PATCH 144/225] Update changelog --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf172a90e..b1634fdfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +# 1.19.2 (UNRELEASED) + +- New `Repository.load_filter_list(...)` and `FilterList` + [#1444](https://github.com/libgit2/pygit2/pull/1444) + +- Build: Fix static build conflict with numpy + [#1446](https://github.com/libgit2/pygit2/pull/1446) + +- CI: Fix create GitHub release + [#1448](https://github.com/libgit2/pygit2/pull/1448) + + # 1.19.1 (2025-12-29) - Update wheels to libgit2 1.9.2 and OpenSSL 3.5 From 4ad82f831e33e58cffa7a5b0752a3aef90b250de Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 1 Mar 2026 15:16:59 +0000 Subject: [PATCH 145/225] chore(CI): bump the actions group with 2 updates Bumps the actions group with 2 updates: [actions/upload-artifact](https://github.com/actions/upload-artifact) and [actions/download-artifact](https://github.com/actions/download-artifact). Updates `actions/upload-artifact` from 6 to 7 - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v6...v7) Updates `actions/download-artifact` from 7 to 8 - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v7...v8) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions - dependency-name: actions/download-artifact dependency-version: '8' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions ... Signed-off-by: dependabot[bot] --- .github/workflows/wheels.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 1c4acf4d2..06028447d 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -57,7 +57,7 @@ jobs: CIBW_ARCHS_WINDOWS: ${{ matrix.name == 'windows-x86' && 'auto32' || 'native' }} run: python -m cibuildwheel --output-dir wheelhouse - - uses: actions/upload-artifact@v6 + - uses: actions/upload-artifact@v7 with: name: wheels-${{ matrix.name }} path: ./wheelhouse/*.whl @@ -89,7 +89,7 @@ jobs: CIBW_ARCHS: ppc64le CIBW_ENVIRONMENT: LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.2 LIBGIT2=/project/ci - - uses: actions/upload-artifact@v6 + - uses: actions/upload-artifact@v7 with: name: wheels-linux-ppc path: ./wheelhouse/*.whl @@ -111,7 +111,7 @@ jobs: - name: Build sdist run: pipx run build --sdist --outdir dist - - uses: actions/upload-artifact@v6 + - uses: actions/upload-artifact@v7 with: name: wheels-sdist path: dist/* @@ -121,7 +121,7 @@ jobs: run: python .github/workflows/parse_release_notes.py - name: Upload Release Notes - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: release-notes path: ReleaseNotes.md @@ -136,7 +136,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/download-artifact@v7 + - uses: actions/download-artifact@v8 with: path: dist pattern: wheels-* @@ -152,7 +152,7 @@ jobs: runs-on: ubuntu-24.04 steps: - - uses: actions/download-artifact@v7 + - uses: actions/download-artifact@v8 with: path: dist pattern: wheels-* @@ -167,7 +167,7 @@ jobs: password: ${{ secrets.PYPI_API_TOKEN }} skip-existing: true - - uses: actions/download-artifact@v7 + - uses: actions/download-artifact@v8 with: name: release-notes - name: Create GitHub Release From 070e6b239d31e4cd39447cab01769074c289d916 Mon Sep 17 00:00:00 2001 From: Iliyas Jorio Date: Mon, 2 Mar 2026 07:26:02 +0100 Subject: [PATCH 146/225] Fix signature info in Odb.read's docstring It doesn't return the object's size separately. --- src/odb.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/odb.c b/src/odb.c index 97181aa72..da4105e21 100644 --- a/src/odb.c +++ b/src/odb.c @@ -170,7 +170,7 @@ Odb_read_raw(git_odb *odb, const git_oid *oid, size_t len) } PyDoc_STRVAR(Odb_read__doc__, - "read(oid) -> type, data, size\n" + "read(oid) -> type, data\n" "\n" "Read raw object data from the object db."); From b4ed38868f98a05d185abd3b6534e71b7b03d1cf Mon Sep 17 00:00:00 2001 From: Iliyas Jorio Date: Mon, 2 Mar 2026 07:34:04 +0100 Subject: [PATCH 147/225] Odb.read returns ObjectType enum instead of int --- pygit2/_pygit2.pyi | 2 +- src/odb.c | 14 +++++++++++--- src/pygit2.c | 3 +++ test/test_odb.py | 3 +++ 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index a30a911a1..b425e47db 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -518,7 +518,7 @@ class Odb: def add_backend(self, backend: OdbBackend, priority: int) -> None: ... def add_disk_alternate(self, path: str | Path) -> None: ... def exists(self, oid: _OidArg) -> bool: ... - def read(self, oid: _OidArg) -> tuple[int, bytes]: ... + def read(self, oid: _OidArg) -> tuple[ObjectType, bytes]: ... def write(self, type: int, data: bytes | str) -> Oid: ... def __contains__(self, other: _OidArg) -> bool: ... def __iter__(self) -> Iterator[Oid]: ... # Odb_as_iter diff --git a/src/odb.c b/src/odb.c index da4105e21..5dedc64a8 100644 --- a/src/odb.c +++ b/src/odb.c @@ -37,6 +37,8 @@ extern PyTypeObject OdbBackendType; +extern PyObject *ObjectTypeEnum; + static git_otype int_to_loose_object_type(int type_id) { @@ -170,7 +172,7 @@ Odb_read_raw(git_odb *odb, const git_oid *oid, size_t len) } PyDoc_STRVAR(Odb_read__doc__, - "read(oid) -> type, data\n" + "read(oid: Oid) -> tuple[enums.ObjectType, bytes]\n" "\n" "Read raw object data from the object db."); @@ -180,6 +182,8 @@ Odb_read(Odb *self, PyObject *py_hex) git_oid oid; git_odb_object *obj; size_t len; + git_object_t type; + PyObject* type_enum; PyObject* tuple; len = py_oid_to_git_oid(py_hex, &oid); @@ -190,9 +194,13 @@ Odb_read(Odb *self, PyObject *py_hex) if (obj == NULL) return NULL; + // Convert type to ObjectType enum + type = git_odb_object_type(obj); + type_enum = pygit2_enum(ObjectTypeEnum, type); + tuple = Py_BuildValue( - "(ny#)", - git_odb_object_type(obj), + "(Oy#)", + type_enum, git_odb_object_data(obj), git_odb_object_size(obj)); diff --git a/src/pygit2.c b/src/pygit2.c index 96cec5f19..91fb2cb48 100644 --- a/src/pygit2.c +++ b/src/pygit2.c @@ -47,6 +47,7 @@ PyObject *FileModeEnum; PyObject *FileStatusEnum; PyObject *MergeAnalysisEnum; PyObject *MergePreferenceEnum; +PyObject *ObjectTypeEnum; PyObject *ReferenceTypeEnum; extern PyTypeObject RepositoryType; @@ -354,6 +355,7 @@ forget_enums(void) Py_CLEAR(FileStatusEnum); Py_CLEAR(MergeAnalysisEnum); Py_CLEAR(MergePreferenceEnum); + Py_CLEAR(ObjectTypeEnum); Py_CLEAR(ReferenceTypeEnum); } @@ -389,6 +391,7 @@ _cache_enums(PyObject *self, PyObject *args) CACHE_PYGIT2_ENUM(FileStatus); CACHE_PYGIT2_ENUM(MergeAnalysis); CACHE_PYGIT2_ENUM(MergePreference); + CACHE_PYGIT2_ENUM(ObjectType); CACHE_PYGIT2_ENUM(ReferenceType); #undef CACHE_PYGIT2_ENUM diff --git a/test/test_odb.py b/test/test_odb.py index 4d023135f..0c2605388 100644 --- a/test/test_odb.py +++ b/test/test_odb.py @@ -76,13 +76,16 @@ def test_read(odb: Odb) -> None: a = odb.read(BLOB_HEX) assert ab == a assert (ObjectType.BLOB, b'a contents\n') == a + assert isinstance(a[0], ObjectType) a2 = odb.read('7f129fd57e31e935c6d60a0c794efe4e6927664b') assert (ObjectType.BLOB, b'a contents 2\n') == a2 + assert isinstance(a2[0], ObjectType) a_hex_prefix = BLOB_HEX[:4] a3 = odb.read(a_hex_prefix) assert (ObjectType.BLOB, b'a contents\n') == a3 + assert isinstance(a3[0], ObjectType) def test_write(odb: Odb) -> None: From 555b1f36e7e0ce7cfdfa9450d4a8cc95194ccb81 Mon Sep 17 00:00:00 2001 From: Iliyas Jorio Date: Mon, 2 Mar 2026 08:00:08 +0100 Subject: [PATCH 148/225] Add Odb.read_header --- pygit2/_pygit2.pyi | 1 + src/odb.c | 39 +++++++++++++++++++++++++++++++++++++++ test/test_odb.py | 17 +++++++++++++++-- 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index b425e47db..19dd61ffc 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -519,6 +519,7 @@ class Odb: def add_disk_alternate(self, path: str | Path) -> None: ... def exists(self, oid: _OidArg) -> bool: ... def read(self, oid: _OidArg) -> tuple[ObjectType, bytes]: ... + def read_header(self, oid: _OidArg) -> tuple[ObjectType, int]: ... def write(self, type: int, data: bytes | str) -> Oid: ... def __contains__(self, other: _OidArg) -> bool: ... def __iter__(self) -> Iterator[Oid]: ... # Odb_as_iter diff --git a/src/odb.c b/src/odb.c index 5dedc64a8..e72a099e0 100644 --- a/src/odb.c +++ b/src/odb.c @@ -208,6 +208,44 @@ Odb_read(Odb *self, PyObject *py_hex) return tuple; } +PyDoc_STRVAR(Odb_read_header__doc__, + "read_header(oid: Oid) -> tuple[enums.ObjectType, size\n" + "\n" + "Read the header of an object from the database, without reading its full\n" + "contents.\n" + "\n" + "The header includes the type and the length of an object.\n" + "\n" + "Note that most backends do not support reading only the header of an object,\n" + "so the whole object may be read and then the header will be returned."); + +PyObject * +Odb_read_header(Odb *self, PyObject *py_hex) +{ + git_oid oid; + int err; + size_t len; + git_object_t type; + PyObject* type_enum; + PyObject* tuple; + + len = py_oid_to_git_oid(py_hex, &oid); + if (len == 0) + return NULL; + + err = git_odb_read_header(&len, &type, self->odb, &oid); + if (err != 0) { + Error_set_oid(err, &oid, len); + return NULL; + } + + // Convert type to ObjectType enum + type_enum = pygit2_enum(ObjectTypeEnum, type); + + tuple = Py_BuildValue("(On)", type_enum, len); + return tuple; +} + PyDoc_STRVAR(Odb_write__doc__, "write(type: int, data: bytes) -> Oid\n" "\n" @@ -309,6 +347,7 @@ Odb_add_backend(Odb *self, PyObject *args) PyMethodDef Odb_methods[] = { METHOD(Odb, add_disk_alternate, METH_O), METHOD(Odb, read, METH_O), + METHOD(Odb, read_header, METH_O), METHOD(Odb, write, METH_VARARGS), METHOD(Odb, exists, METH_O), METHOD(Odb, add_backend, METH_VARARGS), diff --git a/test/test_odb.py b/test/test_odb.py index 0c2605388..f0657af54 100644 --- a/test/test_odb.py +++ b/test/test_odb.py @@ -41,6 +41,7 @@ BLOB_HEX = 'af431f20fc541ed6d5afede3e2dc7160f6f01f16' BLOB_RAW = binascii.unhexlify(BLOB_HEX.encode('ascii')) BLOB_OID = Oid(raw=BLOB_RAW) +BLOB_CONTENTS = b'a contents\n' def test_emptyodb(barerepo: Repository) -> None: @@ -75,7 +76,7 @@ def test_read(odb: Odb) -> None: ab = odb.read(BLOB_OID) a = odb.read(BLOB_HEX) assert ab == a - assert (ObjectType.BLOB, b'a contents\n') == a + assert (ObjectType.BLOB, BLOB_CONTENTS) == a assert isinstance(a[0], ObjectType) a2 = odb.read('7f129fd57e31e935c6d60a0c794efe4e6927664b') @@ -84,10 +85,22 @@ def test_read(odb: Odb) -> None: a_hex_prefix = BLOB_HEX[:4] a3 = odb.read(a_hex_prefix) - assert (ObjectType.BLOB, b'a contents\n') == a3 + assert (ObjectType.BLOB, BLOB_CONTENTS) == a3 assert isinstance(a3[0], ObjectType) +def test_read_header(odb: Odb) -> None: + with pytest.raises(TypeError): + odb.read_header(123) # type: ignore + utils.assertRaisesWithArg(KeyError, '1' * 40, odb.read_header, '1' * 40) + + ab = odb.read_header(BLOB_OID) + a = odb.read_header(BLOB_HEX) + assert ab == a + assert (ObjectType.BLOB, len(BLOB_CONTENTS)) == a + assert isinstance(a[0], ObjectType) + + def test_write(odb: Odb) -> None: data = b'hello world' # invalid object type From 885553b6468e71efcdd333fbfef8651b30729187 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Sat, 7 Mar 2026 17:31:01 +0100 Subject: [PATCH 149/225] Memory review filter_register with Kimi's help Also define PyMethodDef as static to avoid possible build failures, and add .vimrc --- .vimrc | 16 ++++++++++++++++ src/branch.c | 2 +- src/diff.c | 4 ++-- src/filter.c | 2 ++ src/mailmap.c | 2 +- src/note.c | 2 +- src/object.c | 2 +- src/odb.c | 2 +- src/odb_backend.c | 2 +- src/patch.c | 2 +- src/pygit2.c | 46 +++++++++++++++++++++++++-------------------- src/refdb.c | 8 +++----- src/refdb_backend.c | 2 +- src/reference.c | 2 +- src/repository.c | 2 +- src/tag.c | 2 +- src/tree.c | 2 +- src/treebuilder.c | 2 +- src/utils.c | 29 ++++++++++++++++++++++++++++ src/utils.h | 1 + src/walker.c | 2 +- src/worktree.c | 2 +- 22 files changed, 94 insertions(+), 42 deletions(-) create mode 100644 .vimrc diff --git a/.vimrc b/.vimrc new file mode 100644 index 000000000..66868d60a --- /dev/null +++ b/.vimrc @@ -0,0 +1,16 @@ +" pygit2 local vimrc - C extension configuration + +" Get Python include path dynamically +let s:python_include = system('python3 -c "import sysconfig; print(sysconfig.get_path(''include''))"')[:-2] + +" Configure ALE C linters with proper includes +let g:ale_c_cc_options = '-std=c11 -Wall -I' . s:python_include . ' -I/usr/local/include' +let g:ale_c_gcc_options = '-std=c11 -Wall -I' . s:python_include . ' -I/usr/local/include' +let g:ale_c_clang_options = '-std=c11 -Wall -I' . s:python_include . ' -I/usr/local/include' + +" If you have libgit2 in a non-standard location, add it: +" let g:ale_c_cc_options .= ' -I/usr/local/include/git2' + +" Optional: Explicitly set which linters to use for C +let g:ale_linters = get(g:, 'ale_linters', {}) +let g:ale_linters.c = ['cc', 'clangtidy'] " or ['gcc'] if you prefer diff --git a/src/branch.c b/src/branch.c index 60e0e2dc6..51705ab92 100644 --- a/src/branch.c +++ b/src/branch.c @@ -275,7 +275,7 @@ Branch_upstream_name__get__(Branch *self) } -PyMethodDef Branch_methods[] = { +static PyMethodDef Branch_methods[] = { METHOD(Branch, delete, METH_NOARGS), METHOD(Branch, is_head, METH_NOARGS), METHOD(Branch, is_checked_out, METH_NOARGS), diff --git a/src/diff.c b/src/diff.c index 11e0f9972..2ae542a78 100644 --- a/src/diff.c +++ b/src/diff.c @@ -236,7 +236,7 @@ PyMemberDef DiffFile_members[] = { {NULL} }; -PyMethodDef DiffFile_methods[] = { +static PyMethodDef DiffFile_methods[] = { METHOD(DiffFile, from_c, METH_STATIC | METH_O), {NULL}, }; @@ -914,7 +914,7 @@ DiffStats_dealloc(DiffStats *self) PyObject_Del(self); } -PyMethodDef DiffStats_methods[] = { +static PyMethodDef DiffStats_methods[] = { METHOD(DiffStats, format, METH_VARARGS | METH_KEYWORDS), {NULL} }; diff --git a/src/filter.c b/src/filter.c index 730dbcf77..3362441f5 100644 --- a/src/filter.c +++ b/src/filter.c @@ -551,7 +551,9 @@ void pygit2_filter_cleanup(git_filter *self, void *payload) void pygit2_filter_shutdown(git_filter *self) { pygit2_filter *filter = (pygit2_filter *)self; + PyGILState_STATE gil = PyGILState_Ensure(); + free((void*)filter->filter.attributes); Py_DECREF(filter->py_filter_cls); free(filter); PyGILState_Release(gil); diff --git a/src/mailmap.c b/src/mailmap.c index 4bfc60faf..f79d2b93e 100644 --- a/src/mailmap.c +++ b/src/mailmap.c @@ -188,7 +188,7 @@ Mailmap_dealloc(Mailmap *self) } -PyMethodDef Mailmap_methods[] = { +static PyMethodDef Mailmap_methods[] = { METHOD(Mailmap, add_entry, METH_VARARGS | METH_KEYWORDS), METHOD(Mailmap, resolve, METH_VARARGS), METHOD(Mailmap, resolve_signature, METH_VARARGS), diff --git a/src/note.c b/src/note.c index cb25e39c0..0c014578b 100644 --- a/src/note.c +++ b/src/note.c @@ -99,7 +99,7 @@ Note_dealloc(Note *self) } -PyMethodDef Note_methods[] = { +static PyMethodDef Note_methods[] = { METHOD(Note, remove, METH_VARARGS), {NULL} }; diff --git a/src/object.c b/src/object.c index 15127c7f8..f8cba2ea9 100644 --- a/src/object.c +++ b/src/object.c @@ -302,7 +302,7 @@ PyGetSetDef Object_getseters[] = { {NULL} }; -PyMethodDef Object_methods[] = { +static PyMethodDef Object_methods[] = { METHOD(Object, read_raw, METH_NOARGS), METHOD(Object, peel, METH_O), {NULL} diff --git a/src/odb.c b/src/odb.c index e72a099e0..e8720fc0a 100644 --- a/src/odb.c +++ b/src/odb.c @@ -344,7 +344,7 @@ Odb_add_backend(Odb *self, PyObject *args) } -PyMethodDef Odb_methods[] = { +static PyMethodDef Odb_methods[] = { METHOD(Odb, add_disk_alternate, METH_O), METHOD(Odb, read, METH_O), METHOD(Odb, read_header, METH_O), diff --git a/src/odb_backend.c b/src/odb_backend.c index a189aec53..2139f2201 100644 --- a/src/odb_backend.c +++ b/src/odb_backend.c @@ -518,7 +518,7 @@ OdbBackend_refresh(OdbBackend *self) * - readstream * - freshen */ -PyMethodDef OdbBackend_methods[] = { +static PyMethodDef OdbBackend_methods[] = { METHOD(OdbBackend, read, METH_O), METHOD(OdbBackend, read_prefix, METH_O), METHOD(OdbBackend, read_header, METH_O), diff --git a/src/patch.c b/src/patch.c index 256e7e0fe..478364c68 100644 --- a/src/patch.c +++ b/src/patch.c @@ -235,7 +235,7 @@ Patch_hunks__get__(Patch *self) } -PyMethodDef Patch_methods[] = { +static PyMethodDef Patch_methods[] = { {"create_from", (PyCFunction) Patch_create_from, METH_KEYWORDS | METH_VARARGS | METH_STATIC, Patch_create_from__doc__}, {NULL} diff --git a/src/pygit2.c b/src/pygit2.c index 91fb2cb48..9ddf6e764 100644 --- a/src/pygit2.c +++ b/src/pygit2.c @@ -305,45 +305,51 @@ filter_register(PyObject *self, PyObject *args, PyObject *kwds) int priority = GIT_FILTER_DRIVER_PRIORITY; char *keywords[] = {"name", "filter_cls", "priority", NULL}; pygit2_filter *filter; - PyObject *py_attrs; - PyObject *result = Py_None; int err; if (!PyArg_ParseTupleAndKeywords(args, kwds, "s#O|i", keywords, &name, &size, &py_filter_cls, &priority)) return NULL; - py_attrs = PyObject_GetAttrString(py_filter_cls, "attributes"); - if (py_attrs == NULL) + /* py_attrs = py_filter_cls.attributes */ + PyObject* py_attrs = PyObject_GetAttrString(py_filter_cls, "attributes"); + if (py_attrs == NULL) { return NULL; + } + char* attrs = pgit_strdup(py_attrs); + Py_DECREF(py_attrs); + if (attrs == NULL) { + return NULL; + } + /* allocate memory */ filter = malloc(sizeof(pygit2_filter)); - if (filter == NULL) - { - return PyExc_MemoryError; + if (filter == NULL) { + free(attrs); + return PyErr_NoMemory(); } memset(filter, 0, sizeof(pygit2_filter)); - git_filter_init(&filter->filter, GIT_FILTER_VERSION); - filter->filter.attributes = PyUnicode_AsUTF8(py_attrs); + /* initialize git_filter */ + git_filter_init(&filter->filter, GIT_FILTER_VERSION); + filter->filter.attributes = attrs; filter->filter.shutdown = pygit2_filter_shutdown; filter->filter.check = pygit2_filter_check; filter->filter.stream = pygit2_filter_stream; filter->filter.cleanup = pygit2_filter_cleanup; - filter->py_filter_cls = py_filter_cls; - Py_INCREF(py_filter_cls); - if ((err = git_filter_register(name, &filter->filter, priority)) < 0) - goto error; + /* keep reference to Python filter */ + filter->py_filter_cls = py_filter_cls; - goto done; + /* git register filter */ + if ((err = git_filter_register(name, &filter->filter, priority)) < 0) { + free(attrs); + free(filter); + return Error_set(err); + } -error: - Py_DECREF(py_filter_cls); - free(filter); -done: - Py_DECREF(py_attrs); - return result; + Py_INCREF(py_filter_cls); /* libgit2 now owns this reference, will decref in shutdown */ + Py_RETURN_NONE; } static void diff --git a/src/refdb.c b/src/refdb.c index 5558d47ac..70c687473 100644 --- a/src/refdb.c +++ b/src/refdb.c @@ -125,13 +125,11 @@ Refdb_open(PyObject *self, Repository *repo) return wrap_refdb(refdb); } -PyMethodDef Refdb_methods[] = { +static PyMethodDef Refdb_methods[] = { METHOD(Refdb, compress, METH_NOARGS), METHOD(Refdb, set_backend, METH_O), - {"new", (PyCFunction) Refdb_new, - METH_O | METH_STATIC, Refdb_new__doc__}, - {"open", (PyCFunction) Refdb_open, - METH_O | METH_STATIC, Refdb_open__doc__}, + {"new", (PyCFunction) Refdb_new, METH_O | METH_STATIC, Refdb_new__doc__}, + {"open", (PyCFunction) Refdb_open, METH_O | METH_STATIC, Refdb_open__doc__}, {NULL} }; diff --git a/src/refdb_backend.c b/src/refdb_backend.c index 6f6b0da08..ea351dc28 100644 --- a/src/refdb_backend.c +++ b/src/refdb_backend.c @@ -772,7 +772,7 @@ RefdbBackend_ensure_log(RefdbBackend *self, PyObject *_ref_name) } } -PyMethodDef RefdbBackend_methods[] = { +static PyMethodDef RefdbBackend_methods[] = { METHOD(RefdbBackend, exists, METH_O), METHOD(RefdbBackend, lookup, METH_O), METHOD(RefdbBackend, write, METH_VARARGS), diff --git a/src/reference.c b/src/reference.c index b535cbe48..fbce62d1a 100644 --- a/src/reference.c +++ b/src/reference.c @@ -658,7 +658,7 @@ PyTypeObject RefLogEntryType = { 0, /* tp_new */ }; -PyMethodDef Reference_methods[] = { +static PyMethodDef Reference_methods[] = { METHOD(Reference, delete, METH_NOARGS), METHOD(Reference, rename, METH_O), METHOD(Reference, resolve, METH_NOARGS), diff --git a/src/repository.c b/src/repository.c index 1e57d8f58..24a548486 100644 --- a/src/repository.c +++ b/src/repository.c @@ -2423,7 +2423,7 @@ Repository_listall_mergeheads(Repository *self, PyObject *args) } } -PyMethodDef Repository_methods[] = { +static PyMethodDef Repository_methods[] = { METHOD(Repository, create_blob, METH_VARARGS), METHOD(Repository, create_blob_fromworkdir, METH_O), METHOD(Repository, create_blob_fromdisk, METH_O), diff --git a/src/tag.c b/src/tag.c index 0d42f0f56..d384171a9 100644 --- a/src/tag.c +++ b/src/tag.c @@ -142,7 +142,7 @@ Tag_raw_message__get__(Tag *self) return PyBytes_FromString(message); } -PyMethodDef Tag_methods[] = { +static PyMethodDef Tag_methods[] = { METHOD(Tag, get_object, METH_NOARGS), {NULL} }; diff --git a/src/tree.c b/src/tree.c index ef40e275f..2e7906aaa 100644 --- a/src/tree.c +++ b/src/tree.c @@ -405,7 +405,7 @@ PyMappingMethods Tree_as_mapping = { 0, /* mp_ass_subscript */ }; -PyMethodDef Tree_methods[] = { +static PyMethodDef Tree_methods[] = { METHOD(Tree, diff_to_tree, METH_VARARGS | METH_KEYWORDS), METHOD(Tree, diff_to_workdir, METH_VARARGS | METH_KEYWORDS), METHOD(Tree, diff_to_index, METH_VARARGS | METH_KEYWORDS), diff --git a/src/treebuilder.c b/src/treebuilder.c index 8c47477b8..629442bc4 100644 --- a/src/treebuilder.c +++ b/src/treebuilder.c @@ -160,7 +160,7 @@ TreeBuilder_clear(TreeBuilder *self) Py_RETURN_NONE; } -PyMethodDef TreeBuilder_methods[] = { +static PyMethodDef TreeBuilder_methods[] = { METHOD(TreeBuilder, clear, METH_NOARGS), METHOD(TreeBuilder, get, METH_O), METHOD(TreeBuilder, insert, METH_VARARGS), diff --git a/src/utils.c b/src/utils.c index 614b9770b..be90dba4d 100644 --- a/src/utils.c +++ b/src/utils.c @@ -151,6 +151,35 @@ pgit_borrow(PyObject *value) } +char* +pgit_strdup(PyObject *value) +{ + const char *str; + char *copy; + size_t len; + + if (PyUnicode_Check(value)) { + str = PyUnicode_AsUTF8(value); + if (str == NULL) + return NULL; + len = strlen(str); + } + else { + Error_type_error("unexpected %.200s", value); + return NULL; + } + + copy = malloc(len + 1); + if (copy == NULL) { + PyErr_NoMemory(); + return NULL; + } + + memcpy(copy, str, len + 1); + return copy; +} + + static git_otype py_type_to_git_type(PyTypeObject *py_type) { diff --git a/src/utils.h b/src/utils.h index c1b8989dd..2ea28ec29 100644 --- a/src/utils.h +++ b/src/utils.h @@ -90,6 +90,7 @@ to_unicode_n(const char *value, size_t len, const char *encoding, const char* pgit_borrow(PyObject *value); const char* pgit_borrow_encoding(PyObject *value, const char *encoding, const char *errors, PyObject **tvalue); char* pgit_borrow_fsdefault(PyObject *value, PyObject **tvalue); +char* pgit_strdup(PyObject *value); //PyObject * get_pylist_from_git_strarray(git_strarray *strarray); diff --git a/src/walker.c b/src/walker.c index b4967a33f..7b1e9d8d5 100644 --- a/src/walker.c +++ b/src/walker.c @@ -163,7 +163,7 @@ Walker_iternext(Walker *self) return wrap_object((git_object*)commit, self->repo, NULL); } -PyMethodDef Walker_methods[] = { +static PyMethodDef Walker_methods[] = { METHOD(Walker, hide, METH_O), METHOD(Walker, push, METH_O), METHOD(Walker, reset, METH_NOARGS), diff --git a/src/worktree.c b/src/worktree.c index 2ed3772fc..b403485a1 100644 --- a/src/worktree.c +++ b/src/worktree.c @@ -93,7 +93,7 @@ Worktree_dealloc(Worktree *self) } -PyMethodDef Worktree_methods[] = { +static PyMethodDef Worktree_methods[] = { METHOD(Worktree, prune, METH_VARARGS), {NULL} }; From 6b027dacc4559a1a5c9936c9350962a841f6a6f3 Mon Sep 17 00:00:00 2001 From: Jah-yee Date: Thu, 19 Mar 2026 16:25:12 +0800 Subject: [PATCH 150/225] fix: set build-backend in pyproject.toml Remove wheel from build requirements and add proper build-backend per PEP 517. This fixes the issue where pip install would not use build isolation properly. Fixes #1452 --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9d8a4d22a..7d008a96f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,6 @@ [build-system] -requires = ["setuptools", "wheel"] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" [tool.cibuildwheel] enable = ["pypy"] From a44a305e94b43be4e986876d1f8ad3c1f73220be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Sat, 21 Mar 2026 13:57:24 +0100 Subject: [PATCH 151/225] Update changelog --- CHANGELOG.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1634fdfa..8d038f952 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,17 @@ # 1.19.2 (UNRELEASED) +- Fix refcount and error handling issues in `filter_register(...)` + - New `Repository.load_filter_list(...)` and `FilterList` [#1444](https://github.com/libgit2/pygit2/pull/1444) -- Build: Fix static build conflict with numpy - [#1446](https://github.com/libgit2/pygit2/pull/1446) +- New `Odb.read_header(...)` and now `Odb.read(...)` returns `enums.ObjectType` instead of int + [#1450](https://github.com/libgit2/pygit2/pull/1450) -- CI: Fix create GitHub release +- Build and CI fixes + [#1446](https://github.com/libgit2/pygit2/pull/1446) [#1448](https://github.com/libgit2/pygit2/pull/1448) + [#1455](https://github.com/libgit2/pygit2/pull/1455) # 1.19.1 (2025-12-29) From be7adf8b6d0bf17409bdaa4a62326182116a0ad0 Mon Sep 17 00:00:00 2001 From: Vruyr Gyolchanyan Date: Wed, 25 Mar 2026 12:54:27 -0400 Subject: [PATCH 152/225] Fixed RuntimeError when iterating git config entries with valueless keys. (#1457) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fixed RuntimeError when iterating git config entries with valueless keys. * Added unit tests. * Return Type Fixes for Valueless Config Entries The `get_multivar` method accepts any key name — there is no multivar marker in the git config format. A valueless key can therefore appear among multivar results, so `__next__` on `ConfigMultivarIterator` returns `str | None`. Valueless keys are also returned as `None` by `__getitem__`. --- pygit2/config.py | 12 ++++++------ test/test_config.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/pygit2/config.py b/pygit2/config.py index f05a8d13a..5ffb6896d 100644 --- a/pygit2/config.py +++ b/pygit2/config.py @@ -73,7 +73,7 @@ def _next_entry(self) -> 'ConfigEntry': class ConfigMultivarIterator(ConfigIterator): - def __next__(self) -> str: # type: ignore[override] + def __next__(self) -> str | None: # type: ignore[override] entry = self._next_entry() return entry.value @@ -137,7 +137,7 @@ def __contains__(self, key: str | bytes) -> bool: return True - def __getitem__(self, key: str | bytes) -> str: + def __getitem__(self, key: str | bytes) -> str | None: """ When using the mapping interface, the value is returned as a string. In order to apply the git-config parsing rules, you can use @@ -365,8 +365,8 @@ def raw_name(self) -> bytes: return ffi.string(self._entry.name) @cached_property - def raw_value(self) -> bytes: - return ffi.string(self.c_value) + def raw_value(self) -> bytes | None: + return ffi.string(self.c_value) if self.c_value != ffi.NULL else None @cached_property def level(self) -> int: @@ -379,6 +379,6 @@ def name(self) -> str: return self.raw_name.decode('utf-8') @property - def value(self) -> str: + def value(self) -> str | None: """The entry's value as a string.""" - return self.raw_value.decode('utf-8') + return self.raw_value.decode('utf-8') if self.raw_value is not None else None diff --git a/test/test_config.py b/test/test_config.py index 247d55c51..2d1a2a117 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -185,6 +185,35 @@ def test_iterator(config: Config) -> None: assert lst['core.bare'] +def test_valueless_key_iteration() -> None: + # A valueless key (no `= value`) has a NULL value pointer in libgit2. + # Iterating over such entries must not raise a RuntimeError. + with open(CONFIG_FILENAME, 'w') as new_file: + new_file.write('[section]\n\tvaluelesskey\n\tnormalkey = somevalue\n') + + config = Config() + config.add_file(CONFIG_FILENAME, 6) + + entries = {entry.name: entry for entry in config} + assert 'section.valuelesskey' in entries + assert 'section.normalkey' in entries + + +def test_valueless_key_value() -> None: + # A valueless key must expose value=None and raw_value=None. + with open(CONFIG_FILENAME, 'w') as new_file: + new_file.write('[section]\n\tvaluelesskey\n\tnormalkey = somevalue\n') + + config = Config() + config.add_file(CONFIG_FILENAME, 6) + + entries = {entry.name: entry for entry in config} + assert entries['section.valuelesskey'].raw_value is None + assert entries['section.valuelesskey'].value is None + assert entries['section.normalkey'].raw_value == b'somevalue' + assert entries['section.normalkey'].value == 'somevalue' + + def test_parsing() -> None: assert Config.parse_bool('on') assert Config.parse_bool('1') From 627c4c1d4eee3471bd05d5bbedc52f20089fe27d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Sun, 29 Mar 2026 13:40:08 +0200 Subject: [PATCH 153/225] Release 1.19.2 --- AUTHORS.md | 3 +++ CHANGELOG.md | 5 ++++- docs/conf.py | 2 +- pygit2/_build.py | 2 +- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/AUTHORS.md b/AUTHORS.md index 6c4b13628..8a04c1bee 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -138,6 +138,7 @@ Authors: Adam Spiers Adrien Nader Albin Söderström + Alexander Shadchin Alexandru Fikl Andrew Chin Andrew McNulty @@ -182,6 +183,7 @@ Authors: Hugh Cole-Baker Isabella Stephens Jacob Swanson + Jah-yee Jasper Lievisse Adriaanse Jimisola Laursen Jiri Benc @@ -237,6 +239,7 @@ Authors: Timo Röhling Victor Florea Vladimir Rutsky + Vruyr Gyolchanyan William Schueller Wim Jeantine-Glenn Yu Jianjian diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d038f952..754bb14ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,10 @@ -# 1.19.2 (UNRELEASED) +# 1.19.2 (2026-03-29) - Fix refcount and error handling issues in `filter_register(...)` +- Fix config with valueless keys + [#1457](https://github.com/libgit2/pygit2/pull/1457) + - New `Repository.load_filter_list(...)` and `FilterList` [#1444](https://github.com/libgit2/pygit2/pull/1444) diff --git a/docs/conf.py b/docs/conf.py index c5cb68fac..4c21e4f32 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,7 +23,7 @@ # author = '' # The full version, including alpha/beta/rc tags -release = '1.19.1' +release = '1.19.2' # -- General configuration --------------------------------------------------- diff --git a/pygit2/_build.py b/pygit2/_build.py index 985fcd87d..976c98f4c 100644 --- a/pygit2/_build.py +++ b/pygit2/_build.py @@ -34,7 +34,7 @@ # # The version number of pygit2 # -__version__ = '1.19.1' +__version__ = '1.19.2' # From 7fd5bfde012647a32d4418c1c0c689f37481bbb0 Mon Sep 17 00:00:00 2001 From: MukundaKatta Date: Mon, 20 Apr 2026 11:38:08 -0700 Subject: [PATCH 154/225] docs(repository.diff): fix docstring keyword 'flag' -> 'flags' The BaseRepository.diff signature takes 'flags: DiffOption = ...', not 'flag', but the docstring's Keyword arguments block listed 'flag'. Closes #1138 --- pygit2/repository.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygit2/repository.py b/pygit2/repository.py index 2605eee36..b5e59e875 100644 --- a/pygit2/repository.py +++ b/pygit2/repository.py @@ -614,7 +614,7 @@ def diff( If 'b' is None, by default the working directory is compared to 'a'. If 'cached' is set to True, the index/staging area is used for comparing. - flag + flags A combination of enums.DiffOption constants. context_lines From d32573ec6ddf1a9e14ce10c939d6dfada2997cc9 Mon Sep 17 00:00:00 2001 From: Ethan Meng Date: Wed, 29 Apr 2026 22:49:34 -0500 Subject: [PATCH 155/225] fix(repository): dereference reflog identity correctly Since its introduction in commit 81520c9c (Update to libgit2 v0.23), the reflog identity getter (Repository.ident) has been passing the (char **) variables directly to ffi.string() as C-strings, when they really are pointers to C-strings. Fix by dereferencing the pointer to a C-string first. Furthermore, Repository.ident assumes that said variables hold valid C-strings, when the reflog identity is initialized to NULL, and can be explicitly set to such with `set_ident (None, None)`. So map None to ffi.NULL (which maybe_string() happens to already do, so I simply delegated to that...). I also added a few regression tests to ensure neither issue shows up again. Signed-off-by: Ethan Meng --- pygit2/repository.py | 4 ++-- test/test_repository.py | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/pygit2/repository.py b/pygit2/repository.py index b5e59e875..b03342f82 100644 --- a/pygit2/repository.py +++ b/pygit2/repository.py @@ -81,7 +81,7 @@ from .remotes import RemoteCollection from .submodules import SubmoduleCollection from .transaction import ReferenceTransaction -from .utils import StrArray, to_bytes +from .utils import StrArray, maybe_string, to_bytes if TYPE_CHECKING: from pygit2._libgit2.ffi import ( @@ -1585,7 +1585,7 @@ def ident(self): err = C.git_repository_ident(cname, cemail, self._repo) check_error(err) - return (ffi.string(cname).decode('utf-8'), ffi.string(cemail).decode('utf-8')) + return (maybe_string(cname[0]), maybe_string(cemail[0])) def set_ident(self, name: str, email: str) -> None: """Set the identity to be used for reference operations. diff --git a/test/test_repository.py b/test/test_repository.py index 68913ff5c..446aaae70 100644 --- a/test/test_repository.py +++ b/test/test_repository.py @@ -641,6 +641,19 @@ def test_default_signature(testrepo: Repository) -> None: assert 'rjh@example.com' == sig.email +def test_ident_get_set(testrepo: Repository) -> None: + # By default, reflog identity should be unset. + assert testrepo.ident == (None, None) + + cname = 'C O Mitter' + cemail = 'committer@example.com' + testrepo.set_ident(cname, cemail) + assert testrepo.ident == (cname, cemail) + + testrepo.set_ident(None, None) + assert testrepo.ident == (None, None) + + def test_new_repo(tmp_path: Path) -> None: repo = init_repository(tmp_path, False) From 379b4d82f0d8d97a9f06cdb37286a735dd01244e Mon Sep 17 00:00:00 2001 From: Ethan Meng Date: Thu, 30 Apr 2026 00:19:03 -0500 Subject: [PATCH 156/225] fix(repository): amend annotations on reflog identity Since set_ident() already has type annotations, it seems fitting that we adorn ident() with those too. :) Also, to_bytes() is perfectly capable of mapping None to ffi.NULL, so the parameters on set_ident() should be `str | None` (aka Optional[str]). Signed-off-by: Ethan Meng --- pygit2/repository.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pygit2/repository.py b/pygit2/repository.py index b03342f82..506fc1255 100644 --- a/pygit2/repository.py +++ b/pygit2/repository.py @@ -1578,7 +1578,7 @@ def get_attr( # Identity for reference operations # @property - def ident(self): + def ident(self) -> tuple[Optional[str], Optional[str]]: cname = ffi.new('char **') cemail = ffi.new('char **') @@ -1587,7 +1587,7 @@ def ident(self): return (maybe_string(cname[0]), maybe_string(cemail[0])) - def set_ident(self, name: str, email: str) -> None: + def set_ident(self, name: Optional[str], email: Optional[str]) -> None: """Set the identity to be used for reference operations. Updates to some references also append data to their From e7bd6d26c6d7ffd17bfb1e5c52d39a76d95fb1c7 Mon Sep 17 00:00:00 2001 From: Ethan Meng Date: Wed, 29 Apr 2026 23:20:58 -0500 Subject: [PATCH 157/225] docs(repository): fix default_signature typos Repository.default_signature is a read-only descriptor, not a getter method. Signed-off-by: Ethan Meng --- pygit2/repository.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pygit2/repository.py b/pygit2/repository.py index 506fc1255..660ac4fa2 100644 --- a/pygit2/repository.py +++ b/pygit2/repository.py @@ -1278,7 +1278,7 @@ def stash( Example:: >>> repo = pygit2.Repository('.') - >>> repo.stash(repo.default_signature(), 'WIP: stashing') + >>> repo.stash(repo.default_signature, 'WIP: stashing') """ opts = ffi.new('git_stash_save_options *') @@ -1347,7 +1347,7 @@ def stash_apply( Example:: >>> repo = pygit2.Repository('.') - >>> repo.stash(repo.default_signature(), 'WIP: stashing') + >>> repo.stash(repo.default_signature, 'WIP: stashing') >>> repo.stash_apply(strategy=CheckoutStrategy.ALLOW_CONFLICTS) """ with git_stash_apply_options( From ce094444859280de9b006867c71d6cbd938c1980 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Sun, 3 May 2026 11:22:10 +0200 Subject: [PATCH 158/225] COPYING fix text mismatch Fixes #1458 --- COPYING | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/COPYING b/COPYING index 631492395..6445bf87a 100644 --- a/COPYING +++ b/COPYING @@ -316,9 +316,8 @@ the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software; you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation; either version 2 of the License, or - (at your option) any later version. + it under the terms of the GNU General Public License, version 2, + 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 From beb465aaf90ca20bd85a295699d247cc3c80e267 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Sun, 3 May 2026 11:40:07 +0200 Subject: [PATCH 159/225] CI: Build wheels from sdist Fixes #1454 Assisted-by: Kimi-k2.6 --- .github/workflows/wheels.yml | 97 +++++++++++++++++++----------------- 1 file changed, 51 insertions(+), 46 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 06028447d..3beb42e0d 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -15,9 +15,42 @@ on: - 'docs/**' jobs: + sdist: + runs-on: ubuntu-latest + outputs: + release_title: ${{ steps.parse_changelog.outputs.release_title }} + steps: + - uses: actions/checkout@v6 + with: + # avoid leaking credentials in uploaded artifacts + persist-credentials: false + + - uses: actions/setup-python@v6 + with: + python-version: '3.13' + + - name: Build sdist + run: pipx run build --sdist --outdir dist + + - uses: actions/upload-artifact@v7 + with: + name: wheels-sdist + path: dist/* + + - name: parse CHANGELOG for release notes + id: parse_changelog + run: python .github/workflows/parse_release_notes.py + + - name: Upload Release Notes + uses: actions/upload-artifact@v7 + with: + name: release-notes + path: ReleaseNotes.md + build_wheels: name: Wheels for ${{ matrix.name }} runs-on: ${{ matrix.os }} + needs: sdist strategy: # let other jobs in matrix complete if one fails fail-fast: false @@ -40,22 +73,24 @@ jobs: os: windows-11-arm steps: - - uses: actions/checkout@v6 - with: - # avoid leaking credentials in uploaded artifacts - persist-credentials: false - - uses: actions/setup-python@v6 with: python-version: '3.13' + - name: Download sdist + uses: actions/download-artifact@v8 + with: + name: wheels-sdist + path: dist + - name: Install cibuildwheel run: python -m pip install cibuildwheel~=3.3 - name: Build wheels + shell: bash env: CIBW_ARCHS_WINDOWS: ${{ matrix.name == 'windows-x86' && 'auto32' || 'native' }} - run: python -m cibuildwheel --output-dir wheelhouse + run: python -m cibuildwheel dist/*.tar.gz --output-dir wheelhouse - uses: actions/upload-artifact@v7 with: @@ -65,13 +100,9 @@ jobs: build_wheels_ppc: name: Wheels for linux-ppc runs-on: ubuntu-24.04 + needs: sdist steps: - - uses: actions/checkout@v6 - with: - # avoid leaking credentials in uploaded artifacts - persist-credentials: false - - uses: actions/setup-python@v6 with: python-version: '3.13' @@ -80,11 +111,18 @@ jobs: with: platforms: linux/ppc64le + - name: Download sdist + uses: actions/download-artifact@v8 + with: + name: wheels-sdist + path: dist + - name: Install cibuildwheel - run: python -m pip install -r requirements-wheel.txt + run: python -m pip install cibuildwheel~=3.3 - name: Build wheels - run: python -m cibuildwheel --output-dir wheelhouse + shell: bash + run: python -m cibuildwheel dist/*.tar.gz --output-dir wheelhouse env: CIBW_ARCHS: ppc64le CIBW_ENVIRONMENT: LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.2 LIBGIT2=/project/ci @@ -94,39 +132,6 @@ jobs: name: wheels-linux-ppc path: ./wheelhouse/*.whl - sdist: - runs-on: ubuntu-latest - outputs: - release_title: ${{ steps.parse_changelog.outputs.release_title }} - steps: - - uses: actions/checkout@v6 - with: - # avoid leaking credentials in uploaded artifacts - persist-credentials: false - - - uses: actions/setup-python@v6 - with: - python-version: '3.13' - - - name: Build sdist - run: pipx run build --sdist --outdir dist - - - uses: actions/upload-artifact@v7 - with: - name: wheels-sdist - path: dist/* - - - name: parse CHANGELOG for release notes - id: parse_changelog - run: python .github/workflows/parse_release_notes.py - - - name: Upload Release Notes - uses: actions/upload-artifact@v7 - with: - name: release-notes - path: ReleaseNotes.md - - twine-check: name: Twine check # It is good to do this check on non-tagged commits. From 433c93fd1d62d9e05ee5c0de5d7a829ac55194ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Sun, 3 May 2026 13:27:56 +0200 Subject: [PATCH 160/225] CI: fixing wheels Assisted-by: Kimi-k2.6 --- build.sh | 2 +- pyproject.toml | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/build.sh b/build.sh index c2d654d8a..de089ea29 100644 --- a/build.sh +++ b/build.sh @@ -217,7 +217,7 @@ if [ "$CIBUILDWHEEL" = "1" ]; then cp $OPENSSL_PREFIX/*.dylib $PREFIX/lib/ echo "PREFIX " $PREFIX echo "OPENSSL_PREFIX" $OPENSSL_PREFIX - ls -l /Users/runner/work/pygit2/pygit2/ci/ + ls -l $PREFIX ls -l $PREFIX/lib fi # we're done building dependencies, cibuildwheel action will take over diff --git a/pyproject.toml b/pyproject.toml index 7d008a96f..2da74b50e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,12 +9,12 @@ skip = "*musllinux_ppc64le" archs = ["native"] build-frontend = "default" dependency-versions = "pinned" -environment = {LIBGIT2_VERSION="1.9.2", LIBSSH2_VERSION="1.11.1", OPENSSL_VERSION="3.5.4", LIBGIT2="/project/ci"} +environment = {LIBGIT2_VERSION="1.9.2", LIBSSH2_VERSION="1.11.1", OPENSSL_VERSION="3.5.4"} before-all = "sh build.sh" test-command = "pytest" test-sources = ["test", "pytest.ini"] -before-test = "pip install -r {project}/requirements-test.txt" +before-test = "pip install -r {package}/requirements-test.txt" # Will avoid testing on emulated architectures (specifically ppc64le) # Also, skip testing pypy on macOS arm64 due issue with bootstrapping git config paths # see https://github.com/libgit2/pygit2/issues/1442 @@ -28,8 +28,7 @@ select = "*-musllinux*" repair-wheel-command = "LD_LIBRARY_PATH=/project/ci/lib auditwheel repair -w {dest_dir} {wheel}" [tool.cibuildwheel.macos] -environment = {LIBGIT2_VERSION="1.9.2", LIBSSH2_VERSION="1.11.1", OPENSSL_VERSION="3.5.4", LIBGIT2="/Users/runner/work/pygit2/pygit2/ci"} -repair-wheel-command = "DYLD_LIBRARY_PATH=/Users/runner/work/pygit2/pygit2/ci/lib delocate-wheel --require-archs {delocate_archs} -w {dest_dir} {wheel}" +repair-wheel-command = "DYLD_LIBRARY_PATH={package}/ci/lib delocate-wheel --require-archs {delocate_archs} -w {dest_dir} {wheel}" [tool.cibuildwheel.windows] environment.LIBGIT2_SRC = "build/libgit2_src" From c5db72d0acb42e1d4bc6d5da6cc0d785c94ebf6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Mon, 4 May 2026 10:38:37 +0200 Subject: [PATCH 161/225] CI: fixing wheels Assisted-by: Kimi-k2.6 --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 2da74b50e..a388cd294 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependency-versions = "pinned" environment = {LIBGIT2_VERSION="1.9.2", LIBSSH2_VERSION="1.11.1", OPENSSL_VERSION="3.5.4"} before-all = "sh build.sh" +before-build = "export LIBGIT2='{package}/ci'" test-command = "pytest" test-sources = ["test", "pytest.ini"] before-test = "pip install -r {package}/requirements-test.txt" @@ -34,6 +35,7 @@ repair-wheel-command = "DYLD_LIBRARY_PATH={package}/ci/lib delocate-wheel --requ environment.LIBGIT2_SRC = "build/libgit2_src" environment.LIBGIT2_VERSION = "1.9.2" before-all = "powershell -File build.ps1" +before-build = "" [[tool.cibuildwheel.overrides]] select="*-win_amd64" From 6a7a59f1c46c99048733a7f728276b0f0e73f77c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Sun, 31 May 2026 17:47:11 +0200 Subject: [PATCH 162/225] Update to libgit2 v1.9.4 and fix wheels --- .github/workflows/lint.yml | 2 +- .github/workflows/tests.yml | 2 +- .github/workflows/wheels.yml | 2 +- Makefile | 2 +- build.sh | 8 ++++---- docs/install.rst | 14 +++++++------- pyproject.toml | 5 ++--- 7 files changed, 17 insertions(+), 18 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 0b5290c9e..eb4e2acf9 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -31,4 +31,4 @@ jobs: run: ruff check - name: Check typing with mypy - run: LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.2 /bin/sh build.sh mypy + run: LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.4 /bin/sh build.sh mypy diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1c02cb843..08bbe45b5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -25,5 +25,5 @@ jobs: apt-get update -q -y apt-get install -q -y cmake libssl-dev python3-dev python3-venv wget run: | - LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.2 /bin/sh build.sh test + LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.4 /bin/sh build.sh test continue-on-error: true # Tests are expected to fail, see issue #812 diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 3beb42e0d..9b16bc41a 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -125,7 +125,7 @@ jobs: run: python -m cibuildwheel dist/*.tar.gz --output-dir wheelhouse env: CIBW_ARCHS: ppc64le - CIBW_ENVIRONMENT: LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.2 LIBGIT2=/project/ci + CIBW_ENVIRONMENT: LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.4 LIBGIT2=/project/ci - uses: actions/upload-artifact@v7 with: diff --git a/Makefile b/Makefile index 80107aae7..4cd932c0a 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ .PHONY: build html build: - OPENSSL_VERSION=3.5.4 LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.2 sh build.sh + OPENSSL_VERSION=3.5.4 LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.4 sh build.sh html: build make -C docs html diff --git a/build.sh b/build.sh index de089ea29..f17542c1e 100644 --- a/build.sh +++ b/build.sh @@ -22,14 +22,14 @@ # # sh build.sh # -# Build libgit2 1.9.2 (will use libssh2 if available), then build pygit2 +# Build libgit2 1.9.4 (will use libssh2 if available), then build pygit2 # inplace: # -# LIBGIT2_VERSION=1.9.2 sh build.sh +# LIBGIT2_VERSION=1.9.4 sh build.sh # -# Build libssh2 1.11.1 and libgit2 1.9.2, then build pygit2 inplace: +# Build libssh2 1.11.1 and libgit2 1.9.4, then build pygit2 inplace: # -# LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.2 sh build.sh +# LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.4 sh build.sh # # Build inplace and run the tests: # diff --git a/docs/install.rst b/docs/install.rst index 1ba7efc9d..a72319256 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -158,9 +158,9 @@ directory, do: .. code-block:: sh :caption: On Linux using bash - wget https://github.com/libgit2/libgit2/archive/refs/tags/v1.9.2.tar.gz -O libgit2-1.9.2.tar.gz - tar -xzf libgit2-1.9.2.tar.gz - cd libgit2-1.9.2/ + wget https://github.com/libgit2/libgit2/archive/refs/tags/v1.9.4.tar.gz -O libgit2-1.9.4.tar.gz + tar -xzf libgit2-1.9.4.tar.gz + cd libgit2-1.9.4/ cmake . make sudo make install @@ -245,9 +245,9 @@ Install libgit2 (see we define the installation prefix): .. code-block:: sh :caption: On Linux using bash - wget https://github.com/libgit2/libgit2/archive/refs/tags/v1.9.2.tar.gz -O libgit2-1.9.2.tar.gz - tar xzf libgit2-1.9.2.tar.gz - cd libgit2-1.9.2/ + wget https://github.com/libgit2/libgit2/archive/refs/tags/v1.9.4.tar.gz -O libgit2-1.9.4.tar.gz + tar xzf libgit2-1.9.4.tar.gz + cd libgit2-1.9.4/ cmake . -DCMAKE_INSTALL_PREFIX=$LIBGIT2 cmake --build . --target install @@ -307,7 +307,7 @@ variable. The following recipe shows you how to do it: .. code-block:: pwsh :caption: On Windows using PowerShell (and CMake v3.21 or newer) - git clone --depth=1 -b v1.9.2 https://github.com/libgit2/libgit2.git + git clone --depth=1 -b v1.9.4 https://github.com/libgit2/libgit2.git $env:CMAKE_INSTALL_PREFIX = "C:/Dev/libgit2" $env:CMAKE_GENERATOR = "Visual Studio 17 2022" $env:CMAKE_GENERATOR_PLATFORM = "x64" # or "Win32" or "ARM64" diff --git a/pyproject.toml b/pyproject.toml index a388cd294..59bf27c8a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,10 +9,9 @@ skip = "*musllinux_ppc64le" archs = ["native"] build-frontend = "default" dependency-versions = "pinned" -environment = {LIBGIT2_VERSION="1.9.2", LIBSSH2_VERSION="1.11.1", OPENSSL_VERSION="3.5.4"} +environment = {LIBGIT2="$(pwd)/ci", LIBGIT2_VERSION="1.9.4", LIBSSH2_VERSION="1.11.1", OPENSSL_VERSION="3.5.4"} before-all = "sh build.sh" -before-build = "export LIBGIT2='{package}/ci'" test-command = "pytest" test-sources = ["test", "pytest.ini"] before-test = "pip install -r {package}/requirements-test.txt" @@ -33,7 +32,7 @@ repair-wheel-command = "DYLD_LIBRARY_PATH={package}/ci/lib delocate-wheel --requ [tool.cibuildwheel.windows] environment.LIBGIT2_SRC = "build/libgit2_src" -environment.LIBGIT2_VERSION = "1.9.2" +environment.LIBGIT2_VERSION = "1.9.4" before-all = "powershell -File build.ps1" before-build = "" From 354aafd01373cafd792609dbc5446437cc9c690e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Tue, 2 Jun 2026 13:51:22 +0200 Subject: [PATCH 163/225] Check return value of calloc Fixes #1443 Also remove unused macros Assisted-by: Kimi-k2.6 --- src/odb_backend.c | 4 ++++ src/refdb_backend.c | 19 +++++++++++++------ src/utils.h | 17 ----------------- 3 files changed, 17 insertions(+), 23 deletions(-) diff --git a/src/odb_backend.c b/src/odb_backend.c index 2139f2201..c28cd03f3 100644 --- a/src/odb_backend.c +++ b/src/odb_backend.c @@ -255,6 +255,10 @@ OdbBackend_init(OdbBackend *self, PyObject *args, PyObject *kwds) // Create the C backend pgit_odb_backend *custom_backend = calloc(1, sizeof(pgit_odb_backend)); + if (custom_backend == NULL) { + PyErr_NoMemory(); + return -1; + } custom_backend->backend.version = GIT_ODB_BACKEND_VERSION; // Fill the member methods diff --git a/src/refdb_backend.c b/src/refdb_backend.c index ea351dc28..447585ebe 100644 --- a/src/refdb_backend.c +++ b/src/refdb_backend.c @@ -36,6 +36,7 @@ #include "wildmatch.h" #include #include +#include extern PyTypeObject ReferenceType; extern PyTypeObject RepositoryType; @@ -137,8 +138,12 @@ pygit2_refdb_backend_iterator(git_reference_iterator **iter, PyObject *iterator = PyObject_GetIter((PyObject *)be->RefdbBackend); assert(iterator); - struct pygit2_refdb_iterator *pyiter = - calloc(1, sizeof(struct pygit2_refdb_iterator)); + struct pygit2_refdb_iterator *pyiter = calloc(1, sizeof(struct pygit2_refdb_iterator)); + if (pyiter == NULL) { + Py_DECREF(iterator); + git_error_set(GIT_ERROR_NOMEMORY, "out of memory"); + return GIT_ERROR; + } *iter = (git_reference_iterator *)pyiter; pyiter->iterator = iterator; pyiter->base.next = pygit2_refdb_iterator_next; @@ -389,18 +394,20 @@ int RefdbBackend_init(RefdbBackend *self, PyObject *args, PyObject *kwds) { if (args && PyTuple_Size(args) > 0) { - PyErr_SetString(PyExc_TypeError, - "RefdbBackend takes no arguments"); + PyErr_SetString(PyExc_TypeError, "RefdbBackend takes no arguments"); return -1; } if (kwds && PyDict_Size(kwds) > 0) { - PyErr_SetString(PyExc_TypeError, - "RefdbBackend takes no keyword arguments"); + PyErr_SetString(PyExc_TypeError, "RefdbBackend takes no keyword arguments"); return -1; } struct pygit2_refdb_backend *be = calloc(1, sizeof(struct pygit2_refdb_backend)); + if (be == NULL) { + PyErr_NoMemory(); + return -1; + } git_refdb_init_backend(&be->backend, GIT_REFDB_BACKEND_VERSION); be->RefdbBackend = (PyObject *)self; diff --git a/src/utils.h b/src/utils.h index 2ea28ec29..e906b1129 100644 --- a/src/utils.h +++ b/src/utils.h @@ -128,23 +128,6 @@ PyObject *pygit2_enum(PyObject *enum_type, int value); {#attr, attr_type, offsetof(type, attr), READONLY, PyDoc_STR(docstr)} -/* Helpers for memory allocation */ -#define CALLOC(ptr, num, size, label) \ - ptr = calloc((num), size);\ - if (ptr == NULL) {\ - err = GIT_ERROR;\ - giterr_set_oom();\ - goto label;\ - } - -#define MALLOC(ptr, size, label) \ - ptr = malloc(size);\ - if (ptr == NULL) {\ - err = GIT_ERROR;\ - giterr_set_oom();\ - goto label;\ - } - /* Helpers to make type init shorter. */ #define INIT_TYPE(type, base, new) \ type.tp_base = base; \ From 84e29270430f05201b86efa724dc3731ea7d4a28 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 6 Jun 2026 10:06:15 +0200 Subject: [PATCH 164/225] chore(CI): bump docker/setup-qemu-action (#1459) Bumps the actions group with 1 update in the / directory: [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action). Updates `docker/setup-qemu-action` from 3 to 4 - [Release notes](https://github.com/docker/setup-qemu-action/releases) - [Commits](https://github.com/docker/setup-qemu-action/compare/v3...v4) --- updated-dependencies: - dependency-name: docker/setup-qemu-action dependency-version: '4' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/wheels.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 9b16bc41a..a94c3ec82 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -107,7 +107,7 @@ jobs: with: python-version: '3.13' - - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-qemu-action@v4 with: platforms: linux/ppc64le From 5acab242a40c1cbd815c7aeb51fd14f8e99ba07d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Sat, 6 Jun 2026 10:18:39 +0200 Subject: [PATCH 165/225] odb backend: fix type in call to PyArg_ParseTuple Fixes #1368 --- src/odb_backend.c | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/odb_backend.c b/src/odb_backend.c index c28cd03f3..5b079add3 100644 --- a/src/odb_backend.c +++ b/src/odb_backend.c @@ -72,11 +72,13 @@ pgit_odb_backend_read(void **ptr, size_t *sz, git_object_t *type, const char *bytes; Py_ssize_t type_value; - if (!PyArg_ParseTuple(result, "ny#", &type_value, &bytes, sz) || !bytes) { + Py_ssize_t py_sz; + if (!PyArg_ParseTuple(result, "ny#", &type_value, &bytes, &py_sz) || !bytes) { Py_DECREF(result); return GIT_EUSER; } *type = (git_object_t)type_value; + *sz = (size_t)py_sz; *ptr = git_odb_backend_data_alloc(_be, *sz); if (!*ptr) { @@ -106,12 +108,14 @@ pgit_odb_backend_read_prefix(git_oid *oid_out, void **ptr, size_t *sz, git_objec // Parse output from callback PyObject *py_oid_out; Py_ssize_t type_value; + Py_ssize_t py_sz; const char *bytes; - if (!PyArg_ParseTuple(result, "ny#O", &type_value, &bytes, sz, &py_oid_out) || !bytes) { + if (!PyArg_ParseTuple(result, "ny#O", &type_value, &bytes, &py_sz, &py_oid_out) || !bytes) { Py_DECREF(result); return GIT_EUSER; } *type = (git_object_t)type_value; + *sz = (size_t)py_sz; *ptr = git_odb_backend_data_alloc(_be, *sz); if (!*ptr) { From a2faf0c15bbba04718f7782b5ba49c6d760f5509 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Sun, 7 Jun 2026 08:50:55 +0200 Subject: [PATCH 166/225] index: fix use-after-free in add_conflict() IndexEntry._to_c() returns both the git_index_entry struct and the CFFI-allocated char[] that centry.path points to. add_conflict() was discarding the char[] reference with `_`, so CFFI freed the path string while centry.path still pointed to it. When git_index_conflict_add() read the dangling pointer, it copied garbage into the index (observed as EEEE... on hardened allocators). Keep the path references alive until after the C function returns. Fixes #1417 --- pygit2/index.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pygit2/index.py b/pygit2/index.py index 6ce8a39c6..e80273cec 100644 --- a/pygit2/index.py +++ b/pygit2/index.py @@ -255,11 +255,11 @@ def add_conflict( centry_ours: ffi.NULL_TYPE | ffi.GitIndexEntryC = ffi.NULL centry_theirs: ffi.NULL_TYPE | ffi.GitIndexEntryC = ffi.NULL if ancestor is not None: - centry_ancestor, _ = ancestor._to_c() + centry_ancestor, path_ancestor = ancestor._to_c() if ours is not None: - centry_ours, _ = ours._to_c() + centry_ours, path_ours = ours._to_c() if theirs is not None: - centry_theirs, _ = theirs._to_c() + centry_theirs, path_theirs = theirs._to_c() err = C.git_index_conflict_add( self._index, centry_ancestor, centry_ours, centry_theirs ) From 63fb13b58d6594c34bfa16f817ebebdb16fef555 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Mon, 8 Jun 2026 10:29:52 +0200 Subject: [PATCH 167/225] Update changelog --- CHANGELOG.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 754bb14ec..365eb9ced 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +# 1.19.3 (UNRELEASED) + +- Memory fixes + [#1368](https://github.com/libgit2/pygit2/issues/1368) + [#1417](https://github.com/libgit2/pygit2/issues/1417) + [#1443](https://github.com/libgit2/pygit2/issues/1443) + +- Fix `Repository.ident` + [#1461](https://github.com/libgit2/pygit2/pull/1461) + +- Build/CI fixes and updates + [#1454](https://github.com/libgit2/pygit2/issues/1454) + [#1459](https://github.com/libgit2/pygit2/pull/1459) + +- Documentation fixes + [#1458](https://github.com/libgit2/pygit2/issues/1458) + [#1460](https://github.com/libgit2/pygit2/pull/1460) + + # 1.19.2 (2026-03-29) - Fix refcount and error handling issues in `filter_register(...)` From 21fb1bbc886affc9a8f6ecb172d3a6bcd45126c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Wed, 10 Jun 2026 19:52:22 +0200 Subject: [PATCH 168/225] Fix C annotations ("sh build.sh stubtest" pass) Fixes #1333 --- CHANGELOG.md | 3 +- pygit2/_pygit2.pyi | 273 ++++++++++++++++++++++++--------------------- 2 files changed, 148 insertions(+), 128 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 365eb9ced..3b407e679 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,8 @@ [#1454](https://github.com/libgit2/pygit2/issues/1454) [#1459](https://github.com/libgit2/pygit2/pull/1459) -- Documentation fixes +- Documentation and annotation fixes + [#1333](https://github.com/libgit2/pygit2/issues/1333) [#1458](https://github.com/libgit2/pygit2/issues/1458) [#1460](https://github.com/libgit2/pygit2/pull/1460) diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index 19dd61ffc..1832bdfba 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -13,6 +13,8 @@ from typing import ( # noqa: UP035 overload, ) +from typing_extensions import disjoint_base, final + from . import Index from ._libgit2.ffi import ( GitCommitC, @@ -41,10 +43,6 @@ from .enums import ( ) from .filter import Filter -GIT_OBJ_BLOB = Literal[3] -GIT_OBJ_COMMIT = Literal[1] -GIT_OBJ_TAG = Literal[4] -GIT_OBJ_TREE = Literal[2] LIBGIT2_VER_MAJOR: int LIBGIT2_VER_MINOR: int @@ -259,50 +257,52 @@ GIT_FILTER_NO_SYSTEM_ATTRIBUTES: int GIT_FILTER_ATTRIBUTES_FROM_HEAD: int GIT_FILTER_ATTRIBUTES_FROM_COMMIT: int -T = TypeVar('T') +_T = TypeVar('_T') -class _ObjectBase(Generic[T]): - _pointer: _Pointer[T] +class _ObjectBase(Generic[_T]): + _pointer: _Pointer[_T] filemode: FileMode id: Oid name: str | None raw_name: bytes | None short_id: str - type: 'Literal[GIT_OBJ_COMMIT] | Literal[GIT_OBJ_TREE] | Literal[GIT_OBJ_TAG] | Literal[GIT_OBJ_BLOB]' + type: 'Literal[ObjectType.COMMIT] | Literal[ObjectType.TREE] | Literal[ObjectType.TAG] | Literal[ObjectType.BLOB]' type_str: "Literal['commit'] | Literal['tree'] | Literal['tag'] | Literal['blob']" author: Signature committer: Signature tree: Tree @overload def peel( - self, target_type: 'Literal[GIT_OBJ_COMMIT, ObjectType.COMMIT] | Type[Commit]' + self, target_type: 'Literal[ObjectType.COMMIT] | Type[Commit]', / ) -> 'Commit': ... @overload def peel( - self, target_type: 'Literal[GIT_OBJ_TREE, ObjectType.TREE] | Type[Tree]' + self, target_type: 'Literal[ObjectType.TREE] | Type[Tree]', / ) -> 'Tree': ... @overload def peel( - self, target_type: 'Literal[GIT_OBJ_TAG, ObjectType.TAG] | Type[Tag]' + self, target_type: 'Literal[ObjectType.TAG] | Type[Tag]', / ) -> 'Tag': ... @overload def peel( - self, target_type: 'Literal[GIT_OBJ_BLOB, ObjectType.BLOB] | Type[Blob]' + self, target_type: 'Literal[ObjectType.BLOB] | Type[Blob]', / ) -> 'Blob': ... @overload - def peel(self, target_type: 'None') -> 'Commit|Tree|Tag|Blob': ... + def peel(self, target_type: 'None', /) -> 'Commit|Tree|Tag|Blob': ... def read_raw(self) -> bytes: ... - def __eq__(self, other) -> bool: ... - def __ge__(self, other) -> bool: ... - def __gt__(self, other) -> bool: ... + def __eq__(self, other, /) -> bool: ... + def __ge__(self, other, /) -> bool: ... + def __gt__(self, other, /) -> bool: ... def __hash__(self) -> int: ... - def __le__(self, other) -> bool: ... - def __lt__(self, other) -> bool: ... - def __ne__(self, other) -> bool: ... + def __le__(self, other, /) -> bool: ... + def __lt__(self, other, /) -> bool: ... + def __ne__(self, other, /) -> bool: ... +@disjoint_base class Object(_ObjectBase[GitObjectC]): pass +@final class Reference: name: str raw_name: bytes @@ -315,27 +315,28 @@ class Reference: def delete(self) -> None: ... def log(self) -> Iterator[RefLogEntry]: ... @overload - def peel(self, type: 'Literal[GIT_OBJ_COMMIT] | Type[Commit]') -> 'Commit': ... + def peel(self, type: 'Literal[ObjectType.COMMIT] | Type[Commit]') -> 'Commit': ... @overload - def peel(self, type: 'Literal[GIT_OBJ_TREE] | Type[Tree]') -> 'Tree': ... + def peel(self, type: 'Literal[ObjectType.TREE] | Type[Tree]') -> 'Tree': ... @overload - def peel(self, type: 'Literal[GIT_OBJ_TAG] | Type[Tag]') -> 'Tag': ... + def peel(self, type: 'Literal[ObjectType.TAG] | Type[Tag]') -> 'Tag': ... @overload - def peel(self, type: 'Literal[GIT_OBJ_BLOB] | Type[Blob]') -> 'Blob': ... + def peel(self, type: 'Literal[ObjectType.BLOB] | Type[Blob]') -> 'Blob': ... @overload def peel(self, type: 'None' = None) -> 'Commit|Tree|Tag|Blob': ... - def rename(self, new_name: str) -> None: ... + def rename(self, new_name: str, /) -> None: ... def resolve(self) -> Reference: ... def set_target(self, target: _OidArg, message: str = ...) -> None: ... - def __eq__(self, other) -> bool: ... - def __ge__(self, other) -> bool: ... - def __gt__(self, other) -> bool: ... - def __le__(self, other) -> bool: ... - def __lt__(self, other) -> bool: ... - def __ne__(self, other) -> bool: ... + def __eq__(self, other, /) -> bool: ... + def __ge__(self, other, /) -> bool: ... + def __gt__(self, other, /) -> bool: ... + def __le__(self, other, /) -> bool: ... + def __lt__(self, other, /) -> bool: ... + def __ne__(self, other, /) -> bool: ... class AlreadyExistsError(ValueError): ... +@final class Blob(Object): data: bytes is_binary: bool @@ -364,10 +365,10 @@ class Blob(Object): flags: BlobFilter = BlobFilter.CHECK_FOR_BINARY, commit_id: Optional[Oid] = None, ) -> None: ... - def __buffer__(self, flags: int) -> memoryview: ... - def __release_buffer__(self, buffer: memoryview) -> None: ... + def __buffer__(self, flags: int, /) -> memoryview: ... -class Branch(Reference): +@final +class Branch(Reference): # type: ignore[misc] branch_name: str raw_branch_name: bytes remote_name: str @@ -378,24 +379,9 @@ class Branch(Reference): def is_head(self) -> bool: ... def rename(self, name: str, force: bool = False) -> 'Branch': ... # type: ignore[override] -class FetchOptions: - # incomplete - depth: int - proxy_opts: GitProxyOptionsC -class CloneOptions: - # incomplete - version: int - checkout_opts: object - fetch_opts: FetchOptions - bare: int - local: object - checkout_branch: object - repository_cb: object - repository_cb_payload: object - remote_cb: object - remote_cb_payload: object +@final class Commit(_ObjectBase[GitCommitC]): _pointer: _Pointer[GitCommitC] author: Signature @@ -412,6 +398,7 @@ class Commit(_ObjectBase[GitCommitC]): tree: Tree tree_id: Oid +@disjoint_base class Diff: deltas: Iterator[DiffDelta] patch: str | None @@ -431,11 +418,12 @@ class Diff: @staticmethod def from_c(diff, repo) -> Diff: ... @staticmethod - def parse_diff(git_diff: str | bytes) -> Diff: ... - def __getitem__(self, index: int) -> Patch | None: ... # Diff_getitem + def parse_diff(git_diff: str | bytes, /) -> Diff: ... + def __getitem__(self, index: int, /) -> Patch | None: ... # Diff_getitem def __iter__(self) -> Iterator[Patch | None]: ... # -> DiffIter def __len__(self) -> int: ... +@final class DiffDelta: flags: DiffFlag is_binary: bool @@ -446,6 +434,7 @@ class DiffDelta: status: DeltaStatus def status_char(self) -> str: ... +@final class DiffFile: flags: DiffFlag id: Oid @@ -454,8 +443,9 @@ class DiffFile: raw_path: bytes size: int @staticmethod - def from_c(bytes) -> DiffFile: ... + def from_c(bytes, /) -> DiffFile: ... +@disjoint_base class DiffHunk: header: str lines: list[DiffLine] @@ -464,6 +454,7 @@ class DiffHunk: old_lines: int old_start: int +@final class DiffLine: content: str content_offset: int @@ -473,20 +464,26 @@ class DiffLine: origin: str raw_content: bytes +@disjoint_base class DiffStats: deletions: int files_changed: int insertions: int def format(self, format: DiffStatsFormat, width: int) -> str: ... +@final class FilterSource: - # probably incomplete repo: object - pass + path: str + filemode: int + oid: Oid | None + mode: int + flags: int class GitError(Exception): ... class InvalidSpecError(ValueError): ... +@final class Mailmap: def __init__(self, *args) -> None: ... def add_entry( @@ -503,6 +500,7 @@ class Mailmap: def resolve(self, name: str, email: str) -> tuple[str, str]: ... def resolve_signature(self, sig: Signature) -> Signature: ... +@final class Note: annotated_id: Oid id: Oid @@ -512,46 +510,52 @@ class Note: self, author: Signature, committer: Signature, ref: str = 'refs/notes/commits' ) -> None: ... +@final class Odb: backends: Iterator[OdbBackend] def __init__(self, *args, **kwargs) -> None: ... def add_backend(self, backend: OdbBackend, priority: int) -> None: ... - def add_disk_alternate(self, path: str | Path) -> None: ... - def exists(self, oid: _OidArg) -> bool: ... - def read(self, oid: _OidArg) -> tuple[ObjectType, bytes]: ... - def read_header(self, oid: _OidArg) -> tuple[ObjectType, int]: ... + def add_disk_alternate(self, path: str | Path, /) -> None: ... + def exists(self, oid: _OidArg, /) -> bool: ... + def read(self, oid: _OidArg, /) -> tuple[ObjectType, bytes]: ... + def read_header(self, oid: _OidArg, /) -> tuple[ObjectType, int]: ... def write(self, type: int, data: bytes | str) -> Oid: ... - def __contains__(self, other: _OidArg) -> bool: ... + def __contains__(self, other: _OidArg, /) -> bool: ... def __iter__(self) -> Iterator[Oid]: ... # Odb_as_iter +@disjoint_base class OdbBackend: def __init__(self, *args, **kwargs) -> None: ... - def exists(self, oid: _OidArg) -> bool: ... - def exists_prefix(self, partial_id: _OidArg) -> Oid: ... - def read(self, oid: _OidArg) -> tuple[int, bytes]: ... - def read_header(self, oid: _OidArg) -> tuple[int, int]: ... - def read_prefix(self, oid: _OidArg) -> tuple[int, bytes, Oid]: ... + def exists(self, oid: _OidArg, /) -> bool: ... + def exists_prefix(self, partial_id: _OidArg, /) -> Oid: ... + def read(self, oid: _OidArg, /) -> tuple[int, bytes]: ... + def read_header(self, oid: _OidArg, /) -> tuple[int, int]: ... + def read_prefix(self, oid: _OidArg, /) -> tuple[int, bytes, Oid]: ... def refresh(self) -> None: ... def __iter__(self) -> Iterator[Oid]: ... # OdbBackend_as_iter +@final class OdbBackendLoose(OdbBackend): def __init__(self, *args, **kwargs) -> None: ... +@final class OdbBackendPack(OdbBackend): def __init__(self, *args, **kwargs) -> None: ... +@final class Oid: raw: bytes def __init__(self, raw: bytes = ..., hex: str = ...) -> None: ... - def __eq__(self, other) -> bool: ... - def __ge__(self, other) -> bool: ... - def __gt__(self, other) -> bool: ... + def __eq__(self, other, /) -> bool: ... + def __ge__(self, other, /) -> bool: ... + def __gt__(self, other, /) -> bool: ... def __hash__(self) -> int: ... - def __le__(self, other) -> bool: ... - def __lt__(self, other) -> bool: ... - def __ne__(self, other) -> bool: ... + def __le__(self, other, /) -> bool: ... + def __lt__(self, other, /) -> bool: ... + def __ne__(self, other, /) -> bool: ... def __bool__(self) -> bool: ... +@final class Patch: data: bytes delta: DiffDelta @@ -570,6 +574,7 @@ class Patch: interhunk_lines: int = 0, ) -> Patch: ... +@final class RefLogEntry: committer: Signature message: str @@ -577,25 +582,27 @@ class RefLogEntry: oid_old: Oid def __init__(self, *args, **kwargs) -> None: ... +@final class Refdb: - def __init__(self, *args, **kwargs) -> None: ... + def __init__(self) -> None: ... def compress(self) -> None: ... @staticmethod - def new(repo: Repository) -> Refdb: ... + def new(repo: Repository, /) -> Refdb: ... @staticmethod - def open(repo: Repository) -> Refdb: ... - def set_backend(self, backend: RefdbBackend) -> None: ... + def open(repo: Repository, /) -> Refdb: ... + def set_backend(self, backend: RefdbBackend, /) -> None: ... +@disjoint_base class RefdbBackend: def __init__(self, *args, **kwargs) -> None: ... def compress(self) -> None: ... def delete( self, ref_name: str, old_id: _OidArg, old_target: str | None ) -> None: ... - def ensure_log(self, ref_name: str) -> bool: ... - def exists(self, refname: str) -> bool: ... - def has_log(self, ref_name: str) -> bool: ... - def lookup(self, refname: str) -> Reference: ... + def ensure_log(self, ref_name: str, /) -> bool: ... + def exists(self, refname: str, /) -> bool: ... + def has_log(self, ref_name: str, /) -> bool: ... + def lookup(self, refname: str, /) -> Reference: ... def rename( self, old_name: str, new_name: str, force: bool, who: Signature, message: str ) -> Reference: ... @@ -608,8 +615,8 @@ class RefdbBackend: old: None | _OidArg, old_target: None | str, ) -> None: ... - def __iter__(self) -> Iterator[Reference]: ... +@final class RefdbFsBackend(RefdbBackend): def __init__(self, *args, **kwargs) -> None: ... @@ -619,14 +626,6 @@ class _StrArray: # incomplete count: int -class PushOptions: - version: int - pb_parallelism: int - callbacks: object # TODO - proxy_opts: GitProxyOptionsC - follow_redirects: object # TODO - custom_headers: _StrArray - remote_push_options: _StrArray class _LsRemotesDict(TypedDict): local: bool @@ -635,9 +634,22 @@ class _LsRemotesDict(TypedDict): symref_target: str | None oid: Oid +@disjoint_base class Repository: def TreeBuilder(self, src: Tree | _OidArg = ...) -> TreeBuilder: ... - def _disown(self, *args, **kwargs) -> None: ... + def __init__(self, /, *args, **kwargs) -> None: ... + def _disown(self) -> None: ... + default_signature: Signature + head: Reference + head_is_detached: bool + head_is_unborn: bool + is_bare: bool + is_empty: bool + is_shallow: bool + odb: Odb + path: str | None + refdb: Refdb + workdir: str | None def add_worktree( self, name: str, path: str | Path, ref: Reference = ... ) -> Worktree: ... @@ -650,12 +662,12 @@ class Repository: def apply( self, diff: Diff, location: ApplyLocation = ApplyLocation.WORKDIR ) -> None: ... - def cherrypick(self, id: _OidArg) -> None: ... + def cherrypick(self, id: _OidArg, /) -> None: ... def compress_references(self) -> None: ... def create_blob(self, data: str | bytes) -> Oid: ... - def create_blob_fromdisk(self, path: str) -> Oid: ... - def create_blob_fromiobase(self, iobase: IOBase) -> Oid: ... - def create_blob_fromworkdir(self, path: str | Path) -> Oid: ... + def create_blob_fromdisk(self, path: str, /) -> Oid: ... + def create_blob_fromiobase(self, iobase: IOBase, /) -> Oid: ... + def create_blob_fromworkdir(self, path: str | Path, /) -> Oid: ... def create_branch(self, name: str, commit: Commit, force=False) -> Branch: ... def create_commit( self, @@ -698,9 +710,9 @@ class Repository: self, name: str, oid: _OidArg, type: ObjectType, tagger: Signature, message: str ) -> Oid: ... def descendant_of(self, oid1: _OidArg, oid2: _OidArg) -> bool: ... - def expand_id(self, hex: str) -> Oid: ... + def expand_id(self, hex: str, /) -> Oid: ... def free(self) -> None: ... - def git_object_lookup_prefix(self, oid: _OidArg) -> Object: ... + def git_object_lookup_prefix(self, oid: _OidArg, /) -> Object: ... def list_worktrees(self) -> list[str]: ... def listall_branches(self, flag: BranchType = BranchType.LOCAL) -> list[str]: ... def listall_mergeheads(self) -> list[Oid]: ... @@ -712,8 +724,8 @@ class Repository: def lookup_note( self, annotated_id: str, ref: str = 'refs/notes/commits' ) -> Note: ... - def lookup_reference(self, name: str) -> Reference: ... - def lookup_reference_dwim(self, name: str) -> Reference: ... + def lookup_reference(self, name: str, /) -> Reference: ... + def lookup_reference_dwim(self, name: str, /) -> Reference: ... def lookup_worktree(self, name: str) -> Worktree: ... def merge_analysis( self, their_head: _OidArg, our_ref: str = 'HEAD' @@ -730,28 +742,30 @@ class Repository: def references_iterator_init(self) -> Iterator[Reference]: ... def references_iterator_next( self, - iter: Iterator[T], + iter: Iterator[_T], references_return_type: ReferenceFilter = ReferenceFilter.ALL, ) -> Reference: ... def reset(self, oid: _OidArg, reset_type: ResetMode) -> None: ... - def revparse(self, revspec: str) -> RevSpec: ... - def revparse_ext(self, revision: str) -> tuple[Object, Reference]: ... - def revparse_single(self, revision: str) -> Object: ... - def set_odb(self, odb: Odb) -> None: ... - def set_refdb(self, refdb: Refdb) -> None: ... + def revparse(self, revspec: str, /) -> RevSpec: ... + def revparse_ext(self, revision: str, /) -> tuple[Object, Reference]: ... + def revparse_single(self, revision: str, /) -> Object: ... + def set_odb(self, odb: Odb, /) -> None: ... + def set_refdb(self, refdb: Refdb, /) -> None: ... def status( self, untracked_files: str = 'all', ignored: bool = False ) -> dict[str, int]: ... - def status_file(self, path: str) -> int: ... + def status_file(self, path: str, /) -> int: ... def walk( self, oid: _OidArg | None, sort_mode: SortMode = SortMode.NONE ) -> Walker: ... +@disjoint_base class RevSpec: flags: int from_object: Object to_object: Object +@final class Signature: _encoding: str | None _pointer: _Pointer[GitSignatureC] @@ -769,24 +783,26 @@ class Signature: offset: int = 0, encoding: Optional[str] = None, ) -> None: ... - def __eq__(self, other) -> bool: ... - def __ge__(self, other) -> bool: ... - def __gt__(self, other) -> bool: ... - def __le__(self, other) -> bool: ... - def __lt__(self, other) -> bool: ... - def __ne__(self, other) -> bool: ... - + def __eq__(self, other, /) -> bool: ... + def __ge__(self, other, /) -> bool: ... + def __gt__(self, other, /) -> bool: ... + def __le__(self, other, /) -> bool: ... + def __lt__(self, other, /) -> bool: ... + def __ne__(self, other, /) -> bool: ... + +@final class Stash: commit_id: Oid message: str raw_message: bytes - def __eq__(self, other) -> bool: ... - def __ge__(self, other) -> bool: ... - def __gt__(self, other) -> bool: ... - def __le__(self, other) -> bool: ... - def __lt__(self, other) -> bool: ... - def __ne__(self, other) -> bool: ... - + def __eq__(self, other, /) -> bool: ... + def __ge__(self, other, /) -> bool: ... + def __gt__(self, other, /) -> bool: ... + def __le__(self, other, /) -> bool: ... + def __lt__(self, other, /) -> bool: ... + def __ne__(self, other, /) -> bool: ... + +@final class Tag(Object): message: str name: str @@ -818,30 +834,33 @@ class Tree(Object): context_lines: int = 3, interhunk_lines: int = 0, ) -> Diff: ... - def __contains__(self, other: str) -> bool: ... # Tree_contains - def __getitem__(self, index: str | int) -> Tree | Blob: ... # Tree_subscript + def __contains__(self, other: str, /) -> bool: ... # Tree_contains + def __getitem__(self, index: str | int, /) -> Tree | Blob: ... # Tree_subscript def __iter__(self) -> Iterator[Object]: ... def __len__(self) -> int: ... # Tree_len - def __rtruediv__(self, other: str) -> Tree | Blob: ... - def __truediv__(self, other: str) -> Tree | Blob: ... # Tree_divide + def __rtruediv__(self, other: str, /) -> Tree | Blob: ... + def __truediv__(self, other: str, /) -> Tree | Blob: ... # Tree_divide +@disjoint_base class TreeBuilder: def clear(self) -> None: ... - def get(self, name: str) -> Object: ... + def get(self, name: str, /) -> Object: ... def insert(self, name: str, oid: _OidArg, attr: int) -> None: ... - def remove(self, name: str) -> None: ... + def remove(self, name: str, /) -> None: ... def write(self) -> Oid: ... def __len__(self) -> int: ... +@final class Walker: - def hide(self, oid: _OidArg) -> None: ... - def push(self, oid: _OidArg) -> None: ... + def hide(self, oid: _OidArg, /) -> None: ... + def push(self, oid: _OidArg, /) -> None: ... def reset(self) -> None: ... def simplify_first_parent(self) -> None: ... - def sort(self, mode: SortMode) -> None: ... + def sort(self, mode: SortMode, /) -> None: ... def __iter__(self) -> Iterator[Commit]: ... # Walker: ... def __next__(self) -> Commit: ... +@final class Worktree: is_prunable: bool name: str @@ -854,7 +873,7 @@ def discover_repository( def hash(data: bytes | str) -> Oid: ... def hashfile(path: str) -> Oid: ... def init_file_backend(path: str, flags: int = 0) -> object: ... -def reference_is_valid_name(refname: str) -> bool: ... +def reference_is_valid_name(refname: str, /) -> bool: ... def tree_entry_cmp(a: Object, b: Object) -> int: ... def _cache_enums() -> None: ... def filter_register(name: str, filter: type[Filter]) -> None: ... From 282985aabb3a2d5c6c0233db91ca859739ee809d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Wed, 10 Jun 2026 20:03:33 +0200 Subject: [PATCH 169/225] Fix ruff format --diff --- pygit2/_pygit2.pyi | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index 1832bdfba..5650f195a 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -43,7 +43,6 @@ from .enums import ( ) from .filter import Filter - LIBGIT2_VER_MAJOR: int LIBGIT2_VER_MINOR: int LIBGIT2_VER_REVISION: int @@ -280,9 +279,7 @@ class _ObjectBase(Generic[_T]): self, target_type: 'Literal[ObjectType.TREE] | Type[Tree]', / ) -> 'Tree': ... @overload - def peel( - self, target_type: 'Literal[ObjectType.TAG] | Type[Tag]', / - ) -> 'Tag': ... + def peel(self, target_type: 'Literal[ObjectType.TAG] | Type[Tag]', /) -> 'Tag': ... @overload def peel( self, target_type: 'Literal[ObjectType.BLOB] | Type[Blob]', / @@ -379,8 +376,6 @@ class Branch(Reference): # type: ignore[misc] def is_head(self) -> bool: ... def rename(self, name: str, force: bool = False) -> 'Branch': ... # type: ignore[override] - - @final class Commit(_ObjectBase[GitCommitC]): _pointer: _Pointer[GitCommitC] @@ -626,7 +621,6 @@ class _StrArray: # incomplete count: int - class _LsRemotesDict(TypedDict): local: bool loid: Oid | None From 7ab18e0ff1d8e195d1df6fddd9432277bfd62f71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Wed, 10 Jun 2026 20:06:41 +0200 Subject: [PATCH 170/225] Fix "ruff check" --- pygit2/_pygit2.pyi | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index 5650f195a..f107a2686 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -10,16 +10,16 @@ from typing import ( # noqa: UP035 Type, TypedDict, TypeVar, + final, overload, ) -from typing_extensions import disjoint_base, final +from typing_extensions import disjoint_base from . import Index from ._libgit2.ffi import ( GitCommitC, GitObjectC, - GitProxyOptionsC, GitSignatureC, _Pointer, ) From bcd60f47afac7b0e1d2a525bb4d8fd095bdf5751 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Wed, 10 Jun 2026 20:25:07 +0200 Subject: [PATCH 171/225] Fix mypy --- pygit2/callbacks.py | 7 +++---- test/test_refdb_backend.py | 5 +---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/pygit2/callbacks.py b/pygit2/callbacks.py index adcfca528..0faab7702 100644 --- a/pygit2/callbacks.py +++ b/pygit2/callbacks.py @@ -66,7 +66,7 @@ from collections.abc import Callable, Generator from contextlib import contextmanager from functools import wraps -from typing import TYPE_CHECKING, Optional, ParamSpec, TypeVar +from typing import TYPE_CHECKING, Any, Optional, ParamSpec, TypeVar # pygit2 from ._pygit2 import DiffFile, Oid @@ -81,7 +81,6 @@ if TYPE_CHECKING: from pygit2._libgit2.ffi import GitProxyOptionsC - from ._pygit2 import CloneOptions, PushOptions from .remotes import PushUpdate, TransferProgress # # The payload is the way to pass information from the pygit2 API, through @@ -92,7 +91,7 @@ class Payload: repository: Callable | None remote: Callable | None - clone_options: 'CloneOptions' + clone_options: Any def __init__(self, **kw: object) -> None: for key, value in kw.items(): @@ -125,7 +124,7 @@ class RemoteCallbacks(Payload): RemoteCallbacks(certificate=certificate). """ - push_options: 'PushOptions' + push_options: Any def __init__( self, diff --git a/test/test_refdb_backend.py b/test/test_refdb_backend.py index bfc375515..4c9402f7b 100644 --- a/test/test_refdb_backend.py +++ b/test/test_refdb_backend.py @@ -25,7 +25,7 @@ """Tests for Refdb objects.""" -from collections.abc import Generator, Iterator +from collections.abc import Generator from pathlib import Path import pytest @@ -77,9 +77,6 @@ def has_log(self, ref_name: str) -> bool: def ensure_log(self, ref_name: str) -> bool: return self.source.ensure_log(ref_name) - def __iter__(self) -> Iterator[Reference]: - return iter(self.source) - @pytest.fixture def repo(testrepo: Repository) -> Generator[Repository, None, None]: From d040bc33daaa530ab2529940b58524840dbdd03f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Thu, 11 Jun 2026 09:05:06 +0200 Subject: [PATCH 172/225] cibuildwheel: update to VS 18 Assisted-by: Kimi-k2.6 --- docs/install.rst | 2 +- pyproject.toml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/install.rst b/docs/install.rst index a72319256..2a9bfe20e 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -309,7 +309,7 @@ variable. The following recipe shows you how to do it: git clone --depth=1 -b v1.9.4 https://github.com/libgit2/libgit2.git $env:CMAKE_INSTALL_PREFIX = "C:/Dev/libgit2" - $env:CMAKE_GENERATOR = "Visual Studio 17 2022" + $env:CMAKE_GENERATOR = "Visual Studio 18 2026" $env:CMAKE_GENERATOR_PLATFORM = "x64" # or "Win32" or "ARM64" cmake -B libgit2/build -S libgit2 cmake --build libgit2/build --config release --target install diff --git a/pyproject.toml b/pyproject.toml index 59bf27c8a..e8cde5848 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ before-build = "" [[tool.cibuildwheel.overrides]] select="*-win_amd64" inherit.environment="append" -environment.CMAKE_GENERATOR = "Visual Studio 17 2022" +environment.CMAKE_GENERATOR = "Visual Studio 18 2026" environment.CMAKE_GENERATOR_PLATFORM = "x64" environment.CMAKE_INSTALL_PREFIX = "C:/libgit2_install_x86_64" environment.LIBGIT2 = "C:/libgit2_install_x86_64" @@ -47,7 +47,7 @@ environment.LIBGIT2 = "C:/libgit2_install_x86_64" [[tool.cibuildwheel.overrides]] select="*-win32" inherit.environment="append" -environment.CMAKE_GENERATOR = "Visual Studio 17 2022" +environment.CMAKE_GENERATOR = "Visual Studio 18 2026" environment.CMAKE_GENERATOR_PLATFORM = "Win32" environment.CMAKE_INSTALL_PREFIX = "C:/libgit2_install_x86" environment.LIBGIT2 = "C:/libgit2_install_x86" @@ -55,7 +55,7 @@ environment.LIBGIT2 = "C:/libgit2_install_x86" [[tool.cibuildwheel.overrides]] select="*-win_arm64" inherit.environment="append" -environment.CMAKE_GENERATOR = "Visual Studio 17 2022" +environment.CMAKE_GENERATOR = "Visual Studio 18 2026" environment.CMAKE_GENERATOR_PLATFORM = "ARM64" environment.CMAKE_INSTALL_PREFIX = "C:/libgit2_install_arm64" environment.LIBGIT2 = "C:/libgit2_install_arm64" From dd6267525da340d8a586cfde48c99ed51a16d154 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Thu, 11 Jun 2026 10:53:53 +0200 Subject: [PATCH 173/225] CI: Fix windows arm build Assisted-by: Kimi-k2.6 --- build.ps1 | 2 ++ docs/install.rst | 2 +- pyproject.toml | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/build.ps1 b/build.ps1 index 204a201b8..ce40959bf 100644 --- a/build.ps1 +++ b/build.ps1 @@ -1,3 +1,5 @@ +$ErrorActionPreference = 'Stop' + if (!(Test-Path -Path "build")) { # in case the pygit2 package build/ workspace has not been created by cibuildwheel yet mkdir build diff --git a/docs/install.rst b/docs/install.rst index 2a9bfe20e..36833b477 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -309,7 +309,7 @@ variable. The following recipe shows you how to do it: git clone --depth=1 -b v1.9.4 https://github.com/libgit2/libgit2.git $env:CMAKE_INSTALL_PREFIX = "C:/Dev/libgit2" - $env:CMAKE_GENERATOR = "Visual Studio 18 2026" + $env:CMAKE_GENERATOR = "Visual Studio 17 2022" # or "Visual Studio 18 2026" $env:CMAKE_GENERATOR_PLATFORM = "x64" # or "Win32" or "ARM64" cmake -B libgit2/build -S libgit2 cmake --build libgit2/build --config release --target install diff --git a/pyproject.toml b/pyproject.toml index e8cde5848..c7f64c072 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,7 +55,7 @@ environment.LIBGIT2 = "C:/libgit2_install_x86" [[tool.cibuildwheel.overrides]] select="*-win_arm64" inherit.environment="append" -environment.CMAKE_GENERATOR = "Visual Studio 18 2026" +environment.CMAKE_GENERATOR = "Visual Studio 17 2022" environment.CMAKE_GENERATOR_PLATFORM = "ARM64" environment.CMAKE_INSTALL_PREFIX = "C:/libgit2_install_arm64" environment.LIBGIT2 = "C:/libgit2_install_arm64" From 319561cb70ddfd7cd21ceb8b47663e035610a389 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Thu, 11 Jun 2026 12:23:14 +0200 Subject: [PATCH 174/225] Add contributing guidelines Fixes #410 Assisted-by: Kimi-k2.6 --- CHANGELOG.md | 1 + CONTRIBUTING.md | 91 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b407e679..d5fc654ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ [#1459](https://github.com/libgit2/pygit2/pull/1459) - Documentation and annotation fixes + [#410](https://github.com/libgit2/pygit2/issues/410) [#1333](https://github.com/libgit2/pygit2/issues/1333) [#1458](https://github.com/libgit2/pygit2/issues/1458) [#1460](https://github.com/libgit2/pygit2/pull/1460) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..2c65ed652 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,91 @@ +# Contributing to pygit2 + +Thank you for your interest in improving pygit2! This document covers how to submit pull requests and help others in the community. + +--- + +## Pull Requests + +We welcome pull requests that fix bugs, add features, improve documentation, or clean up code. To ensure a smooth review process, please follow the steps below. + +### Development Setup + +See the [install documentation](https://www.pygit2.org/install.html) for instructions on building pygit2 and its libgit2 dependency. + +### Making Changes + +1. **Fork the repository** and create a feature branch. +2. **Follow the existing code style** (see below). +3. **Add or update tests** for any new or changed behavior. Tests live in `test/` and are run with `pytest`. +4. **Update type stubs** (`pygit2/_pygit2.pyi`) if you modify the C extension's public API. +5. **Update `pygit2/__init__.py`** if you add new public symbols that should be re-exported. +6. **Ensure the test suite passes**: + ```bash + pytest + ``` +7. **Run the linters and type checker**: + ```bash + ruff format --diff + ruff check + sh build.sh mypy # or: mypy + sh build.sh stubtest # validate .pyi stubs + ``` +8. **Build the documentation** if you changed it (requires `sphinx-rtd-theme`): + ```bash + make -C docs html + ``` +9. **Write a clear commit message** explaining the *what* and *why*. + +### Code Style + +- **Python:** We target Python 3.11+. Use single quotes. Run `ruff format` and `ruff check` before submitting. +- **C:** We use C11. Follow `-std=c11 -Wall`. Match the style of the surrounding code in `src/`. +- **Copyright headers:** All source files must include the standard GPLv2 copyright header. Copy it from an existing file. +- **Docstrings:** Use the style shown in `docs/development.rst`: + ```python + def f(a, b): + """ + The general description goes here. + + Returns: bla bla. + + Parameters: + + a : + Bla bla. + + b : + Bla bla. + """ + ``` + +### Pull Request Review + +- All PRs require review from a maintainer. +- CI will run tests, linting, and type checks automatically. +- Be responsive to feedback and willing to iterate. +- Keep PRs focused. A pull request that does one thing well is easier to review than a large, mixed one. + +--- + +## Helping Others + +You do not need to write code to contribute. Helping others is valuable: + +- **Answer questions** in open issues and pull requests. If you know the answer, share it. +- **Review PRs.** Even if you are not a maintainer, constructive reviews from the community are welcome. +- **Improve documentation.** Doc fixes, clarifications, and typo corrections can be submitted as PRs just like code. +- **Reproduce reported issues.** Confirming a bug on your system helps maintainers prioritize fixes. + +--- + +## Commit Messages + +- Use the present tense and imperative mood (e.g., "Add support for…", not "Added support for…"). +- Keep the subject line under 72 characters. +- Reference related issues with `Fixes #123` or `Closes #456` when applicable. +- If you used AI assistance while preparing the change, mention it in the commit message with a tag such as `Assisted-by: Kimi-k2.6` (or the appropriate model name). + +--- + +Thank you for contributing! From 2d9fc7d32b17660f06306f40b15a5c7e34529e59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Thu, 11 Jun 2026 12:41:42 +0200 Subject: [PATCH 175/225] docs: fix warnings when building the docs --- docs/submodule.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/submodule.rst b/docs/submodule.rst index 578806f45..4bdeee283 100644 --- a/docs/submodule.rst +++ b/docs/submodule.rst @@ -7,6 +7,7 @@ dedicated subdirectory of the repositories tree. .. autoclass:: pygit2.Repository :members: listall_submodules + :noindex: .. py:attribute:: Repository.submodules From 926acaf176960a63fa591dfa677502fdc3743123 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Thu, 11 Jun 2026 12:58:04 +0200 Subject: [PATCH 176/225] Add AGENTS.md file Assisted-by: Kimi-k2.6 --- AGENTS.md | 188 +++++++++++++++++++++++++++++++++++++++++++++++++++ CHANGELOG.md | 2 + 2 files changed, 190 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..56fa23070 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,188 @@ +# pygit2 Agent Guide + +## Project Overview + +**pygit2** is a Python library providing bindings to [libgit2](https://libgit2.org/), the shared C library that implements Git plumbing operations. It exposes both a low-level API (direct libgit2 wrappers) and a high-level Pythonic API for repository manipulation. + +- **Version**: 1.19.2 +- **License**: GPLv2 with linking exception +- **Maintainer**: J. David Ibáñez +- **Python Support**: 3.11 – 3.14 and PyPy3 7.3+ +- **libgit2 Version**: 1.9.4 +- **Homepage**: +- **Repository**: + +## Architecture + +The project uses a **hybrid C/Python** architecture with two compiled extension modules: + +- **`src/`** — C11 source files that compile into the `_pygit2` C extension module. Each file generally maps to a libgit2 concept: + - Core objects: `blob.c`, `commit.c`, `object.c`, `tag.c`, `tree.c` + - Repository and refs: `repository.c`, `branch.c`, `reference.c`, `refdb.c`, `refdb_backend.c`, `revspec.c` + - Diff and patch: `diff.c`, `patch.c` + - ODB and backend: `odb.c`, `odb_backend.c` + - Filters and mailmap: `filter.c`, `mailmap.c` + - Notes and signatures: `note.c`, `signature.c` + - Main module init: `pygit2.c` + +- **`pygit2/`** — The main Python package. + - **`_pygit2*.so`** — Compiled C extension (built from `src/`). + - **`_libgit2.abi3.so`** — CFFI-generated ABI module built from `pygit2/_run.py`. + - **`decl/`** — C header stub files used by CFFI to define the libgit2 API surface (e.g., `types.h`, `repository.h`, `callbacks.h`, `diff.h`, `remote.h`). + - **`_build.py`** — Build-time helpers and the canonical `__version__` string. Also used at runtime to locate libgit2. Must remain importable without the rest of the package being built because `setup.py` imports it. + - **`_run.py`** — CFFI build script that aggregates `decl/*.h` and compiles `pygit2._libgit2`. + - **`ffi.py`** — Runtime import of the CFFI `ffi` and `lib` (`C`) objects. + - **`_pygit2.pyi`** — Type stubs for the C extension. Keep it in sync when adding or changing low-level APIs. + - **`py.typed`** — PEP 561 marker indicating the package is typed. + - **High-level modules** — Pure-Python wrappers that sit on top of the C extension: + `repository.py`, `callbacks.py`, `config.py`, `index.py`, `remotes.py`, `settings.py`, `submodules.py`, `transaction.py`, `filter.py`, `blob.py`, `blame.py`, `credentials.py`, `errors.py`, `options.py`, `packbuilder.py`, `references.py`, `refspec.py`, `utils.py`, `enums.py`, `legacyenums.py`. + +- **`test/`** — pytest suite with fixture-based repository handling. +- **`docs/`** — Sphinx documentation (RTD theme). + +## Key Configuration Files + +- **`setup.py`** — setuptools entry point. Builds both the C extension (`src/*.c`) and the CFFI extension (`pygit2/_run.py:ffi`). +- **`pyproject.toml`** — Build system requirements, `cibuildwheel` configuration, `ruff` settings, and `codespell` settings. +- **`setup.cfg`** — Legacy pycodestyle configuration. +- **`pytest.ini`** — pytest configuration (`--capture=no -ra --verbose`, `testpaths = test/`). +- **`mypy.ini`** — mypy configuration with strict settings. +- **`mypy-stubtest.ini`** — mypy configuration for `stubtest` against `_pygit2.pyi`. +- **`requirements.txt`** — Runtime/build requirements (`cffi>=2.0`, `setuptools` for Python >= 3.12). +- **`requirements-test.txt`** — Test requirements (`pytest`, `pytest-cov`). +- **`requirements-typing.txt`** — Typing requirements (`mypy`, `types-cffi`). +- **`Makefile`** — Convenience targets: `make` builds deps + extension inplace; `make html` builds docs. + +## Build and Test Commands + +### Quick Development Build (inplace) + +Requires libgit2 development headers and library to be installed on the system or pointed to via the `LIBGIT2` environment variable. + +```bash +python setup.py build_ext --inplace +pytest +``` + +### Full Build with Dependencies + +The `build.sh` script can download, compile, and bundle libgit2 (and optionally libssh2 and OpenSSL) into a local prefix. On Windows, `build.ps1` handles libgit2 compilation via CMake. + +```bash +# Build inplace with bundled libgit2/libssh2/OpenSSL +make + +# Or manually: +LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.4 sh build.sh + +# Build inplace + run tests +sh build.sh test + +# Build a wheel, install it, and run tests +sh build.sh wheel + +# Run tests with coverage +sh build.sh test # (build.sh already adds --cov=pygit2) + +# Run mypy type checking +sh build.sh mypy + +# Run stubtest against the .pyi file +sh build.sh stubtest +``` + +### Environment Variables + +- `LIBGIT2` — Base path where libgit2 is installed (default: `/usr/local` or `%ProgramFiles%\libgit2` on Windows). +- `LIBGIT2_LIB` — Override the library directory specifically. +- `LIBGIT2_VERSION` — If set, `build.sh` downloads and builds this libgit2 version. +- `LIBSSH2_VERSION` — If set, `build.sh` downloads and builds libssh2 with SSH support. +- `OPENSSL_VERSION` — If set, `build.sh` downloads and builds OpenSSL (mainly used for macOS universal builds on CI). + +### Documentation Build + +```bash +# Build the extension first, then docs +make # builds deps + extension +make -C docs html # requires sphinx-rtd-theme +``` + +## Code Style Guidelines + +### Python + +- **Formatter / Linter**: [ruff](https://docs.astral.sh/ruff/) + - Target Python: 3.11+ + - Quote style: single quotes + - Selected rules: `E4`, `E7`, `E9`, `F`, `I`, `UP035`, `UP007` +- **Type checker**: mypy (strict settings enabled; see `mypy.ini`) +- All Python source files must include the standard GPLv2 copyright header. + +### C + +- Standard: C11 +- All C source files must include the standard GPLv2 copyright header. +- The `.vimrc` at repo root configures ALE with `-std=c11 -Wall` and includes the Python headers and `/usr/local/include`. + +### Docstrings + +Use the following style (from `docs/development.rst`): + +```python +def f(a, b): + """ + The general description goes here. + + Returns: bla bla. + + Parameters: + + a : + Bla bla. + + b : + Bla bla. + + Examples:: + + >>> f(...) + """ +``` + +## Testing Instructions + +- **Runner**: pytest +- **Configuration**: `pytest.ini` + ```ini + [pytest] + addopts = --capture=no -ra --verbose + testpaths = test/ + ``` +- **Fixtures**: Defined in `test/conftest.py`. They yield `pygit2.Repository` instances extracted from zipped sample repos in `test/data/` (e.g., `testrepo.zip`, `barerepo.zip`). +- **Test utilities**: `test/utils.py` provides helpers such as `TemporaryRepository`, network/proxy/SSH skip markers, and `diff_safeiter`. +- **Isolation**: The session-scoped `global_git_config` fixture clears `GLOBAL`, `XDG`, and `SYSTEM` config search paths to ensure reproducibility. +- **Coverage**: `pytest-cov` is used; run via `sh build.sh test`. + +## CI / Deployment + +GitHub Actions workflows live in `.github/workflows/`: + +- **`tests.yml`** — Runs on s390x via QEMU (allowed to fail; see issue #812). +- **`lint.yml`** — Runs `ruff format --diff`, `ruff check`, and `sh build.sh mypy`. +- **`wheels.yml`** — Uses `cibuildwheel` to build wheels for Linux (amd64, arm64, ppc64le, musl), macOS (intel, arm64, PyPy), and Windows (x64, x86, arm64). Publishes to PyPI and creates GitHub Releases on version tags (`v*`). +- **`codespell.yml`** — Spell checking. + +## Security Considerations + +- The project links against OpenSSL and libssh2. CI pins specific versions of these libraries when building wheels. +- Wheel repair commands (`auditwheel`, `delocate-wheel`) bundle shared libraries so wheels are self-contained. +- Credentials callbacks (`RemoteCallbacks`, `get_credentials`) are the primary interface for supplying secrets; never hardcode credentials in tests. +- Valgrind support: see `docs/development.rst` and `misc/valgrind-python.supp` for memory-leak debugging instructions. + +## Useful Notes for Agents + +- **Do not assume libgit2 is installed globally.** Check for `LIBGIT2` or use `build.sh`. +- **`pygit2/_build.py`** is imported by `setup.py`; it must remain importable without the rest of the package being built. +- **CFFI and setuptools extensions are both built from `setup.py`.** `ext_modules` builds the C extension from `src/*.c`; `cffi_modules` triggers the CFFI build via `pygit2/_run.py:ffi`. +- **`.pyi` stub file**: `pygit2/_pygit2.pyi` provides type stubs for the C extension. Keep it in sync when adding or changing low-level APIs. +- **`pygit2/__init__.py`** is large because it re-exports a vast surface of constants and classes. Follow existing patterns when adding new public symbols. diff --git a/CHANGELOG.md b/CHANGELOG.md index d5fc654ca..41a24d0e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ [#1458](https://github.com/libgit2/pygit2/issues/1458) [#1460](https://github.com/libgit2/pygit2/pull/1460) +- Add `AGENTS.md` file generated by Kimi-k2.6 + # 1.19.2 (2026-03-29) From 9433ecde22a59f4f3aaef8302518d2874df24411 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Thu, 11 Jun 2026 20:41:21 +0200 Subject: [PATCH 177/225] Documentation updates and fixes - Add new callbacks documentation page covering RemoteCallbacks, CheckoutCallbacks, StashApplyCallbacks, and Passthrough. - Add new enums documentation page covering all pygit2.enums classes. - Document missing classes: RemoteHead, PushUpdate, FilterList. - Document missing methods: Repository.load_filter_list, Repository.hashfile. - Fix create_tag docstring showing message as optional when it is required. - Fix Odb.read_header docstring return type. - Fix docstring reST formatting errors in callbacks.py and enums.py. - Update version examples in general.rst to libgit2 1.9.4. - Fix typo in branches.rst example. - Fix bytes/string example in objects.rst create_blob snippet. - Add callbacks.rst and enums.rst to the documentation toctree. - Add .docenv to .gitignore. Fixes: #1323 Fixes: #1289 Assisted-by: Kimi-k2.6 --- .gitignore | 1 + CHANGELOG.md | 2 + docs/branches.rst | 2 +- docs/callbacks.rst | 40 +++++++++ docs/conf.py | 2 +- docs/enums.rst | 193 ++++++++++++++++++++++++++++++++++++++++++++ docs/filters.rst | 11 +++ docs/general.rst | 26 +++--- docs/index.rst | 2 + docs/objects.rst | 10 ++- docs/remotes.rst | 22 +++++ docs/revparse.rst | 3 + pygit2/callbacks.py | 2 +- pygit2/enums.py | 2 +- src/odb.c | 2 +- src/repository.c | 2 +- 16 files changed, 305 insertions(+), 17 deletions(-) create mode 100644 docs/callbacks.rst create mode 100644 docs/enums.rst diff --git a/.gitignore b/.gitignore index a9106ffba..be504cd4e 100644 --- a/.gitignore +++ b/.gitignore @@ -239,6 +239,7 @@ pyrightconfig.json # custom ignore paths /.envrc /venv* +/.docenv /ci/ *.swp /pygit2/_libgit2.c diff --git a/CHANGELOG.md b/CHANGELOG.md index 41a24d0e4..016446c85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ - Documentation and annotation fixes [#410](https://github.com/libgit2/pygit2/issues/410) + [#1289](https://github.com/libgit2/pygit2/issues/1289) + [#1323](https://github.com/libgit2/pygit2/issues/1323) [#1333](https://github.com/libgit2/pygit2/issues/1333) [#1458](https://github.com/libgit2/pygit2/issues/1458) [#1460](https://github.com/libgit2/pygit2/pull/1460) diff --git a/docs/branches.rst b/docs/branches.rst index 5afe6673e..7242efa9a 100644 --- a/docs/branches.rst +++ b/docs/branches.rst @@ -35,7 +35,7 @@ Example:: >>> # Create a local branch, branching from master >>> new_branch = repo.branches.local.create('new-branch', repo[master_branch.target]) - >>> And delete it + >>> # And delete it >>> repo.branches.delete('new-branch') diff --git a/docs/callbacks.rst b/docs/callbacks.rst new file mode 100644 index 000000000..e4e417557 --- /dev/null +++ b/docs/callbacks.rst @@ -0,0 +1,40 @@ +********************************************************************** +Callbacks +********************************************************************** + +Many pygit2 operations accept callback objects. The callbacks module provides +base classes that you can subclass to customize behavior such as progress +reporting, credential lookup, or checkout notifications. + +.. contents:: Contents + :local: + + +Remote callbacks +================ + +.. autoclass:: pygit2.RemoteCallbacks + :members: + + +Checkout callbacks +================== + +.. autoclass:: pygit2.CheckoutCallbacks + :members: + + +Stash apply callbacks +===================== + +.. autoclass:: pygit2.StashApplyCallbacks + :members: + + +Passthrough +=========== + +Callbacks may raise :exc:`pygit2.Passthrough` to tell libgit2 to behave as if +the callback had not been set. This is useful when a callback only wants to +handle some cases and let libgit2 use its default behavior for the rest. See +:doc:`general` for the exception reference. diff --git a/docs/conf.py b/docs/conf.py index 4c21e4f32..fdae4b2a8 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,7 +23,7 @@ # author = '' # The full version, including alpha/beta/rc tags -release = '1.19.2' +release = '1.19.3' # -- General configuration --------------------------------------------------- diff --git a/docs/enums.rst b/docs/enums.rst new file mode 100644 index 000000000..5774ddaa3 --- /dev/null +++ b/docs/enums.rst @@ -0,0 +1,193 @@ +********************************************************************** +Enums +********************************************************************** + +pygit2 exposes libgit2 constants as Python enums in the :mod:`pygit2.enums` +module. They are preferred over the top-level ``GIT_*`` integer constants. + +.. contents:: Contents + :local: + + +Repository +========== + +.. autoclass:: pygit2.enums.RepositoryInitFlag + :members: + +.. autoclass:: pygit2.enums.RepositoryInitMode + :members: + +.. autoclass:: pygit2.enums.RepositoryOpenFlag + :members: + +.. autoclass:: pygit2.enums.RepositoryState + :members: + + +References and branches +======================= + +.. autoclass:: pygit2.enums.BranchType + :members: + +.. autoclass:: pygit2.enums.ReferenceFilter + :members: + +.. autoclass:: pygit2.enums.ReferenceType + :members: + +.. autoclass:: pygit2.enums.ResetMode + :members: + +.. autoclass:: pygit2.enums.RevSpecFlag + :members: + + +Objects +======= + +.. autoclass:: pygit2.enums.ObjectType + :members: + +.. autoclass:: pygit2.enums.FileMode + :members: + + +Diff +==== + +.. autoclass:: pygit2.enums.DeltaStatus + :members: + +.. autoclass:: pygit2.enums.DiffFind + :members: + +.. autoclass:: pygit2.enums.DiffFlag + :members: + +.. autoclass:: pygit2.enums.DiffOption + :members: + +.. autoclass:: pygit2.enums.DiffStatsFormat + :members: + + +Status +====== + +.. autoclass:: pygit2.enums.FileStatus + :members: + + +Checkout +======== + +.. autoclass:: pygit2.enums.CheckoutNotify + :members: + +.. autoclass:: pygit2.enums.CheckoutStrategy + :members: + + +Merge +===== + +.. autoclass:: pygit2.enums.MergeAnalysis + :members: + +.. autoclass:: pygit2.enums.MergeFavor + :members: + +.. autoclass:: pygit2.enums.MergeFileFlag + :members: + +.. autoclass:: pygit2.enums.MergeFlag + :members: + +.. autoclass:: pygit2.enums.MergePreference + :members: + + +Blame +===== + +.. autoclass:: pygit2.enums.BlameFlag + :members: + + +Filters +======= + +.. autoclass:: pygit2.enums.FilterMode + :members: + +.. autoclass:: pygit2.enums.FilterFlag + :members: + +.. autoclass:: pygit2.enums.BlobFilter + :members: + + +Attributes +========== + +.. autoclass:: pygit2.enums.AttrCheck + :members: + + +Remotes +======= + +.. autoclass:: pygit2.enums.CredentialType + :members: + +.. autoclass:: pygit2.enums.FetchPrune + :members: + + +Submodules +========== + +.. autoclass:: pygit2.enums.SubmoduleIgnore + :members: + +.. autoclass:: pygit2.enums.SubmoduleStatus + :members: + + +Stash +===== + +.. autoclass:: pygit2.enums.StashApplyProgress + :members: + + +Revwalk +======= + +.. autoclass:: pygit2.enums.SortMode + :members: + +.. autoclass:: pygit2.enums.DescribeStrategy + :members: + + +Apply +===== + +.. autoclass:: pygit2.enums.ApplyLocation + :members: + + +Library +======= + +.. autoclass:: pygit2.enums.Feature + :members: + +.. autoclass:: pygit2.enums.Option + :members: + +.. autoclass:: pygit2.enums.ConfigLevel + :members: diff --git a/docs/filters.rst b/docs/filters.rst index 6c29a753b..33b24aa06 100644 --- a/docs/filters.rst +++ b/docs/filters.rst @@ -19,6 +19,17 @@ Registering filters .. autofunction:: pygit2.filter_register .. autofunction:: pygit2.filter_unregister +Loading filters +=============== + +.. automethod:: pygit2.Repository.load_filter_list + +The FilterList type +------------------- + +.. autoclass:: pygit2.filter.FilterList + :members: + Example ======= diff --git a/docs/general.rst b/docs/general.rst index 7f1edf333..fb702ace4 100644 --- a/docs/general.rst +++ b/docs/general.rst @@ -18,41 +18,41 @@ library that has been built against. The version number has a .. py:data:: LIBGIT2_VER_MAJOR Integer value of the major version number. For example, for the version - ``0.26.0``:: + ``1.9.4``:: >>> print(pygit2.LIBGIT2_VER_MAJOR) - 0 + 1 .. py:data:: LIBGIT2_VER_MINOR Integer value of the minor version number. For example, for the version - ``0.26.0``:: + ``1.9.4``:: >>> print(pygit2.LIBGIT2_VER_MINOR) - 26 + 9 .. py:data:: LIBGIT2_VER_REVISION Integer value of the revision version number. For example, for the version - ``0.26.0``:: + ``1.9.4``:: >>> print(pygit2.LIBGIT2_VER_REVISION) - 0 + 4 .. py:data:: LIBGIT2_VER Tuple value of the revision version numbers. For example, for the version - ``0.26.0``:: + ``1.9.4``:: >>> print(pygit2.LIBGIT2_VER) - (0, 26, 0) + (1, 9, 4) .. py:data:: LIBGIT2_VERSION The libgit2 version number as a string:: >>> print(pygit2.LIBGIT2_VERSION) - '0.26.0' + '1.9.4' Options ========= @@ -80,3 +80,11 @@ Exception when trying to create an object (reference, etc) that already exists. :undoc-members: Exception when an input specification such as a reference name is invalid. + +.. autoexception:: pygit2.Passthrough + :members: + :show-inheritance: + :undoc-members: + +Exception that can be raised from a callback to tell libgit2 to behave as if +that callback had not been set. See :doc:`callbacks` for details. diff --git a/docs/index.rst b/docs/index.rst index bc922c548..653a8556b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -62,9 +62,11 @@ Table of Contents backends blame branches + callbacks commit_log config diff + enums features filters index_file diff --git a/docs/objects.rst b/docs/objects.rst index 9aed0282a..e6cfa39ed 100644 --- a/docs/objects.rst +++ b/docs/objects.rst @@ -112,10 +112,10 @@ them to the Git object database: Example: - >>> id = repo.create_blob('foo bar') # Creates blob from a byte string + >>> id = repo.create_blob(b'foo bar') # Creates blob from a byte string >>> blob = repo[id] >>> blob.data - 'foo bar' + b'foo bar' There are also some functions to calculate the id for a byte string without creating the blob object: @@ -123,6 +123,12 @@ creating the blob object: .. autofunction:: pygit2.hash .. autofunction:: pygit2.hashfile +To calculate the hash of a file using the repository's filtering rules (e.g. +``core.safecrlf``), use the repository's instance method: + +.. automethod:: pygit2.Repository.hashfile + :noindex: + Streaming blob content ---------------------- diff --git a/docs/remotes.rst b/docs/remotes.rst index 3b9968911..4b98f9ffd 100644 --- a/docs/remotes.rst +++ b/docs/remotes.rst @@ -22,7 +22,11 @@ The Remote type The RemoteCallbacks type ======================== +See :doc:`callbacks` for the full reference. The following autoclass is only +included here for discoverability. + .. autoclass:: pygit2.RemoteCallbacks + :noindex: :members: The TransferProgress type @@ -33,6 +37,24 @@ This class contains the data which is available to us during a fetch. .. autoclass:: pygit2.remotes.TransferProgress :members: +The RemoteHead type +=================== + +Description of a reference advertised by a remote server, returned by +:meth:`pygit2.Remote.list_heads`. + +.. autoclass:: pygit2.remotes.RemoteHead + :members: + +The PushUpdate type +=================== + +Represents an update which will be performed on the remote during push. +Passed to :meth:`pygit2.RemoteCallbacks.push_negotiation`. + +.. autoclass:: pygit2.remotes.PushUpdate + :members: + The Refspec type =================== diff --git a/docs/revparse.rst b/docs/revparse.rst index 5d1cc812e..2cb613f77 100644 --- a/docs/revparse.rst +++ b/docs/revparse.rst @@ -17,5 +17,8 @@ You can use any of the fancy `` forms supported by libgit2:: Constants: .. py:data:: pygit2.enums.RevSpecFlag.SINGLE + :noindex: .. py:data:: pygit2.enums.RevSpecFlag.RANGE + :noindex: .. py:data:: pygit2.enums.RevSpecFlag.MERGE_BASE + :noindex: diff --git a/pygit2/callbacks.py b/pygit2/callbacks.py index 0faab7702..20bfad401 100644 --- a/pygit2/callbacks.py +++ b/pygit2/callbacks.py @@ -308,7 +308,7 @@ def checkout_notify( Raising an exception from this callback will cancel the checkout. The exception will be propagated back and raised by the - Repository.checkout_... call. + ``Repository.checkout_...`` call. Notification callbacks are made prior to modifying any files on disk, so canceling on any notification will still happen prior to any files diff --git a/pygit2/enums.py b/pygit2/enums.py index d690e73ae..5534b85b5 100644 --- a/pygit2/enums.py +++ b/pygit2/enums.py @@ -363,7 +363,7 @@ class DiffFind(IntFlag): """Flags to control the behavior of diff rename/copy detection.""" FIND_BY_CONFIG = _pygit2.GIT_DIFF_FIND_BY_CONFIG - """ Obey `diff.renames`. Overridden by any other FIND_... flag. """ + """ Obey ``diff.renames``. Overridden by any other ``FIND_...`` flag. """ FIND_RENAMES = _pygit2.GIT_DIFF_FIND_RENAMES """ Look for renames? (`--find-renames`) """ diff --git a/src/odb.c b/src/odb.c index e8720fc0a..85c07b804 100644 --- a/src/odb.c +++ b/src/odb.c @@ -209,7 +209,7 @@ Odb_read(Odb *self, PyObject *py_hex) } PyDoc_STRVAR(Odb_read_header__doc__, - "read_header(oid: Oid) -> tuple[enums.ObjectType, size\n" + "read_header(oid: Oid) -> tuple[enums.ObjectType, int]\n" "\n" "Read the header of an object from the database, without reading its full\n" "contents.\n" diff --git a/src/repository.c b/src/repository.c index 24a548486..2276eb773 100644 --- a/src/repository.c +++ b/src/repository.c @@ -1216,7 +1216,7 @@ Repository_create_commit_with_signature(Repository *self, PyObject *args) } PyDoc_STRVAR(Repository_create_tag__doc__, - "create_tag(name: str, oid: Oid, type: enums.ObjectType, tagger: Signature[, message: str]) -> Oid\n" + "create_tag(name: str, oid: Oid, type: enums.ObjectType, tagger: Signature, message: str) -> Oid\n" "\n" "Create a new tag object, return its oid."); From d86662e5e517a7f5c90dddfd9931daefbd37e522 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Sat, 13 Jun 2026 08:11:01 +0200 Subject: [PATCH 178/225] Release 1.19.3 --- .mailmap | 1 + AGENTS.md | 191 ++++++++++++++++++++++++++++++++++------------- AUTHORS.md | 2 + CHANGELOG.md | 2 +- pygit2/_build.py | 2 +- 5 files changed, 146 insertions(+), 52 deletions(-) diff --git a/.mailmap b/.mailmap index 9ee281b19..ec2a26cd6 100644 --- a/.mailmap +++ b/.mailmap @@ -22,6 +22,7 @@ Matthew Duggan Matthew Gamble Matthias Bartelmeß Mikhail Yushkovskiy +Mukunda Rao Katta Nabijacz Leweli Nicolas Rybowski Óscar San José diff --git a/AGENTS.md b/AGENTS.md index 56fa23070..5bd5eec0a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,10 +2,13 @@ ## Project Overview -**pygit2** is a Python library providing bindings to [libgit2](https://libgit2.org/), the shared C library that implements Git plumbing operations. It exposes both a low-level API (direct libgit2 wrappers) and a high-level Pythonic API for repository manipulation. +**pygit2** is a Python library that provides bindings to +[libgit2](https://libgit2.org/), the shared C library that implements Git +plumbing operations. It exposes both a low-level API (direct libgit2 wrappers) +and a high-level, Pythonic API for repository manipulation. -- **Version**: 1.19.2 -- **License**: GPLv2 with linking exception +- **Version**: 1.19.3 (canonical version is defined in `pygit2/_build.py`) +- **License**: GPLv2 with linking exception (see `COPYING`) - **Maintainer**: J. David Ibáñez - **Python Support**: 3.11 – 3.14 and PyPy3 7.3+ - **libgit2 Version**: 1.9.4 @@ -14,50 +17,78 @@ ## Architecture -The project uses a **hybrid C/Python** architecture with two compiled extension modules: +The project uses a **hybrid C/Python** architecture with two compiled extension +modules: -- **`src/`** — C11 source files that compile into the `_pygit2` C extension module. Each file generally maps to a libgit2 concept: +- **`src/`** — C11 source and header files that compile into the `_pygit2` C + extension module. Each file generally maps to a libgit2 concept: - Core objects: `blob.c`, `commit.c`, `object.c`, `tag.c`, `tree.c` - - Repository and refs: `repository.c`, `branch.c`, `reference.c`, `refdb.c`, `refdb_backend.c`, `revspec.c` + - Repository, refs and branches: `repository.c`, `branch.c`, `reference.c`, + `refdb.c`, `refdb_backend.c`, `revspec.c`, `worktree.c` - Diff and patch: `diff.c`, `patch.c` - - ODB and backend: `odb.c`, `odb_backend.c` - - Filters and mailmap: `filter.c`, `mailmap.c` - - Notes and signatures: `note.c`, `signature.c` - - Main module init: `pygit2.c` + - ODB and backends: `odb.c`, `odb_backend.c` + - Index, walking and helpers: `treebuilder.c`, `walker.c`, `oid.c`, + `note.c`, `signature.c`, `mailmap.c`, `stash.c` + - Filters: `filter.c` + - Module infrastructure: `pygit2.c`, `error.c`, `utils.c`, `wildmatch.c` + - Headers: `*.h` files mirroring the C sources (e.g. `repository.h`, + `diff.h`, `types.h`, `error.h`) - **`pygit2/`** — The main Python package. - - **`_pygit2*.so`** — Compiled C extension (built from `src/`). - - **`_libgit2.abi3.so`** — CFFI-generated ABI module built from `pygit2/_run.py`. - - **`decl/`** — C header stub files used by CFFI to define the libgit2 API surface (e.g., `types.h`, `repository.h`, `callbacks.h`, `diff.h`, `remote.h`). - - **`_build.py`** — Build-time helpers and the canonical `__version__` string. Also used at runtime to locate libgit2. Must remain importable without the rest of the package being built because `setup.py` imports it. - - **`_run.py`** — CFFI build script that aggregates `decl/*.h` and compiles `pygit2._libgit2`. + - **`_pygit2*.so`** — Compiled C extension built from `src/`. + - **`_libgit2.abi3.so`** — CFFI-generated ABI module built from + `pygit2/_run.py`. + - **`decl/`** — C header stub files used by CFFI to define the libgit2 API + surface (e.g. `types.h`, `repository.h`, `callbacks.h`, `diff.h`, + `remote.h`). `pygit2/_run.py` concatenates these stubs in a specific order + before passing them to CFFI. + - **`_build.py`** — Build-time helpers and the canonical `__version__` + string. Also used at runtime to locate libgit2. It must remain importable + without the rest of the package being built because `setup.py` imports it. + - **`_run.py`** — CFFI build script that aggregates `decl/*.h` and compiles + `pygit2._libgit2`. - **`ffi.py`** — Runtime import of the CFFI `ffi` and `lib` (`C`) objects. - - **`_pygit2.pyi`** — Type stubs for the C extension. Keep it in sync when adding or changing low-level APIs. + - **`_pygit2.pyi`** — Type stubs for the C extension. Keep it in sync when + adding or changing low-level APIs. - **`py.typed`** — PEP 561 marker indicating the package is typed. - - **High-level modules** — Pure-Python wrappers that sit on top of the C extension: - `repository.py`, `callbacks.py`, `config.py`, `index.py`, `remotes.py`, `settings.py`, `submodules.py`, `transaction.py`, `filter.py`, `blob.py`, `blame.py`, `credentials.py`, `errors.py`, `options.py`, `packbuilder.py`, `references.py`, `refspec.py`, `utils.py`, `enums.py`, `legacyenums.py`. + - **High-level modules** — Pure-Python wrappers that sit on top of the C + extension: + `repository.py`, `callbacks.py`, `config.py`, `index.py`, `remotes.py`, + `settings.py`, `submodules.py`, `transaction.py`, `filter.py`, `blob.py`, + `blame.py`, `branches.py`, `credentials.py`, `errors.py`, `options.py`, + `packbuilder.py`, `references.py`, `refspec.py`, `utils.py`, `enums.py`, + `legacyenums.py`. - **`test/`** — pytest suite with fixture-based repository handling. - **`docs/`** — Sphinx documentation (RTD theme). ## Key Configuration Files -- **`setup.py`** — setuptools entry point. Builds both the C extension (`src/*.c`) and the CFFI extension (`pygit2/_run.py:ffi`). -- **`pyproject.toml`** — Build system requirements, `cibuildwheel` configuration, `ruff` settings, and `codespell` settings. +- **`setup.py`** — setuptools entry point. Builds both the C extension + (`src/*.c`) and the CFFI extension (`pygit2/_run.py:ffi`). +- **`pyproject.toml`** — Build-system requirements, `cibuildwheel` + configuration, `ruff` settings, and `codespell` settings. - **`setup.cfg`** — Legacy pycodestyle configuration. -- **`pytest.ini`** — pytest configuration (`--capture=no -ra --verbose`, `testpaths = test/`). +- **`pytest.ini`** — pytest configuration (`--capture=no -ra --verbose`, + `testpaths = test/`). - **`mypy.ini`** — mypy configuration with strict settings. -- **`mypy-stubtest.ini`** — mypy configuration for `stubtest` against `_pygit2.pyi`. -- **`requirements.txt`** — Runtime/build requirements (`cffi>=2.0`, `setuptools` for Python >= 3.12). +- **`mypy-stubtest.ini`** — mypy configuration for `stubtest` against + `_pygit2.pyi`. +- **`requirements.txt`** — Runtime/build requirements (`cffi>=2.0`, + `setuptools` for Python >= 3.12). - **`requirements-test.txt`** — Test requirements (`pytest`, `pytest-cov`). - **`requirements-typing.txt`** — Typing requirements (`mypy`, `types-cffi`). -- **`Makefile`** — Convenience targets: `make` builds deps + extension inplace; `make html` builds docs. +- **`Makefile`** — Convenience targets: `make` builds dependencies + extension + inplace; `make html` builds docs. +- **`.vimrc`** — Local editor configuration for C development with ALE + (`-std=c11 -Wall`, Python include path, `/usr/local/include`). ## Build and Test Commands ### Quick Development Build (inplace) -Requires libgit2 development headers and library to be installed on the system or pointed to via the `LIBGIT2` environment variable. +Requires libgit2 development headers and library to be installed on the system +or pointed to via the `LIBGIT2` environment variable. ```bash python setup.py build_ext --inplace @@ -66,7 +97,9 @@ pytest ### Full Build with Dependencies -The `build.sh` script can download, compile, and bundle libgit2 (and optionally libssh2 and OpenSSL) into a local prefix. On Windows, `build.ps1` handles libgit2 compilation via CMake. +The `build.sh` script can download, compile, and bundle libgit2 (and optionally +libssh2, OpenSSL, and zlib) into a local prefix. On Windows, `build.ps1` +handles libgit2 compilation via CMake. ```bash # Build inplace with bundled libgit2/libssh2/OpenSSL @@ -75,14 +108,14 @@ make # Or manually: LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.4 sh build.sh -# Build inplace + run tests +# Build inplace and run the tests sh build.sh test -# Build a wheel, install it, and run tests +# Build a wheel, install it, and run the tests sh build.sh wheel # Run tests with coverage -sh build.sh test # (build.sh already adds --cov=pygit2) +sh build.sh test # build.sh adds --cov=pygit2 # Run mypy type checking sh build.sh mypy @@ -91,13 +124,32 @@ sh build.sh mypy sh build.sh stubtest ``` +`build.sh` creates a virtual environment under `ci//` by default, +where `` is computed by `build_tag.py`. Use the `PYTHON` +environment variable to select a different interpreter (default: `python3`). + ### Environment Variables -- `LIBGIT2` — Base path where libgit2 is installed (default: `/usr/local` or `%ProgramFiles%\libgit2` on Windows). +Variables consumed by `setup.py` / `pygit2/_build.py`: + +- `LIBGIT2` — Base path where libgit2 is installed (default: `/usr/local` or + `%ProgramFiles%\libgit2` on Windows). - `LIBGIT2_LIB` — Override the library directory specifically. -- `LIBGIT2_VERSION` — If set, `build.sh` downloads and builds this libgit2 version. -- `LIBSSH2_VERSION` — If set, `build.sh` downloads and builds libssh2 with SSH support. -- `OPENSSL_VERSION` — If set, `build.sh` downloads and builds OpenSSL (mainly used for macOS universal builds on CI). + +Variables consumed by `build.sh`: + +- `LIBGIT2_VERSION` — If set, download and build this libgit2 version. +- `LIBSSH2_VERSION` — If set, download and build libssh2 with SSH support. +- `OPENSSL_VERSION` — If set, download and build OpenSSL (mainly used for + macOS universal builds on CI). +- `ZLIB_VERSION` — If set, download and build zlib. +- `BUILD_TYPE` — CMake build type (default: `Debug`). +- `PYTHON` — Python interpreter to use (default: `python3`). +- `PREFIX` — Installation prefix (default: `$(pwd)/ci/$PYTHON_TAG`). +- `CIBUILDWHEEL` — Set to `1` when invoked by cibuildwheel; changes package + manager and directory layout. +- `AUDITWHEEL_PLAT` — Linux platform for auditwheel repair. +- `LIBSSH2_OPENSSL` — Where to find OpenSSL when building libssh2. ### Documentation Build @@ -117,12 +169,16 @@ make -C docs html # requires sphinx-rtd-theme - Selected rules: `E4`, `E7`, `E9`, `F`, `I`, `UP035`, `UP007` - **Type checker**: mypy (strict settings enabled; see `mypy.ini`) - All Python source files must include the standard GPLv2 copyright header. +- `pygit2/__init__.py` is large because it re-exports a large surface of + constants and classes; follow existing patterns when adding new public + symbols. ### C - Standard: C11 - All C source files must include the standard GPLv2 copyright header. -- The `.vimrc` at repo root configures ALE with `-std=c11 -Wall` and includes the Python headers and `/usr/local/include`. +- The `.vimrc` at repo root configures ALE with `-std=c11 -Wall` and includes + the Python headers and `/usr/local/include`. ### Docstrings @@ -158,31 +214,66 @@ def f(a, b): addopts = --capture=no -ra --verbose testpaths = test/ ``` -- **Fixtures**: Defined in `test/conftest.py`. They yield `pygit2.Repository` instances extracted from zipped sample repos in `test/data/` (e.g., `testrepo.zip`, `barerepo.zip`). -- **Test utilities**: `test/utils.py` provides helpers such as `TemporaryRepository`, network/proxy/SSH skip markers, and `diff_safeiter`. -- **Isolation**: The session-scoped `global_git_config` fixture clears `GLOBAL`, `XDG`, and `SYSTEM` config search paths to ensure reproducibility. +- **Fixtures**: Defined in `test/conftest.py`. They yield `pygit2.Repository` + instances extracted from zipped sample repos in `test/data/` (e.g. + `testrepo.zip`, `barerepo.zip`). Named fixtures include `testrepo`, + `testrepo_path`, `barerepo`, `barerepo_path`, `emptyrepo`, `dirtyrepo`, + `mergerepo`, `encodingrepo`, `testrepopacked`, `gpgsigned`, `blameflagsrepo`, + and `pygit2_empty_key`. +- **Test utilities**: `test/utils.py` provides helpers such as + `TemporaryRepository`, `gen_blob_sha1`, `rmtree`, `diff_safeiter`, and + markers like `requires_network`, `requires_proxy`, `requires_ssh`, + `requires_refcount`, `fails_in_macos`, and `requires_future_libgit2`. +- **Isolation**: The session-scoped `global_git_config` fixture clears + `GLOBAL`, `XDG`, and `SYSTEM` config search paths to ensure reproducibility. - **Coverage**: `pytest-cov` is used; run via `sh build.sh test`. ## CI / Deployment GitHub Actions workflows live in `.github/workflows/`: -- **`tests.yml`** — Runs on s390x via QEMU (allowed to fail; see issue #812). -- **`lint.yml`** — Runs `ruff format --diff`, `ruff check`, and `sh build.sh mypy`. -- **`wheels.yml`** — Uses `cibuildwheel` to build wheels for Linux (amd64, arm64, ppc64le, musl), macOS (intel, arm64, PyPy), and Windows (x64, x86, arm64). Publishes to PyPI and creates GitHub Releases on version tags (`v*`). -- **`codespell.yml`** — Spell checking. +- **`tests.yml`** — Runs on s390x via QEMU (`uraimo/run-on-arch-action`). + Allowed to fail; see issue #812. +- **`lint.yml`** — Runs `ruff format --diff`, `ruff check`, and + `sh build.sh mypy`. +- **`wheels.yml`** — Uses `cibuildwheel` to build wheels for Linux (amd64, + arm64, ppc64le, musl), macOS (intel, arm64, PyPy), and Windows (x64, x86, + arm64). It also builds an sdist, runs a `twine check`, publishes to PyPI, + and creates a GitHub Release on version tags (`v*`). +- **`codespell.yml`** — Spell checking with the codespell action. + +The `cibuildwheel` configuration in `pyproject.toml` pins: + +- `LIBGIT2_VERSION="1.9.4"` +- `LIBSSH2_VERSION="1.11.1"` +- `OPENSSL_VERSION="3.5.4"` + +and skips `*musllinux_ppc64le` plus testing on `*-*linux_ppc64le` and +`pp*-macosx_arm64`. ## Security Considerations -- The project links against OpenSSL and libssh2. CI pins specific versions of these libraries when building wheels. -- Wheel repair commands (`auditwheel`, `delocate-wheel`) bundle shared libraries so wheels are self-contained. -- Credentials callbacks (`RemoteCallbacks`, `get_credentials`) are the primary interface for supplying secrets; never hardcode credentials in tests. -- Valgrind support: see `docs/development.rst` and `misc/valgrind-python.supp` for memory-leak debugging instructions. +- The project links against OpenSSL and libssh2. CI pins specific versions of + these libraries when building wheels. +- Wheel repair commands (`auditwheel`, `delocate-wheel`) bundle shared + libraries so wheels are self-contained. +- Credentials callbacks (`RemoteCallbacks`, `get_credentials`) are the primary + interface for supplying secrets; never hardcode credentials in tests. +- Valgrind support: see `docs/development.rst` and + `misc/valgrind-python.supp` for memory-leak debugging instructions. ## Useful Notes for Agents -- **Do not assume libgit2 is installed globally.** Check for `LIBGIT2` or use `build.sh`. -- **`pygit2/_build.py`** is imported by `setup.py`; it must remain importable without the rest of the package being built. -- **CFFI and setuptools extensions are both built from `setup.py`.** `ext_modules` builds the C extension from `src/*.c`; `cffi_modules` triggers the CFFI build via `pygit2/_run.py:ffi`. -- **`.pyi` stub file**: `pygit2/_pygit2.pyi` provides type stubs for the C extension. Keep it in sync when adding or changing low-level APIs. -- **`pygit2/__init__.py`** is large because it re-exports a vast surface of constants and classes. Follow existing patterns when adding new public symbols. +- **Do not assume libgit2 is installed globally.** Check for `LIBGIT2` or use + `build.sh` / `make`. +- **`pygit2/_build.py`** is imported by `setup.py`; it must remain importable + without the rest of the package being built. +- **CFFI and setuptools extensions are both built from `setup.py`.** + `ext_modules` builds the C extension from `src/*.c`; `cffi_modules` triggers + the CFFI build via `pygit2/_run.py:ffi`. +- **`.pyi` stub file**: `pygit2/_pygit2.pyi` provides type stubs for the C + extension. Keep it in sync when adding or changing low-level APIs. +- **Header stub order matters**: `pygit2/_run.py` concatenates `decl/*.h` in a + fixed list; add new stubs in the correct position if dependencies require it. +- Run the full test suite and type checks before considering a change complete: + `sh build.sh test` and `sh build.sh mypy`. diff --git a/AUTHORS.md b/AUTHORS.md index 8a04c1bee..0868ce4a1 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -74,6 +74,7 @@ Authors: Christian Häggström Edmundo Carmona Antoranz Erik Johnson + Ethan Meng Filip Rindler Fraser Tweedale Grégoire ROCHER @@ -206,6 +207,7 @@ Authors: Matěj Cepl Maxwell G Michał Górny + Mukunda Rao Katta Na'aman Hirschfeld Nicolas Rybowski Nicolás Sanguinetti diff --git a/CHANGELOG.md b/CHANGELOG.md index 016446c85..becd66f21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# 1.19.3 (UNRELEASED) +# 1.19.3 (2026-06-13) - Memory fixes [#1368](https://github.com/libgit2/pygit2/issues/1368) diff --git a/pygit2/_build.py b/pygit2/_build.py index 976c98f4c..3a5908b86 100644 --- a/pygit2/_build.py +++ b/pygit2/_build.py @@ -34,7 +34,7 @@ # # The version number of pygit2 # -__version__ = '1.19.2' +__version__ = '1.19.3' # From b7c234caf841206d90b902aae3c590c43e3161cf Mon Sep 17 00:00:00 2001 From: WANG Xuerui Date: Tue, 16 Jun 2026 15:00:59 +0800 Subject: [PATCH 179/225] ci: generalize emulated Linux wheel builds Turn the current ppc64le build into the sole matrix entry in a new general build job for all emulated Linux builds, to make supporting additional architectures easier. Human note: The entirety of the commit message is written manually. Assisted-by: GPT-5.5 --- .github/workflows/wheels.yml | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index a94c3ec82..ea196ad54 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -97,10 +97,17 @@ jobs: name: wheels-${{ matrix.name }} path: ./wheelhouse/*.whl - build_wheels_ppc: - name: Wheels for linux-ppc + build_wheels_emulated_linux: + name: Wheels for ${{ matrix.name }} runs-on: ubuntu-24.04 needs: sdist + strategy: + fail-fast: true + matrix: + include: + - name: linux-ppc64le + qemu_platform: linux/ppc64le + cibw_arch: ppc64le steps: - uses: actions/setup-python@v6 @@ -109,7 +116,7 @@ jobs: - uses: docker/setup-qemu-action@v4 with: - platforms: linux/ppc64le + platforms: ${{ matrix.qemu_platform }} - name: Download sdist uses: actions/download-artifact@v8 @@ -124,12 +131,12 @@ jobs: shell: bash run: python -m cibuildwheel dist/*.tar.gz --output-dir wheelhouse env: - CIBW_ARCHS: ppc64le + CIBW_ARCHS: ${{ matrix.cibw_arch }} CIBW_ENVIRONMENT: LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.4 LIBGIT2=/project/ci - uses: actions/upload-artifact@v7 with: - name: wheels-linux-ppc + name: wheels-${{ matrix.name }} path: ./wheelhouse/*.whl twine-check: @@ -137,7 +144,7 @@ jobs: # It is good to do this check on non-tagged commits. # Note, pypa/gh-action-pypi-publish (see job below) does this automatically. if: ${{ !startsWith(github.ref, 'refs/tags/v') }} - needs: [build_wheels, build_wheels_ppc, sdist] + needs: [build_wheels, build_wheels_emulated_linux, sdist] runs-on: ubuntu-latest steps: @@ -151,7 +158,7 @@ jobs: pypi: if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') - needs: [build_wheels, build_wheels_ppc, sdist] + needs: [build_wheels, build_wheels_emulated_linux, sdist] permissions: contents: write # to create GitHub Release runs-on: ubuntu-24.04 From 2fcb39e04872a7f761997abc98d2771e5ab47385 Mon Sep 17 00:00:00 2001 From: WANG Xuerui Date: Tue, 16 Jun 2026 15:01:19 +0800 Subject: [PATCH 180/225] ci: add riscv64 wheel builds RISC-V wheels have been supported by cibuildwheel and PyPI for a while, so let's build binary wheels for it as many of the boards currently on the market are rather slow to make building on the spot rather painful. Human note: The entirety of the commit message is written manually. See: https://github.com/pypa/cibuildwheel/pull/2366 See: https://github.com/pypi/warehouse/issues/18243 Assisted-by: GPT-5.5 --- .github/workflows/wheels.yml | 3 +++ pyproject.toml | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index ea196ad54..1c969c826 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -108,6 +108,9 @@ jobs: - name: linux-ppc64le qemu_platform: linux/ppc64le cibw_arch: ppc64le + - name: linux-riscv64 + qemu_platform: linux/riscv64 + cibw_arch: riscv64 steps: - uses: actions/setup-python@v6 diff --git a/pyproject.toml b/pyproject.toml index c7f64c072..6e19895f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,10 +15,10 @@ before-all = "sh build.sh" test-command = "pytest" test-sources = ["test", "pytest.ini"] before-test = "pip install -r {package}/requirements-test.txt" -# Will avoid testing on emulated architectures (specifically ppc64le) +# Will avoid testing on emulated architectures (specifically ppc64le and riscv64) # Also, skip testing pypy on macOS arm64 due issue with bootstrapping git config paths # see https://github.com/libgit2/pygit2/issues/1442 -test-skip = "*-*linux_ppc64le pp*-macosx_arm64" +test-skip = "*-*linux_ppc64le *-*linux_riscv64 pp*-macosx_arm64" [tool.cibuildwheel.linux] repair-wheel-command = "LD_LIBRARY_PATH=/project/ci/lib64 auditwheel repair -w {dest_dir} {wheel}" From 793d0ee726d53f01841a95924e213cafd54bbf56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Mon, 22 Jun 2026 17:18:50 +0200 Subject: [PATCH 181/225] Remove deprecated legacyenums module and GIT_* constants Use pygit2.enums instead --- CHANGELOG.md | 11 ++++ pygit2/__init__.py | 64 ------------------------ pygit2/legacyenums.py | 113 ------------------------------------------ 3 files changed, 11 insertions(+), 177 deletions(-) delete mode 100644 pygit2/legacyenums.py diff --git a/CHANGELOG.md b/CHANGELOG.md index becd66f21..48f461f18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +# Unreleased + +- Add riscv64 wheels + [#1463](https://github.com/libgit2/pygit2/pull/1463) + +Breaking changes: + +- Remove deprecated `pygit2.legacyenums` module and `GIT_*` constants, + use `pygit2.enums` instead + + # 1.19.3 (2026-06-13) - Memory fixes diff --git a/pygit2/__init__.py b/pygit2/__init__.py index 52ff96382..8d4346ff1 100644 --- a/pygit2/__init__.py +++ b/pygit2/__init__.py @@ -310,7 +310,6 @@ from .ffi import C, ffi from .filter import Filter from .index import Index, IndexEntry -from .legacyenums import * from .options import ( GIT_OPT_ADD_SSL_X509_CERT, GIT_OPT_DISABLE_PACK_KEEP_FILE_CHECKS, @@ -915,69 +914,6 @@ def filter_unregister(name: str) -> None: 'index', 'Index', 'IndexEntry', - 'legacyenums', - 'GIT_FEATURE_THREADS', - 'GIT_FEATURE_HTTPS', - 'GIT_FEATURE_SSH', - 'GIT_FEATURE_NSEC', - 'GIT_REPOSITORY_INIT_BARE', - 'GIT_REPOSITORY_INIT_NO_REINIT', - 'GIT_REPOSITORY_INIT_NO_DOTGIT_DIR', - 'GIT_REPOSITORY_INIT_MKDIR', - 'GIT_REPOSITORY_INIT_MKPATH', - 'GIT_REPOSITORY_INIT_EXTERNAL_TEMPLATE', - 'GIT_REPOSITORY_INIT_RELATIVE_GITLINK', - 'GIT_REPOSITORY_INIT_SHARED_UMASK', - 'GIT_REPOSITORY_INIT_SHARED_GROUP', - 'GIT_REPOSITORY_INIT_SHARED_ALL', - 'GIT_REPOSITORY_OPEN_NO_SEARCH', - 'GIT_REPOSITORY_OPEN_CROSS_FS', - 'GIT_REPOSITORY_OPEN_BARE', - 'GIT_REPOSITORY_OPEN_NO_DOTGIT', - 'GIT_REPOSITORY_OPEN_FROM_ENV', - 'GIT_REPOSITORY_STATE_NONE', - 'GIT_REPOSITORY_STATE_MERGE', - 'GIT_REPOSITORY_STATE_REVERT', - 'GIT_REPOSITORY_STATE_REVERT_SEQUENCE', - 'GIT_REPOSITORY_STATE_CHERRYPICK', - 'GIT_REPOSITORY_STATE_CHERRYPICK_SEQUENCE', - 'GIT_REPOSITORY_STATE_BISECT', - 'GIT_REPOSITORY_STATE_REBASE', - 'GIT_REPOSITORY_STATE_REBASE_INTERACTIVE', - 'GIT_REPOSITORY_STATE_REBASE_MERGE', - 'GIT_REPOSITORY_STATE_APPLY_MAILBOX', - 'GIT_REPOSITORY_STATE_APPLY_MAILBOX_OR_REBASE', - 'GIT_ATTR_CHECK_FILE_THEN_INDEX', - 'GIT_ATTR_CHECK_INDEX_THEN_FILE', - 'GIT_ATTR_CHECK_INDEX_ONLY', - 'GIT_ATTR_CHECK_NO_SYSTEM', - 'GIT_ATTR_CHECK_INCLUDE_HEAD', - 'GIT_ATTR_CHECK_INCLUDE_COMMIT', - 'GIT_FETCH_PRUNE_UNSPECIFIED', - 'GIT_FETCH_PRUNE', - 'GIT_FETCH_NO_PRUNE', - 'GIT_CHECKOUT_NOTIFY_NONE', - 'GIT_CHECKOUT_NOTIFY_CONFLICT', - 'GIT_CHECKOUT_NOTIFY_DIRTY', - 'GIT_CHECKOUT_NOTIFY_UPDATED', - 'GIT_CHECKOUT_NOTIFY_UNTRACKED', - 'GIT_CHECKOUT_NOTIFY_IGNORED', - 'GIT_CHECKOUT_NOTIFY_ALL', - 'GIT_STASH_APPLY_PROGRESS_NONE', - 'GIT_STASH_APPLY_PROGRESS_LOADING_STASH', - 'GIT_STASH_APPLY_PROGRESS_ANALYZE_INDEX', - 'GIT_STASH_APPLY_PROGRESS_ANALYZE_MODIFIED', - 'GIT_STASH_APPLY_PROGRESS_ANALYZE_UNTRACKED', - 'GIT_STASH_APPLY_PROGRESS_CHECKOUT_UNTRACKED', - 'GIT_STASH_APPLY_PROGRESS_CHECKOUT_MODIFIED', - 'GIT_STASH_APPLY_PROGRESS_DONE', - 'GIT_CREDENTIAL_USERPASS_PLAINTEXT', - 'GIT_CREDENTIAL_SSH_KEY', - 'GIT_CREDENTIAL_SSH_CUSTOM', - 'GIT_CREDENTIAL_DEFAULT', - 'GIT_CREDENTIAL_SSH_INTERACTIVE', - 'GIT_CREDENTIAL_USERNAME', - 'GIT_CREDENTIAL_SSH_MEMORY', 'packbuilder', 'PackBuilder', 'refspec', diff --git a/pygit2/legacyenums.py b/pygit2/legacyenums.py deleted file mode 100644 index 176534a6b..000000000 --- a/pygit2/legacyenums.py +++ /dev/null @@ -1,113 +0,0 @@ -# Copyright 2010-2025 The pygit2 contributors -# -# This file is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License, version 2, -# as published by the Free Software Foundation. -# -# In addition to the permissions in the GNU General Public License, -# the authors give you unlimited permission to link the compiled -# version of this file into combinations with other programs, -# and to distribute those combinations without any restriction -# coming from the use of this file. (The General Public License -# restrictions do apply in other respects; for example, they cover -# modification of the file, and distribution when not linked into -# a combined executable.) -# -# This file 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; see the file COPYING. If not, write to -# the Free Software Foundation, 51 Franklin Street, Fifth Floor, -# Boston, MA 02110-1301, USA. - -""" -GIT_* enum values for compatibility with legacy code. - -These values are deprecated starting with pygit2 1.14. -User programs should migrate to the enum classes defined in `pygit2.enums`. - -Note that our C module _pygit2 already exports many libgit2 enums -(which are all imported by __init__.py). This file only exposes the enums -that are not available through _pygit2. -""" - -from . import enums - -GIT_FEATURE_THREADS = enums.Feature.THREADS -GIT_FEATURE_HTTPS = enums.Feature.HTTPS -GIT_FEATURE_SSH = enums.Feature.SSH -GIT_FEATURE_NSEC = enums.Feature.NSEC - -GIT_REPOSITORY_INIT_BARE = enums.RepositoryInitFlag.BARE -GIT_REPOSITORY_INIT_NO_REINIT = enums.RepositoryInitFlag.NO_REINIT -GIT_REPOSITORY_INIT_NO_DOTGIT_DIR = enums.RepositoryInitFlag.NO_DOTGIT_DIR -GIT_REPOSITORY_INIT_MKDIR = enums.RepositoryInitFlag.MKDIR -GIT_REPOSITORY_INIT_MKPATH = enums.RepositoryInitFlag.MKPATH -GIT_REPOSITORY_INIT_EXTERNAL_TEMPLATE = enums.RepositoryInitFlag.EXTERNAL_TEMPLATE -GIT_REPOSITORY_INIT_RELATIVE_GITLINK = enums.RepositoryInitFlag.RELATIVE_GITLINK - -GIT_REPOSITORY_INIT_SHARED_UMASK = enums.RepositoryInitMode.SHARED_UMASK -GIT_REPOSITORY_INIT_SHARED_GROUP = enums.RepositoryInitMode.SHARED_GROUP -GIT_REPOSITORY_INIT_SHARED_ALL = enums.RepositoryInitMode.SHARED_ALL - -GIT_REPOSITORY_OPEN_NO_SEARCH = enums.RepositoryOpenFlag.NO_SEARCH -GIT_REPOSITORY_OPEN_CROSS_FS = enums.RepositoryOpenFlag.CROSS_FS -GIT_REPOSITORY_OPEN_BARE = enums.RepositoryOpenFlag.BARE -GIT_REPOSITORY_OPEN_NO_DOTGIT = enums.RepositoryOpenFlag.NO_DOTGIT -GIT_REPOSITORY_OPEN_FROM_ENV = enums.RepositoryOpenFlag.FROM_ENV - -GIT_REPOSITORY_STATE_NONE = enums.RepositoryState.NONE -GIT_REPOSITORY_STATE_MERGE = enums.RepositoryState.MERGE -GIT_REPOSITORY_STATE_REVERT = enums.RepositoryState.REVERT -GIT_REPOSITORY_STATE_REVERT_SEQUENCE = enums.RepositoryState.REVERT_SEQUENCE -GIT_REPOSITORY_STATE_CHERRYPICK = enums.RepositoryState.CHERRYPICK -GIT_REPOSITORY_STATE_CHERRYPICK_SEQUENCE = enums.RepositoryState.CHERRYPICK_SEQUENCE -GIT_REPOSITORY_STATE_BISECT = enums.RepositoryState.BISECT -GIT_REPOSITORY_STATE_REBASE = enums.RepositoryState.REBASE -GIT_REPOSITORY_STATE_REBASE_INTERACTIVE = enums.RepositoryState.REBASE_INTERACTIVE -GIT_REPOSITORY_STATE_REBASE_MERGE = enums.RepositoryState.REBASE_MERGE -GIT_REPOSITORY_STATE_APPLY_MAILBOX = enums.RepositoryState.APPLY_MAILBOX -GIT_REPOSITORY_STATE_APPLY_MAILBOX_OR_REBASE = ( - enums.RepositoryState.APPLY_MAILBOX_OR_REBASE -) - -GIT_ATTR_CHECK_FILE_THEN_INDEX = enums.AttrCheck.FILE_THEN_INDEX -GIT_ATTR_CHECK_INDEX_THEN_FILE = enums.AttrCheck.INDEX_THEN_FILE -GIT_ATTR_CHECK_INDEX_ONLY = enums.AttrCheck.INDEX_ONLY -GIT_ATTR_CHECK_NO_SYSTEM = enums.AttrCheck.NO_SYSTEM -GIT_ATTR_CHECK_INCLUDE_HEAD = enums.AttrCheck.INCLUDE_HEAD -GIT_ATTR_CHECK_INCLUDE_COMMIT = enums.AttrCheck.INCLUDE_COMMIT - -GIT_FETCH_PRUNE_UNSPECIFIED = enums.FetchPrune.UNSPECIFIED -GIT_FETCH_PRUNE = enums.FetchPrune.PRUNE -GIT_FETCH_NO_PRUNE = enums.FetchPrune.NO_PRUNE - -GIT_CHECKOUT_NOTIFY_NONE = enums.CheckoutNotify.NONE -GIT_CHECKOUT_NOTIFY_CONFLICT = enums.CheckoutNotify.CONFLICT -GIT_CHECKOUT_NOTIFY_DIRTY = enums.CheckoutNotify.DIRTY -GIT_CHECKOUT_NOTIFY_UPDATED = enums.CheckoutNotify.UPDATED -GIT_CHECKOUT_NOTIFY_UNTRACKED = enums.CheckoutNotify.UNTRACKED -GIT_CHECKOUT_NOTIFY_IGNORED = enums.CheckoutNotify.IGNORED -GIT_CHECKOUT_NOTIFY_ALL = enums.CheckoutNotify.ALL - -GIT_STASH_APPLY_PROGRESS_NONE = enums.StashApplyProgress.NONE -GIT_STASH_APPLY_PROGRESS_LOADING_STASH = enums.StashApplyProgress.LOADING_STASH -GIT_STASH_APPLY_PROGRESS_ANALYZE_INDEX = enums.StashApplyProgress.ANALYZE_INDEX -GIT_STASH_APPLY_PROGRESS_ANALYZE_MODIFIED = enums.StashApplyProgress.ANALYZE_MODIFIED -GIT_STASH_APPLY_PROGRESS_ANALYZE_UNTRACKED = enums.StashApplyProgress.ANALYZE_UNTRACKED -GIT_STASH_APPLY_PROGRESS_CHECKOUT_UNTRACKED = ( - enums.StashApplyProgress.CHECKOUT_UNTRACKED -) -GIT_STASH_APPLY_PROGRESS_CHECKOUT_MODIFIED = enums.StashApplyProgress.CHECKOUT_MODIFIED -GIT_STASH_APPLY_PROGRESS_DONE = enums.StashApplyProgress.DONE - -GIT_CREDENTIAL_USERPASS_PLAINTEXT = enums.CredentialType.USERPASS_PLAINTEXT -GIT_CREDENTIAL_SSH_KEY = enums.CredentialType.SSH_KEY -GIT_CREDENTIAL_SSH_CUSTOM = enums.CredentialType.SSH_CUSTOM -GIT_CREDENTIAL_DEFAULT = enums.CredentialType.DEFAULT -GIT_CREDENTIAL_SSH_INTERACTIVE = enums.CredentialType.SSH_INTERACTIVE -GIT_CREDENTIAL_USERNAME = enums.CredentialType.USERNAME -GIT_CREDENTIAL_SSH_MEMORY = enums.CredentialType.SSH_MEMORY From 5e1b040f962bd97102f78af7fa8c53ff3091d5da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Mon, 22 Jun 2026 17:44:52 +0200 Subject: [PATCH 182/225] Remove deprecated support for passing str to Repository.merge() Pass a Commit, Oid, or Reference object instead --- CHANGELOG.md | 5 ++++- pygit2/repository.py | 14 ++------------ test/test_merge.py | 23 ++--------------------- 3 files changed, 8 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48f461f18..aef738597 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# Unreleased +# 1.20.0 (UNRELEASED) - Add riscv64 wheels [#1463](https://github.com/libgit2/pygit2/pull/1463) @@ -8,6 +8,9 @@ Breaking changes: - Remove deprecated `pygit2.legacyenums` module and `GIT_*` constants, use `pygit2.enums` instead +- Remove deprecated support for passing `str` to `Repository.merge(...)`, + pass a `Commit`, `Oid`, or `Reference` object instead + # 1.19.3 (2026-06-13) diff --git a/pygit2/repository.py b/pygit2/repository.py index 660ac4fa2..2cfe5a186 100644 --- a/pygit2/repository.py +++ b/pygit2/repository.py @@ -983,7 +983,7 @@ def merge_trees( def merge( self, - source: Reference | Commit | Oid | str, + source: Reference | Commit | Oid, favor: MergeFavor = MergeFavor.NORMAL, flags: MergeFlag = MergeFlag.FIND_RENAMES, file_flags: MergeFileFlag = MergeFileFlag.DEFAULT, @@ -1003,8 +1003,6 @@ def merge( It is preferable to pass in a Reference, because this enriches the merge with additional information (for example, Repository.message will specify the name of the branch being merged). - Previous versions of pygit2 allowed passing in a partial commit - hash as a string; this is deprecated. favor An enums.MergeFavor constant specifying how to deal with file-level conflicts. @@ -1026,15 +1024,7 @@ def merge( check_error(err) else: # Annotated commit from commit id - if isinstance(source, str): - # For backwards compatibility, parse a string as a partial commit hash - warnings.warn( - 'Passing str to Repository.merge is deprecated. ' - 'Pass Commit, Oid, or a Reference (such as a Branch) instead.', - DeprecationWarning, - ) - oid = self[source].peel(Commit).id - elif isinstance(source, Commit): + if isinstance(source, Commit): oid = source.id elif isinstance(source, Oid): oid = source diff --git a/test/test_merge.py b/test/test_merge.py index 492c00346..1c9174dcc 100644 --- a/test/test_merge.py +++ b/test/test_merge.py @@ -34,22 +34,12 @@ from pygit2.enums import FileStatus, MergeAnalysis, MergeFavor, MergeFileFlag, MergeFlag -@pytest.mark.parametrize('id', [None, 42]) -def test_merge_invalid_type(mergerepo: Repository, id: None | int) -> None: +@pytest.mark.parametrize('id', [None, 42, '5ebeeebb320790caf276b9fc8b24546d63316533']) +def test_merge_invalid_type(mergerepo: Repository, id: None | int | str) -> None: with pytest.raises(TypeError): mergerepo.merge(id) # type:ignore -# TODO: Once Repository.merge drops support for str arguments, -# add an extra parameter to test_merge_invalid_type above -# to make sure we cover legacy code. -def test_merge_string_argument_deprecated(mergerepo: Repository) -> None: - branch_head_hex = '5ebeeebb320790caf276b9fc8b24546d63316533' - - with pytest.warns(DeprecationWarning, match=r'Pass Commit.+instead'): - mergerepo.merge(branch_head_hex) - - def test_merge_analysis_uptodate(mergerepo: Repository) -> None: branch_head_hex = '5ebeeebb320790caf276b9fc8b24546d63316533' branch_id = mergerepo[branch_head_hex].id @@ -91,15 +81,6 @@ def test_merge_no_fastforward_no_conflicts(mergerepo: Repository) -> None: assert {} == mergerepo.status() -def test_merge_invalid_hex(mergerepo: Repository) -> None: - branch_head_hex = '12345678' - with ( - pytest.raises(KeyError), - pytest.warns(DeprecationWarning, match=r'Pass Commit.+instead'), - ): - mergerepo.merge(branch_head_hex) - - def test_merge_already_something_in_index(mergerepo: Repository) -> None: branch_head_hex = '03490f16b15a09913edb3a067a3dc67fbb8d41f1' branch_oid = mergerepo[branch_head_hex].id From 28b4342cd9e8d03acbb73f4d391443a5caa8a644 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Mon, 22 Jun 2026 18:04:17 +0200 Subject: [PATCH 183/225] Repository.merge_file_from_index() returns MergeFileResult by default The previous string-returning behavior is still available with `use_deprecated=True`, but is deprecated. --- CHANGELOG.md | 4 ++ pygit2/repository.py | 16 +++--- test/test_repository.py | 95 +++++++++++++++++++++--------------- test/test_repository_bare.py | 2 +- 4 files changed, 69 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aef738597..bcef1bdb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ Breaking changes: - Remove deprecated support for passing `str` to `Repository.merge(...)`, pass a `Commit`, `Oid`, or `Reference` object instead +- `Repository.merge_file_from_index(...)` now returns `MergeFileResult` + by default. The previous string-returning behavior is still available + with `use_deprecated=True`, but is deprecated. + # 1.19.3 (2026-06-13) diff --git a/pygit2/repository.py b/pygit2/repository.py index 2cfe5a186..b3b0eb364 100644 --- a/pygit2/repository.py +++ b/pygit2/repository.py @@ -813,13 +813,13 @@ def merge_file_from_index( ancestor: 'IndexEntry | None', ours: 'IndexEntry | None', theirs: 'IndexEntry | None', - use_deprecated: bool = True, - ) -> 'str | MergeFileResult | None': + use_deprecated: bool = False, + ) -> 'MergeFileResult | str | None': """Merge files from index. - Returns: A string with the content of the file containing - possible conflicts if use_deprecated==True. - If use_deprecated==False then it returns an instance of MergeFileResult. + Returns: An instance of MergeFileResult by default. + If use_deprecated==True then it returns a string with the content of + the file containing possible conflicts. ancestor The index entry which will be used as a common @@ -829,9 +829,9 @@ def merge_file_from_index( theirs The index entry which will be merged into "ours" use_deprecated - This controls what will be returned. If use_deprecated==True (default), - a string with the contents of the file will be returned. - An instance of MergeFileResult will be returned otherwise. + This controls what will be returned. If use_deprecated==False (default), + an instance of MergeFileResult will be returned. + A string with the contents of the file will be returned otherwise. """ cmergeresult = ffi.new('git_merge_file_result *') diff --git a/test/test_repository.py b/test/test_repository.py index 446aaae70..25a8e4dcf 100644 --- a/test/test_repository.py +++ b/test/test_repository.py @@ -1073,46 +1073,63 @@ def get_hello_txt_from_repo() -> str: assert isinstance(blob, Blob) return blob.data.decode() - # no change - res = testrepo.merge_file_from_index(hello_txt, hello_txt, hello_txt) - assert res == get_hello_txt_from_repo() + with pytest.warns(DeprecationWarning, match='Getting an str'): + # no change + res = testrepo.merge_file_from_index( + hello_txt, hello_txt, hello_txt, use_deprecated=True + ) + assert res == get_hello_txt_from_repo() - # executable switch on ours - res = testrepo.merge_file_from_index(hello_txt, hello_txt_executable, hello_txt) - assert res == get_hello_txt_from_repo() + # executable switch on ours + res = testrepo.merge_file_from_index( + hello_txt, hello_txt_executable, hello_txt, use_deprecated=True + ) + assert res == get_hello_txt_from_repo() - # executable switch on theirs - res = testrepo.merge_file_from_index(hello_txt, hello_txt, hello_txt_executable) - assert res == get_hello_txt_from_repo() + # executable switch on theirs + res = testrepo.merge_file_from_index( + hello_txt, hello_txt, hello_txt_executable, use_deprecated=True + ) + assert res == get_hello_txt_from_repo() - # executable switch on both - res = testrepo.merge_file_from_index( - hello_txt, hello_txt_executable, hello_txt_executable - ) - assert res == get_hello_txt_from_repo() + # executable switch on both + res = testrepo.merge_file_from_index( + hello_txt, hello_txt_executable, hello_txt_executable, use_deprecated=True + ) + assert res == get_hello_txt_from_repo() - # path switch on ours - res = testrepo.merge_file_from_index(hello_txt, hello_world, hello_txt) - assert res == get_hello_txt_from_repo() + # path switch on ours + res = testrepo.merge_file_from_index( + hello_txt, hello_world, hello_txt, use_deprecated=True + ) + assert res == get_hello_txt_from_repo() - # path switch on theirs - res = testrepo.merge_file_from_index(hello_txt, hello_txt, hello_world) - assert res == get_hello_txt_from_repo() + # path switch on theirs + res = testrepo.merge_file_from_index( + hello_txt, hello_txt, hello_world, use_deprecated=True + ) + assert res == get_hello_txt_from_repo() - # path switch on both - res = testrepo.merge_file_from_index(hello_txt, hello_world, hello_world) - assert res == get_hello_txt_from_repo() + # path switch on both + res = testrepo.merge_file_from_index( + hello_txt, hello_world, hello_world, use_deprecated=True + ) + assert res == get_hello_txt_from_repo() - # path switch on ours, executable flag switch on theirs - res = testrepo.merge_file_from_index(hello_txt, hello_world, hello_txt_executable) - assert res == get_hello_txt_from_repo() + # path switch on ours, executable flag switch on theirs + res = testrepo.merge_file_from_index( + hello_txt, hello_world, hello_txt_executable, use_deprecated=True + ) + assert res == get_hello_txt_from_repo() - # path switch on theirs, executable flag switch on ours - res = testrepo.merge_file_from_index(hello_txt, hello_txt_executable, hello_world) - assert res == get_hello_txt_from_repo() + # path switch on theirs, executable flag switch on ours + res = testrepo.merge_file_from_index( + hello_txt, hello_txt_executable, hello_world, use_deprecated=True + ) + assert res == get_hello_txt_from_repo() -def test_merge_file_from_index_non_deprecated(testrepo: Repository) -> None: +def test_merge_file_from_index(testrepo: Repository) -> None: hello_txt = testrepo.index['hello.txt'] hello_txt_executable = IndexEntry( hello_txt.path, hello_txt.id, FileMode.BLOB_EXECUTABLE @@ -1126,7 +1143,7 @@ def get_hello_txt_from_repo() -> str: # no change res = testrepo.merge_file_from_index( - hello_txt, hello_txt, hello_txt, use_deprecated=False + hello_txt, hello_txt, hello_txt ) assert res == MergeFileResult( True, hello_txt.path, hello_txt.mode, get_hello_txt_from_repo() @@ -1134,7 +1151,7 @@ def get_hello_txt_from_repo() -> str: # executable switch on ours res = testrepo.merge_file_from_index( - hello_txt, hello_txt_executable, hello_txt, use_deprecated=False + hello_txt, hello_txt_executable, hello_txt ) assert res == MergeFileResult( True, @@ -1145,7 +1162,7 @@ def get_hello_txt_from_repo() -> str: # executable switch on theirs res = testrepo.merge_file_from_index( - hello_txt, hello_txt, hello_txt_executable, use_deprecated=False + hello_txt, hello_txt, hello_txt_executable ) assert res == MergeFileResult( True, @@ -1156,7 +1173,7 @@ def get_hello_txt_from_repo() -> str: # executable switch on both res = testrepo.merge_file_from_index( - hello_txt, hello_txt_executable, hello_txt_executable, use_deprecated=False + hello_txt, hello_txt_executable, hello_txt_executable ) assert res == MergeFileResult( True, @@ -1167,7 +1184,7 @@ def get_hello_txt_from_repo() -> str: # path switch on ours res = testrepo.merge_file_from_index( - hello_txt, hello_world, hello_txt, use_deprecated=False + hello_txt, hello_world, hello_txt ) assert res == MergeFileResult( True, hello_world.path, hello_txt.mode, get_hello_txt_from_repo() @@ -1175,7 +1192,7 @@ def get_hello_txt_from_repo() -> str: # path switch on theirs res = testrepo.merge_file_from_index( - hello_txt, hello_txt, hello_world, use_deprecated=False + hello_txt, hello_txt, hello_world ) assert res == MergeFileResult( True, hello_world.path, hello_txt.mode, get_hello_txt_from_repo() @@ -1183,13 +1200,13 @@ def get_hello_txt_from_repo() -> str: # path switch on both res = testrepo.merge_file_from_index( - hello_txt, hello_world, hello_world, use_deprecated=False + hello_txt, hello_world, hello_world ) assert res == MergeFileResult(True, None, hello_txt.mode, get_hello_txt_from_repo()) # path switch on ours, executable flag switch on theirs res = testrepo.merge_file_from_index( - hello_txt, hello_world, hello_txt_executable, use_deprecated=False + hello_txt, hello_world, hello_txt_executable ) assert res == MergeFileResult( True, @@ -1200,7 +1217,7 @@ def get_hello_txt_from_repo() -> str: # path switch on theirs, executable flag switch on ours res = testrepo.merge_file_from_index( - hello_txt, hello_txt_executable, hello_world, use_deprecated=False + hello_txt, hello_txt_executable, hello_world ) assert res == MergeFileResult( True, diff --git a/test/test_repository_bare.py b/test/test_repository_bare.py index 4021fc8b3..86cb15711 100644 --- a/test/test_repository_bare.py +++ b/test/test_repository_bare.py @@ -241,7 +241,7 @@ def create_conflict_file(repo: Repository, branch: Branch, content: str) -> Oid: (a, t, o) = index.conflicts['conflict'] diff = barerepo.merge_file_from_index(a, t, o) assert ( - diff + diff.contents == """<<<<<<< conflict ASCII - abc ======= From a71de72f40c3052fbd7bfbfb6975f8faadc271fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Mon, 22 Jun 2026 18:55:40 +0200 Subject: [PATCH 184/225] Remove deprecated Remote.ls_remotes(...) Use Remote.list_heads(...) instead Assisted-by: Kimi-k2.7 --- AGENTS.md | 11 +++++++---- CHANGELOG.md | 3 +++ pygit2/remotes.py | 25 ------------------------- test/test_remote.py | 25 ------------------------- test/test_repository.py | 32 ++++++++------------------------ 5 files changed, 18 insertions(+), 78 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5bd5eec0a..8a692330d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,8 +56,7 @@ modules: `repository.py`, `callbacks.py`, `config.py`, `index.py`, `remotes.py`, `settings.py`, `submodules.py`, `transaction.py`, `filter.py`, `blob.py`, `blame.py`, `branches.py`, `credentials.py`, `errors.py`, `options.py`, - `packbuilder.py`, `references.py`, `refspec.py`, `utils.py`, `enums.py`, - `legacyenums.py`. + `packbuilder.py`, `references.py`, `refspec.py`, `utils.py`, `enums.py`. - **`test/`** — pytest suite with fixture-based repository handling. - **`docs/`** — Sphinx documentation (RTD theme). @@ -167,6 +166,9 @@ make -C docs html # requires sphinx-rtd-theme - Target Python: 3.11+ - Quote style: single quotes - Selected rules: `E4`, `E7`, `E9`, `F`, `I`, `UP035`, `UP007` + - Run `ruff format` and `ruff check` on changed files before committing. + CI runs `ruff format --diff` and `ruff check`; formatting failures will + fail the build. - **Type checker**: mypy (strict settings enabled; see `mypy.ini`) - All Python source files must include the standard GPLv2 copyright header. - `pygit2/__init__.py` is large because it re-exports a large surface of @@ -275,5 +277,6 @@ and skips `*musllinux_ppc64le` plus testing on `*-*linux_ppc64le` and extension. Keep it in sync when adding or changing low-level APIs. - **Header stub order matters**: `pygit2/_run.py` concatenates `decl/*.h` in a fixed list; add new stubs in the correct position if dependencies require it. -- Run the full test suite and type checks before considering a change complete: - `sh build.sh test` and `sh build.sh mypy`. +- Run the full test suite, type checks, and linting/formatting before + considering a change complete: + `sh build.sh test`, `sh build.sh mypy`, `ruff check .`, and `ruff format .`. diff --git a/CHANGELOG.md b/CHANGELOG.md index bcef1bdb3..519d43b0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ Breaking changes: by default. The previous string-returning behavior is still available with `use_deprecated=True`, but is deprecated. +- Remove deprecated `Remote.ls_remotes(...)`, use `Remote.list_heads(...)` + instead + # 1.19.3 (2026-06-13) diff --git a/pygit2/remotes.py b/pygit2/remotes.py index 0603a6f9d..b940e2947 100644 --- a/pygit2/remotes.py +++ b/pygit2/remotes.py @@ -25,7 +25,6 @@ from __future__ import annotations -import warnings from collections.abc import Generator, Iterator from typing import TYPE_CHECKING, Any, Literal @@ -281,30 +280,6 @@ def list_heads( return results - def ls_remotes( - self, - callbacks: RemoteCallbacks | None = None, - proxy: str | None | bool = None, - connect: bool = True, - ) -> list[dict[str, Any]]: - """ - Deprecated interface to list_heads - """ - warnings.warn('Use list_heads', DeprecationWarning) - - heads = self.list_heads(callbacks, proxy, connect) - - return [ - { - 'local': h.local, - 'oid': h.oid, - 'loid': h.loid if h.local else None, - 'name': h.name, - 'symref_target': h.symref_target, - } - for h in heads - ] - def prune(self, callbacks: RemoteCallbacks | None = None) -> None: """Perform a prune against this remote.""" with git_remote_callbacks(callbacks) as payload: diff --git a/test/test_remote.py b/test/test_remote.py index d4c3bea68..fda4fe3ee 100644 --- a/test/test_remote.py +++ b/test/test_remote.py @@ -204,31 +204,6 @@ def test_list_heads(testrepo: Repository) -> None: assert next(iter(r for r in refs if r.name == 'refs/tags/v0.28.2')) -@utils.requires_network -def test_ls_remotes_deprecated(testrepo: Repository) -> None: - assert 1 == len(testrepo.remotes) - remote = testrepo.remotes[0] - - new_refs = remote.list_heads() - - with pytest.warns(DeprecationWarning, match='Use list_heads'): - old_refs = remote.ls_remotes() - - assert new_refs - assert old_refs - - for new, old in zip(new_refs, old_refs, strict=True): - assert new.name == old['name'] - assert new.oid == old['oid'] - assert new.local == old['local'] - assert new.symref_target == old['symref_target'] - if new.local: - assert new.loid == old['loid'] - else: - assert new.loid == pygit2.Oid(b'') - assert old['loid'] is None - - @utils.requires_network def test_list_heads_without_implicit_connect(testrepo: Repository) -> None: assert 1 == len(testrepo.remotes) diff --git a/test/test_repository.py b/test/test_repository.py index 25a8e4dcf..30496e84a 100644 --- a/test/test_repository.py +++ b/test/test_repository.py @@ -1142,17 +1142,13 @@ def get_hello_txt_from_repo() -> str: return blob.data.decode() # no change - res = testrepo.merge_file_from_index( - hello_txt, hello_txt, hello_txt - ) + res = testrepo.merge_file_from_index(hello_txt, hello_txt, hello_txt) assert res == MergeFileResult( True, hello_txt.path, hello_txt.mode, get_hello_txt_from_repo() ) # executable switch on ours - res = testrepo.merge_file_from_index( - hello_txt, hello_txt_executable, hello_txt - ) + res = testrepo.merge_file_from_index(hello_txt, hello_txt_executable, hello_txt) assert res == MergeFileResult( True, hello_txt.path, @@ -1161,9 +1157,7 @@ def get_hello_txt_from_repo() -> str: ) # executable switch on theirs - res = testrepo.merge_file_from_index( - hello_txt, hello_txt, hello_txt_executable - ) + res = testrepo.merge_file_from_index(hello_txt, hello_txt, hello_txt_executable) assert res == MergeFileResult( True, hello_txt.path, @@ -1183,31 +1177,23 @@ def get_hello_txt_from_repo() -> str: ) # path switch on ours - res = testrepo.merge_file_from_index( - hello_txt, hello_world, hello_txt - ) + res = testrepo.merge_file_from_index(hello_txt, hello_world, hello_txt) assert res == MergeFileResult( True, hello_world.path, hello_txt.mode, get_hello_txt_from_repo() ) # path switch on theirs - res = testrepo.merge_file_from_index( - hello_txt, hello_txt, hello_world - ) + res = testrepo.merge_file_from_index(hello_txt, hello_txt, hello_world) assert res == MergeFileResult( True, hello_world.path, hello_txt.mode, get_hello_txt_from_repo() ) # path switch on both - res = testrepo.merge_file_from_index( - hello_txt, hello_world, hello_world - ) + res = testrepo.merge_file_from_index(hello_txt, hello_world, hello_world) assert res == MergeFileResult(True, None, hello_txt.mode, get_hello_txt_from_repo()) # path switch on ours, executable flag switch on theirs - res = testrepo.merge_file_from_index( - hello_txt, hello_world, hello_txt_executable - ) + res = testrepo.merge_file_from_index(hello_txt, hello_world, hello_txt_executable) assert res == MergeFileResult( True, hello_world.path, @@ -1216,9 +1202,7 @@ def get_hello_txt_from_repo() -> str: ) # path switch on theirs, executable flag switch on ours - res = testrepo.merge_file_from_index( - hello_txt, hello_txt_executable, hello_world - ) + res = testrepo.merge_file_from_index(hello_txt, hello_txt_executable, hello_world) assert res == MergeFileResult( True, hello_world.path, From 3e131e9708bbebeabfd0ee2fe5305e15307b83e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Tue, 23 Jun 2026 10:21:42 +0200 Subject: [PATCH 185/225] Fix mypy Assisted-by: Kimi-k2.7 --- pygit2/repository.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/pygit2/repository.py b/pygit2/repository.py index b3b0eb364..e90ac0ad5 100644 --- a/pygit2/repository.py +++ b/pygit2/repository.py @@ -30,7 +30,7 @@ from pathlib import Path from string import hexdigits from time import time -from typing import TYPE_CHECKING, Optional, overload +from typing import TYPE_CHECKING, Literal, Optional, overload # Import from pygit2 from ._pygit2 import ( @@ -808,13 +808,31 @@ def _merge_options( return opts + @overload + def merge_file_from_index( + self, + ancestor: 'IndexEntry | None', + ours: 'IndexEntry | None', + theirs: 'IndexEntry | None', + use_deprecated: Literal[True], + ) -> str: ... + + @overload + def merge_file_from_index( + self, + ancestor: 'IndexEntry | None', + ours: 'IndexEntry | None', + theirs: 'IndexEntry | None', + use_deprecated: Literal[False] = False, + ) -> MergeFileResult: ... + def merge_file_from_index( self, ancestor: 'IndexEntry | None', ours: 'IndexEntry | None', theirs: 'IndexEntry | None', use_deprecated: bool = False, - ) -> 'MergeFileResult | str | None': + ) -> 'MergeFileResult | str': """Merge files from index. Returns: An instance of MergeFileResult by default. @@ -851,6 +869,8 @@ def merge_file_from_index( mergeFileResult = MergeFileResult._from_c(cmergeresult) C.git_merge_file_result_free(cmergeresult) + assert mergeFileResult is not None + if use_deprecated: warnings.warn( 'Getting an str from Repository.merge_file_from_index is deprecated. ' From c2a3c98b7fead3fa5bab5a3e4baccc91b0893486 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Thu, 25 Jun 2026 16:42:06 +0200 Subject: [PATCH 186/225] tests: write test artifacts to tmp_path instead of project root --- test/test_archive.py | 28 ++++++++++++------------ test/test_config.py | 47 ++++++++++++++++++++--------------------- test/test_nonunicode.py | 10 ++++----- 3 files changed, 43 insertions(+), 42 deletions(-) diff --git a/test/test_archive.py b/test/test_archive.py index bd8ef8644..092fdd63f 100644 --- a/test/test_archive.py +++ b/test/test_archive.py @@ -33,9 +33,13 @@ def check_writing( - repo: Repository, treeish: str | Tree | Oid | Object, timestamp: int | None = None + repo: Repository, + treeish: str | Tree | Oid | Object, + tmp_path: Path, + timestamp: int | None = None, ) -> None: - archive = tarfile.open('foo.tar', mode='w') + archive_path = tmp_path / 'foo.tar' + archive = tarfile.open(archive_path, mode='w') repo.write_archive(treeish, archive) index = Index() @@ -51,19 +55,17 @@ def check_writing( assert timestamp == fileinfo.mtime archive.close() - path = Path('foo.tar') - assert path.is_file() - path.unlink() + assert archive_path.is_file() -def test_write_tree(testrepo: Repository) -> None: - check_writing(testrepo, TREE_HASH) - check_writing(testrepo, Oid(hex=TREE_HASH)) - check_writing(testrepo, testrepo[TREE_HASH]) +def test_write_tree(testrepo: Repository, tmp_path: Path) -> None: + check_writing(testrepo, TREE_HASH, tmp_path) + check_writing(testrepo, Oid(hex=TREE_HASH), tmp_path) + check_writing(testrepo, testrepo[TREE_HASH], tmp_path) -def test_write_commit(testrepo: Repository) -> None: +def test_write_commit(testrepo: Repository, tmp_path: Path) -> None: commit_timestamp = testrepo[COMMIT_HASH].committer.time - check_writing(testrepo, COMMIT_HASH, commit_timestamp) - check_writing(testrepo, Oid(hex=COMMIT_HASH), commit_timestamp) - check_writing(testrepo, testrepo[COMMIT_HASH], commit_timestamp) + check_writing(testrepo, COMMIT_HASH, tmp_path, commit_timestamp) + check_writing(testrepo, Oid(hex=COMMIT_HASH), tmp_path, commit_timestamp) + check_writing(testrepo, testrepo[COMMIT_HASH], tmp_path, commit_timestamp) diff --git a/test/test_config.py b/test/test_config.py index 2d1a2a117..aacfcf6e4 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -32,16 +32,15 @@ from . import utils -CONFIG_FILENAME = 'test_config' + +@pytest.fixture +def config_path(tmp_path: Path) -> Path: + return tmp_path / 'test_config' @pytest.fixture def config(testrepo: Repository) -> Generator[object, None, None]: yield testrepo.config - try: - Path(CONFIG_FILENAME).unlink() - except OSError: - pass def test_config(config: Config) -> None: @@ -64,42 +63,42 @@ def test_system_config() -> None: pass -def test_new() -> None: +def test_new(config_path: Path) -> None: # Touch file - open(CONFIG_FILENAME, 'w').close() + config_path.touch() - config_write = Config(CONFIG_FILENAME) + config_write = Config(str(config_path)) assert config_write is not None config_write['core.bare'] = False config_write['core.editor'] = 'ed' - config_read = Config(CONFIG_FILENAME) + config_read = Config(str(config_path)) assert 'core.bare' in config_read assert not config_read.get_bool('core.bare') assert 'core.editor' in config_read assert config_read['core.editor'] == 'ed' -def test_add() -> None: - with open(CONFIG_FILENAME, 'w') as new_file: +def test_add(config_path: Path) -> None: + with open(config_path, 'w') as new_file: new_file.write('[this]\n\tthat = true\n') new_file.write('[something "other"]\n\there = false') config = Config() - config.add_file(CONFIG_FILENAME, 0) + config.add_file(config_path, 0) assert 'this.that' in config assert config.get_bool('this.that') assert 'something.other.here' in config assert not config.get_bool('something.other.here') -def test_add_aspath() -> None: - with open(CONFIG_FILENAME, 'w') as new_file: +def test_add_aspath(config_path: Path) -> None: + with open(config_path, 'w') as new_file: new_file.write('[this]\n\tthat = true\n') config = Config() - config.add_file(Path(CONFIG_FILENAME), 0) + config.add_file(config_path, 0) assert 'this.that' in config @@ -148,12 +147,12 @@ def test_write(config: Config) -> None: assert 'core.dummy3' not in config -def test_multivar() -> None: - with open(CONFIG_FILENAME, 'w') as new_file: +def test_multivar(config_path: Path) -> None: + with open(config_path, 'w') as new_file: new_file.write('[this]\n\tthat = foobar\n\tthat = foobeer\n') config = Config() - config.add_file(CONFIG_FILENAME, 6) + config.add_file(config_path, 6) assert 'this.that' in config assert ['foobar', 'foobeer'] == list(config.get_multivar('this.that')) @@ -185,27 +184,27 @@ def test_iterator(config: Config) -> None: assert lst['core.bare'] -def test_valueless_key_iteration() -> None: +def test_valueless_key_iteration(config_path: Path) -> None: # A valueless key (no `= value`) has a NULL value pointer in libgit2. # Iterating over such entries must not raise a RuntimeError. - with open(CONFIG_FILENAME, 'w') as new_file: + with open(config_path, 'w') as new_file: new_file.write('[section]\n\tvaluelesskey\n\tnormalkey = somevalue\n') config = Config() - config.add_file(CONFIG_FILENAME, 6) + config.add_file(config_path, 6) entries = {entry.name: entry for entry in config} assert 'section.valuelesskey' in entries assert 'section.normalkey' in entries -def test_valueless_key_value() -> None: +def test_valueless_key_value(config_path: Path) -> None: # A valueless key must expose value=None and raw_value=None. - with open(CONFIG_FILENAME, 'w') as new_file: + with open(config_path, 'w') as new_file: new_file.write('[section]\n\tvaluelesskey\n\tnormalkey = somevalue\n') config = Config() - config.add_file(CONFIG_FILENAME, 6) + config.add_file(config_path, 6) entries = {entry.name: entry for entry in config} assert entries['section.valuelesskey'].raw_value is None diff --git a/test/test_nonunicode.py b/test/test_nonunicode.py index cdf1ce167..04d3d8a88 100644 --- a/test/test_nonunicode.py +++ b/test/test_nonunicode.py @@ -25,9 +25,9 @@ """Tests for non unicode byte strings""" -import os import shutil import sys +from pathlib import Path import pytest @@ -45,12 +45,12 @@ @utils.requires_network @works_in_linux -def test_nonunicode_branchname(testrepo: Repository) -> None: - folderpath = 'temp_repo_nonutf' - if os.path.exists(folderpath): +def test_nonunicode_branchname(testrepo: Repository, tmp_path: Path) -> None: + folderpath = tmp_path / 'temp_repo_nonutf' + if folderpath.exists(): shutil.rmtree(folderpath) newrepo = pygit2.clone_repository( - path=folderpath, url='https://github.com/pygit2/test_branch_notutf.git' + path=str(folderpath), url='https://github.com/pygit2/test_branch_notutf.git' ) bstring = b'\xc3master' assert bstring in [ From 666278b6a40cf320f8d389a5db365c005df42cae Mon Sep 17 00:00:00 2001 From: Nick Williams Date: Thu, 25 Jun 2026 13:26:14 -0500 Subject: [PATCH 187/225] Closes #1464: Add support for libgit2's custom_headers (#1465) * Add support for custom headers feature from libgit2 * Add support for custom headers feature from libgit2 * Code review feedback * Revert change to .gitignore after rebase --- pygit2/_libgit2/ffi.pyi | 39 ++++++++++- pygit2/callbacks.py | 92 ++++++++++++++++++------- pygit2/remotes.py | 18 ++--- test/test_remote.py | 146 ++++++++++++++++++++++++++++++++++++++-- 4 files changed, 258 insertions(+), 37 deletions(-) diff --git a/pygit2/_libgit2/ffi.pyi b/pygit2/_libgit2/ffi.pyi index a0710caec..690abf2b5 100644 --- a/pygit2/_libgit2/ffi.pyi +++ b/pygit2/_libgit2/ffi.pyi @@ -87,10 +87,36 @@ class GitRepositoryC: # def _from_c(cls, ptr: 'GitRepositoryC', owned: bool) -> 'Repository': ... pass +class GitRemoteCallbacksC: + # TODO: Several Anys need filling in + version: int + sideband_progress: Any + completion: Any + credentials: Any + certificate_check: Any + transfer_progress: Any + update_tips: Any + pack_progress: Any + push_transfer_progress: Any + push_update_reference: Any + push_negotiation: Any + transport: Any + remote_ready: Any + payload: Any + resolve_url: Any + update_refs: Any + class GitFetchOptionsC: # TODO: FetchOptions exist in _pygit2.pyi # incomplete depth: int + callbacks: GitRemoteCallbacksC + custom_headers: GitStrrayC + +class GitPushOptionsC: + # TODO incomplete + callbacks: GitRemoteCallbacksC + custom_headers: GitStrrayC class GitSubmoduleC: pass @@ -225,7 +251,11 @@ class GitRepositoryInitOptionsC: origin_url: ArrayC[char] class GitCloneOptionsC: - pass + # TODO: Several Anys need filling in + repository_cb: Any + repository_cb_payload: Any + remote_cb: Any + remote_cb_payload: Any class GitPackbuilderC: pass @@ -256,6 +286,8 @@ def new(a: Literal['git_repository **']) -> _Pointer[GitRepositoryC]: ... @overload def new(a: Literal['git_remote **']) -> _Pointer[GitRemoteC]: ... @overload +def new(a: Literal['git_remote_callbacks *']) -> GitRemoteCallbacksC: ... +@overload def new(a: Literal['git_transaction **']) -> _Pointer[GitTransactionC]: ... @overload def new(a: Literal['git_repository_init_options *']) -> GitRepositoryInitOptionsC: ... @@ -276,8 +308,12 @@ def new(a: Literal['git_blob **']) -> _Pointer[GitBlobC]: ... @overload def new(a: Literal['git_clone_options *']) -> GitCloneOptionsC: ... @overload +def new(a: Literal['git_fetch_options *']) -> GitFetchOptionsC: ... +@overload def new(a: Literal['git_merge_options *']) -> GitMergeOptionsC: ... @overload +def new(a: Literal['git_push_options *']) -> GitPushOptionsC: ... +@overload def new(a: Literal['git_blame_options *']) -> GitBlameOptionsC: ... @overload def new(a: Literal['git_annotated_commit **']) -> _Pointer[GitAnnotatedCommitC]: ... @@ -364,6 +400,7 @@ def new( a: Literal['char *[]'], b: list[Any] ) -> ArrayC[char_pointer]: ... # For string arrays def addressof(a: object, attribute: str) -> _Pointer[object]: ... +def new_handle(a: T) -> _Pointer[T]: ... class buffer(bytes): def __init__(self, a: object) -> None: ... diff --git a/pygit2/callbacks.py b/pygit2/callbacks.py index 20bfad401..1072f8cfe 100644 --- a/pygit2/callbacks.py +++ b/pygit2/callbacks.py @@ -79,9 +79,15 @@ _Credentials = Username | UserPass | Keypair if TYPE_CHECKING: - from pygit2._libgit2.ffi import GitProxyOptionsC + from pygit2._libgit2.ffi import ( + GitCloneOptionsC, + GitFetchOptionsC, + GitProxyOptionsC, + GitPushOptionsC, + GitStrrayC, + ) - from .remotes import PushUpdate, TransferProgress + from .remotes import PushUpdate, Remote, TransferProgress # # The payload is the way to pass information from the pygit2 API, through # libgit2, to the Python callbacks. And back. @@ -92,6 +98,9 @@ class Payload: repository: Callable | None remote: Callable | None clone_options: Any + fetch_options: Any + push_options: Any + remote_callbacks: Any def __init__(self, **kw: object) -> None: for key, value in kw.items(): @@ -120,12 +129,10 @@ class RemoteCallbacks(Payload): method, or if it's a constant value, pass the value to the constructor, e.g. RemoteCallbacks(credentials=credentials). - You can as well pass the certificate the same way, for example: - RemoteCallbacks(certificate=certificate). + You can as well pass the certificate check callback the same way, for example: + RemoteCallbacks(certificate_check=certificate_check). """ - push_options: Any - def __init__( self, credentials: _Credentials | None = None, @@ -262,6 +269,19 @@ def push_update_reference(self, refname: str, message: str) -> None: Rejection message from the remote. If None, the update was accepted. """ + def custom_headers(self) -> list[str] | None: + """ + Custom headers callback. Override with your own function to return a + list of custom headers that should be used when connecting to, pushing + to, or fetching from the remote. + + Example use case to authenticate with bearer tokens instead of username/password: + + return [f"Authorization: Bearer {token}"] + + Returns: list of header strings or None + """ + class CheckoutCallbacks(Payload): """Base class for pygit2 checkout callbacks. @@ -352,7 +372,22 @@ def stash_apply_progress(self, progress: StashApplyProgress) -> None: @contextmanager -def git_clone_options(payload, opts=None): +def git_custom_headers( + payload: RemoteCallbacks, + opts_custom_headers: Optional['GitStrrayC'] = None, +) -> Generator[StrArray, Any, None]: + custom_headers = payload.custom_headers() or None + with StrArray(custom_headers) as headers_array: + if opts_custom_headers is not None: + headers_array.assign_to(opts_custom_headers) + yield headers_array + + +@contextmanager +def git_clone_options( + payload: RemoteCallbacks, + opts: Optional['GitCloneOptionsC'] = None, +) -> Generator[RemoteCallbacks, Any, None]: if opts is None: opts = ffi.new('git_clone_options *') C.git_clone_options_init(opts, C.GIT_CLONE_OPTIONS_VERSION) @@ -374,7 +409,10 @@ def git_clone_options(payload, opts=None): @contextmanager -def git_fetch_options(payload, opts=None): +def git_fetch_options( + payload: RemoteCallbacks | None, + opts: Optional['GitFetchOptionsC'] = None, +) -> Generator[RemoteCallbacks, Any, None]: if payload is None: payload = RemoteCallbacks() @@ -392,15 +430,16 @@ def git_fetch_options(payload, opts=None): handle = ffi.new_handle(payload) opts.callbacks.payload = handle - # Give back control - payload.fetch_options = opts - payload._stored_exception = None - yield payload + with git_custom_headers(payload, opts.custom_headers): + # Give back control + payload.fetch_options = opts + payload._stored_exception = None + yield payload @contextmanager def git_proxy_options( - payload: object, + payload: 'Remote | RemoteCallbacks', opts: Optional['GitProxyOptionsC'] = None, proxy: None | bool | str = None, ) -> Generator['GitProxyOptionsC', None, None]: @@ -414,20 +453,24 @@ def git_proxy_options( elif type(proxy) is str: opts.type = C.GIT_PROXY_SPECIFIED # Keep url in memory, otherwise memory is freed and bad things happen - payload.__proxy_url = ffi.new('char[]', to_bytes(proxy)) # type: ignore[attr-defined] - opts.url = payload.__proxy_url # type: ignore[attr-defined] + payload.__proxy_url = ffi.new('char[]', to_bytes(proxy)) # type: ignore[union-attr] + opts.url = payload.__proxy_url # type: ignore[union-attr] else: raise TypeError('Proxy must be None, True, or a string') yield opts @contextmanager -def git_push_options(payload, opts=None): +def git_push_options( + payload: RemoteCallbacks | None, + opts: Optional['GitPushOptionsC'] = None, +) -> Generator[RemoteCallbacks, Any, None]: if payload is None: payload = RemoteCallbacks() - opts = ffi.new('git_push_options *') - C.git_push_options_init(opts, C.GIT_PUSH_OPTIONS_VERSION) + if opts is None: + opts = ffi.new('git_push_options *') + C.git_push_options_init(opts, C.GIT_PUSH_OPTIONS_VERSION) # Plug callbacks opts.callbacks.sideband_progress = C._sideband_progress_cb @@ -448,14 +491,17 @@ def git_push_options(payload, opts=None): handle = ffi.new_handle(payload) opts.callbacks.payload = handle - # Give back control - payload.push_options = opts - payload._stored_exception = None - yield payload + with git_custom_headers(payload, opts.custom_headers): + # Give back control + payload.push_options = opts + payload._stored_exception = None + yield payload @contextmanager -def git_remote_callbacks(payload): +def git_remote_callbacks( + payload: RemoteCallbacks | None, +) -> Generator[RemoteCallbacks, Any, None]: if payload is None: payload = RemoteCallbacks() diff --git a/pygit2/remotes.py b/pygit2/remotes.py index b940e2947..265b1783c 100644 --- a/pygit2/remotes.py +++ b/pygit2/remotes.py @@ -34,6 +34,7 @@ from . import utils from ._pygit2 import Oid from .callbacks import ( + git_custom_headers, git_fetch_options, git_proxy_options, git_push_options, @@ -189,14 +190,15 @@ def connect( """ with git_proxy_options(self, proxy=proxy) as proxy_opts: with git_remote_callbacks(callbacks) as payload: - err = C.git_remote_connect( - self._remote, - direction, - payload.remote_callbacks, - proxy_opts, - ffi.NULL, - ) - payload.check_error(err) + with git_custom_headers(payload) as custom_headers: + err = C.git_remote_connect( + self._remote, + direction, + payload.remote_callbacks, + proxy_opts, + custom_headers.ptr, + ) + payload.check_error(err) def fetch( self, diff --git a/test/test_remote.py b/test/test_remote.py index fda4fe3ee..5f4e59e74 100644 --- a/test/test_remote.py +++ b/test/test_remote.py @@ -30,7 +30,8 @@ import pytest import pygit2 -from pygit2 import Remote, Repository +from pygit2 import Remote, RemoteCallbacks, Repository +from pygit2.ffi import ffi from pygit2.remotes import PushUpdate, TransferProgress from . import utils @@ -485,8 +486,6 @@ def test_push_non_fast_forward_commits_to_remote_fails( def test_push_options(origin: Repository, clone: Repository, remote: Remote) -> None: - from pygit2 import RemoteCallbacks - callbacks = RemoteCallbacks() remote.push(['refs/heads/master'], callbacks) remote_push_options = callbacks.push_options.remote_push_options @@ -515,8 +514,6 @@ def test_push_options(origin: Repository, clone: Repository, remote: Remote) -> def test_push_threads(origin: Repository, clone: Repository, remote: Remote) -> None: - from pygit2 import RemoteCallbacks - callbacks = RemoteCallbacks() remote.push(['refs/heads/master'], callbacks) assert callbacks.push_options.pb_parallelism == 1 @@ -562,3 +559,142 @@ def push_negotiation(self, updates: list[PushUpdate]) -> None: assert the_updates[0].dst == new_tip_id assert origin.branches['master'].target == new_tip_id + + +class HeaderCallbacks(RemoteCallbacks): + def custom_headers(self) -> list[str] | None: + return ['X-Other-One: foo', 'X-Other-Two: bar'] + + +def test_git_custom_headers_context_manager( + origin: Repository, + clone: Repository, + remote: Remote, +) -> None: + from pygit2.callbacks import git_custom_headers, git_fetch_options, git_push_options + + class EmptyHeaderCallbacks(RemoteCallbacks): + def custom_headers(self) -> list[str] | None: + return [] + + callbacks = RemoteCallbacks() + with git_custom_headers(callbacks) as headers: + assert headers.ptr == ffi.NULL + + callbacks = EmptyHeaderCallbacks() + with git_custom_headers(callbacks) as headers: + assert headers.ptr == ffi.NULL + + callbacks = HeaderCallbacks() + with git_custom_headers(callbacks) as headers: + ptr = headers.ptr + assert ptr != ffi.NULL + assert ptr.count == 2 # type: ignore[union-attr] + assert ffi.string(ptr.strings[0]) == b'X-Other-One: foo' # type: ignore[union-attr,index] + assert ffi.string(ptr.strings[1]) == b'X-Other-Two: bar' # type: ignore[union-attr,index] + + callbacks = RemoteCallbacks() + with git_fetch_options(callbacks) as payload: + assert payload.fetch_options.custom_headers.count == 0 + assert payload.fetch_options.custom_headers.strings == ffi.NULL + + callbacks = EmptyHeaderCallbacks() + with git_fetch_options(callbacks) as payload: + assert payload.fetch_options.custom_headers.count == 0 + assert payload.fetch_options.custom_headers.strings == ffi.NULL + + callbacks = HeaderCallbacks() + with git_fetch_options(callbacks) as payload: + assert payload.fetch_options.custom_headers.count == 2 + assert ( + ffi.string(payload.fetch_options.custom_headers.strings[0]) + == b'X-Other-One: foo' + ) + assert ( + ffi.string(payload.fetch_options.custom_headers.strings[1]) + == b'X-Other-Two: bar' + ) + + callbacks = RemoteCallbacks() + with git_push_options(callbacks) as payload: + assert payload.push_options.custom_headers.count == 0 + assert payload.push_options.custom_headers.strings == ffi.NULL + + callbacks = EmptyHeaderCallbacks() + with git_push_options(callbacks) as payload: + assert payload.push_options.custom_headers.count == 0 + assert payload.push_options.custom_headers.strings == ffi.NULL + + callbacks = HeaderCallbacks() + with git_push_options(callbacks) as payload: + assert payload.push_options.custom_headers.count == 2 + assert ( + ffi.string(payload.push_options.custom_headers.strings[0]) + == b'X-Other-One: foo' + ) + assert ( + ffi.string(payload.push_options.custom_headers.strings[1]) + == b'X-Other-Two: bar' + ) + + +def test_push_headers(origin: Repository, clone: Repository, remote: Remote) -> None: + callbacks = RemoteCallbacks() + remote.push(['refs/heads/master'], callbacks=callbacks) + assert callbacks.push_options.custom_headers.count == 0 + assert callbacks.push_options.custom_headers.strings == ffi.NULL + + callbacks = HeaderCallbacks() + remote.push(['refs/heads/master'], callbacks=callbacks) + assert callbacks.push_options.custom_headers.count == 2 + assert callbacks.push_options.custom_headers.strings != ffi.NULL + # strings pointed to by callbacks.push_options.custom_headers.strings[] are already freed + + # make sure the custom headers don't "stick around" + callbacks = RemoteCallbacks() + remote.push(['refs/heads/master'], callbacks=callbacks) + assert callbacks.push_options.custom_headers.count == 0 + assert callbacks.push_options.custom_headers.strings == ffi.NULL + + +def test_fetch_headers(origin: Repository, clone: Repository, remote: Remote) -> None: + callbacks = RemoteCallbacks() + remote.fetch(['refs/heads/master'], callbacks=callbacks) + assert callbacks.fetch_options.custom_headers.count == 0 + assert callbacks.fetch_options.custom_headers.strings == ffi.NULL + + callbacks = HeaderCallbacks() + remote.fetch(['refs/heads/master'], callbacks=callbacks) + assert callbacks.fetch_options.custom_headers.count == 2 + assert callbacks.fetch_options.custom_headers.strings != ffi.NULL + # strings pointed to by callbacks.fetch_options.custom_headers.strings[] are already freed + + # make sure the custom headers don't "stick around" + callbacks = RemoteCallbacks() + remote.fetch(['refs/heads/master'], callbacks=callbacks) + assert callbacks.fetch_options.custom_headers.count == 0 + assert callbacks.fetch_options.custom_headers.strings == ffi.NULL + + +@utils.requires_network +def test_connect_headers(testrepo: Repository) -> None: + # This is just a check that having custom headers doesn't cause errors. As far as I can tell, + # there's no way to assert that C.git_remote_connect was called with the headers except for + # having a remote server that expects the headers and fails without them. + + assert 1 == len(testrepo.remotes) + remote = testrepo.remotes[0] + + callbacks = RemoteCallbacks() + remote.connect(callbacks=callbacks) + refs = remote.list_heads(connect=False) + assert refs + # Check that a known ref is returned. + assert next(iter(r for r in refs if r.name == 'refs/tags/v0.28.2')) + + callbacks = HeaderCallbacks() + remote.connect(callbacks=callbacks) + refs = remote.list_heads(connect=False) + assert refs + # Check that a known ref is returned. + assert next(iter(r for r in refs if r.name == 'refs/tags/v0.28.2')) From 471d63ed235e20c6cded64a491f18447ae5fcb57 Mon Sep 17 00:00:00 2001 From: Nick Williams Date: Thu, 25 Jun 2026 19:20:58 -0500 Subject: [PATCH 188/225] Fix error when snapshotting non-repo config, and improve config docs - Most of this change is documentation improvement for the `Config` class and some of its methods. - Calling `Config.snapshot()` previously raised an attribute error if the `Config` did not come from a repository (i.e. the system or global config or a config loaded from a file). This is fixed by ensuring that `self._repo` is always set to something (`None` by default). - Passing a `PathLike` into `Config.__init__()` resulted in a value error unneccesarily. This is supported now. - Added tests that confirm this behavior. --- pygit2/config.py | 76 ++++++++++++++++++++++++++++++++++---------- pygit2/repository.py | 2 +- test/test_config.py | 74 +++++++++++++++++++++++++++++++++++++----- 3 files changed, 127 insertions(+), 25 deletions(-) diff --git a/pygit2/config.py b/pygit2/config.py index 5ffb6896d..e7522ffee 100644 --- a/pygit2/config.py +++ b/pygit2/config.py @@ -25,8 +25,7 @@ from collections.abc import Callable, Iterator from os import PathLike -from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional try: from functools import cached_property @@ -43,7 +42,7 @@ from .repository import BaseRepository -def str_to_bytes(value: str | PathLike[str] | bytes, name: str) -> bytes: +def str_to_bytes(value: str | bytes, name: str) -> bytes: if not isinstance(value, str): raise TypeError(f'{name} must be a string') @@ -79,25 +78,53 @@ def __next__(self) -> str | None: # type: ignore[override] class Config: - """Git configuration management.""" - - _repo: 'BaseRepository' + """Git configuration management. + + This class is for the reading and writing of Git configuration files. + Configuration files are read individually, either by passing a path into + the constructor or by using one of the static methods + :meth:`Config.get_system_config`, :meth:`Config.get_global_config`, or + :meth:`Config.get_xdg_config`. Additional files can be loaded into the + `Config` object using :meth:`Config.add_file`. + + Changes made to the configuration with :meth:`Config.set_multivar` are + immediately persisted to disk. Reads performed with accessor methods like + :meth:`Config.get_multivar` or :meth:`Config.__getitem__` may result in + reading from different versions of the configuration file if this or + another process has modified the file. To avoid this and have all read + operations occur against the same version of the configuration file, use + :meth:`Config.snapshot()` to create a snapshot of the current config. This + is especially important when iterating, as the contents of the config + might change mid-iteration if you don't use a snapshot. + + This class can technically be used to manually read and write a repository's + configuration file by pointing the constructor to the repository's + ``.git/config`` file, but this is not recommended. The resulting ``Config`` + object represents only the configuration directly within ``.git/config``. + It does not represent the total effective configuration for that repository + that includes the combined system, global (user), and global (user) XDG. + Instead, use :meth:`BaseRepository.config` for loading a repository's + configuration. + """ + + _repo: Optional['BaseRepository'] _config: 'GitConfigC' - def __init__(self, path: str | None = None) -> None: + def __init__(self, path: PathLike | str | None = None) -> None: cconfig = ffi.new('git_config **') if not path: err = C.git_config_new(cconfig) else: - path_bytes = str_to_bytes(path, 'path') + path_bytes = to_bytes(path) err = C.git_config_open_ondisk(cconfig, path_bytes) check_error(err, io=True) + self._repo = None self._config = cconfig[0] @classmethod - def from_c(cls, repo: 'BaseRepository', ptr: 'GitConfigC') -> 'Config': + def from_c(cls, repo: Optional['BaseRepository'], ptr: 'GitConfigC') -> 'Config': config = cls.__new__(cls) config._repo = repo config._config = ptr @@ -200,7 +227,8 @@ def set_multivar( self, name: str | bytes, regex: str | bytes, value: str | bytes ) -> None: """Set a multivar ''name'' to ''value''. ''regexp'' is a regular - expression to indicate which values to replace. + expression to indicate which values to replace. Changes are persisted + to the configuration file(s) backing this ``Config``. """ name = str_to_bytes(name, 'name') regex = str_to_bytes(regex, 'regex') @@ -211,7 +239,8 @@ def set_multivar( def delete_multivar(self, name: str | bytes, regex: str | bytes) -> None: """Delete a multivar ''name''. ''regexp'' is a regular expression to - indicate which values to delete. + indicate which values to delete. Changes are persisted to the + configuration file(s) backing this ``Config``. """ name = str_to_bytes(name, 'name') regex = str_to_bytes(regex, 'regex') @@ -249,7 +278,7 @@ def get_int(self, key: bytes | str) -> int: return res[0] - def add_file(self, path: str | Path, level: int = 0, force: int = 0) -> None: + def add_file(self, path: str | PathLike, level: int = 0, force: int = 0) -> None: """Add a config file instance to an existing config.""" err = C.git_config_add_file_ondisk( @@ -258,7 +287,7 @@ def add_file(self, path: str | Path, level: int = 0, force: int = 0) -> None: check_error(err) def snapshot(self) -> 'Config': - """Create a snapshot from this Config object. + """Create a snapshot from this ``Config`` object. This means that looking up multiple values will use the same version of the configuration files. @@ -305,17 +334,32 @@ def _from_found_config(fn: Callable) -> 'Config': @staticmethod def get_system_config() -> 'Config': - """Return a object representing the system configuration file.""" + """Return a ``Config`` object representing the system configuration file. + + The system configuration file is the one found at ``/etc/gitconfig`` or + ``%PROGRAMFILES%\\Git\\etc\\gitconfig``, depending on the operating system. + """ return Config._from_found_config(C.git_config_find_system) @staticmethod def get_global_config() -> 'Config': - """Return a object representing the global configuration file.""" + """Return a ``Config`` object representing the global configuration file. + + The global configuration file is the one found at the standard user config + location for Git, which is ``$HOME/.gitconfig``. This will not find the file + at the XDG-compatible user config file location (for that, see + :meth:`Config.get_xdg_config`). + """ return Config._from_found_config(C.git_config_find_global) @staticmethod def get_xdg_config() -> 'Config': - """Return a object representing the global configuration file.""" + """Return a ``Config`` object representing the XDG-compatible global configuration file. + + The XDG-compatible user config file follows the XDG Base Directory Specification. + This file is located at ``$HOME/.config/git/config``. This will not find the file + at the standard user config location (for that, see :meth:`Config.get_global_config`). + """ return Config._from_found_config(C.git_config_find_xdg) diff --git a/pygit2/repository.py b/pygit2/repository.py index e90ac0ad5..f3c34e81a 100644 --- a/pygit2/repository.py +++ b/pygit2/repository.py @@ -291,7 +291,7 @@ def __repr__(self) -> str: def config(self) -> Config: """The configuration file for this repository. - If a the configuration hasn't been set yet, the default config for + If the configuration hasn't been set yet, the default config for repository will be returned, including global and system configurations (if they are available). """ diff --git a/test/test_config.py b/test/test_config.py index aacfcf6e4..88c7d0c44 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -28,7 +28,7 @@ import pytest -from pygit2 import Config, Repository +from pygit2 import Config, Repository, Settings from . import utils @@ -39,7 +39,7 @@ def config_path(tmp_path: Path) -> Path: @pytest.fixture -def config(testrepo: Repository) -> Generator[object, None, None]: +def config(testrepo: Repository) -> Generator[Config, None, None]: yield testrepo.config @@ -50,17 +50,16 @@ def test_config(config: Config) -> None: def test_global_config() -> None: try: assert Config.get_global_config() is not None - except IOError: - # There is no user config - pass + except IOError as e: + settings = Settings() + pytest.skip(f'Unavailable for testing with home dir = {settings.homedir}: {e}') def test_system_config() -> None: try: assert Config.get_system_config() is not None - except IOError: - # There is no system config - pass + except IOError as e: + pytest.skip(f'Unavailable for testing: {e}') def test_new(config_path: Path) -> None: @@ -219,3 +218,62 @@ def test_parsing() -> None: assert 5 == Config.parse_int('5') assert 1024 == Config.parse_int('1k') + + +def test_repository_config_snapshot(config: Config) -> None: + assert 'core.bare' in config + assert not config.get_bool('core.bare') + assert 'core.editor' in config + assert config['core.editor'] == 'ed' + assert 'core.repositoryformatversion' in config + assert config.get_int('core.repositoryformatversion') == 0 + + snapshot = config.snapshot() + assert 'core.bare' in snapshot + assert not snapshot.get_bool('core.bare') + assert 'core.editor' in snapshot + assert snapshot['core.editor'] == 'ed' + assert 'core.repositoryformatversion' in snapshot + assert snapshot.get_int('core.repositoryformatversion') == 0 + + assert 'core.snapshot1' not in config + assert 'core.snapshot1' not in snapshot + config['core.snapshot1'] = 42 + assert 'core.snapshot1' in config + assert 'core.snapshot1' not in snapshot + assert config.get_int('core.snapshot1') == 42 + utils.assertRaisesWithArg( + KeyError, + 'core.snapshot1', + lambda: snapshot.get_int('core.snapshot1'), + ) + + +def test_non_repository_config_snapshot(config_path: Path) -> None: + with config_path.open('w') as new_file: + new_file.write('[this]\n\tthat = true\n') + new_file.write('[something "other"]\n\there = false') + + config = Config(config_path) + assert 'this.that' in config + assert config.get_bool('this.that') + assert 'something.other.here' in config + assert not config.get_bool('something.other.here') + + snapshot = config.snapshot() + assert 'this.that' in snapshot + assert snapshot.get_bool('this.that') + assert 'something.other.here' in snapshot + assert not snapshot.get_bool('something.other.here') + + assert 'this.snapshot1' not in config + assert 'this.snapshot1' not in snapshot + config['this.snapshot1'] = 42 + assert 'this.snapshot1' in config + assert 'this.snapshot1' not in snapshot + assert config.get_int('this.snapshot1') == 42 + utils.assertRaisesWithArg( + KeyError, + 'this.snapshot1', + lambda: snapshot.get_int('this.snapshot1'), + ) From 463d7f47f6bae5c4ad145d9ec65f760bf9d152a5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:58:38 +0200 Subject: [PATCH 189/225] chore(CI): bump actions/checkout from 6 to 7 in the actions group (#1470) Bumps the actions group with 1 update: [actions/checkout](https://github.com/actions/checkout). Updates `actions/checkout` from 6 to 7 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codespell.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/tests.yml | 2 +- .github/workflows/wheels.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml index 58f0813a3..8a46486fc 100644 --- a/.github/workflows/codespell.yml +++ b/.github/workflows/codespell.yml @@ -18,7 +18,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Annotate locations with typos uses: codespell-project/codespell-problem-matcher@v1 - name: Codespell diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index eb4e2acf9..6a53f4c24 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -14,7 +14,7 @@ jobs: steps: - name: Checkout pygit2 - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up Python uses: actions/setup-python@v6 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 08bbe45b5..c61a68aea 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,7 +14,7 @@ jobs: if: github.ref == 'refs/heads/master' steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Build & test uses: uraimo/run-on-arch-action@v3 diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 1c969c826..0d5610bde 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -20,7 +20,7 @@ jobs: outputs: release_title: ${{ steps.parse_changelog.outputs.release_title }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: # avoid leaking credentials in uploaded artifacts persist-credentials: false From 1c0351091fc7642814df71a17093e29d90ffe5b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Sat, 18 Jul 2026 09:41:57 +0200 Subject: [PATCH 190/225] Update changelog --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 519d43b0a..d30dd2703 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # 1.20.0 (UNRELEASED) +- New `RemoteCallbacks.custom_headers()` + [#1465](https://github.com/libgit2/pygit2/pull/1465) + +- Fix `Config.snapshot()` for non-repository configs, allow `PathLike` in + `Config.__init__()`, and improve `Config` documentation + [#1468](https://github.com/libgit2/pygit2/pull/1468) + - Add riscv64 wheels [#1463](https://github.com/libgit2/pygit2/pull/1463) From ed2e1ad238e687948399fdef85b35aa1917e24c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Mon, 20 Jul 2026 19:30:27 +0200 Subject: [PATCH 191/225] Update to libgit2 1.9.6 --- .github/workflows/lint.yml | 2 +- .github/workflows/tests.yml | 2 +- .github/workflows/wheels.yml | 2 +- AGENTS.md | 6 +++--- CHANGELOG.md | 2 ++ Makefile | 2 +- build.sh | 8 ++++---- docs/general.rst | 14 +++++++------- docs/install.rst | 14 +++++++------- pyproject.toml | 4 ++-- 10 files changed, 29 insertions(+), 27 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 6a53f4c24..6408d34ba 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -31,4 +31,4 @@ jobs: run: ruff check - name: Check typing with mypy - run: LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.4 /bin/sh build.sh mypy + run: LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.6 /bin/sh build.sh mypy diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c61a68aea..2cd0c6c03 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -25,5 +25,5 @@ jobs: apt-get update -q -y apt-get install -q -y cmake libssl-dev python3-dev python3-venv wget run: | - LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.4 /bin/sh build.sh test + LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.6 /bin/sh build.sh test continue-on-error: true # Tests are expected to fail, see issue #812 diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 0d5610bde..e0745bed5 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -135,7 +135,7 @@ jobs: run: python -m cibuildwheel dist/*.tar.gz --output-dir wheelhouse env: CIBW_ARCHS: ${{ matrix.cibw_arch }} - CIBW_ENVIRONMENT: LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.4 LIBGIT2=/project/ci + CIBW_ENVIRONMENT: LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.6 LIBGIT2=/project/ci - uses: actions/upload-artifact@v7 with: diff --git a/AGENTS.md b/AGENTS.md index 8a692330d..78b6c354e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ and a high-level, Pythonic API for repository manipulation. - **License**: GPLv2 with linking exception (see `COPYING`) - **Maintainer**: J. David Ibáñez - **Python Support**: 3.11 – 3.14 and PyPy3 7.3+ -- **libgit2 Version**: 1.9.4 +- **libgit2 Version**: 1.9.6 - **Homepage**: - **Repository**: @@ -105,7 +105,7 @@ handles libgit2 compilation via CMake. make # Or manually: -LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.4 sh build.sh +LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.6 sh build.sh # Build inplace and run the tests sh build.sh test @@ -246,7 +246,7 @@ GitHub Actions workflows live in `.github/workflows/`: The `cibuildwheel` configuration in `pyproject.toml` pins: -- `LIBGIT2_VERSION="1.9.4"` +- `LIBGIT2_VERSION="1.9.6"` - `LIBSSH2_VERSION="1.11.1"` - `OPENSSL_VERSION="3.5.4"` diff --git a/CHANGELOG.md b/CHANGELOG.md index d30dd2703..50677c382 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ - Add riscv64 wheels [#1463](https://github.com/libgit2/pygit2/pull/1463) +- Update wheels to libgit2 1.9.6 + Breaking changes: - Remove deprecated `pygit2.legacyenums` module and `GIT_*` constants, diff --git a/Makefile b/Makefile index 4cd932c0a..876d5cb2b 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ .PHONY: build html build: - OPENSSL_VERSION=3.5.4 LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.4 sh build.sh + OPENSSL_VERSION=3.5.4 LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.6 sh build.sh html: build make -C docs html diff --git a/build.sh b/build.sh index f17542c1e..14c9c1cd3 100644 --- a/build.sh +++ b/build.sh @@ -22,14 +22,14 @@ # # sh build.sh # -# Build libgit2 1.9.4 (will use libssh2 if available), then build pygit2 +# Build libgit2 1.9.6 (will use libssh2 if available), then build pygit2 # inplace: # -# LIBGIT2_VERSION=1.9.4 sh build.sh +# LIBGIT2_VERSION=1.9.6 sh build.sh # -# Build libssh2 1.11.1 and libgit2 1.9.4, then build pygit2 inplace: +# Build libssh2 1.11.1 and libgit2 1.9.6, then build pygit2 inplace: # -# LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.4 sh build.sh +# LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.6 sh build.sh # # Build inplace and run the tests: # diff --git a/docs/general.rst b/docs/general.rst index fb702ace4..a155357b6 100644 --- a/docs/general.rst +++ b/docs/general.rst @@ -18,7 +18,7 @@ library that has been built against. The version number has a .. py:data:: LIBGIT2_VER_MAJOR Integer value of the major version number. For example, for the version - ``1.9.4``:: + ``1.9.6``:: >>> print(pygit2.LIBGIT2_VER_MAJOR) 1 @@ -26,7 +26,7 @@ library that has been built against. The version number has a .. py:data:: LIBGIT2_VER_MINOR Integer value of the minor version number. For example, for the version - ``1.9.4``:: + ``1.9.6``:: >>> print(pygit2.LIBGIT2_VER_MINOR) 9 @@ -34,25 +34,25 @@ library that has been built against. The version number has a .. py:data:: LIBGIT2_VER_REVISION Integer value of the revision version number. For example, for the version - ``1.9.4``:: + ``1.9.6``:: >>> print(pygit2.LIBGIT2_VER_REVISION) - 4 + 6 .. py:data:: LIBGIT2_VER Tuple value of the revision version numbers. For example, for the version - ``1.9.4``:: + ``1.9.6``:: >>> print(pygit2.LIBGIT2_VER) - (1, 9, 4) + (1, 9, 6) .. py:data:: LIBGIT2_VERSION The libgit2 version number as a string:: >>> print(pygit2.LIBGIT2_VERSION) - '1.9.4' + '1.9.6' Options ========= diff --git a/docs/install.rst b/docs/install.rst index 36833b477..f2c083733 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -158,9 +158,9 @@ directory, do: .. code-block:: sh :caption: On Linux using bash - wget https://github.com/libgit2/libgit2/archive/refs/tags/v1.9.4.tar.gz -O libgit2-1.9.4.tar.gz - tar -xzf libgit2-1.9.4.tar.gz - cd libgit2-1.9.4/ + wget https://github.com/libgit2/libgit2/archive/refs/tags/v1.9.6.tar.gz -O libgit2-1.9.6.tar.gz + tar -xzf libgit2-1.9.6.tar.gz + cd libgit2-1.9.6/ cmake . make sudo make install @@ -245,9 +245,9 @@ Install libgit2 (see we define the installation prefix): .. code-block:: sh :caption: On Linux using bash - wget https://github.com/libgit2/libgit2/archive/refs/tags/v1.9.4.tar.gz -O libgit2-1.9.4.tar.gz - tar xzf libgit2-1.9.4.tar.gz - cd libgit2-1.9.4/ + wget https://github.com/libgit2/libgit2/archive/refs/tags/v1.9.6.tar.gz -O libgit2-1.9.6.tar.gz + tar xzf libgit2-1.9.6.tar.gz + cd libgit2-1.9.6/ cmake . -DCMAKE_INSTALL_PREFIX=$LIBGIT2 cmake --build . --target install @@ -307,7 +307,7 @@ variable. The following recipe shows you how to do it: .. code-block:: pwsh :caption: On Windows using PowerShell (and CMake v3.21 or newer) - git clone --depth=1 -b v1.9.4 https://github.com/libgit2/libgit2.git + git clone --depth=1 -b v1.9.6 https://github.com/libgit2/libgit2.git $env:CMAKE_INSTALL_PREFIX = "C:/Dev/libgit2" $env:CMAKE_GENERATOR = "Visual Studio 17 2022" # or "Visual Studio 18 2026" $env:CMAKE_GENERATOR_PLATFORM = "x64" # or "Win32" or "ARM64" diff --git a/pyproject.toml b/pyproject.toml index 6e19895f1..422ea0e10 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ skip = "*musllinux_ppc64le" archs = ["native"] build-frontend = "default" dependency-versions = "pinned" -environment = {LIBGIT2="$(pwd)/ci", LIBGIT2_VERSION="1.9.4", LIBSSH2_VERSION="1.11.1", OPENSSL_VERSION="3.5.4"} +environment = {LIBGIT2="$(pwd)/ci", LIBGIT2_VERSION="1.9.6", LIBSSH2_VERSION="1.11.1", OPENSSL_VERSION="3.5.4"} before-all = "sh build.sh" test-command = "pytest" @@ -32,7 +32,7 @@ repair-wheel-command = "DYLD_LIBRARY_PATH={package}/ci/lib delocate-wheel --requ [tool.cibuildwheel.windows] environment.LIBGIT2_SRC = "build/libgit2_src" -environment.LIBGIT2_VERSION = "1.9.4" +environment.LIBGIT2_VERSION = "1.9.6" before-all = "powershell -File build.ps1" before-build = "" From 24efa5809d44c01a0b6c30a380e461666dedf86c Mon Sep 17 00:00:00 2001 From: Ralian Date: Thu, 9 Jul 2026 21:34:32 -0600 Subject: [PATCH 192/225] encoding: improve UTF-8 decode strategy --- pygit2/__init__.py | 10 +++++----- pygit2/blame.py | 4 ++-- pygit2/callbacks.py | 17 +++++++++++----- pygit2/config.py | 6 +++--- pygit2/filter.py | 4 ++-- pygit2/index.py | 26 ++++++++++++------------ pygit2/options.py | 18 ++++++++--------- pygit2/packbuilder.py | 4 ++-- pygit2/repository.py | 6 +++--- pygit2/submodules.py | 9 +++------ pygit2/utils.py | 44 +++++++++++++++++++++++++++++++++++++++++ src/diff.c | 13 +++++++++++- src/repository.c | 9 ++++++++- test/test_nonunicode.py | 16 +++++++++++++++ 14 files changed, 134 insertions(+), 52 deletions(-) diff --git a/pygit2/__init__.py b/pygit2/__init__.py index 8d4346ff1..54a065480 100644 --- a/pygit2/__init__.py +++ b/pygit2/__init__.py @@ -365,7 +365,7 @@ from .settings import Settings from .submodules import Submodule from .transaction import ReferenceTransaction -from .utils import to_bytes, to_str +from .utils import decode_fs_path, encode_fs_path, to_bytes, to_str # Features features = enums.Feature(C.git_libgit2_features()) @@ -430,7 +430,7 @@ def init_repository( options.mode = mode if workdir_path: - workdir_path_ref = ffi.new('char []', to_bytes(workdir_path)) + workdir_path_ref = ffi.new('char []', encode_fs_path(workdir_path)) options.workdir_path = workdir_path_ref if description: @@ -438,7 +438,7 @@ def init_repository( options.description = description_ref if template_path: - template_path_ref = ffi.new('char []', to_bytes(template_path)) + template_path_ref = ffi.new('char []', encode_fs_path(template_path)) options.template_path = template_path_ref if initial_head: @@ -451,7 +451,7 @@ def init_repository( # Call crepository = ffi.new('git_repository **') - err = C.git_repository_init_ext(crepository, to_bytes(path), options) + err = C.git_repository_init_ext(crepository, encode_fs_path(path), options) check_error(err) # Ok @@ -536,7 +536,7 @@ def clone_repository( with git_fetch_options(payload, opts=opts.fetch_opts): with git_proxy_options(payload, opts.fetch_opts.proxy_opts, proxy): crepo = ffi.new('git_repository **') - err = C.git_clone(crepo, to_bytes(url), to_bytes(path), opts) + err = C.git_clone(crepo, to_bytes(url), encode_fs_path(path), opts) payload.check_error(err) # Ok diff --git a/pygit2/blame.py b/pygit2/blame.py index 9854f3afd..b677f3b4f 100644 --- a/pygit2/blame.py +++ b/pygit2/blame.py @@ -30,7 +30,7 @@ # Import from pygit2 from .ffi import C, ffi -from .utils import GenericIterator +from .utils import GenericIterator, maybe_string if TYPE_CHECKING: from ._libgit2.ffi import GitBlameC, GitHunkC, GitSignatureC @@ -110,7 +110,7 @@ def orig_path(self) -> None | str: if not path: return None - return ffi.string(path).decode('utf-8') + return maybe_string(path) class Blame: diff --git a/pygit2/callbacks.py b/pygit2/callbacks.py index 1072f8cfe..bd5c0d031 100644 --- a/pygit2/callbacks.py +++ b/pygit2/callbacks.py @@ -74,7 +74,14 @@ from .enums import CheckoutNotify, CheckoutStrategy, CredentialType, StashApplyProgress from .errors import Passthrough, check_error from .ffi import C, ffi -from .utils import StrArray, maybe_string, ptr_to_bytes, to_bytes +from .utils import ( + StrArray, + decode_fs_path, + encode_fs_path, + maybe_string, + ptr_to_bytes, + to_bytes, +) _Credentials = Username | UserPass | Keypair @@ -784,13 +791,13 @@ def get_credentials(fn, url, username, allowed): def _checkout_notify_cb( why, path_cstr, baseline, target, workdir, data: CheckoutCallbacks ): - pypath = maybe_string(path_cstr) + pypath = decode_fs_path(path_cstr) pybaseline = DiffFile.from_c(ptr_to_bytes(baseline)) pytarget = DiffFile.from_c(ptr_to_bytes(target)) pyworkdir = DiffFile.from_c(ptr_to_bytes(workdir)) try: - data.checkout_notify(why, pypath, pybaseline, pytarget, pyworkdir) # type: ignore[arg-type] + data.checkout_notify(why, pypath, pybaseline, pytarget, pyworkdir) except Passthrough: # Unlike most other operations with optional callbacks, checkout # doesn't support the GIT_PASSTHROUGH return code, so we must bypass @@ -805,7 +812,7 @@ def _checkout_notify_cb( @libgit2_callback_void def _checkout_progress_cb(path, completed_steps, total_steps, data: CheckoutCallbacks): - data.checkout_progress(maybe_string(path), completed_steps, total_steps) # type: ignore[arg-type] + data.checkout_progress(decode_fs_path(path), completed_steps, total_steps) def _git_checkout_options( @@ -839,7 +846,7 @@ def _git_checkout_options( opts.checkout_strategy = int(strategy) if directory: - target_dir = ffi.new('char[]', to_bytes(directory)) + target_dir = ffi.new('char[]', encode_fs_path(directory)) refs.append(target_dir) opts.target_directory = target_dir diff --git a/pygit2/config.py b/pygit2/config.py index e7522ffee..877bf0f0e 100644 --- a/pygit2/config.py +++ b/pygit2/config.py @@ -35,7 +35,7 @@ # Import from pygit2 from .errors import check_error from .ffi import C, ffi -from .utils import to_bytes +from .utils import encode_fs_path, to_bytes if TYPE_CHECKING: from ._libgit2.ffi import GitConfigC, GitConfigEntryC @@ -116,7 +116,7 @@ def __init__(self, path: PathLike | str | None = None) -> None: if not path: err = C.git_config_new(cconfig) else: - path_bytes = to_bytes(path) + path_bytes = encode_fs_path(path) err = C.git_config_open_ondisk(cconfig, path_bytes) check_error(err, io=True) @@ -282,7 +282,7 @@ def add_file(self, path: str | PathLike, level: int = 0, force: int = 0) -> None """Add a config file instance to an existing config.""" err = C.git_config_add_file_ondisk( - self._config, to_bytes(path), level, ffi.NULL, force + self._config, encode_fs_path(path), level, ffi.NULL, force ) check_error(err) diff --git a/pygit2/filter.py b/pygit2/filter.py index 6fc953e3e..9c53b4710 100644 --- a/pygit2/filter.py +++ b/pygit2/filter.py @@ -32,7 +32,7 @@ from ._pygit2 import Blob, FilterSource from .errors import check_error from .ffi import C, ffi -from .utils import to_bytes +from .utils import encode_fs_path, to_bytes if TYPE_CHECKING: from ._libgit2.ffi import GitFilterListC @@ -178,7 +178,7 @@ def apply_to_file(self, repo: BaseRepository, path: str) -> bytes: Return the filtered contents. """ buf = ffi.new('git_buf *') - c_path = to_bytes(path) + c_path = encode_fs_path(path) err = C.git_filter_list_apply_to_file(buf, self._pointer, repo._repo, c_path) check_error(err) try: diff --git a/pygit2/index.py b/pygit2/index.py index e80273cec..6af3b933c 100644 --- a/pygit2/index.py +++ b/pygit2/index.py @@ -33,7 +33,7 @@ from .enums import DiffOption, FileMode from .errors import check_error from .ffi import C, ffi -from .utils import GenericIterator, StrArray, to_bytes, to_str +from .utils import GenericIterator, StrArray, decode_fs_path, encode_fs_path if typing.TYPE_CHECKING: from .repository import Repository @@ -52,7 +52,7 @@ def __init__(self, path: str | PathLike[str] | None = None) -> None: to read from and write to. """ cindex = ffi.new('git_index **') - err = C.git_index_open(cindex, to_bytes(path)) + err = C.git_index_open(cindex, encode_fs_path(path)) check_error(err) self._repo = None @@ -79,7 +79,7 @@ def __len__(self) -> int: return C.git_index_entrycount(self._index) def __contains__(self, path) -> bool: - err = C.git_index_find(ffi.NULL, self._index, to_bytes(path)) + err = C.git_index_find(ffi.NULL, self._index, encode_fs_path(path)) if err == C.GIT_ENOTFOUND: return False @@ -89,7 +89,7 @@ def __contains__(self, path) -> bool: def __getitem__(self, key: str | int | PathLike[str]) -> 'IndexEntry': centry = ffi.NULL if isinstance(key, str) or hasattr(key, '__fspath__'): - centry = C.git_index_get_bypath(self._index, to_bytes(key), 0) + centry = C.git_index_get_bypath(self._index, encode_fs_path(key), 0) elif isinstance(key, int): if key >= 0: centry = C.git_index_get_byindex(self._index, key) @@ -180,12 +180,12 @@ def write_tree(self, repo: 'Repository | None' = None) -> Oid: def remove(self, path: PathLike[str] | str, level: int = 0) -> None: """Remove an entry from the Index.""" - err = C.git_index_remove(self._index, to_bytes(path), level) + err = C.git_index_remove(self._index, encode_fs_path(path), level) check_error(err, io=True) def remove_directory(self, path: PathLike[str] | str, level: int = 0) -> None: """Remove a directory from the Index.""" - err = C.git_index_remove_directory(self._index, to_bytes(path), level) + err = C.git_index_remove_directory(self._index, encode_fs_path(path), level) check_error(err, io=True) def remove_all(self, pathspecs: typing.Sequence[str | PathLike[str]]) -> None: @@ -221,7 +221,7 @@ def add(self, path_or_entry: 'IndexEntry | str | PathLike[str]') -> None: err = C.git_index_add(self._index, centry) elif isinstance(path_or_entry, str) or hasattr(path_or_entry, '__fspath__'): path = path_or_entry - err = C.git_index_add_bypath(self._index, to_bytes(path)) + err = C.git_index_add_bypath(self._index, encode_fs_path(path)) else: raise TypeError('argument must be string, Path or IndexEntry') @@ -420,7 +420,7 @@ def _from_c(cls, centry): return None automergeable = centry.automergeable != 0 - path = to_str(ffi.string(centry.path)) if centry.path else None + path = decode_fs_path(ffi.string(centry.path)) if centry.path else None mode = FileMode(centry.mode) contents = ffi.string(centry.ptr, centry.len).decode('utf-8') @@ -475,7 +475,7 @@ def _to_c(self) -> tuple['ffi.GitIndexEntryC', 'ffi.ArrayC[ffi.char]']: # basically memcpy() ffi.buffer(ffi.addressof(centry, 'id'))[:] = self.id.raw[:] centry.mode = int(self.mode) - path = ffi.new('char[]', to_bytes(self.path)) + path = ffi.new('char[]', encode_fs_path(self.path)) centry.path = path return centry, path @@ -486,7 +486,7 @@ def _from_c(cls, centry): return None entry = cls.__new__(cls) - entry.path = to_str(ffi.string(centry.path)) + entry.path = decode_fs_path(ffi.string(centry.path)) entry.mode = FileMode(centry.mode) entry.id = Oid(raw=bytes(ffi.buffer(ffi.addressof(centry, 'id'))[:])) @@ -503,7 +503,7 @@ def __getitem__(self, path): ctheirs = ffi.new('git_index_entry **') err = C.git_index_conflict_get( - cancestor, cours, ctheirs, self._index._index, to_bytes(path) + cancestor, cours, ctheirs, self._index._index, encode_fs_path(path) ) check_error(err) @@ -514,7 +514,7 @@ def __getitem__(self, path): return ancestor, ours, theirs def __delitem__(self, path): - err = C.git_index_conflict_remove(self._index._index, to_bytes(path)) + err = C.git_index_conflict_remove(self._index._index, encode_fs_path(path)) check_error(err) def __iter__(self): @@ -526,7 +526,7 @@ def __contains__(self, path): ctheirs = ffi.new('git_index_entry **') err = C.git_index_conflict_get( - cancestor, cours, ctheirs, self._index._index, to_bytes(path) + cancestor, cours, ctheirs, self._index._index, encode_fs_path(path) ) if err == C.GIT_ENOTFOUND: return False diff --git a/pygit2/options.py b/pygit2/options.py index ad96253bc..5d4405f53 100644 --- a/pygit2/options.py +++ b/pygit2/options.py @@ -34,7 +34,7 @@ from .errors import check_error from .ffi import C, ffi -from .utils import to_bytes, to_str +from .utils import decode_fs_path, encode_fs_path, to_bytes, to_str if TYPE_CHECKING: from ._libgit2.ffi import NULL_TYPE, ArrayC, char, char_pointer @@ -348,7 +348,7 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) try: if buf.ptr != ffi.NULL: - result = to_str(ffi.string(buf.ptr)) + result = decode_fs_path(ffi.string(buf.ptr)) else: result = None finally: @@ -366,7 +366,7 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) if path is None: path_cdata = ffi.NULL else: - path_bytes = to_bytes(path) + path_bytes = encode_fs_path(path) path_cdata = ffi.new('char[]', path_bytes) err = C.git_libgit2_opts(option_type, ffi.cast('int', level), path_cdata) @@ -423,14 +423,14 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) if cert_file is None: cert_file_cdata = ffi.NULL else: - cert_file_bytes = to_bytes(cert_file) + cert_file_bytes = encode_fs_path(cert_file) cert_file_cdata = ffi.new('char[]', cert_file_bytes) cert_dir_cdata: ArrayC[char] | NULL_TYPE if cert_dir is None: cert_dir_cdata = ffi.NULL else: - cert_dir_bytes = to_bytes(cert_dir) + cert_dir_bytes = encode_fs_path(cert_dir) cert_dir_cdata = ffi.new('char[]', cert_dir_bytes) err = C.git_libgit2_opts(option_type, cert_file_cdata, cert_dir_cdata) @@ -476,7 +476,7 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) try: if buf.ptr != ffi.NULL: - result = to_str(ffi.string(buf.ptr)) + result = decode_fs_path(ffi.string(buf.ptr)) else: result = None finally: @@ -492,7 +492,7 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) if path is None: template_path_cdata = ffi.NULL else: - path_bytes = to_bytes(path) + path_bytes = encode_fs_path(path) template_path_cdata = ffi.new('char[]', path_bytes) err = C.git_libgit2_opts(option_type, template_path_cdata) @@ -688,7 +688,7 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) try: if buf.ptr != ffi.NULL: - result = to_str(ffi.string(buf.ptr)) + result = decode_fs_path(ffi.string(buf.ptr)) else: result = None finally: @@ -705,7 +705,7 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) if path is None: homedir_cdata = ffi.NULL else: - path_bytes = to_bytes(path) + path_bytes = encode_fs_path(path) homedir_cdata = ffi.new('char[]', path_bytes) err = C.git_libgit2_opts(option_type, homedir_cdata) diff --git a/pygit2/packbuilder.py b/pygit2/packbuilder.py index baa546303..f9b77c5d3 100644 --- a/pygit2/packbuilder.py +++ b/pygit2/packbuilder.py @@ -29,7 +29,7 @@ # Import from pygit2 from .errors import check_error from .ffi import C, ffi -from .utils import to_bytes +from .utils import encode_fs_path if TYPE_CHECKING: from pygit2 import Oid, Repository @@ -76,7 +76,7 @@ def set_threads(self, n_threads: int) -> int: return C.git_packbuilder_set_threads(self._packbuilder, n_threads) def write(self, path: str | bytes | PathLike[str] | None = None) -> None: - path_bytes = ffi.NULL if path is None else to_bytes(path) + path_bytes = ffi.NULL if path is None else encode_fs_path(path) err = C.git_packbuilder_write( self._packbuilder, path_bytes, 0, ffi.NULL, ffi.NULL ) diff --git a/pygit2/repository.py b/pygit2/repository.py index f3c34e81a..3fa9a43e1 100644 --- a/pygit2/repository.py +++ b/pygit2/repository.py @@ -81,7 +81,7 @@ from .remotes import RemoteCollection from .submodules import SubmoduleCollection from .transaction import ReferenceTransaction -from .utils import StrArray, maybe_string, to_bytes +from .utils import StrArray, decode_fs_path, encode_fs_path, maybe_string, to_bytes if TYPE_CHECKING: from pygit2._libgit2.ffi import ( @@ -219,7 +219,7 @@ def hashfile( If this is `None` and the `path` parameter is a file within the repository's working directory, then the `path` will be used. """ - c_path = to_bytes(path) + c_path = encode_fs_path(path) c_as_path: ffi.NULL_TYPE | bytes if as_path is None: @@ -1824,7 +1824,7 @@ def __init__( if hasattr(path, '__fspath__'): path = path.__fspath__() if not isinstance(path, str): - path = path.decode('utf-8') + path = decode_fs_path(path) path_backend = init_file_backend(path, int(flags)) super().__init__(path_backend) else: diff --git a/pygit2/submodules.py b/pygit2/submodules.py index 4ff00980d..67a328827 100644 --- a/pygit2/submodules.py +++ b/pygit2/submodules.py @@ -138,14 +138,12 @@ def reload(self, force: bool = False) -> None: @property def name(self): """Name of the submodule.""" - name = C.git_submodule_name(self._subm) - return ffi.string(name).decode('utf-8') + return maybe_string(C.git_submodule_name(self._subm)) @property def path(self): """Path of the submodule.""" - path = C.git_submodule_path(self._subm) - return ffi.string(path).decode('utf-8') + return maybe_string(C.git_submodule_path(self._subm)) @property def url(self) -> str | None: @@ -164,8 +162,7 @@ def url(self, url: str) -> None: @property def branch(self): """Branch that is to be tracked by the submodule.""" - branch = C.git_submodule_branch(self._subm) - return ffi.string(branch).decode('utf-8') + return maybe_string(C.git_submodule_branch(self._subm)) @property def head_id(self) -> Oid | None: diff --git a/pygit2/utils.py b/pygit2/utils.py index 4a36a06fe..69147ee2b 100644 --- a/pygit2/utils.py +++ b/pygit2/utils.py @@ -34,6 +34,7 @@ Protocol, TypeVar, Union, + cast, overload, ) @@ -44,6 +45,9 @@ from ._libgit2.ffi import ArrayC, GitStrrayC, char, char_pointer +PathStrOrBytes = str | bytes | os.PathLike[str] | os.PathLike[bytes] + + def maybe_string(ptr: 'char_pointer | None') -> str | None: if not ptr: return None @@ -51,6 +55,46 @@ def maybe_string(ptr: 'char_pointer | None') -> str | None: return ffi.string(ptr).decode('utf8', errors='surrogateescape') +@overload +def decode_fs_path(ptr_or_bytes: 'char_pointer') -> str: ... +@overload +def decode_fs_path(ptr_or_bytes: None) -> None: ... +@overload +def decode_fs_path(ptr_or_bytes: bytes) -> str: ... +def decode_fs_path(ptr_or_bytes: 'char_pointer | bytes | None') -> str | None: + if ptr_or_bytes is None: + return None + + if isinstance(ptr_or_bytes, bytes): + return os.fsdecode(ptr_or_bytes) + + if not ptr_or_bytes: + return None + + return os.fsdecode(ffi.string(ptr_or_bytes)) + + +@overload +def encode_fs_path(s: PathStrOrBytes) -> bytes: ... +@overload +def encode_fs_path(s: 'ffi.NULL_TYPE | None') -> 'ffi.NULL_TYPE': ... +def encode_fs_path( + s: PathStrOrBytes | 'ffi.NULL_TYPE | None', +) -> bytes | 'ffi.NULL_TYPE': + if s is None or s == ffi.NULL: + return ffi.NULL + + s_pathlike = cast(PathStrOrBytes, s) + + if hasattr(s_pathlike, '__fspath__'): + return os.fsencode(s_pathlike) + + if isinstance(s_pathlike, bytes): + return s_pathlike + + return os.fsencode(s_pathlike) + + @overload def to_bytes( s: str | bytes | os.PathLike[str] | os.PathLike[bytes], diff --git a/src/diff.c b/src/diff.c index 2ae542a78..b580737f1 100644 --- a/src/diff.c +++ b/src/diff.c @@ -228,9 +228,19 @@ DiffFile_mode__get__(DiffFile *self) return pygit2_enum(FileModeEnum, self->mode); } +PyDoc_STRVAR(DiffFile_path__doc__, "Path to the entry."); + +PyObject * +DiffFile_path__get__(DiffFile *self) +{ + if (self->path == NULL) + Py_RETURN_NONE; + + return PyUnicode_DecodeFSDefault(self->path); +} + PyMemberDef DiffFile_members[] = { MEMBER(DiffFile, id, T_OBJECT, "Oid of the item."), - MEMBER(DiffFile, path, T_STRING, "Path to the entry."), MEMBER(DiffFile, raw_path, T_OBJECT, "Path to the entry (bytes)."), MEMBER(DiffFile, size, T_LONG, "Size of the entry."), {NULL} @@ -244,6 +254,7 @@ static PyMethodDef DiffFile_methods[] = { PyGetSetDef DiffFile_getsetters[] = { GETTER(DiffFile, flags), GETTER(DiffFile, mode), + GETTER(DiffFile, path), {NULL}, }; diff --git a/src/repository.c b/src/repository.c index 2276eb773..1348e23b8 100644 --- a/src/repository.c +++ b/src/repository.c @@ -1797,7 +1797,14 @@ Repository_status(Repository *self, PyObject *args, PyObject *kw) if (status == NULL) goto error; - err = PyDict_SetItemString(dict, path, status); + PyObject *py_path = PyUnicode_DecodeFSDefault(path); + if (py_path == NULL) { + Py_CLEAR(status); + goto error; + } + + err = PyDict_SetItem(dict, py_path, status); + Py_DECREF(py_path); Py_CLEAR(status); if (err < 0) diff --git a/test/test_nonunicode.py b/test/test_nonunicode.py index 04d3d8a88..76cfb7a0f 100644 --- a/test/test_nonunicode.py +++ b/test/test_nonunicode.py @@ -25,6 +25,7 @@ """Tests for non unicode byte strings""" +import os import shutil import sys from pathlib import Path @@ -33,6 +34,8 @@ import pygit2 from pygit2 import Repository +from pygit2.enums import FileStatus +from pygit2.utils import encode_fs_path from . import utils @@ -57,3 +60,16 @@ def test_nonunicode_branchname(testrepo: Repository, tmp_path: Path) -> None: (ref.split('/')[-1]).encode('utf8', 'surrogateescape') for ref in newrepo.listall_references() ] # Remote branch among references: 'refs/remotes/origin/\udcc3master' + + +@works_in_linux +def test_nonunicode_status_path(tmp_path: Path) -> None: + repo = pygit2.init_repository(str(tmp_path / 'repo'), bare=False) + path_bytes = 'éléphant'.encode('latin1') + filepath = Path(repo.workdir) / path_bytes.decode('utf-8', 'surrogateescape') + filepath.write_bytes(b'dummy') + git_status = repo.status() + path_key = os.fsdecode(path_bytes) + assert path_bytes in [encode_fs_path(path) for path in git_status] + assert git_status[path_key] & FileStatus.WT_NEW + assert encode_fs_path(path_key) == path_bytes From 91637ff61ac7fa45b1de2b1a0ba1a21ac7ff1be9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Wed, 5 Aug 2026 08:41:04 +0200 Subject: [PATCH 193/225] Closes #693: Wrap libgit2's rebase API Add Repository.rebase_init() and Repository.rebase_open(), returning a Rebase object that drives git_rebase_next/commit/finish/abort through the Python iterator protocol, plus RebaseOperation and enums.RebaseOperationType. The options expose inmemory, quiet, rewrite_notes_ref, the merge favor/flags/file_flags knobs, checkout strategy, and conflict marker labels. Also fix enums.CheckoutStrategy.CONFLICT_STYLE_ZDIFF3, which was mistakenly bound to the DIFF3 constant. The tests cover the native API (clean replay, hunk-level conflict markers, custom labels, diff3 style, unresolved-conflict and already-applied errors, abort, in-memory rebase, fast-forward via finish, resuming with rebase_open) and keep a "manual rebase" implementation built from pygit2 primitives as a behavioral baseline to compare edge cases against. Assisted-by: Claude Fable 5 --- CHANGELOG.md | 7 + docs/index.rst | 1 + docs/rebase.rst | 79 ++++ pygit2/__init__.py | 1 + pygit2/_libgit2/ffi.pyi | 26 ++ pygit2/_run.py | 1 + pygit2/decl/rebase.h | 80 ++++ pygit2/enums.py | 30 +- pygit2/rebase.py | 246 +++++++++++++ pygit2/repository.py | 249 ++++++++++++- test/test_rebase.py | 793 ++++++++++++++++++++++++++++++++++++++++ 11 files changed, 1492 insertions(+), 21 deletions(-) create mode 100644 docs/rebase.rst create mode 100644 pygit2/decl/rebase.h create mode 100644 pygit2/rebase.py create mode 100644 test/test_rebase.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 50677c382..05d3a3847 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,13 @@ - Update wheels to libgit2 1.9.6 +- Wrap libgit2's rebase API: add `Repository.rebase_init(...)` and + `Repository.rebase_open(...)` returning a `Rebase` object, plus + `RebaseOperation` and `enums.RebaseOperationType` + +- Fix `enums.CheckoutStrategy.CONFLICT_STYLE_ZDIFF3`, which was mistakenly + bound to the `DIFF3` constant + Breaking changes: - Remove deprecated `pygit2.legacyenums` module and `GIT_*` constants, diff --git a/docs/index.rst b/docs/index.rst index 653a8556b..af3380e59 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -75,6 +75,7 @@ Table of Contents objects oid packing + rebase references transactions remotes diff --git a/docs/rebase.rst b/docs/rebase.rst new file mode 100644 index 000000000..be2dc2c96 --- /dev/null +++ b/docs/rebase.rst @@ -0,0 +1,79 @@ +********************************************************************** +Rebase +********************************************************************** + +.. contents:: + +.. automethod:: pygit2.Repository.rebase_init +.. automethod:: pygit2.Repository.rebase_open + +The Rebase type +==================== + +.. autoclass:: pygit2.Rebase + :members: + :special-members: __len__, __getitem__, __next__ + +.. autoclass:: pygit2.RebaseOperation + :members: + +Example +======= + +Rebase the current branch onto its upstream:: + + >>> committer = repo.default_signature + >>> rebase = repo.rebase_init(upstream=repo.branches['origin/master']) + >>> for operation in rebase: + ... # If repo.index.conflicts is not None at this point, the + ... # operation left conflicts in the index and conflict markers + ... # in the working directory. Resolve them, stage each + ... # resolution with repo.index.add(path), and only then commit. + ... rebase.commit(committer=committer) + >>> rebase.finish(committer) + +Use ``abort()`` instead of ``finish()`` to reset the repository and the +working directory to their state before the rebase began. + +With ``inmemory=True`` the rebase does not touch HEAD, the repository +state, or the working directory; each step's result is available as +``rebase.inmemory_index`` and updating the branch reference afterwards is +the caller's responsibility. + +Working with rebase operations +============================== + +Iterating over a ``Rebase`` yields a :py:class:`~pygit2.RebaseOperation` +describing each step. ``len()`` and indexing expose the same operations +up front, without advancing the rebase, so the plan can be inspected +before applying it:: + + >>> rebase = repo.rebase_init(upstream=repo.branches['origin/master']) + >>> for i in range(len(rebase)): + ... print(rebase[i]) + + + +A rebase started with ``rebase_init()`` replays the non-merge commits +in ``upstream..branch``; merge commits are skipped, linearizing the +history, just like plain ``git rebase`` (libgit2 has no equivalent of +``--rebase-merges``). Every operation's ``type`` is therefore +``RebaseOperationType.PICK``, ``id`` names the original commit being +replayed, and ``exec`` is ``None``. The remaining +``RebaseOperationType`` values mirror the verbs of git's interactive +rebase, which libgit2 does not implement (as of 1.9): they are declared +for completeness but never produced. Looking the original commit up is +useful for progress reporting or for reusing its metadata:: + + >>> from pygit2.enums import RebaseOperationType + >>> committer = repo.default_signature + >>> for operation in rebase: + ... assert operation.type == RebaseOperationType.PICK + ... original = repo[operation.id] + ... step, total = rebase.current_index + 1, len(rebase) + ... print(f'[{step}/{total}] picking {original.short_id}:', + ... original.message.strip()) + ... rebase.commit(committer=committer) + [1/2] picking 4a3fe06: Add feature + [2/2] picking 8ae4a25: Fix tests + >>> rebase.finish(committer) diff --git a/pygit2/__init__.py b/pygit2/__init__.py index 8d4346ff1..4cd1a2415 100644 --- a/pygit2/__init__.py +++ b/pygit2/__init__.py @@ -360,6 +360,7 @@ option, ) from .packbuilder import PackBuilder +from .rebase import Rebase, RebaseOperation from .remotes import Remote from .repository import Repository from .settings import Settings diff --git a/pygit2/_libgit2/ffi.pyi b/pygit2/_libgit2/ffi.pyi index 690abf2b5..a784feb69 100644 --- a/pygit2/_libgit2/ffi.pyi +++ b/pygit2/_libgit2/ffi.pyi @@ -172,6 +172,9 @@ class GitBufC: class GitCheckoutOptionsC: # incomplete checkout_strategy: int + ancestor_label: ArrayC[char] + our_label: ArrayC[char] + their_label: ArrayC[char] class GitCommitC: pass @@ -271,6 +274,22 @@ class GitProxyOptionsC: # certificate_check # payload +class GitRebaseC: + pass + +class GitRebaseOperationC: + type: int + id: GitOidC + exec: char_pointer + +class GitRebaseOptionsC: + version: int + quiet: int + inmemory: int + rewrite_notes_ref: ArrayC[char] + merge_options: GitMergeOptionsC + checkout_options: GitCheckoutOptionsC + class GitRemoteC: pass @@ -382,6 +401,12 @@ def new(a: Literal['git_stash_save_options *']) -> GitStashSaveOptionsC: ... @overload def new(a: Literal['git_strarray *']) -> GitStrrayC: ... @overload +def new(a: Literal['git_rebase **']) -> _Pointer[GitRebaseC]: ... +@overload +def new(a: Literal['git_rebase_options *']) -> GitRebaseOptionsC: ... +@overload +def new(a: Literal['git_rebase_operation **']) -> _Pointer[GitRebaseOperationC]: ... +@overload def new(a: Literal['git_tree **']) -> _Pointer[GitTreeC]: ... @overload def new(a: Literal['git_buf *'], b: tuple[NULL_TYPE, Literal[0]]) -> GitBufC: ... @@ -401,6 +426,7 @@ def new( ) -> ArrayC[char_pointer]: ... # For string arrays def addressof(a: object, attribute: str) -> _Pointer[object]: ... def new_handle(a: T) -> _Pointer[T]: ... +def gc(cdata: T, destructor: Any, size: int = ...) -> T: ... class buffer(bytes): def __init__(self, a: object) -> None: ... diff --git a/pygit2/_run.py b/pygit2/_run.py index 85d31f69d..9aace11cf 100644 --- a/pygit2/_run.py +++ b/pygit2/_run.py @@ -74,6 +74,7 @@ 'graph.h', 'index.h', 'merge.h', + 'rebase.h', 'net.h', 'refspec.h', 'repository.h', diff --git a/pygit2/decl/rebase.h b/pygit2/decl/rebase.h new file mode 100644 index 000000000..542143bfb --- /dev/null +++ b/pygit2/decl/rebase.h @@ -0,0 +1,80 @@ +typedef struct git_rebase git_rebase; + +#define GIT_REBASE_OPTIONS_VERSION ... +#define GIT_REBASE_NO_OPERATION ... + +typedef enum { + GIT_REBASE_OPERATION_PICK = 0, + GIT_REBASE_OPERATION_REWORD, + GIT_REBASE_OPERATION_EDIT, + GIT_REBASE_OPERATION_SQUASH, + GIT_REBASE_OPERATION_FIXUP, + GIT_REBASE_OPERATION_EXEC +} git_rebase_operation_t; + +typedef struct { + unsigned int version; + int quiet; + int inmemory; + const char *rewrite_notes_ref; + git_merge_options merge_options; + git_checkout_options checkout_options; + ...; +} git_rebase_options; + +typedef struct { + git_rebase_operation_t type; + const git_oid id; + const char *exec; + ...; +} git_rebase_operation; + +int git_rebase_options_init(git_rebase_options *opts, unsigned int version); + +int git_rebase_init( + git_rebase **out, + git_repository *repo, + const git_annotated_commit *branch, + const git_annotated_commit *upstream, + const git_annotated_commit *onto, + const git_rebase_options *opts); + +int git_rebase_open( + git_rebase **out, + git_repository *repo, + const git_rebase_options *opts); + +const char *git_rebase_orig_head_name(git_rebase *rebase); +const git_oid *git_rebase_orig_head_id(git_rebase *rebase); +const char *git_rebase_onto_name(git_rebase *rebase); +const git_oid *git_rebase_onto_id(git_rebase *rebase); + +size_t git_rebase_operation_entrycount(git_rebase *rebase); +size_t git_rebase_operation_current(git_rebase *rebase); +git_rebase_operation *git_rebase_operation_byindex( + git_rebase *rebase, + size_t idx); + +int git_rebase_next( + git_rebase_operation **operation, + git_rebase *rebase); + +int git_rebase_inmemory_index( + git_index **index, + git_rebase *rebase); + +int git_rebase_commit( + git_oid *id, + git_rebase *rebase, + const git_signature *author, + const git_signature *committer, + const char *message_encoding, + const char *message); + +int git_rebase_abort(git_rebase *rebase); + +int git_rebase_finish( + git_rebase *rebase, + const git_signature *signature); + +void git_rebase_free(git_rebase *rebase); diff --git a/pygit2/enums.py b/pygit2/enums.py index 5534b85b5..bd74ea6ae 100644 --- a/pygit2/enums.py +++ b/pygit2/enums.py @@ -225,7 +225,7 @@ class CheckoutStrategy(IntFlag): notifications; don't update the working directory or index. """ - CONFLICT_STYLE_ZDIFF3 = _pygit2.GIT_CHECKOUT_CONFLICT_STYLE_DIFF3 + CONFLICT_STYLE_ZDIFF3 = _pygit2.GIT_CHECKOUT_CONFLICT_STYLE_ZDIFF3 """ Include common ancestor data in zdiff3 format for conflicts """ @@ -998,6 +998,34 @@ class Option(IntEnum): ADD_SSL_X509_CERT = options.GIT_OPT_ADD_SSL_X509_CERT +class RebaseOperationType(IntEnum): + """Type of rebase operation in-progress after calling Rebase.next().""" + + PICK = C.GIT_REBASE_OPERATION_PICK + """The given commit is to be cherry-picked. The client should commit + the changes and continue if there are no conflicts.""" + + REWORD = C.GIT_REBASE_OPERATION_REWORD + """The given commit is to be cherry-picked, but the client should prompt + the user to provide an updated commit message.""" + + EDIT = C.GIT_REBASE_OPERATION_EDIT + """The given commit is to be cherry-picked, but the client should stop + to allow the user to edit the changes before committing them.""" + + SQUASH = C.GIT_REBASE_OPERATION_SQUASH + """The given commit is to be squashed into the previous commit. The + commit message will be merged with the previous message.""" + + FIXUP = C.GIT_REBASE_OPERATION_FIXUP + """The given commit is to be squashed into the previous commit. The + commit message from this commit will be discarded.""" + + EXEC = C.GIT_REBASE_OPERATION_EXEC + """No commit will be cherry-picked. The client should run the given + command and (if successful) continue.""" + + class ReferenceFilter(IntEnum): """Filters for References.iterator().""" diff --git a/pygit2/rebase.py b/pygit2/rebase.py new file mode 100644 index 000000000..e9aa1014d --- /dev/null +++ b/pygit2/rebase.py @@ -0,0 +1,246 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file 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; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +from typing import TYPE_CHECKING + +# Import from pygit2 +from ._pygit2 import Oid, Signature +from .enums import RebaseOperationType +from .errors import check_error +from .ffi import C, ffi +from .index import Index +from .utils import maybe_string + +if TYPE_CHECKING: + from ._libgit2.ffi import GitRebaseC, GitRebaseOperationC + from .repository import BaseRepository + + +def _signature_ptr(signature: 'Signature | None'): + """Return a git_signature* cdata for the given signature, or ffi.NULL. + + The returned pointer borrows the memory owned by the Signature object, + which the caller must keep alive for the duration of the C call. + """ + if signature is None: + return ffi.NULL + ptr = ffi.new('git_signature **') + ffi.buffer(ptr)[:] = signature._pointer[:] + return ptr[0] + + +class RebaseOperation: + """A single instruction to be performed during a rebase.""" + + def __init__(self, type: RebaseOperationType, id: Oid, exec: 'str | None') -> None: + self.type = type + 'The type of rebase operation.' + + self.id = id + """The commit ID being cherry-picked. This will be populated for + all operations except those of type RebaseOperationType.EXEC.""" + + self.exec = exec + """The executable the user has requested be run. This will only + be populated for operations of type RebaseOperationType.EXEC.""" + + @classmethod + def _from_c(cls, coperation: 'GitRebaseOperationC') -> 'RebaseOperation': + type = RebaseOperationType(coperation.type) + id = Oid(raw=bytes(ffi.buffer(ffi.addressof(coperation, 'id'))[:])) + exec = maybe_string(coperation.exec) + return cls(type, id, exec) + + def __repr__(self) -> str: + return f'' + + +class Rebase: + """An in-progress rebase. + + Returned by Repository.rebase_init() and Repository.rebase_open(). + Iterating over this object performs the rebase operations one by one; + each must be committed with commit(), after resolving any conflicts + that were left in the repository's index. Finalize with finish(), or + roll everything back with abort(). + """ + + def __init__( + self, repo: 'BaseRepository', crebase: 'GitRebaseC', refs: list + ) -> None: + """The constructor is for internal use only.""" + self._repo = repo + self._rebase = ffi.gc(crebase, C.git_rebase_free) + # Keep alive the git_rebase_options and every cdata it points into: + # libgit2 reads the options during __next__() and abort(), long + # after rebase_init() returned. + self._refs = refs + + def __len__(self) -> int: + """The total number of rebase operations.""" + return C.git_rebase_operation_entrycount(self._rebase) + + def __getitem__(self, index: int) -> RebaseOperation: + """The rebase operation at the given index.""" + if index < 0: + index += len(self) + if index < 0: + raise IndexError('rebase operation index out of range') + coperation = C.git_rebase_operation_byindex(self._rebase, index) + if coperation == ffi.NULL: + raise IndexError('rebase operation index out of range') + return RebaseOperation._from_c(coperation) + + def __iter__(self) -> 'Rebase': + return self + + def __next__(self) -> RebaseOperation: + """ + Perform the next rebase operation and return it. + + If the operation is one that applies a patch (which is any + operation except RebaseOperationType.EXEC) then the patch will be + applied and the index and working directory will be updated with + the changes. If there are conflicts, you will need to address + those before calling commit(). + + Raises StopIteration when there are no more operations to perform. + """ + coperation = ffi.new('git_rebase_operation **') + err = C.git_rebase_next(coperation, self._rebase) + check_error(err) # raises StopIteration on GIT_ITEROVER + return RebaseOperation._from_c(coperation[0]) + + @property + def current_index(self) -> 'int | None': + """The index of the rebase operation that is currently being + applied, or None if the first operation has not yet been applied + (because __next__() has not been called yet).""" + index = C.git_rebase_operation_current(self._rebase) + if index == C.GIT_REBASE_NO_OPERATION: + return None + return index + + @property + def inmemory_index(self) -> Index: + """ + The index produced by the last operation, which is the result of + __next__() and which will be committed by the next invocation of + commit(). This is useful for resolving conflicts in an in-memory + rebase before committing them. + + This is only applicable for in-memory rebases; for rebases within + a working directory, the changes were applied to the repository's + index. + """ + cindex = ffi.new('git_index **') + err = C.git_rebase_inmemory_index(cindex, self._rebase) + check_error(err) + return Index.from_c(self._repo, cindex) + + def commit( + self, + committer: Signature, + author: 'Signature | None' = None, + message: 'str | None' = None, + ) -> Oid: + """ + Commit the current patch and return the id of the new commit. You + must have resolved any conflicts that were introduced during the + patch application from the last __next__() invocation. + + Raises GitError if there are unmerged changes in the index, or if + the current commit has already been applied to the upstream and + there is nothing to commit. + + Parameters: + + committer : Signature + The committer of the rebase. + + author : Signature + The author of the updated commit, or None to keep the author + from the original commit. + + message : str + The message for this commit, or None to use the message from + the original commit. + """ + cmessage = ( + ffi.new('char[]', message.encode('utf-8')) + if message is not None + else ffi.NULL + ) + coid = ffi.new('git_oid *') + err = C.git_rebase_commit( + coid, + self._rebase, + _signature_ptr(author), + _signature_ptr(committer), + ffi.NULL, + cmessage, + ) + check_error(err) + return Oid(raw=bytes(ffi.buffer(coid)[:])) + + def finish(self, signature: 'Signature | None' = None) -> None: + """ + Finish the rebase once all patches have been applied. + + Parameters: + + signature : Signature + The identity that is finishing the rebase (optional). + """ + err = C.git_rebase_finish(self._rebase, _signature_ptr(signature)) + check_error(err) + + def abort(self) -> None: + """Abort the rebase, resetting the repository and working + directory to their state before the rebase began.""" + err = C.git_rebase_abort(self._rebase) + check_error(err) + + @property + def orig_head_name(self) -> 'str | None': + """The original HEAD ref name.""" + return maybe_string(C.git_rebase_orig_head_name(self._rebase)) + + @property + def orig_head_id(self) -> Oid: + """The original HEAD id.""" + coid = C.git_rebase_orig_head_id(self._rebase) + return Oid(raw=bytes(ffi.buffer(coid)[:])) + + @property + def onto_name(self) -> 'str | None': + """The onto ref name.""" + return maybe_string(C.git_rebase_onto_name(self._rebase)) + + @property + def onto_id(self) -> Oid: + """The onto id.""" + coid = C.git_rebase_onto_id(self._rebase) + return Oid(raw=bytes(ffi.buffer(coid)[:])) diff --git a/pygit2/repository.py b/pygit2/repository.py index f3c34e81a..64c177cfc 100644 --- a/pygit2/repository.py +++ b/pygit2/repository.py @@ -77,6 +77,7 @@ from .filter import FilterList from .index import Index, IndexEntry, MergeFileResult from .packbuilder import PackBuilder +from .rebase import Rebase from .references import References from .remotes import RemoteCollection from .submodules import SubmoduleCollection @@ -86,7 +87,9 @@ if TYPE_CHECKING: from pygit2._libgit2.ffi import ( ArrayC, + GitAnnotatedCommitC, GitMergeOptionsC, + GitRebaseOptionsC, GitRepositoryC, _Pointer, char, @@ -1001,6 +1004,32 @@ def merge_trees( return Index.from_c(self, cindex) + def _annotated_commit( + self, source: 'Reference | Commit | Oid | None' + ) -> '_Pointer[GitAnnotatedCommitC] | None': + """Return a git_annotated_commit** cdata for the given source, or + None if source is None. The caller must free the result with + git_annotated_commit_free.""" + if source is None: + return None + commit_ptr = ffi.new('git_annotated_commit **') + if isinstance(source, Reference): + cptr = ffi.new('struct git_reference **') + ffi.buffer(cptr)[:] = source._pointer[:] # type: ignore[attr-defined] + err = C.git_annotated_commit_from_ref(commit_ptr, self._repo, cptr[0]) + else: + if isinstance(source, Commit): + oid = source.id + elif isinstance(source, Oid): + oid = source + else: + raise TypeError('expected Reference, Commit, or Oid') + c_id = ffi.new('git_oid *') + ffi.buffer(c_id)[:] = oid.raw[:] + err = C.git_annotated_commit_lookup(commit_ptr, self._repo, c_id) + check_error(err) + return commit_ptr + def merge( self, source: Reference | Commit | Oid, @@ -1035,26 +1064,9 @@ def merge( A combination of enums.MergeFileFlag constants. """ - if isinstance(source, Reference): - # Annotated commit from ref - cptr = ffi.new('struct git_reference **') - ffi.buffer(cptr)[:] = source._pointer[:] # type: ignore[attr-defined] - commit_ptr = ffi.new('git_annotated_commit **') - err = C.git_annotated_commit_from_ref(commit_ptr, self._repo, cptr[0]) - check_error(err) - else: - # Annotated commit from commit id - if isinstance(source, Commit): - oid = source.id - elif isinstance(source, Oid): - oid = source - else: - raise TypeError('expected Reference, Commit, or Oid') - c_id = ffi.new('git_oid *') - ffi.buffer(c_id)[:] = oid.raw[:] - commit_ptr = ffi.new('git_annotated_commit **') - err = C.git_annotated_commit_lookup(commit_ptr, self._repo, c_id) - check_error(err) + commit_ptr = self._annotated_commit(source) + if commit_ptr is None: + raise TypeError('expected Reference, Commit, or Oid') merge_opts = self._merge_options(favor, flags, file_flags) @@ -1068,6 +1080,203 @@ def merge( C.git_annotated_commit_free(commit_ptr[0]) check_error(err) + # + # Rebasing + # + def _rebase_options( + self, + inmemory: bool, + quiet: bool, + rewrite_notes_ref: 'str | None', + favor: MergeFavor, + flags: MergeFlag, + file_flags: MergeFileFlag, + checkout_strategy: 'CheckoutStrategy | None', + ancestor_label: 'str | None', + our_label: 'str | None', + their_label: 'str | None', + ) -> 'tuple[GitRebaseOptionsC, list]': + """Return a git_rebase_options pointer plus the list of cdata + objects that must be kept alive for as long as libgit2 may read + the options.""" + opts = ffi.new('git_rebase_options *') + err = C.git_rebase_options_init(opts, C.GIT_REBASE_OPTIONS_VERSION) + check_error(err) + refs: list = [opts] + + opts.inmemory = int(inmemory) + opts.quiet = int(quiet) + if rewrite_notes_ref is not None: + notes_ref = ffi.new('char[]', to_bytes(rewrite_notes_ref)) + refs.append(notes_ref) + opts.rewrite_notes_ref = notes_ref + + merge_opts = self._merge_options(favor, flags, file_flags) + ffi.buffer(ffi.addressof(opts, 'merge_options'))[:] = ffi.buffer(merge_opts)[:] + + if checkout_strategy is not None: + opts.checkout_options.checkout_strategy = int(checkout_strategy) + labels = ( + ('ancestor_label', ancestor_label), + ('our_label', our_label), + ('their_label', their_label), + ) + for field, label in labels: + if label is not None: + clabel = ffi.new('char[]', to_bytes(label)) + refs.append(clabel) + setattr(opts.checkout_options, field, clabel) + + return opts, refs + + def rebase_init( + self, + branch: 'Reference | Commit | Oid | None' = None, + upstream: 'Reference | Commit | Oid | None' = None, + onto: 'Reference | Commit | Oid | None' = None, + *, + inmemory: bool = False, + quiet: bool = False, + rewrite_notes_ref: 'str | None' = None, + favor: MergeFavor = MergeFavor.NORMAL, + flags: MergeFlag = MergeFlag.FIND_RENAMES, + file_flags: MergeFileFlag = MergeFileFlag.DEFAULT, + checkout_strategy: 'CheckoutStrategy | None' = None, + ancestor_label: 'str | None' = None, + our_label: 'str | None' = None, + their_label: 'str | None' = None, + ) -> Rebase: + """ + Initialize a rebase operation to rebase the changes in `branch` + relative to `upstream` onto another branch, and return a Rebase + object. To begin the rebase process, iterate over it; commit each + successful operation with Rebase.commit(), then call + Rebase.finish() or Rebase.abort(). + + Parameters: + + branch + The terminal commit to rebase: a Reference, Commit, or commit + Oid. None means rebase the current branch. + + upstream + The commit to begin rebasing from. None means rebase all + reachable commits. + + onto + The branch to rebase onto. None means rebase onto the given + upstream. + + inmemory + Begin an in-memory rebase, which will allow callers to step + through the rebase operations and commit the rebased changes, + but will not rewind HEAD or update the repository to be in a + rebasing state. This will not interfere with the working + directory. + + quiet + Instruct other clients working on this rebase that you want a + quiet rebase experience. This has no effect upon libgit2 + directly, but is provided for interoperability between Git + tools. + + rewrite_notes_ref + Name of the notes reference used to rewrite notes for rebased + commits when finishing the rebase. If None, the + `notes.rewriteRef` configuration option is examined. + + favor + An enums.MergeFavor constant specifying how to deal with + file-level conflicts. For all but NORMAL, the index will not + record a conflict. + + flags + A combination of enums.MergeFlag constants. + + file_flags + A combination of enums.MergeFileFlag constants. For example, + MergeFileFlag.STYLE_DIFF3 asks for conflict markers that + include the common ancestor content. + + checkout_strategy + A CheckoutStrategy value controlling how files are written + during Rebase.__next__() and Rebase.abort(), or None for + libgit2's default. + + ancestor_label, our_label, their_label + Override the labels used in conflict markers. By default + libgit2 labels the "ours" side with the name of the branch + being rebased onto, and the "theirs" side with the summary of + the commit being replayed. + """ + opts, refs = self._rebase_options( + inmemory, + quiet, + rewrite_notes_ref, + favor, + flags, + file_flags, + checkout_strategy, + ancestor_label, + our_label, + their_label, + ) + branch_c = self._annotated_commit(branch) + upstream_c = self._annotated_commit(upstream) + onto_c = self._annotated_commit(onto) + + crebase = ffi.new('git_rebase **') + err = C.git_rebase_init( + crebase, + self._repo, + branch_c[0] if branch_c is not None else ffi.NULL, + upstream_c[0] if upstream_c is not None else ffi.NULL, + onto_c[0] if onto_c is not None else ffi.NULL, + opts, + ) + for commit_c in (branch_c, upstream_c, onto_c): + if commit_c is not None: + C.git_annotated_commit_free(commit_c[0]) + check_error(err) + return Rebase(self, crebase[0], refs) + + def rebase_open( + self, + *, + inmemory: bool = False, + quiet: bool = False, + rewrite_notes_ref: 'str | None' = None, + favor: MergeFavor = MergeFavor.NORMAL, + flags: MergeFlag = MergeFlag.FIND_RENAMES, + file_flags: MergeFileFlag = MergeFileFlag.DEFAULT, + checkout_strategy: 'CheckoutStrategy | None' = None, + ancestor_label: 'str | None' = None, + our_label: 'str | None' = None, + their_label: 'str | None' = None, + ) -> Rebase: + """ + Open an existing rebase that was previously started by either an + invocation of rebase_init() or by another client. + + The keyword arguments have the same meaning as in rebase_init(). + """ + opts, refs = self._rebase_options( + inmemory, + quiet, + rewrite_notes_ref, + favor, + flags, + file_flags, + checkout_strategy, + ancestor_label, + our_label, + their_label, + ) + crebase = ffi.new('git_rebase **') + err = C.git_rebase_open(crebase, self._repo, opts) + check_error(err) + return Rebase(self, crebase[0], refs) + # # Prepared message (MERGE_MSG) # diff --git a/test/test_rebase.py b/test/test_rebase.py new file mode 100644 index 000000000..efebfdd0a --- /dev/null +++ b/test/test_rebase.py @@ -0,0 +1,793 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file 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; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +"""Tests for rebasing. + +Two flavors are covered here: + +1. "Manual rebase": rebasing implemented from first principles out of + merge_base(), walk(), merge_trees(), create_commit() and checkout, the + way applications had to before pygit2 wrapped libgit2's rebase API. + These tests are kept as a behavioral baseline to compare edge cases + against. + +2. The native rebase API: Repository.rebase_init() / rebase_open() and the + Rebase object, wrapping libgit2's git_rebase_* functions. +""" + +from itertools import count +from pathlib import Path + +import pytest + +import pygit2 +from pygit2 import ( + Blob, + Commit, + Index, + IndexEntry, + Oid, + Reference, + Repository, + Signature, +) +from pygit2.enums import ( + CheckoutStrategy, + FileMode, + RebaseOperationType, + RepositoryState, + SortMode, +) + +# Fixed base timestamp (like libgit2's examples/rebase.c) so that commit ids +# do not depend on the clock. +_timestamps = count(1_700_000_000) + +FileSpec = tuple[str, str, str] # path, content, commit message + + +def _signature() -> Signature: + return Signature('Test User', 'test@example.com', time=next(_timestamps), offset=0) + + +def _tip(ref: Reference) -> Oid: + target = ref.target + assert isinstance(target, Oid), 'symbolic reference where a commit was expected' + return target + + +def _commit_index(repo: Repository, message: str) -> Oid: + index = repo.index + index.write() + tree = index.write_tree() + signature = _signature() + parents = [] if repo.head_is_unborn else [repo.head.target] + return repo.create_commit('HEAD', signature, signature, message, tree, parents) + + +def _commit_file(repo: Repository, name: str, content: str, message: str) -> Oid: + (Path(repo.workdir) / name).write_text(content) + repo.index.add(name) + return _commit_index(repo, message) + + +def _commit_removal(repo: Repository, name: str, message: str) -> Oid: + (Path(repo.workdir) / name).unlink() + repo.index.remove(name) + return _commit_index(repo, message) + + +def _diverge( + repo: Repository, upstream: list[FileSpec], local: list[FileSpec] +) -> tuple[Oid, list[Oid]]: + """Grow an 'upstream' branch and the current branch from the current HEAD. + + Returns the tip of the upstream branch and the local commit ids, and + leaves the repository back on the original branch. + """ + main = repo.head.shorthand + repo.branches.local.create('upstream', repo.head.peel(Commit)) + repo.checkout(repo.branches['upstream']) + for name, content, message in upstream: + _commit_file(repo, name, content, message) + upstream_target = _tip(repo.branches['upstream']) + repo.checkout(repo.branches[main]) + local_oids = [ + _commit_file(repo, name, content, message) for name, content, message in local + ] + return upstream_target, local_oids + + +def _entry_text(repo: Repository, entry: IndexEntry | None) -> str: + """One side of a conflict: '' for a deleted side, newline-terminated text + otherwise.""" + if entry is None: + return '' + blob = repo[entry.id] + assert isinstance(blob, Blob) + text = blob.data.decode('utf-8', errors='replace') + if text and not text.endswith('\n'): + text += '\n' + return text + + +def _resolve_with_markers(repo: Repository, merge_index: Index, commit: Commit) -> None: + """Resolve every conflict by embedding both sides of the file in git-style + conflict markers, then stage the marked-up file, the way `git rebase` + leaves conflicting files in the working tree for the user to edit.""" + conflicts = merge_index.conflicts + assert conflicts is not None + resolutions = [] + for ancestor_entry, our_entry, their_entry in conflicts: + some_entry = their_entry or our_entry or ancestor_entry + assert some_entry is not None + content = ( + '<<<<<<< HEAD (rebased)\n' + + _entry_text(repo, our_entry) + + '=======\n' + + _entry_text(repo, their_entry) + + f'>>>>>>> {commit.short_id} ({commit.message.strip()})\n' + ) + blob_oid = repo.create_blob(content.encode('utf-8')) + resolutions.append(IndexEntry(some_entry.path, blob_oid, FileMode.BLOB)) + for entry in resolutions: + del merge_index.conflicts[entry.path] + merge_index.add(entry) + + +def _replay_commit(repo: Repository, commit: Commit, onto: Oid) -> Oid: + """Replay one commit on top of `onto` with a three-way merge of trees.""" + merge_index = repo.merge_trees( + commit.parents[0].tree, # ancestor: state the commit was made against + repo[onto].peel(Commit).tree, # ours: state rebuilt so far + commit.tree, # theirs: the commit being replayed + ) + message = commit.message + if merge_index.conflicts is not None: + _resolve_with_markers(repo, merge_index, commit) + message = ( + f'{message.rstrip()}\n\n[Rebased with conflicts - manual resolution needed]' + ) + tree_oid = merge_index.write_tree(repo) + signature = _signature() + return repo.create_commit(None, signature, signature, message, tree_oid, [onto]) + + +def _fast_forward(repo: Repository, upstream_target: Oid) -> None: + repo.checkout_tree(repo[upstream_target]) # type: ignore[no-untyped-call] + repo.references[repo.head.name].set_target(upstream_target) + + +def _rebase_onto(repo: Repository, upstream_target: Oid) -> None: + """Rebase the current branch onto `upstream_target` from first principles. + + This is the second half of a hand-rolled `git pull --rebase` (the first + half being a fetch): fast-forward when possible, otherwise replay the + diverged local commits one by one on top of the upstream tip. + """ + merge_base = repo.merge_base(repo.head.target, upstream_target) + if merge_base == upstream_target: + # Upstream did not move, there is nothing to rebase onto. + return + if merge_base == repo.head.target: + _fast_forward(repo, upstream_target) + return + + walker = repo.walk(repo.head.target, SortMode.TOPOLOGICAL) + walker.hide(merge_base) + commits_to_replay = list(walker) + commits_to_replay.reverse() # replay oldest first + + repo.checkout_tree(repo[upstream_target]) # type: ignore[no-untyped-call] + current_parent = upstream_target + for commit in commits_to_replay: + current_parent = _replay_commit(repo, commit, current_parent) + + repo.references[repo.head.name].set_target(current_parent) + repo.checkout('HEAD', strategy=CheckoutStrategy.FORCE) + + +def _linear_history(repo: Repository) -> list[str]: + """Commit messages from HEAD down to the root, asserting that the history + contains no merge commits.""" + messages = [] + for commit in repo.walk(repo.head.target, SortMode.TOPOLOGICAL): + assert len(commit.parents) <= 1 + messages.append(commit.message) + return messages + + +@pytest.fixture +def rebaserepo(tmp_path: Path) -> Repository: + repo = pygit2.init_repository(tmp_path / 'rebaserepo') + _commit_file(repo, 'README.md', '# Test Repository\n', 'Initial commit') + _commit_file( + repo, 'file1.txt', 'Content of file 1\nLine 2\nLine 3\n', 'Add file1.txt' + ) + _commit_file( + repo, 'file2.txt', 'Content of file 2\nOriginal content\n', 'Add file2.txt' + ) + return repo + + +def test_rebase_noop_when_up_to_date(rebaserepo: Repository) -> None: + head_before = _tip(rebaserepo.head) + _rebase_onto(rebaserepo, head_before) + assert rebaserepo.head.target == head_before + assert rebaserepo.status() == {} + + +def test_rebase_noop_when_upstream_is_behind(rebaserepo: Repository) -> None: + upstream_target, local_oids = _diverge( + rebaserepo, + upstream=[], + local=[('file4.txt', 'New file 4\n', 'Add file4.txt')], + ) + _rebase_onto(rebaserepo, upstream_target) + assert rebaserepo.head.target == local_oids[0] + assert rebaserepo.status() == {} + + +def test_rebase_fast_forwards_when_local_did_not_diverge( + rebaserepo: Repository, +) -> None: + upstream_target, _ = _diverge( + rebaserepo, + upstream=[ + ('file3.txt', 'New file 3\n', 'Add file3.txt'), + ('file1.txt', 'Content of file 1\nLine 2 changed\n', 'Change file1.txt'), + ], + local=[], + ) + _rebase_onto(rebaserepo, upstream_target) + assert rebaserepo.head.target == upstream_target + workdir = Path(rebaserepo.workdir) + assert (workdir / 'file3.txt').read_text() == 'New file 3\n' + assert (workdir / 'file1.txt').read_text() == 'Content of file 1\nLine 2 changed\n' + assert rebaserepo.status() == {} + + +def test_rebase_replays_diverged_commit_cleanly(rebaserepo: Repository) -> None: + upstream_target, local_oids = _diverge( + rebaserepo, + upstream=[('file3.txt', 'New file 3 from upstream\n', 'Add file3.txt')], + local=[('file4.txt', 'New file 4 from local\n', 'Add file4.txt')], + ) + _rebase_onto(rebaserepo, upstream_target) + + head = rebaserepo.head.peel(Commit) + assert head.message == 'Add file4.txt' + # The replayed commit is a new object with the upstream tip as its parent. + assert head.id != local_oids[0] + assert [parent.id for parent in head.parents] == [upstream_target] + workdir = Path(rebaserepo.workdir) + assert (workdir / 'file3.txt').read_text() == 'New file 3 from upstream\n' + assert (workdir / 'file4.txt').read_text() == 'New file 4 from local\n' + assert rebaserepo.status() == {} + # The pre-rebase commit is still in the object database. + assert rebaserepo.get(local_oids[0]) is not None + + +def test_rebase_replays_multiple_commits_oldest_first(rebaserepo: Repository) -> None: + upstream_target, _ = _diverge( + rebaserepo, + upstream=[('file3.txt', 'upstream\n', 'Upstream commit')], + local=[ + ('a.txt', 'a\n', 'Add a.txt'), + ('b.txt', 'b\n', 'Add b.txt'), + ('c.txt', 'c\n', 'Add c.txt'), + ], + ) + _rebase_onto(rebaserepo, upstream_target) + assert _linear_history(rebaserepo) == [ + 'Add c.txt', + 'Add b.txt', + 'Add a.txt', + 'Upstream commit', + 'Add file2.txt', + 'Add file1.txt', + 'Initial commit', + ] + workdir = Path(rebaserepo.workdir) + for name in ('a.txt', 'b.txt', 'c.txt', 'file3.txt'): + assert (workdir / name).exists() + assert rebaserepo.status() == {} + + +def test_rebase_conflicting_commit_gets_markers(rebaserepo: Repository) -> None: + upstream_target, local_oids = _diverge( + rebaserepo, + upstream=[ + ( + 'file1.txt', + 'Content of file 1\nLine 2 changed upstream\nLine 3\n', + 'Change line 2 upstream', + ) + ], + local=[ + ( + 'file1.txt', + 'Content of file 1\nLine 2 changed locally\nLine 3\n', + 'Change line 2 locally', + ) + ], + ) + original = rebaserepo[local_oids[0]].peel(Commit) + _rebase_onto(rebaserepo, upstream_target) + + head = rebaserepo.head.peel(Commit) + assert head.message == ( + 'Change line 2 locally\n\n[Rebased with conflicts - manual resolution needed]' + ) + assert [parent.id for parent in head.parents] == [upstream_target] + expected = ( + '<<<<<<< HEAD (rebased)\n' + 'Content of file 1\n' + 'Line 2 changed upstream\n' + 'Line 3\n' + '=======\n' + 'Content of file 1\n' + 'Line 2 changed locally\n' + 'Line 3\n' + f'>>>>>>> {original.short_id} (Change line 2 locally)\n' + ) + assert (Path(rebaserepo.workdir) / 'file1.txt').read_text() == expected + assert rebaserepo.index.conflicts is None + assert rebaserepo.status() == {} + + +def test_rebase_conflict_when_local_deleted_a_modified_file( + rebaserepo: Repository, +) -> None: + main = rebaserepo.head.shorthand + rebaserepo.branches.local.create('upstream', rebaserepo.head.peel(Commit)) + rebaserepo.checkout(rebaserepo.branches['upstream']) + _commit_file( + rebaserepo, + 'file2.txt', + 'Content of file 2\nModified upstream\n', + 'Modify file2.txt upstream', + ) + upstream_target = _tip(rebaserepo.branches['upstream']) + rebaserepo.checkout(rebaserepo.branches[main]) + removal_oid = _commit_removal(rebaserepo, 'file2.txt', 'Delete file2.txt') + original = rebaserepo[removal_oid].peel(Commit) + + _rebase_onto(rebaserepo, upstream_target) + + head = rebaserepo.head.peel(Commit) + assert head.message == ( + 'Delete file2.txt\n\n[Rebased with conflicts - manual resolution needed]' + ) + # The deleted side is left empty between the conflict markers. + expected = ( + '<<<<<<< HEAD (rebased)\n' + 'Content of file 2\n' + 'Modified upstream\n' + '=======\n' + f'>>>>>>> {original.short_id} (Delete file2.txt)\n' + ) + assert (Path(rebaserepo.workdir) / 'file2.txt').read_text() == expected + assert rebaserepo.status() == {} + + +def test_rebase_mixed_clean_and_conflicting_commits(rebaserepo: Repository) -> None: + upstream_target, local_oids = _diverge( + rebaserepo, + upstream=[ + ( + 'file1.txt', + 'Content of file 1\nLine 2 changed upstream\nLine 3\n', + 'Change line 2 upstream', + ) + ], + local=[ + ('file4.txt', 'New file 4\n', 'Add file4.txt'), + ( + 'file1.txt', + 'Content of file 1\nLine 2 changed locally\nLine 3\n', + 'Change line 2 locally', + ), + ], + ) + original = rebaserepo[local_oids[1]].peel(Commit) + _rebase_onto(rebaserepo, upstream_target) + + # The clean commit is replayed verbatim, only the conflicting one is + # annotated. + assert _linear_history(rebaserepo) == [ + 'Change line 2 locally\n\n[Rebased with conflicts - manual resolution needed]', + 'Add file4.txt', + 'Change line 2 upstream', + 'Add file2.txt', + 'Add file1.txt', + 'Initial commit', + ] + workdir = Path(rebaserepo.workdir) + assert (workdir / 'file4.txt').read_text() == 'New file 4\n' + expected = ( + '<<<<<<< HEAD (rebased)\n' + 'Content of file 1\n' + 'Line 2 changed upstream\n' + 'Line 3\n' + '=======\n' + 'Content of file 1\n' + 'Line 2 changed locally\n' + 'Line 3\n' + f'>>>>>>> {original.short_id} (Change line 2 locally)\n' + ) + assert (workdir / 'file1.txt').read_text() == expected + assert rebaserepo.status() == {} + + +def test_pull_rebase_after_fetch_from_remote(tmp_path: Path) -> None: + """The full `git pull --rebase` flow against a local "remote".""" + origin_path = tmp_path / 'origin' + origin = pygit2.init_repository(origin_path) + _commit_file(origin, 'README.md', '# Test Repository\n', 'Initial commit') + _commit_file(origin, 'file1.txt', 'Content of file 1\n', 'Add file1.txt') + + local = pygit2.clone_repository(str(origin_path), str(tmp_path / 'local')) + + _commit_file(origin, 'file3.txt', 'From origin\n', 'Add file3.txt in origin') + local_oid = _commit_file(local, 'file4.txt', 'From local\n', 'Add file4.txt local') + + for remote in local.remotes: + remote.fetch() + + branch = local.branches[local.head.shorthand] + upstream = branch.upstream + assert upstream is not None + _rebase_onto(local, _tip(upstream)) + + assert _linear_history(local) == [ + 'Add file4.txt local', + 'Add file3.txt in origin', + 'Add file1.txt', + 'Initial commit', + ] + assert local.head.target != local_oid + workdir = Path(local.workdir) + assert (workdir / 'file3.txt').read_text() == 'From origin\n' + assert (workdir / 'file4.txt').read_text() == 'From local\n' + assert local.status() == {} + + +# --------------------------------------------------------------------------- +# The native rebase API: Repository.rebase_init() / rebase_open() and Rebase +# --------------------------------------------------------------------------- + +CONFLICT_SCENARIO = dict( + upstream=[ + ( + 'file1.txt', + 'Content of file 1\nLine 2 changed upstream\nLine 3\n', + 'Change line 2 upstream', + ) + ], + local=[ + ( + 'file1.txt', + 'Content of file 1\nLine 2 changed locally\nLine 3\n', + 'Change line 2 locally', + ) + ], +) + + +def test_rebase_api_clean(rebaserepo: Repository) -> None: + main = rebaserepo.head.shorthand + upstream_target, local_oids = _diverge( + rebaserepo, + upstream=[('file3.txt', 'New file 3 from upstream\n', 'Add file3.txt')], + local=[ + ('a.txt', 'a\n', 'Add a.txt'), + ('b.txt', 'b\n', 'Add b.txt'), + ], + ) + rebase = rebaserepo.rebase_init(upstream=rebaserepo.branches['upstream']) + + assert len(rebase) == 2 + assert rebase.current_index is None + assert rebase.onto_id == upstream_target + assert rebase.onto_name == 'upstream' + assert rebase.orig_head_name == f'refs/heads/{main}' + assert rebase.orig_head_id == local_oids[-1] + # Operations replay the diverged commits oldest first. + assert [rebase[i].id for i in range(len(rebase))] == local_oids + assert rebase[-1].id == local_oids[-1] + with pytest.raises(IndexError): + rebase[2] + + replayed: list[Oid] = [] + for i, operation in enumerate(rebase): + assert operation.type == RebaseOperationType.PICK + assert operation.id == local_oids[i] + assert operation.exec is None + assert rebase.current_index == i + assert rebaserepo.state() == RepositoryState.REBASE_MERGE + replayed.append(rebase.commit(committer=_signature())) + rebase.finish(_signature()) + + assert rebaserepo.state() == RepositoryState.NONE + assert replayed[0] != local_oids[0] + head = rebaserepo.head.peel(Commit) + assert head.id == replayed[-1] + assert rebaserepo.head.name == f'refs/heads/{main}' + assert _linear_history(rebaserepo)[:4] == [ + 'Add b.txt', + 'Add a.txt', + 'Add file3.txt', + 'Add file2.txt', + ] + workdir = Path(rebaserepo.workdir) + for name in ('a.txt', 'b.txt', 'file3.txt'): + assert (workdir / name).exists() + assert rebaserepo.status() == {} + + +def test_rebase_api_conflict_has_hunk_level_markers(rebaserepo: Repository) -> None: + _, local_oids = _diverge(rebaserepo, **CONFLICT_SCENARIO) + rebase = rebaserepo.rebase_init(upstream=rebaserepo.branches['upstream']) + + next(rebase) + conflicts = rebaserepo.index.conflicts + assert conflicts is not None + ancestor, ours, theirs = conflicts['file1.txt'] + assert ancestor is not None and ours is not None and theirs is not None + + # Unlike the manual whole-file markers, libgit2 wrote hunk-level + # markers into the working directory: common lines stay outside, and + # the sides are labeled with the onto name and the commit summary. + expected = ( + 'Content of file 1\n' + '<<<<<<< upstream\n' + 'Line 2 changed upstream\n' + '=======\n' + 'Line 2 changed locally\n' + '>>>>>>> Change line 2 locally\n' + 'Line 3\n' + ) + assert (Path(rebaserepo.workdir) / 'file1.txt').read_text() == expected + + # Keep the markers as the resolution, the autocommit way: staging the + # file marks the conflict as resolved. + rebaserepo.index.add('file1.txt') + rebaserepo.index.write() + assert rebaserepo.index.conflicts is None + rebase.commit( + committer=_signature(), + message='Change line 2 locally\n\n[Rebased with conflicts]', + ) + with pytest.raises(StopIteration): + next(rebase) + rebase.finish(_signature()) + + head = rebaserepo.head.peel(Commit) + assert head.message == 'Change line 2 locally\n\n[Rebased with conflicts]' + assert (Path(rebaserepo.workdir) / 'file1.txt').read_text() == expected + assert rebaserepo.status() == {} + + +def test_rebase_api_unresolved_conflict_blocks_commit(rebaserepo: Repository) -> None: + _diverge(rebaserepo, **CONFLICT_SCENARIO) + rebase = rebaserepo.rebase_init(upstream=rebaserepo.branches['upstream']) + next(rebase) + with pytest.raises(pygit2.GitError): + rebase.commit(committer=_signature()) + rebase.abort() + + +def test_rebase_api_custom_labels(rebaserepo: Repository) -> None: + _diverge(rebaserepo, **CONFLICT_SCENARIO) + rebase = rebaserepo.rebase_init( + upstream=rebaserepo.branches['upstream'], + our_label='HEAD (rebased)', + their_label='incoming', + ) + next(rebase) + content = (Path(rebaserepo.workdir) / 'file1.txt').read_text() + assert '<<<<<<< HEAD (rebased)\n' in content + assert '>>>>>>> incoming\n' in content + rebase.abort() + + +def test_rebase_api_diff3_conflict_style(rebaserepo: Repository) -> None: + _diverge(rebaserepo, **CONFLICT_SCENARIO) + rebase = rebaserepo.rebase_init( + upstream=rebaserepo.branches['upstream'], + checkout_strategy=CheckoutStrategy.SAFE + | CheckoutStrategy.RECREATE_MISSING + | CheckoutStrategy.CONFLICT_STYLE_DIFF3, + ) + next(rebase) + content = (Path(rebaserepo.workdir) / 'file1.txt').read_text() + # diff3 style includes the common ancestor version of the hunk. + assert '||||||| ancestor\nLine 2\n=======\n' in content + rebase.abort() + + +def test_rebase_api_abort(rebaserepo: Repository) -> None: + main = rebaserepo.head.shorthand + upstream_target, local_oids = _diverge( + rebaserepo, + upstream=[('file3.txt', 'upstream\n', 'Upstream commit')], + local=[ + ('a.txt', 'a\n', 'Add a.txt'), + ('b.txt', 'b\n', 'Add b.txt'), + ], + ) + head_before = _tip(rebaserepo.head) + + rebase = rebaserepo.rebase_init(upstream=rebaserepo.branches['upstream']) + operation = next(rebase) + rebase.commit(committer=_signature()) + operation = next(rebase) + assert operation.id == local_oids[1] + assert rebaserepo.state() == RepositoryState.REBASE_MERGE + + rebase.abort() + + assert rebaserepo.state() == RepositoryState.NONE + assert rebaserepo.head.name == f'refs/heads/{main}' + assert rebaserepo.head.target == head_before + workdir = Path(rebaserepo.workdir) + assert (workdir / 'a.txt').exists() + assert (workdir / 'b.txt').exists() + assert not (workdir / 'file3.txt').exists() + assert rebaserepo.status() == {} + + +def test_rebase_api_inmemory(rebaserepo: Repository) -> None: + main = rebaserepo.head.shorthand + upstream_target, local_oids = _diverge( + rebaserepo, + upstream=[('file3.txt', 'upstream\n', 'Upstream commit')], + local=[('file4.txt', 'local\n', 'Add file4.txt')], + ) + head_before = _tip(rebaserepo.head) + + rebase = rebaserepo.rebase_init( + upstream=rebaserepo.branches['upstream'], inmemory=True + ) + operation = next(rebase) + assert operation.id == local_oids[0] + # The repository is not put into a rebasing state, and the working + # directory is not touched. + assert rebaserepo.state() == RepositoryState.NONE + assert not (Path(rebaserepo.workdir) / 'file3.txt').exists() + + merged = rebase.inmemory_index + assert merged.conflicts is None + assert 'file4.txt' in merged + + new_oid = rebase.commit(committer=_signature()) + rebase.finish(_signature()) + + # HEAD and the branch were left alone; putting the result in place is + # the caller's job, like the manual _fast_forward() epilogue. + assert rebaserepo.head.target == head_before + new_commit = rebaserepo[new_oid].peel(Commit) + assert [parent.id for parent in new_commit.parents] == [upstream_target] + assert new_commit.message == 'Add file4.txt' + + rebaserepo.references[f'refs/heads/{main}'].set_target(new_oid) + rebaserepo.checkout('HEAD', strategy=CheckoutStrategy.FORCE) + assert _linear_history(rebaserepo)[:2] == ['Add file4.txt', 'Upstream commit'] + assert rebaserepo.status() == {} + + +def test_rebase_api_finish_moves_branch_when_local_did_not_diverge( + rebaserepo: Repository, +) -> None: + upstream_target, _ = _diverge( + rebaserepo, + upstream=[('file3.txt', 'New file 3\n', 'Add file3.txt')], + local=[], + ) + rebase = rebaserepo.rebase_init(upstream=rebaserepo.branches['upstream']) + # Local did not diverge: there is nothing to replay, and finishing + # fast-forwards the branch to the upstream tip. + assert len(rebase) == 0 + with pytest.raises(StopIteration): + next(rebase) + rebase.finish(_signature()) + assert rebaserepo.head.target == upstream_target + assert (Path(rebaserepo.workdir) / 'file3.txt').exists() + assert rebaserepo.status() == {} + + +def test_rebase_api_replays_even_when_upstream_is_behind( + rebaserepo: Repository, +) -> None: + """Unlike `git pull --rebase` porcelain (and the manual no-op check), + the plumbing does not detect that the upstream is simply behind: it + replays the local commits, rewriting their ids.""" + upstream_target, local_oids = _diverge( + rebaserepo, + upstream=[], + local=[('file4.txt', 'New file 4\n', 'Add file4.txt')], + ) + rebase = rebaserepo.rebase_init(upstream=rebaserepo.branches['upstream']) + assert len(rebase) == 1 + for _operation in rebase: + rebase.commit(committer=_signature()) + rebase.finish(_signature()) + assert _linear_history(rebaserepo)[:2] == ['Add file4.txt', 'Add file2.txt'] + assert rebaserepo.head.target != local_oids[0] + assert rebaserepo.status() == {} + + +def test_rebase_api_already_applied_commit(rebaserepo: Repository) -> None: + """A local commit whose changes are already present upstream has + nothing left to commit; libgit2 reports that as an error and the + caller simply moves on to the next operation.""" + _diverge( + rebaserepo, + upstream=[('file3.txt', 'identical\n', 'Add file3.txt upstream')], + local=[('file3.txt', 'identical\n', 'Add file3.txt locally')], + ) + rebase = rebaserepo.rebase_init(upstream=rebaserepo.branches['upstream']) + next(rebase) + with pytest.raises(pygit2.GitError): + rebase.commit(committer=_signature()) + with pytest.raises(StopIteration): + next(rebase) + rebase.finish(_signature()) + assert rebaserepo.head.target == _tip(rebaserepo.branches['upstream']) + assert rebaserepo.status() == {} + + +def test_rebase_open_resumes_rebase(rebaserepo: Repository) -> None: + upstream_target, local_oids = _diverge( + rebaserepo, + upstream=[('file3.txt', 'upstream\n', 'Upstream commit')], + local=[ + ('a.txt', 'a\n', 'Add a.txt'), + ('b.txt', 'b\n', 'Add b.txt'), + ], + ) + rebase = rebaserepo.rebase_init(upstream=rebaserepo.branches['upstream']) + next(rebase) + rebase.commit(committer=_signature()) + del rebase + + # Another client (or a later process) picks the rebase up from disk. + resumed = rebaserepo.rebase_open() + assert len(resumed) == 2 + assert resumed.current_index == 0 + assert resumed.onto_id == upstream_target + operation = next(resumed) + assert operation.id == local_oids[1] + resumed.commit(committer=_signature()) + resumed.finish(_signature()) + + assert rebaserepo.state() == RepositoryState.NONE + assert _linear_history(rebaserepo)[:3] == [ + 'Add b.txt', + 'Add a.txt', + 'Upstream commit', + ] + assert rebaserepo.status() == {} From e5f6b422e1009e214090de8efd73a2701a043016 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Wed, 5 Aug 2026 09:39:10 +0200 Subject: [PATCH 194/225] Rebase.commit() returns None for already-applied patches Callers previously could not tell GIT_EAPPLIED apart from any other error: pygit2 exceptions don't carry the libgit2 error code, so driving a rebase loop meant guessing which GitError is safe to skip. Mirror `git rebase`, which skips patches that are already present upstream, by returning None from commit() in that case. Unresolved conflicts still raise GitError. Also link the 1.20.0 changelog entries to the PR. Assisted-by: Claude Fable 5 --- CHANGELOG.md | 2 ++ docs/rebase.rst | 4 ++++ pygit2/rebase.py | 17 ++++++++++------- test/test_rebase.py | 12 +++++++----- 4 files changed, 23 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05d3a3847..1ff9b4b97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,9 +15,11 @@ - Wrap libgit2's rebase API: add `Repository.rebase_init(...)` and `Repository.rebase_open(...)` returning a `Rebase` object, plus `RebaseOperation` and `enums.RebaseOperationType` + [#1483](https://github.com/libgit2/pygit2/pull/1483) - Fix `enums.CheckoutStrategy.CONFLICT_STYLE_ZDIFF3`, which was mistakenly bound to the `DIFF3` constant + [#1483](https://github.com/libgit2/pygit2/pull/1483) Breaking changes: diff --git a/docs/rebase.rst b/docs/rebase.rst index be2dc2c96..1cc27dc8b 100644 --- a/docs/rebase.rst +++ b/docs/rebase.rst @@ -35,6 +35,10 @@ Rebase the current branch onto its upstream:: Use ``abort()`` instead of ``finish()`` to reset the repository and the working directory to their state before the rebase began. +``commit()`` returns ``None`` for a patch that turns out to be already +present upstream; like ``git rebase``, simply move on to the next +operation. + With ``inmemory=True`` the rebase does not touch HEAD, the repository state, or the working directory; each step's result is available as ``rebase.inmemory_index`` and updating the branch reference afterwards is diff --git a/pygit2/rebase.py b/pygit2/rebase.py index e9aa1014d..063c386a8 100644 --- a/pygit2/rebase.py +++ b/pygit2/rebase.py @@ -165,15 +165,16 @@ def commit( committer: Signature, author: 'Signature | None' = None, message: 'str | None' = None, - ) -> Oid: + ) -> 'Oid | None': """ - Commit the current patch and return the id of the new commit. You - must have resolved any conflicts that were introduced during the - patch application from the last __next__() invocation. + Commit the current patch and return the id of the new commit, or + None if the current commit has already been applied to the upstream + and there is nothing to commit — mirroring how `git rebase` skips + already-applied patches. You must have resolved any conflicts that + were introduced during the patch application from the last + __next__() invocation. - Raises GitError if there are unmerged changes in the index, or if - the current commit has already been applied to the upstream and - there is nothing to commit. + Raises GitError if there are unmerged changes in the index. Parameters: @@ -202,6 +203,8 @@ def commit( ffi.NULL, cmessage, ) + if err == C.GIT_EAPPLIED: + return None check_error(err) return Oid(raw=bytes(ffi.buffer(coid)[:])) diff --git a/test/test_rebase.py b/test/test_rebase.py index efebfdd0a..80342924e 100644 --- a/test/test_rebase.py +++ b/test/test_rebase.py @@ -528,7 +528,9 @@ def test_rebase_api_clean(rebaserepo: Repository) -> None: assert operation.exec is None assert rebase.current_index == i assert rebaserepo.state() == RepositoryState.REBASE_MERGE - replayed.append(rebase.commit(committer=_signature())) + new_id = rebase.commit(committer=_signature()) + assert new_id is not None + replayed.append(new_id) rebase.finish(_signature()) assert rebaserepo.state() == RepositoryState.NONE @@ -684,6 +686,7 @@ def test_rebase_api_inmemory(rebaserepo: Repository) -> None: assert 'file4.txt' in merged new_oid = rebase.commit(committer=_signature()) + assert new_oid is not None rebase.finish(_signature()) # HEAD and the branch were left alone; putting the result in place is @@ -742,8 +745,8 @@ def test_rebase_api_replays_even_when_upstream_is_behind( def test_rebase_api_already_applied_commit(rebaserepo: Repository) -> None: """A local commit whose changes are already present upstream has - nothing left to commit; libgit2 reports that as an error and the - caller simply moves on to the next operation.""" + nothing left to commit; commit() reports that by returning None and + the caller simply moves on to the next operation.""" _diverge( rebaserepo, upstream=[('file3.txt', 'identical\n', 'Add file3.txt upstream')], @@ -751,8 +754,7 @@ def test_rebase_api_already_applied_commit(rebaserepo: Repository) -> None: ) rebase = rebaserepo.rebase_init(upstream=rebaserepo.branches['upstream']) next(rebase) - with pytest.raises(pygit2.GitError): - rebase.commit(committer=_signature()) + assert rebase.commit(committer=_signature()) is None with pytest.raises(StopIteration): next(rebase) rebase.finish(_signature()) From aaa4f4ace16ee9fb4238c52e4a40c6fceae0bdd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Wed, 5 Aug 2026 12:51:40 +0200 Subject: [PATCH 195/225] Rebase API review follow-ups - Add rebase, Rebase and RebaseOperation to __all__ - RebaseOperationType docstring: operations are seen when iterating over or indexing a Rebase, there is no next() method - RebaseOperation.id docstring: for EXEC operations this is the zero OID (falsy), matching libgit2's by-value git_oid field Assisted-by: Kimi K3 --- pygit2/__init__.py | 3 +++ pygit2/enums.py | 3 ++- pygit2/rebase.py | 4 ++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/pygit2/__init__.py b/pygit2/__init__.py index 4cd1a2415..70b491bca 100644 --- a/pygit2/__init__.py +++ b/pygit2/__init__.py @@ -917,6 +917,9 @@ def filter_unregister(name: str) -> None: 'IndexEntry', 'packbuilder', 'PackBuilder', + 'rebase', + 'Rebase', + 'RebaseOperation', 'refspec', 'remotes', 'Remote', diff --git a/pygit2/enums.py b/pygit2/enums.py index bd74ea6ae..b3e4e41aa 100644 --- a/pygit2/enums.py +++ b/pygit2/enums.py @@ -999,7 +999,8 @@ class Option(IntEnum): class RebaseOperationType(IntEnum): - """Type of rebase operation in-progress after calling Rebase.next().""" + """Type of a rebase operation, as returned when iterating over or + indexing a Rebase.""" PICK = C.GIT_REBASE_OPERATION_PICK """The given commit is to be cherry-picked. The client should commit diff --git a/pygit2/rebase.py b/pygit2/rebase.py index 063c386a8..fea2d75bc 100644 --- a/pygit2/rebase.py +++ b/pygit2/rebase.py @@ -59,8 +59,8 @@ def __init__(self, type: RebaseOperationType, id: Oid, exec: 'str | None') -> No 'The type of rebase operation.' self.id = id - """The commit ID being cherry-picked. This will be populated for - all operations except those of type RebaseOperationType.EXEC.""" + """The commit ID being cherry-picked. For operations of type + RebaseOperationType.EXEC this is the zero OID.""" self.exec = exec """The executable the user has requested be run. This will only From d65d61e3867ce21da294924b276176df34c3deee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Wed, 5 Aug 2026 13:52:15 +0200 Subject: [PATCH 196/225] encoding: clean up fs_path helpers after #1469 - Simplify encode_fs_path by removing redundant branches. - Fix decode_fs_path overload for NULL pointers. - Use decode_fs_path for BlameHunk.orig_path and Submodule.path. - Update changelog. Assisted-by: Kimi K3 --- CHANGELOG.md | 6 ++++++ pygit2/blame.py | 4 ++-- pygit2/submodules.py | 4 ++-- pygit2/utils.py | 15 ++------------- 4 files changed, 12 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ff9b4b97..df9d7f2d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ `Config.__init__()`, and improve `Config` documentation [#1468](https://github.com/libgit2/pygit2/pull/1468) +- Fix handling of non-UTF-8 file paths: add `decode_fs_path` and + `encode_fs_path`, use the filesystem codec for paths in `Repository.status()`, + `DiffFile.path`, index entries, checkout callbacks, and related APIs + [#1451](https://github.com/libgit2/pygit2/issues/1451) + [#1469](https://github.com/libgit2/pygit2/pull/1469) + - Add riscv64 wheels [#1463](https://github.com/libgit2/pygit2/pull/1463) diff --git a/pygit2/blame.py b/pygit2/blame.py index b677f3b4f..d7d54150c 100644 --- a/pygit2/blame.py +++ b/pygit2/blame.py @@ -30,7 +30,7 @@ # Import from pygit2 from .ffi import C, ffi -from .utils import GenericIterator, maybe_string +from .utils import GenericIterator, decode_fs_path if TYPE_CHECKING: from ._libgit2.ffi import GitBlameC, GitHunkC, GitSignatureC @@ -110,7 +110,7 @@ def orig_path(self) -> None | str: if not path: return None - return maybe_string(path) + return decode_fs_path(path) class Blame: diff --git a/pygit2/submodules.py b/pygit2/submodules.py index 67a328827..14c6aef8b 100644 --- a/pygit2/submodules.py +++ b/pygit2/submodules.py @@ -34,7 +34,7 @@ from .enums import SubmoduleIgnore, SubmoduleStatus from .errors import check_error from .ffi import C, ffi -from .utils import maybe_string, to_bytes +from .utils import decode_fs_path, maybe_string, to_bytes # Need BaseRepository for type hints, but don't let it cause a circular dependency if TYPE_CHECKING: @@ -143,7 +143,7 @@ def name(self): @property def path(self): """Path of the submodule.""" - return maybe_string(C.git_submodule_path(self._subm)) + return decode_fs_path(C.git_submodule_path(self._subm)) @property def url(self) -> str | None: diff --git a/pygit2/utils.py b/pygit2/utils.py index 69147ee2b..c430a1b9e 100644 --- a/pygit2/utils.py +++ b/pygit2/utils.py @@ -34,7 +34,6 @@ Protocol, TypeVar, Union, - cast, overload, ) @@ -56,9 +55,7 @@ def maybe_string(ptr: 'char_pointer | None') -> str | None: @overload -def decode_fs_path(ptr_or_bytes: 'char_pointer') -> str: ... -@overload -def decode_fs_path(ptr_or_bytes: None) -> None: ... +def decode_fs_path(ptr_or_bytes: 'char_pointer | None') -> str | None: ... @overload def decode_fs_path(ptr_or_bytes: bytes) -> str: ... def decode_fs_path(ptr_or_bytes: 'char_pointer | bytes | None') -> str | None: @@ -84,15 +81,7 @@ def encode_fs_path( if s is None or s == ffi.NULL: return ffi.NULL - s_pathlike = cast(PathStrOrBytes, s) - - if hasattr(s_pathlike, '__fspath__'): - return os.fsencode(s_pathlike) - - if isinstance(s_pathlike, bytes): - return s_pathlike - - return os.fsencode(s_pathlike) + return os.fsencode(s) @overload From c5aac4091b567cb8ee200801f8fbc508e4d23ac4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Wed, 5 Aug 2026 16:07:39 +0200 Subject: [PATCH 197/225] encoding: rename internal string helpers and drop public to_bytes Rename internal string encoders for clarity: - maybe_string -> decode_string - to_bytes -> encode_string Keep the new filesystem-path helpers (decode_fs_path, encode_fs_path) internal; use utils.encode_fs_path / utils.encode_string in __init__.py. Remove pygit2.to_bytes from the public API; it was an undocumented accidental public helper. Update changelog. Assisted-by: Kimi K3 --- CHANGELOG.md | 7 ++++--- pygit2/__init__.py | 23 +++++++++++------------ pygit2/callbacks.py | 32 ++++++++++++++++---------------- pygit2/config.py | 12 ++++++------ pygit2/filter.py | 4 ++-- pygit2/options.py | 10 +++++----- pygit2/rebase.py | 8 ++++---- pygit2/refspec.py | 8 ++++---- pygit2/remotes.py | 42 +++++++++++++++++++++--------------------- pygit2/repository.py | 40 +++++++++++++++++++++++----------------- pygit2/submodules.py | 20 ++++++++++---------- pygit2/transaction.py | 16 ++++++++-------- pygit2/utils.py | 10 +++++----- 13 files changed, 119 insertions(+), 113 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df9d7f2d7..eaa41a4d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,8 @@ `Config.__init__()`, and improve `Config` documentation [#1468](https://github.com/libgit2/pygit2/pull/1468) -- Fix handling of non-UTF-8 file paths: add `decode_fs_path` and - `encode_fs_path`, use the filesystem codec for paths in `Repository.status()`, - `DiffFile.path`, index entries, checkout callbacks, and related APIs +- Fix `UnicodeDecodeError` with non-UTF-8 file paths in `Repository.status()`, + `DiffFile.path`, index paths, checkout callbacks, and related APIs [#1451](https://github.com/libgit2/pygit2/issues/1451) [#1469](https://github.com/libgit2/pygit2/pull/1469) @@ -42,6 +41,8 @@ Breaking changes: - Remove deprecated `Remote.ls_remotes(...)`, use `Remote.list_heads(...)` instead +- Remove `pygit2.to_bytes`; it was an undocumented accidental public API + # 1.19.3 (2026-06-13) diff --git a/pygit2/__init__.py b/pygit2/__init__.py index 8645fcc29..7b4986a12 100644 --- a/pygit2/__init__.py +++ b/pygit2/__init__.py @@ -31,7 +31,7 @@ import typing # High level API -from . import enums +from . import enums, utils from ._build import __version__ # Low level API @@ -366,7 +366,7 @@ from .settings import Settings from .submodules import Submodule from .transaction import ReferenceTransaction -from .utils import decode_fs_path, encode_fs_path, to_bytes, to_str +from .utils import to_str # Features features = enums.Feature(C.git_libgit2_features()) @@ -431,28 +431,28 @@ def init_repository( options.mode = mode if workdir_path: - workdir_path_ref = ffi.new('char []', encode_fs_path(workdir_path)) + workdir_path_ref = ffi.new('char []', utils.encode_fs_path(workdir_path)) options.workdir_path = workdir_path_ref if description: - description_ref = ffi.new('char []', to_bytes(description)) + description_ref = ffi.new('char []', utils.encode_string(description)) options.description = description_ref if template_path: - template_path_ref = ffi.new('char []', encode_fs_path(template_path)) + template_path_ref = ffi.new('char []', utils.encode_fs_path(template_path)) options.template_path = template_path_ref if initial_head: - initial_head_ref = ffi.new('char []', to_bytes(initial_head)) + initial_head_ref = ffi.new('char []', utils.encode_string(initial_head)) options.initial_head = initial_head_ref if origin_url: - origin_url_ref = ffi.new('char []', to_bytes(origin_url)) + origin_url_ref = ffi.new('char []', utils.encode_string(origin_url)) options.origin_url = origin_url_ref # Call crepository = ffi.new('git_repository **') - err = C.git_repository_init_ext(crepository, encode_fs_path(path), options) + err = C.git_repository_init_ext(crepository, utils.encode_fs_path(path), options) check_error(err) # Ok @@ -531,13 +531,13 @@ def clone_repository( opts.fetch_opts.depth = depth if checkout_branch: - checkout_branch_ref = ffi.new('char []', to_bytes(checkout_branch)) + checkout_branch_ref = ffi.new('char []', utils.encode_string(checkout_branch)) opts.checkout_branch = checkout_branch_ref with git_fetch_options(payload, opts=opts.fetch_opts): with git_proxy_options(payload, opts.fetch_opts.proxy_opts, proxy): crepo = ffi.new('git_repository **') - err = C.git_clone(crepo, to_bytes(url), encode_fs_path(path), opts) + err = C.git_clone(crepo, utils.encode_string(url), utils.encode_fs_path(path), opts) payload.check_error(err) # Ok @@ -560,7 +560,7 @@ def filter_unregister(name: str) -> None: if FilterList._is_filter_in_use(name): raise RuntimeError(f"filter still in use: '{name}'") - c_name = to_bytes(name) + c_name = utils.encode_string(name) err = C.git_filter_unregister(c_name) check_error(err) @@ -934,7 +934,6 @@ def filter_unregister(name: str) -> None: 'transaction', 'ReferenceTransaction', 'utils', - 'to_bytes', 'to_str', # __init__ module defined symbols 'features', diff --git a/pygit2/callbacks.py b/pygit2/callbacks.py index bd5c0d031..1a4c377c8 100644 --- a/pygit2/callbacks.py +++ b/pygit2/callbacks.py @@ -77,10 +77,10 @@ from .utils import ( StrArray, decode_fs_path, + decode_string, encode_fs_path, - maybe_string, + encode_string, ptr_to_bytes, - to_bytes, ) _Credentials = Username | UserPass | Keypair @@ -460,7 +460,7 @@ def git_proxy_options( elif type(proxy) is str: opts.type = C.GIT_PROXY_SPECIFIED # Keep url in memory, otherwise memory is freed and bad things happen - payload.__proxy_url = ffi.new('char[]', to_bytes(proxy)) # type: ignore[union-attr] + payload.__proxy_url = ffi.new('char[]', encode_string(proxy)) # type: ignore[union-attr] opts.url = payload.__proxy_url # type: ignore[union-attr] else: raise TypeError('Proxy must be None, True, or a string') @@ -644,8 +644,8 @@ def _push_update_reference_cb(ref, msg, data): if not push_update_reference: return 0 - refname = maybe_string(ref) - message = maybe_string(msg) + refname = decode_string(ref) + message = decode_string(msg) push_update_reference(refname, message) return 0 @@ -713,7 +713,7 @@ def _update_tips_cb(refname, a, b, data): if not update_tips: return 0 - s = maybe_string(refname) + s = decode_string(refname) a = Oid(raw=bytes(ffi.buffer(a)[:])) b = Oid(raw=bytes(ffi.buffer(b)[:])) update_tips(s, a, b) @@ -727,8 +727,8 @@ def _update_tips_cb(refname, a, b, data): def get_credentials(fn, url, username, allowed): """Call fn and return the credentials object.""" - url_str = maybe_string(url) - username_str = maybe_string(username) + url_str = decode_string(url) + username_str = decode_string(username) creds = fn(url_str, username_str, allowed) @@ -746,22 +746,22 @@ def get_credentials(fn, url, username, allowed): if cred_type == CredentialType.USERPASS_PLAINTEXT: name, passwd = credential_tuple err = C.git_credential_userpass_plaintext_new( - ccred, to_bytes(name), to_bytes(passwd) + ccred, encode_string(name), encode_string(passwd) ) elif cred_type == CredentialType.SSH_KEY: name, pubkey, privkey, passphrase = credential_tuple - name = to_bytes(name) + name = encode_string(name) if pubkey is None and privkey is None: err = C.git_credential_ssh_key_from_agent(ccred, name) else: err = C.git_credential_ssh_key_new( - ccred, name, to_bytes(pubkey), to_bytes(privkey), to_bytes(passphrase) + ccred, name, encode_string(pubkey), encode_string(privkey), encode_string(passphrase) ) elif cred_type == CredentialType.USERNAME: (name,) = credential_tuple - err = C.git_credential_username_new(ccred, to_bytes(name)) + err = C.git_credential_username_new(ccred, encode_string(name)) elif cred_type == CredentialType.SSH_MEMORY: name, pubkey, privkey, passphrase = credential_tuple @@ -769,10 +769,10 @@ def get_credentials(fn, url, username, allowed): raise TypeError('SSH keys from memory are empty') err = C.git_credential_ssh_key_memory_new( ccred, - to_bytes(name), - to_bytes(pubkey), - to_bytes(privkey), - to_bytes(passphrase), + encode_string(name), + encode_string(pubkey), + encode_string(privkey), + encode_string(passphrase), ) else: raise TypeError('unsupported credential type') diff --git a/pygit2/config.py b/pygit2/config.py index 877bf0f0e..8016e9b46 100644 --- a/pygit2/config.py +++ b/pygit2/config.py @@ -35,7 +35,7 @@ # Import from pygit2 from .errors import check_error from .ffi import C, ffi -from .utils import encode_fs_path, to_bytes +from .utils import encode_fs_path, encode_string if TYPE_CHECKING: from ._libgit2.ffi import GitConfigC, GitConfigEntryC @@ -46,7 +46,7 @@ def str_to_bytes(value: str | bytes, name: str) -> bytes: if not isinstance(value, str): raise TypeError(f'{name} must be a string') - return to_bytes(value) + return encode_string(value) class ConfigIterator: @@ -183,7 +183,7 @@ def __setitem__(self, key: str | bytes, value: bool | int | str | bytes) -> None elif isinstance(value, int): err = C.git_config_set_int64(self._config, key, value) else: - err = C.git_config_set_string(self._config, key, to_bytes(value)) + err = C.git_config_set_string(self._config, key, encode_string(value)) check_error(err) @@ -215,7 +215,7 @@ def get_multivar( to filter the variables we're interested in. """ name = str_to_bytes(name, 'name') - regex_bytes = to_bytes(regex or None) + regex_bytes = encode_string(regex or None) citer = ffi.new('git_config_iterator **') err = C.git_config_multivar_iterator_new(citer, self._config, name, regex_bytes) @@ -305,7 +305,7 @@ def snapshot(self) -> 'Config': @staticmethod def parse_bool(text: str) -> bool: res = ffi.new('int *') - err = C.git_config_parse_bool(res, to_bytes(text)) + err = C.git_config_parse_bool(res, encode_string(text)) check_error(err) return res[0] != 0 @@ -313,7 +313,7 @@ def parse_bool(text: str) -> bool: @staticmethod def parse_int(text: str) -> int: res = ffi.new('int64_t *') - err = C.git_config_parse_int64(res, to_bytes(text)) + err = C.git_config_parse_int64(res, encode_string(text)) check_error(err) return res[0] diff --git a/pygit2/filter.py b/pygit2/filter.py index 9c53b4710..a9980005d 100644 --- a/pygit2/filter.py +++ b/pygit2/filter.py @@ -32,7 +32,7 @@ from ._pygit2 import Blob, FilterSource from .errors import check_error from .ffi import C, ffi -from .utils import encode_fs_path, to_bytes +from .utils import encode_fs_path, encode_string if TYPE_CHECKING: from ._libgit2.ffi import GitFilterListC @@ -152,7 +152,7 @@ def _is_filter_in_use(cls, name: str) -> bool: def __contains__(self, name: str) -> bool: if not isinstance(name, str): raise TypeError('argument must be str') - c_name = to_bytes(name) + c_name = encode_string(name) result = C.git_filter_list_contains(self._pointer, c_name) return bool(result) diff --git a/pygit2/options.py b/pygit2/options.py index 5d4405f53..ca8f0b140 100644 --- a/pygit2/options.py +++ b/pygit2/options.py @@ -34,7 +34,7 @@ from .errors import check_error from .ffi import C, ffi -from .utils import decode_fs_path, encode_fs_path, to_bytes, to_str +from .utils import decode_fs_path, encode_fs_path, encode_string, to_str if TYPE_CHECKING: from ._libgit2.ffi import NULL_TYPE, ArrayC, char, char_pointer @@ -520,7 +520,7 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) check_args(option_type, arg1, arg2, 1) agent = arg1 - agent_bytes = to_bytes(agent) + agent_bytes = encode_string(agent) agent_cdata = ffi.new('char[]', agent_bytes) err = C.git_libgit2_opts(option_type, agent_cdata) @@ -531,7 +531,7 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) check_args(option_type, arg1, arg2, 1) ciphers = arg1 - ciphers_bytes = to_bytes(ciphers) + ciphers_bytes = encode_string(ciphers) ciphers_cdata = ffi.new('char[]', ciphers_bytes) err = C.git_libgit2_opts(option_type, ciphers_cdata) @@ -669,7 +669,7 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) ext_strings: list[ArrayC[char]] = [] # Keep references during the call for i, ext in enumerate(extensions): - ext_bytes = to_bytes(ext) + ext_bytes = encode_string(ext) ext_string: ArrayC[char] = ffi.new('char[]', ext_bytes) ext_strings.append(ext_string) ext_array[i] = ffi.cast('char *', ext_string) @@ -781,7 +781,7 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) check_args(option_type, arg1, arg2, 1) product = arg1 - product_bytes = to_bytes(product) + product_bytes = encode_string(product) product_cdata = ffi.new('char[]', product_bytes) err = C.git_libgit2_opts(option_type, product_cdata) diff --git a/pygit2/rebase.py b/pygit2/rebase.py index fea2d75bc..68428d05d 100644 --- a/pygit2/rebase.py +++ b/pygit2/rebase.py @@ -31,7 +31,7 @@ from .errors import check_error from .ffi import C, ffi from .index import Index -from .utils import maybe_string +from .utils import decode_string if TYPE_CHECKING: from ._libgit2.ffi import GitRebaseC, GitRebaseOperationC @@ -70,7 +70,7 @@ def __init__(self, type: RebaseOperationType, id: Oid, exec: 'str | None') -> No def _from_c(cls, coperation: 'GitRebaseOperationC') -> 'RebaseOperation': type = RebaseOperationType(coperation.type) id = Oid(raw=bytes(ffi.buffer(ffi.addressof(coperation, 'id'))[:])) - exec = maybe_string(coperation.exec) + exec = decode_string(coperation.exec) return cls(type, id, exec) def __repr__(self) -> str: @@ -229,7 +229,7 @@ def abort(self) -> None: @property def orig_head_name(self) -> 'str | None': """The original HEAD ref name.""" - return maybe_string(C.git_rebase_orig_head_name(self._rebase)) + return decode_string(C.git_rebase_orig_head_name(self._rebase)) @property def orig_head_id(self) -> Oid: @@ -240,7 +240,7 @@ def orig_head_id(self) -> Oid: @property def onto_name(self) -> 'str | None': """The onto ref name.""" - return maybe_string(C.git_rebase_onto_name(self._rebase)) + return decode_string(C.git_rebase_onto_name(self._rebase)) @property def onto_id(self) -> Oid: diff --git a/pygit2/refspec.py b/pygit2/refspec.py index 794e8d134..9246782fe 100644 --- a/pygit2/refspec.py +++ b/pygit2/refspec.py @@ -28,7 +28,7 @@ # Import from pygit2 from .errors import check_error from .ffi import C, ffi -from .utils import to_bytes +from .utils import encode_string class Refspec: @@ -67,16 +67,16 @@ def src_matches(self, ref: str) -> bool: """Return True if the given string matches the source of this refspec, False otherwise. """ - return bool(C.git_refspec_src_matches(self._refspec, to_bytes(ref))) + return bool(C.git_refspec_src_matches(self._refspec, encode_string(ref))) def dst_matches(self, ref: str) -> bool: """Return True if the given string matches the destination of this refspec, False otherwise.""" - return bool(C.git_refspec_dst_matches(self._refspec, to_bytes(ref))) + return bool(C.git_refspec_dst_matches(self._refspec, encode_string(ref))) def _transform(self, ref: str, fn: Callable) -> str: buf = ffi.new('git_buf *', (ffi.NULL, 0)) - err = fn(buf, self._refspec, to_bytes(ref)) + err = fn(buf, self._refspec, encode_string(ref)) check_error(err) try: diff --git a/pygit2/remotes.py b/pygit2/remotes.py index 265b1783c..8eeed41e5 100644 --- a/pygit2/remotes.py +++ b/pygit2/remotes.py @@ -44,7 +44,7 @@ from .errors import check_error from .ffi import C, ffi from .refspec import Refspec -from .utils import StrArray, maybe_string, strarray_to_strings, to_bytes +from .utils import StrArray, decode_string, encode_string, strarray_to_strings # Need BaseRepository for type hints, but don't let it cause a circular dependency if TYPE_CHECKING: @@ -77,8 +77,8 @@ def __init__(self, c_struct: Any) -> None: self.local = bool(c_struct.local) self.oid = Oid(raw=bytes(ffi.buffer(c_struct.oid.id)[:])) self.loid = Oid(raw=bytes(ffi.buffer(c_struct.loid.id)[:])) - self.name = maybe_string(c_struct.name) - self.symref_target = maybe_string(c_struct.symref_target) + self.name = decode_string(c_struct.name) + self.symref_target = decode_string(c_struct.symref_target) class PushUpdate: @@ -99,8 +99,8 @@ class PushUpdate: """The new target for the reference""" def __init__(self, c_struct: Any) -> None: - src_refname = maybe_string(c_struct.src_refname) - dst_refname = maybe_string(c_struct.dst_refname) + src_refname = decode_string(c_struct.src_refname) + dst_refname = decode_string(c_struct.dst_refname) assert src_refname is not None, 'libgit2 returned null src_refname' assert dst_refname is not None, 'libgit2 returned null dst_refname' self.src_refname = src_refname @@ -157,19 +157,19 @@ def __del__(self) -> None: def name(self) -> str | None: """Name of the remote""" - return maybe_string(C.git_remote_name(self._remote)) + return decode_string(C.git_remote_name(self._remote)) @property def url(self) -> str | None: """Url of the remote""" - return maybe_string(C.git_remote_url(self._remote)) + return decode_string(C.git_remote_url(self._remote)) @property def push_url(self) -> str | None: """Push url of the remote""" - return maybe_string(C.git_remote_pushurl(self._remote)) + return decode_string(C.git_remote_pushurl(self._remote)) def connect( self, @@ -241,7 +241,7 @@ def fetch( with git_proxy_options(self, payload.fetch_options.proxy_opts, proxy): with StrArray(refspecs) as arr: err = C.git_remote_fetch( - self._remote, arr.ptr, opts, to_bytes(message) + self._remote, arr.ptr, opts, encode_string(message) ) payload.check_error(err) @@ -404,7 +404,7 @@ def __getitem__(self, name: str | int) -> Remote: return list(self)[name] cremote = ffi.new('git_remote **') - err = C.git_remote_lookup(cremote, self._repo._repo, to_bytes(name)) + err = C.git_remote_lookup(cremote, self._repo._repo, encode_string(name)) check_error(err) return Remote(self._repo, cremote[0]) @@ -419,7 +419,7 @@ def _ffi_names(self) -> Generator['char_pointer', None, None]: def names(self) -> Generator[str | None, None, None]: """An iterator over the names of the available remotes.""" for name in self._ffi_names(): - yield maybe_string(name) + yield decode_string(name) def create(self, name: str, url: str, fetch: str | None = None) -> Remote: """Create a new remote with the given name and url. Returns a @@ -430,10 +430,10 @@ def create(self, name: str, url: str, fetch: str | None = None) -> Remote: """ cremote = ffi.new('git_remote **') - name_bytes = to_bytes(name) - url_bytes = to_bytes(url) + name_bytes = encode_string(name) + url_bytes = encode_string(url) if fetch: - fetch_bytes = to_bytes(fetch) + fetch_bytes = encode_string(fetch) err = C.git_remote_create_with_fetchspec( cremote, self._repo._repo, name_bytes, url_bytes, fetch_bytes ) @@ -449,7 +449,7 @@ def create_anonymous(self, url: str) -> Remote: Returns a object. """ cremote = ffi.new('git_remote **') - url_bytes = to_bytes(url) + url_bytes = encode_string(url) err = C.git_remote_create_anonymous(cremote, self._repo._repo, url_bytes) check_error(err) return Remote(self._repo, cremote[0]) @@ -470,7 +470,7 @@ def rename(self, name: str, new_name: str) -> list[str]: problems = ffi.new('git_strarray *') err = C.git_remote_rename( - problems, self._repo._repo, to_bytes(name), to_bytes(new_name) + problems, self._repo._repo, encode_string(name), encode_string(new_name) ) check_error(err) return strarray_to_strings(problems) @@ -480,29 +480,29 @@ def delete(self, name: str) -> None: All remote-tracking branches and configuration settings for the remote will be removed. """ - err = C.git_remote_delete(self._repo._repo, to_bytes(name)) + err = C.git_remote_delete(self._repo._repo, encode_string(name)) check_error(err) def set_url(self, name: str, url: str) -> None: """Set the URL for a remote""" - err = C.git_remote_set_url(self._repo._repo, to_bytes(name), to_bytes(url)) + err = C.git_remote_set_url(self._repo._repo, encode_string(name), encode_string(url)) check_error(err) def set_push_url(self, name: str, url: str) -> None: """Set the push-URL for a remote""" - err = C.git_remote_set_pushurl(self._repo._repo, to_bytes(name), to_bytes(url)) + err = C.git_remote_set_pushurl(self._repo._repo, encode_string(name), encode_string(url)) check_error(err) def add_fetch(self, name: str, refspec: str) -> None: """Add a fetch refspec (str) to the remote""" err = C.git_remote_add_fetch( - self._repo._repo, to_bytes(name), to_bytes(refspec) + self._repo._repo, encode_string(name), encode_string(refspec) ) check_error(err) def add_push(self, name: str, refspec: str) -> None: """Add a push refspec (str) to the remote""" - err = C.git_remote_add_push(self._repo._repo, to_bytes(name), to_bytes(refspec)) + err = C.git_remote_add_push(self._repo._repo, encode_string(name), encode_string(refspec)) check_error(err) diff --git a/pygit2/repository.py b/pygit2/repository.py index 3ffc2d866..522c0c856 100644 --- a/pygit2/repository.py +++ b/pygit2/repository.py @@ -82,7 +82,13 @@ from .remotes import RemoteCollection from .submodules import SubmoduleCollection from .transaction import ReferenceTransaction -from .utils import StrArray, decode_fs_path, encode_fs_path, maybe_string, to_bytes +from .utils import ( + StrArray, + decode_fs_path, + decode_string, + encode_fs_path, + encode_string, +) if TYPE_CHECKING: from pygit2._libgit2.ffi import ( @@ -228,7 +234,7 @@ def hashfile( if as_path is None: c_as_path = ffi.NULL else: - c_as_path = to_bytes(as_path) + c_as_path = encode_string(as_path) c_oid = ffi.new('git_oid *') @@ -257,7 +263,7 @@ def load_filter_list( (CLEAN). """ c_filters = ffi.new('git_filter_list **') - c_path = to_bytes(path) + c_path = encode_string(path) c_mode = int(mode) err = C.git_filter_list_load(c_filters, self._repo, ffi.NULL, c_path, c_mode, 0) @@ -534,7 +540,7 @@ def set_head(self, target: Oid | str) -> None: return # if it's a string, then it's a reference name - err = C.git_repository_set_head(self._repo, to_bytes(target)) + err = C.git_repository_set_head(self._repo, encode_string(target)) check_error(err) # @@ -765,7 +771,7 @@ def blame( options.max_line = max_line cblame = ffi.new('git_blame **') - err = C.git_blame_file(cblame, self._repo, to_bytes(path), options) + err = C.git_blame_file(cblame, self._repo, encode_string(path), options) check_error(err) return Blame._from_c(self, cblame[0]) @@ -1107,7 +1113,7 @@ def _rebase_options( opts.inmemory = int(inmemory) opts.quiet = int(quiet) if rewrite_notes_ref is not None: - notes_ref = ffi.new('char[]', to_bytes(rewrite_notes_ref)) + notes_ref = ffi.new('char[]', encode_string(rewrite_notes_ref)) refs.append(notes_ref) opts.rewrite_notes_ref = notes_ref @@ -1123,7 +1129,7 @@ def _rebase_options( ) for field, label in labels: if label is not None: - clabel = ffi.new('char[]', to_bytes(label)) + clabel = ffi.new('char[]', encode_string(label)) refs.append(clabel) setattr(opts.checkout_options, field, clabel) @@ -1401,7 +1407,7 @@ def describe( # The returned pointer object has ownership on the allocated # memory. Make sure it is kept alive until git_describe_commit() or # git_describe_workdir() are called below. - pattern_char = ffi.new('char[]', to_bytes(pattern)) + pattern_char = ffi.new('char[]', encode_string(pattern)) options.pattern = pattern_char if only_follow_first_parent is not None: options.only_follow_first_parent = only_follow_first_parent @@ -1438,7 +1444,7 @@ def describe( format_options.always_use_long_format = always_use_long_format dirty_ptr = None if dirty_suffix: - dirty_ptr = ffi.new('char[]', to_bytes(dirty_suffix)) + dirty_ptr = ffi.new('char[]', encode_string(dirty_suffix)) format_options.dirty_suffix = dirty_ptr buf = ffi.new('git_buf *', (ffi.NULL, 0)) @@ -1515,7 +1521,7 @@ def stash( opts.stasher = stasher_cptr[0] if message: - message_ref = ffi.new('char[]', to_bytes(message)) + message_ref = ffi.new('char[]', encode_string(message)) opts.message = message_ref if paths: @@ -1776,7 +1782,7 @@ def get_attr( cvalue = ffi.new('char **') err = C.git_attr_get_ext( - cvalue, self._repo, copts, to_bytes(path), to_bytes(name) + cvalue, self._repo, copts, encode_string(path), encode_string(name) ) check_error(err) @@ -1804,7 +1810,7 @@ def ident(self) -> tuple[Optional[str], Optional[str]]: err = C.git_repository_ident(cname, cemail, self._repo) check_error(err) - return (maybe_string(cname[0]), maybe_string(cemail[0])) + return (decode_string(cname[0]), decode_string(cemail[0])) def set_ident(self, name: Optional[str], email: Optional[str]) -> None: """Set the identity to be used for reference operations. @@ -1814,7 +1820,7 @@ def set_ident(self, name: Optional[str], email: Optional[str]) -> None: used. If none is set, it will be read from the configuration. """ - err = C.git_repository_set_ident(self._repo, to_bytes(name), to_bytes(email)) + err = C.git_repository_set_ident(self._repo, encode_string(name), encode_string(email)) check_error(err) def revert(self, commit: Commit) -> None: @@ -1950,9 +1956,9 @@ def amend_commit( # Get refname as C string. if isinstance(refname, Reference): - refname_cstr = ffi.new('char[]', to_bytes(refname.name)) + refname_cstr = ffi.new('char[]', encode_string(refname.name)) elif type(refname) is str: - refname_cstr = ffi.new('char[]', to_bytes(refname)) + refname_cstr = ffi.new('char[]', encode_string(refname)) elif refname is not None: raise TypeError('refname must be a str or Reference') @@ -1970,8 +1976,8 @@ def amend_commit( # Get message and encoding as C strings. if message is not None: - message_cstr = ffi.new('char[]', to_bytes(message, encoding)) - encoding_cstr = ffi.new('char[]', to_bytes(encoding)) + message_cstr = ffi.new('char[]', encode_string(message, encoding)) + encoding_cstr = ffi.new('char[]', encode_string(encoding)) # Get tree as pointer to git_tree. if tree is not None: diff --git a/pygit2/submodules.py b/pygit2/submodules.py index 14c6aef8b..93f70d0ce 100644 --- a/pygit2/submodules.py +++ b/pygit2/submodules.py @@ -34,7 +34,7 @@ from .enums import SubmoduleIgnore, SubmoduleStatus from .errors import check_error from .ffi import C, ffi -from .utils import decode_fs_path, maybe_string, to_bytes +from .utils import decode_fs_path, decode_string, encode_string # Need BaseRepository for type hints, but don't let it cause a circular dependency if TYPE_CHECKING: @@ -138,7 +138,7 @@ def reload(self, force: bool = False) -> None: @property def name(self): """Name of the submodule.""" - return maybe_string(C.git_submodule_name(self._subm)) + return decode_string(C.git_submodule_name(self._subm)) @property def path(self): @@ -149,20 +149,20 @@ def path(self): def url(self) -> str | None: """URL of the submodule.""" url = C.git_submodule_url(self._subm) - return maybe_string(url) + return decode_string(url) @url.setter def url(self, url: str) -> None: crepo = self._repo._repo - cname = ffi.new('char[]', to_bytes(self.name)) - curl = ffi.new('char[]', to_bytes(url)) + cname = ffi.new('char[]', encode_string(self.name)) + curl = ffi.new('char[]', encode_string(url)) err = C.git_submodule_set_url(crepo, cname, curl) check_error(err) @property def branch(self): """Branch that is to be tracked by the submodule.""" - return maybe_string(C.git_submodule_branch(self._subm)) + return decode_string(C.git_submodule_branch(self._subm)) @property def head_id(self) -> Oid | None: @@ -190,7 +190,7 @@ def __getitem__(self, name: str | Path) -> Submodule: Raises KeyError if there is no such submodule. """ csub = ffi.new('git_submodule **') - cpath = ffi.new('char[]', to_bytes(name)) + cpath = ffi.new('char[]', encode_string(name)) err = C.git_submodule_lookup(csub, self._repository._repo, cpath) check_error(err) @@ -246,8 +246,8 @@ def add( The default is 0 (full commit history). """ csub = ffi.new('git_submodule **') - curl = ffi.new('char[]', to_bytes(url)) - cpath = ffi.new('char[]', to_bytes(path)) + curl = ffi.new('char[]', encode_string(url)) + cpath = ffi.new('char[]', encode_string(path)) gitlink = 1 if link else 0 err = C.git_submodule_add_setup( @@ -360,7 +360,7 @@ def status( """ cstatus = ffi.new('unsigned int *') err = C.git_submodule_status( - cstatus, self._repository._repo, to_bytes(name), ignore + cstatus, self._repository._repo, encode_string(name), ignore ) check_error(err) return SubmoduleStatus(cstatus[0]) diff --git a/pygit2/transaction.py b/pygit2/transaction.py index 358b40a98..f8348cb7d 100644 --- a/pygit2/transaction.py +++ b/pygit2/transaction.py @@ -30,7 +30,7 @@ from .errors import check_error from .ffi import C, ffi -from .utils import to_bytes +from .utils import encode_string if TYPE_CHECKING: from ._pygit2 import Oid, Signature @@ -81,7 +81,7 @@ def lock_ref(self, refname: str) -> None: if self._tx is None: raise ValueError('Transaction already closed') - c_refname = ffi.new('char[]', to_bytes(refname)) + c_refname = ffi.new('char[]', encode_string(refname)) err = C.git_transaction_lock_ref(self._tx, c_refname) check_error(err) @@ -108,7 +108,7 @@ def set_target( from ._pygit2 import Oid - c_refname = ffi.new('char[]', to_bytes(refname)) + c_refname = ffi.new('char[]', encode_string(refname)) # Convert target to OID if isinstance(target, str): @@ -118,7 +118,7 @@ def set_target( ffi.buffer(c_oid)[:] = target.raw c_sig = signature._pointer if signature else ffi.NULL - c_msg = ffi.new('char[]', to_bytes(message)) if message else ffi.NULL + c_msg = ffi.new('char[]', encode_string(message)) if message else ffi.NULL err = C.git_transaction_set_target(self._tx, c_refname, c_oid, c_sig, c_msg) check_error(err) @@ -144,10 +144,10 @@ def set_symbolic_target( if self._tx is None: raise ValueError('Transaction already closed') - c_refname = ffi.new('char[]', to_bytes(refname)) - c_target = ffi.new('char[]', to_bytes(target)) + c_refname = ffi.new('char[]', encode_string(refname)) + c_target = ffi.new('char[]', encode_string(target)) c_sig = signature._pointer if signature else ffi.NULL - c_msg = ffi.new('char[]', to_bytes(message)) if message else ffi.NULL + c_msg = ffi.new('char[]', encode_string(message)) if message else ffi.NULL err = C.git_transaction_set_symbolic_target( self._tx, c_refname, c_target, c_sig, c_msg @@ -166,7 +166,7 @@ def remove(self, refname: str) -> None: if self._tx is None: raise ValueError('Transaction already closed') - c_refname = ffi.new('char[]', to_bytes(refname)) + c_refname = ffi.new('char[]', encode_string(refname)) err = C.git_transaction_remove(self._tx, c_refname) check_error(err) diff --git a/pygit2/utils.py b/pygit2/utils.py index c430a1b9e..41545f42f 100644 --- a/pygit2/utils.py +++ b/pygit2/utils.py @@ -47,7 +47,7 @@ PathStrOrBytes = str | bytes | os.PathLike[str] | os.PathLike[bytes] -def maybe_string(ptr: 'char_pointer | None') -> str | None: +def decode_string(ptr: 'char_pointer | None') -> str | None: if not ptr: return None @@ -85,18 +85,18 @@ def encode_fs_path( @overload -def to_bytes( +def encode_string( s: str | bytes | os.PathLike[str] | os.PathLike[bytes], encoding: str = 'utf-8', errors: str = 'strict', ) -> bytes: ... @overload -def to_bytes( +def encode_string( s: Union['ffi.NULL_TYPE', None], encoding: str = 'utf-8', errors: str = 'strict', ) -> Union['ffi.NULL_TYPE']: ... -def to_bytes( +def encode_string( s: Union[str, bytes, 'ffi.NULL_TYPE', os.PathLike[str], os.PathLike[bytes], None], encoding: str = 'utf-8', errors: str = 'strict', @@ -195,7 +195,7 @@ def __init__(self, lst: None | Sequence[str | os.PathLike[str]]): if not isinstance(li, str) and not hasattr(li, '__fspath__'): raise TypeError('Value must be a string or PathLike object') - strings[i] = ffi.new('char []', to_bytes(li)) + strings[i] = ffi.new('char []', encode_string(li)) self.__arr = ffi.new('char *[]', strings) self.__strings = strings From 3f335447ae799aae1f3c2d0b94474a79713bf6f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Wed, 5 Aug 2026 16:44:55 +0200 Subject: [PATCH 198/225] Fix build warning and mypy error - src/repository.c: include Python.h before git2 headers so _POSIX_C_SOURCE is not redefined (Python.h must come first). - pygit2/utils.py: silence mypy arg-type error in encode_fs_path; the == ffi.NULL guard cannot narrow away ffi.NULL_TYPE, same as in encode_string. --- pygit2/utils.py | 2 +- src/repository.c | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pygit2/utils.py b/pygit2/utils.py index 41545f42f..4dba7eb0f 100644 --- a/pygit2/utils.py +++ b/pygit2/utils.py @@ -81,7 +81,7 @@ def encode_fs_path( if s is None or s == ffi.NULL: return ffi.NULL - return os.fsencode(s) + return os.fsencode(s) # type: ignore[arg-type] @overload diff --git a/src/repository.c b/src/repository.c index 1348e23b8..3613b0108 100644 --- a/src/repository.c +++ b/src/repository.c @@ -25,9 +25,9 @@ * Boston, MA 02110-1301, USA. */ -#include #define PY_SSIZE_T_CLEAN #include +#include #include "error.h" #include "types.h" #include "reference.h" From c9366e5f941b31663639ea1767edd42fd5cb4e2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Wed, 5 Aug 2026 17:12:59 +0200 Subject: [PATCH 199/225] Fix ruff formatting and update AGENTS.md Reformat pygit2/__init__.py, pygit2/callbacks.py, pygit2/remotes.py and pygit2/repository.py to fix the 'ruff format --diff' CI failure. Also refresh AGENTS.md (build commands, CI details, mypy strictness for tests) and document that AI-authored commits must carry the Assisted-by trailer. Assisted-by: Kimi Code --- AGENTS.md | 45 +++++++++++++++++++++++++++----------------- pygit2/__init__.py | 8 ++++++-- pygit2/callbacks.py | 6 +++++- pygit2/remotes.py | 12 +++++++++--- pygit2/repository.py | 4 +++- 5 files changed, 51 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 78b6c354e..3bbc87c4e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,7 +56,8 @@ modules: `repository.py`, `callbacks.py`, `config.py`, `index.py`, `remotes.py`, `settings.py`, `submodules.py`, `transaction.py`, `filter.py`, `blob.py`, `blame.py`, `branches.py`, `credentials.py`, `errors.py`, `options.py`, - `packbuilder.py`, `references.py`, `refspec.py`, `utils.py`, `enums.py`. + `packbuilder.py`, `rebase.py`, `references.py`, `refspec.py`, `utils.py`, + `enums.py`. - **`test/`** — pytest suite with fixture-based repository handling. - **`docs/`** — Sphinx documentation (RTD theme). @@ -64,7 +65,8 @@ modules: ## Key Configuration Files - **`setup.py`** — setuptools entry point. Builds both the C extension - (`src/*.c`) and the CFFI extension (`pygit2/_run.py:ffi`). + (`src/*.c`) and the CFFI extension (`pygit2/_run.py:ffi`). On Windows it + also copies `git2.dll` into the package (see `BuildWithDLLs`). - **`pyproject.toml`** — Build-system requirements, `cibuildwheel` configuration, `ruff` settings, and `codespell` settings. - **`setup.cfg`** — Legacy pycodestyle configuration. @@ -72,7 +74,7 @@ modules: `testpaths = test/`). - **`mypy.ini`** — mypy configuration with strict settings. - **`mypy-stubtest.ini`** — mypy configuration for `stubtest` against - `_pygit2.pyi`. + `_pygit2.pyi` (at the repo root). - **`requirements.txt`** — Runtime/build requirements (`cffi>=2.0`, `setuptools` for Python >= 3.12). - **`requirements-test.txt`** — Test requirements (`pytest`, `pytest-cov`). @@ -107,14 +109,12 @@ make # Or manually: LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.6 sh build.sh -# Build inplace and run the tests -sh build.sh test - -# Build a wheel, install it, and run the tests +# Build a wheel and bundle the shared libraries into it sh build.sh wheel +sh build.sh bundle -# Run tests with coverage -sh build.sh test # build.sh adds --cov=pygit2 +# Build inplace and run the tests with coverage (build.sh adds --cov=pygit2) +sh build.sh test # Run mypy type checking sh build.sh mypy @@ -169,7 +169,9 @@ make -C docs html # requires sphinx-rtd-theme - Run `ruff format` and `ruff check` on changed files before committing. CI runs `ruff format --diff` and `ruff check`; formatting failures will fail the build. -- **Type checker**: mypy (strict settings enabled; see `mypy.ini`) +- **Type checker**: mypy (strict settings enabled; see `mypy.ini`). Test + modules additionally require typed defs and calls (`disallow_untyped_defs`, + `disallow_untyped_calls` under `[mypy-test.*]`). - All Python source files must include the standard GPLv2 copyright header. - `pygit2/__init__.py` is large because it re-exports a large surface of constants and classes; follow existing patterns when adding new public @@ -235,13 +237,15 @@ def f(a, b): GitHub Actions workflows live in `.github/workflows/`: - **`tests.yml`** — Runs on s390x via QEMU (`uraimo/run-on-arch-action`). - Allowed to fail; see issue #812. + Allowed to fail (`continue-on-error`); see issue #812. - **`lint.yml`** — Runs `ruff format --diff`, `ruff check`, and `sh build.sh mypy`. -- **`wheels.yml`** — Uses `cibuildwheel` to build wheels for Linux (amd64, - arm64, ppc64le, musl), macOS (intel, arm64, PyPy), and Windows (x64, x86, - arm64). It also builds an sdist, runs a `twine check`, publishes to PyPI, - and creates a GitHub Release on version tags (`v*`). +- **`wheels.yml`** — Uses `cibuildwheel` (`~=3.3`) to build wheels for Linux + (amd64, arm64, ppc64le and riscv64 via QEMU, musl), macOS (intel, arm64, + PyPy), and Windows (x64, x86, arm64). It also builds an sdist, runs a + `twine check` (skipped on version tags), publishes to PyPI, and creates a + GitHub Release on version tags (`v*`), with release notes parsed from + `CHANGELOG.md` by `.github/workflows/parse_release_notes.py`. - **`codespell.yml`** — Spell checking with the codespell action. The `cibuildwheel` configuration in `pyproject.toml` pins: @@ -250,8 +254,10 @@ The `cibuildwheel` configuration in `pyproject.toml` pins: - `LIBSSH2_VERSION="1.11.1"` - `OPENSSL_VERSION="3.5.4"` -and skips `*musllinux_ppc64le` plus testing on `*-*linux_ppc64le` and -`pp*-macosx_arm64`. +and skips `*musllinux_ppc64le` plus testing on `*-*linux_ppc64le`, +`*-*linux_riscv64` and `pp*-macosx_arm64`. On Windows it uses the +`Visual Studio 18 2026` CMake generator for x64/x86 and +`Visual Studio 17 2022` for ARM64. ## Security Considerations @@ -264,6 +270,11 @@ and skips `*musllinux_ppc64le` plus testing on `*-*linux_ppc64le` and - Valgrind support: see `docs/development.rst` and `misc/valgrind-python.supp` for memory-leak debugging instructions. +## Git Commits + +- Commits created by an AI agent must end with the `Assisted-by:` trailer. + Human-authored commits do not need it. + ## Useful Notes for Agents - **Do not assume libgit2 is installed globally.** Check for `LIBGIT2` or use diff --git a/pygit2/__init__.py b/pygit2/__init__.py index 7b4986a12..99b391fb0 100644 --- a/pygit2/__init__.py +++ b/pygit2/__init__.py @@ -531,13 +531,17 @@ def clone_repository( opts.fetch_opts.depth = depth if checkout_branch: - checkout_branch_ref = ffi.new('char []', utils.encode_string(checkout_branch)) + checkout_branch_ref = ffi.new( + 'char []', utils.encode_string(checkout_branch) + ) opts.checkout_branch = checkout_branch_ref with git_fetch_options(payload, opts=opts.fetch_opts): with git_proxy_options(payload, opts.fetch_opts.proxy_opts, proxy): crepo = ffi.new('git_repository **') - err = C.git_clone(crepo, utils.encode_string(url), utils.encode_fs_path(path), opts) + err = C.git_clone( + crepo, utils.encode_string(url), utils.encode_fs_path(path), opts + ) payload.check_error(err) # Ok diff --git a/pygit2/callbacks.py b/pygit2/callbacks.py index 1a4c377c8..da78e8faf 100644 --- a/pygit2/callbacks.py +++ b/pygit2/callbacks.py @@ -756,7 +756,11 @@ def get_credentials(fn, url, username, allowed): err = C.git_credential_ssh_key_from_agent(ccred, name) else: err = C.git_credential_ssh_key_new( - ccred, name, encode_string(pubkey), encode_string(privkey), encode_string(passphrase) + ccred, + name, + encode_string(pubkey), + encode_string(privkey), + encode_string(passphrase), ) elif cred_type == CredentialType.USERNAME: diff --git a/pygit2/remotes.py b/pygit2/remotes.py index 8eeed41e5..17ff08110 100644 --- a/pygit2/remotes.py +++ b/pygit2/remotes.py @@ -485,12 +485,16 @@ def delete(self, name: str) -> None: def set_url(self, name: str, url: str) -> None: """Set the URL for a remote""" - err = C.git_remote_set_url(self._repo._repo, encode_string(name), encode_string(url)) + err = C.git_remote_set_url( + self._repo._repo, encode_string(name), encode_string(url) + ) check_error(err) def set_push_url(self, name: str, url: str) -> None: """Set the push-URL for a remote""" - err = C.git_remote_set_pushurl(self._repo._repo, encode_string(name), encode_string(url)) + err = C.git_remote_set_pushurl( + self._repo._repo, encode_string(name), encode_string(url) + ) check_error(err) def add_fetch(self, name: str, refspec: str) -> None: @@ -504,5 +508,7 @@ def add_fetch(self, name: str, refspec: str) -> None: def add_push(self, name: str, refspec: str) -> None: """Add a push refspec (str) to the remote""" - err = C.git_remote_add_push(self._repo._repo, encode_string(name), encode_string(refspec)) + err = C.git_remote_add_push( + self._repo._repo, encode_string(name), encode_string(refspec) + ) check_error(err) diff --git a/pygit2/repository.py b/pygit2/repository.py index 522c0c856..7628dd5b0 100644 --- a/pygit2/repository.py +++ b/pygit2/repository.py @@ -1820,7 +1820,9 @@ def set_ident(self, name: Optional[str], email: Optional[str]) -> None: used. If none is set, it will be read from the configuration. """ - err = C.git_repository_set_ident(self._repo, encode_string(name), encode_string(email)) + err = C.git_repository_set_ident( + self._repo, encode_string(name), encode_string(email) + ) check_error(err) def revert(self, commit: Commit) -> None: From 74de0db776fea2537431d1bf217a7f244ded29da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Wed, 5 Aug 2026 17:39:50 +0200 Subject: [PATCH 200/225] Drop deprecated setup_requires, fix encode_fs_path annotations on Python < 3.14 Move the cffi build requirement from setup_requires (deprecated fetch_build_eggs path) to [build-system] requires in pyproject.toml. Also quote the encode_fs_path signature annotations in full: mixing a runtime PEP 604 union (PathStrOrBytes) with a string forward reference raised TypeError at import time on Python < 3.14, breaking the wheel tests on 3.11. Python 3.14's lazy annotation evaluation (PEP 649) had masked the issue locally. Assisted-by: Kimi Code CLI --- pygit2/utils.py | 4 ++-- pyproject.toml | 2 +- setup.py | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/pygit2/utils.py b/pygit2/utils.py index 4dba7eb0f..749fc64f4 100644 --- a/pygit2/utils.py +++ b/pygit2/utils.py @@ -76,8 +76,8 @@ def encode_fs_path(s: PathStrOrBytes) -> bytes: ... @overload def encode_fs_path(s: 'ffi.NULL_TYPE | None') -> 'ffi.NULL_TYPE': ... def encode_fs_path( - s: PathStrOrBytes | 'ffi.NULL_TYPE | None', -) -> bytes | 'ffi.NULL_TYPE': + s: 'PathStrOrBytes | ffi.NULL_TYPE | None', +) -> 'bytes | ffi.NULL_TYPE': if s is None or s == ffi.NULL: return ffi.NULL diff --git a/pyproject.toml b/pyproject.toml index 422ea0e10..6ee10527a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools"] +requires = ["setuptools", "cffi>=2.0"] build-backend = "setuptools.build_meta" [tool.cibuildwheel] diff --git a/setup.py b/setup.py index d92087cd1..d960cb8ce 100644 --- a/setup.py +++ b/setup.py @@ -154,7 +154,6 @@ def run(self) -> None: ext_modules=ext_modules, # Requirements python_requires='>=3.11', - setup_requires=['cffi>=2.0'], install_requires=['cffi>=2.0'], # URLs url='https://github.com/libgit2/pygit2', From b7fdcd6286159954143cfc4c477d9087714739bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Wed, 5 Aug 2026 18:18:02 +0200 Subject: [PATCH 201/225] encoding: drop public to_str, rename internal helper to path_to_str Replace the C-boundary uses of to_str in options.py with decode_string, which decodes the char pointer directly (no manual ffi.string call) and handles NULL. Remove pygit2.to_str from the public API; it was an undocumented accidental public helper, like to_bytes. Rename the remaining internal helper to path_to_str, which better describes its only remaining job: normalizing a user-supplied path argument to str in init_repository. Add a TODO noting the decode/encode asymmetry: decode_string uses surrogateescape while encode_string defaults to strict, so a value read from libgit2 with bad bytes cannot be written back without raising. Update changelog. Assisted-by: Kimi K3 --- CHANGELOG.md | 3 ++- pygit2/__init__.py | 4 +--- pygit2/options.py | 17 ++++++----------- pygit2/utils.py | 7 ++++++- 4 files changed, 15 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eaa41a4d1..4242d5596 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,7 +41,8 @@ Breaking changes: - Remove deprecated `Remote.ls_remotes(...)`, use `Remote.list_heads(...)` instead -- Remove `pygit2.to_bytes`; it was an undocumented accidental public API +- Remove `pygit2.to_bytes` and `pygit2.to_str`; they were undocumented + accidental public APIs # 1.19.3 (2026-06-13) diff --git a/pygit2/__init__.py b/pygit2/__init__.py index 99b391fb0..4a69fe044 100644 --- a/pygit2/__init__.py +++ b/pygit2/__init__.py @@ -366,7 +366,6 @@ from .settings import Settings from .submodules import Submodule from .transaction import ReferenceTransaction -from .utils import to_str # Features features = enums.Feature(C.git_libgit2_features()) @@ -456,7 +455,7 @@ def init_repository( check_error(err) # Ok - return Repository(to_str(path)) + return Repository(utils.path_to_str(path)) def clone_repository( @@ -938,7 +937,6 @@ def filter_unregister(name: str) -> None: 'transaction', 'ReferenceTransaction', 'utils', - 'to_str', # __init__ module defined symbols 'features', 'LIBGIT2_VER', diff --git a/pygit2/options.py b/pygit2/options.py index ca8f0b140..80f20b124 100644 --- a/pygit2/options.py +++ b/pygit2/options.py @@ -34,7 +34,7 @@ from .errors import check_error from .ffi import C, ffi -from .utils import decode_fs_path, encode_fs_path, encode_string, to_str +from .utils import decode_fs_path, decode_string, encode_fs_path, encode_string if TYPE_CHECKING: from ._libgit2.ffi import NULL_TYPE, ArrayC, char, char_pointer @@ -507,10 +507,7 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) check_error(err) try: - if buf.ptr != ffi.NULL: - result = to_str(ffi.string(buf.ptr)) - else: - result = None + result = decode_string(buf.ptr) finally: C.git_buf_dispose(buf) @@ -643,8 +640,9 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) # Cast to the non-NULL type for type checking strings = cast('ArrayC[char_pointer]', strarray.strings) for i in range(strarray.count): - if strings[i] != ffi.NULL: - result.append(to_str(ffi.string(strings[i]))) + s = decode_string(strings[i]) + if s is not None: + result.append(s) finally: # Must dispose of the strarray to free the memory C.git_strarray_dispose(strarray) @@ -767,10 +765,7 @@ def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) check_error(err) try: - if buf.ptr != ffi.NULL: - result = to_str(ffi.string(buf.ptr)) - else: - result = None + result = decode_string(buf.ptr) finally: C.git_buf_dispose(buf) diff --git a/pygit2/utils.py b/pygit2/utils.py index 749fc64f4..4e6e25617 100644 --- a/pygit2/utils.py +++ b/pygit2/utils.py @@ -84,6 +84,11 @@ def encode_fs_path( return os.fsencode(s) # type: ignore[arg-type] +# TODO decode_string uses errors='surrogateescape', but encode_string defaults +# to errors='strict', so a value read from libgit2 with bad bytes cannot be +# written back without raising. Decide whether encode_string should default to +# 'surrogateescape' too, and audit every caller to make sure that's safe +# (this is a behavior change, not just a rename). @overload def encode_string( s: str | bytes | os.PathLike[str] | os.PathLike[bytes], @@ -113,7 +118,7 @@ def encode_string( return s.encode(encoding, errors) # type: ignore[union-attr] -def to_str(s: str | bytes | os.PathLike[str] | os.PathLike[bytes]) -> str: +def path_to_str(s: str | bytes | os.PathLike[str] | os.PathLike[bytes]) -> str: if hasattr(s, '__fspath__'): s = os.fspath(s) From 258bf558aeb7f89b283e3ba551bcdde6ff76aad4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 15:15:23 +0000 Subject: [PATCH 202/225] chore(CI): bump actions/setup-python from 6 to 7 in the actions group Bumps the actions group with 1 update: [actions/setup-python](https://github.com/actions/setup-python). Updates `actions/setup-python` from 6 to 7 - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/setup-python dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions ... Signed-off-by: dependabot[bot] --- .github/workflows/lint.yml | 2 +- .github/workflows/wheels.yml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 6408d34ba..68c8dff84 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -17,7 +17,7 @@ jobs: uses: actions/checkout@v7 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: '3.13' diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index e0745bed5..cf32a338e 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -25,7 +25,7 @@ jobs: # avoid leaking credentials in uploaded artifacts persist-credentials: false - - uses: actions/setup-python@v6 + - uses: actions/setup-python@v7 with: python-version: '3.13' @@ -73,7 +73,7 @@ jobs: os: windows-11-arm steps: - - uses: actions/setup-python@v6 + - uses: actions/setup-python@v7 with: python-version: '3.13' @@ -113,7 +113,7 @@ jobs: cibw_arch: riscv64 steps: - - uses: actions/setup-python@v6 + - uses: actions/setup-python@v7 with: python-version: '3.13' From eb512d2c88cd82164f6e6f8b4fcfd6e4e980eb1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Fri, 7 Aug 2026 11:38:16 +0200 Subject: [PATCH 203/225] Fix refcounting bugs in the refdb backend write callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pygit2_refdb_backend_write() passed owned objects to Py_BuildValue() with the N format, which takes ownership, then decref'd the same objects again in the shared cleanup block — a double DECREF that can lead to use-after-free. It also passed a borrowed reference to the Py_True/Py_False singleton with N, and the error path called Py_DECREF() on possibly NULL pointers. Restructure the function with early returns so each failure point releases exactly the objects it owns, and pass a new reference for the force flag via PyBool_FromLong(). Fixes #1471 Assisted-by: Kimi Code --- src/refdb_backend.c | 47 +++++++++++++++++++++++++-------------------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/src/refdb_backend.c b/src/refdb_backend.c index 447585ebe..8dd05db8b 100644 --- a/src/refdb_backend.c +++ b/src/refdb_backend.c @@ -210,33 +210,38 @@ pygit2_refdb_backend_write(git_refdb_backend *_be, const git_signature *_who, const char *message, const git_oid *_old, const char *old_target) { - int err; - PyObject *args = NULL, *ref = NULL, *who = NULL, *old = NULL; struct pygit2_refdb_backend *be = (struct pygit2_refdb_backend *)_be; - // XXX: Drops const - if ((ref = wrap_reference((git_reference *)_ref, NULL)) == NULL) - goto euser; - if ((who = build_signature(NULL, _who, "utf-8")) == NULL) - goto euser; - if ((old = git_oid_to_python(_old)) == NULL) - goto euser; - if ((args = Py_BuildValue("(NNNsNs)", ref, - force ? Py_True : Py_False, - who, message, old, old_target)) == NULL) - goto euser; + PyObject *ref = wrap_reference((git_reference *)_ref, NULL); // XXX: Drops const + if (ref == NULL) { + return GIT_EUSER; + } + + PyObject *who = build_signature(NULL, _who, "utf-8"); + if (who == NULL) { + Py_DECREF(ref); + return GIT_EUSER; + } + + PyObject *old = git_oid_to_python(_old); + if (old == NULL) { + Py_DECREF(ref); + Py_DECREF(who); + return GIT_EUSER; + } + + // Py_BuildValue takes ownership of ref, who and old (N format), even on failure, so + // they must not be decref'd past this point. + PyObject *args = Py_BuildValue("(NNNsNs)", ref, PyBool_FromLong(force), who, message, + old, old_target); + if (args == NULL) { + return GIT_EUSER; + } PyObject_CallObject(be->write, args); - err = git_error_for_exc(); -out: - Py_DECREF(ref); - Py_DECREF(who); - Py_DECREF(old); + int err = git_error_for_exc(); Py_DECREF(args); return err; -euser: - err = GIT_EUSER; - goto out; } static int From 997c65bcbbc19b83a64c262b2c7027a0dd991663 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Fri, 7 Aug 2026 13:40:24 +0200 Subject: [PATCH 204/225] Fix the refdb backend rename callback pygit2_refdb_backend_rename() had an inverted success check on build_signature(): it returned an error precisely when the signature was built successfully, and fell through with a NULL signature when it failed, so the callback could never work. It also decref'd the signature object twice (it is stolen into the argument tuple by Py_BuildValue()'s N format), passed a borrowed reference to the Py_True/Py_False singleton with N, and leaked the callback result when it was not a Reference. Two more bugs kept the function from working once the check was fixed. The Python Signature takes ownership of the git_signature it wraps, but _who belongs to libgit2 and is freed again on return; pass a git_signature_dup() copy instead. And git_reference_dup() cannot be used on the returned reference, because its db field is only set by git_refdb_rename() after the callback returns; transfer the pointer directly, as pygit2_refdb_backend_lookup() already does. Also accept None for the message argument of RefdbBackend.rename(): libgit2 passes NULL when no log message is given (pygit2's Reference.rename() always does), Py_BuildValue() maps that to None, and the Python-side method must round-trip it back to NULL. This matches the z format already used by write() and delete(). Add test_rename_callback, the first test to drive a custom refdb backend through libgit2: assigning repo.backend only sets a Python attribute, so the existing tests never exercised the C callbacks. Fixes #1474 Assisted-by: Kimi Code --- pygit2/_pygit2.pyi | 7 ++++++- src/refdb_backend.c | 40 ++++++++++++++++++++++++++------------ test/test_refdb_backend.py | 26 ++++++++++++++++++++++++- 3 files changed, 59 insertions(+), 14 deletions(-) diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index f107a2686..aac8ccde1 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -599,7 +599,12 @@ class RefdbBackend: def has_log(self, ref_name: str, /) -> bool: ... def lookup(self, refname: str, /) -> Reference: ... def rename( - self, old_name: str, new_name: str, force: bool, who: Signature, message: str + self, + old_name: str, + new_name: str, + force: bool, + who: Signature, + message: str | None, ) -> Reference: ... def write( self, diff --git a/src/refdb_backend.c b/src/refdb_backend.c index 8dd05db8b..6df480eb0 100644 --- a/src/refdb_backend.c +++ b/src/refdb_backend.c @@ -249,31 +249,47 @@ pygit2_refdb_backend_rename(git_reference **out, git_refdb_backend *_be, const char *old_name, const char *new_name, int force, const git_signature *_who, const char *message) { - int err; - PyObject *args, *who; struct pygit2_refdb_backend *be = (struct pygit2_refdb_backend *)_be; - if ((who = build_signature(NULL, _who, "utf-8")) != NULL) + // The Python object takes ownership of the signature, so pass it a copy; + // _who belongs to the caller (libgit2). + git_signature *signature; + int err = git_signature_dup(&signature, _who); + if (err != 0) { + return err; + } + + PyObject *who = build_signature(NULL, signature, "utf-8"); + if (who == NULL) { return GIT_EUSER; - if ((args = Py_BuildValue("(ssNNs)", old_name, new_name, - force ? Py_True : Py_False, who, message)) == NULL) { - Py_DECREF(who); + } + + // Py_BuildValue takes ownership of who (N format), even on failure, so + // it must not be decref'd past this point. + PyObject *args = Py_BuildValue("(ssNNs)", old_name, new_name, + PyBool_FromLong(force), who, message); + if (args == NULL) { return GIT_EUSER; } + Reference *ref = (Reference *)PyObject_CallObject(be->rename, args); - Py_DECREF(who); Py_DECREF(args); - if ((err = git_error_for_exc()) != 0) + err = git_error_for_exc(); + if (err != 0) { return err; + } if (!PyObject_IsInstance((PyObject *)ref, (PyObject *)&ReferenceType)) { PyErr_SetString(PyExc_TypeError, "Expected object of type pygit2.Reference"); + Py_DECREF(ref); return GIT_EUSER; } - git_reference_dup(out, ref->reference); - Py_DECREF(ref); + // Ownership of the underlying git_reference is transferred to libgit2, + // which sets its db field itself once the callback returns, so the Python + // object must not be decref'd; same as in pygit2_refdb_backend_lookup. + *out = ref->reference; return 0; } @@ -625,7 +641,7 @@ RefdbBackend_write(RefdbBackend *self, PyObject *args) } PyDoc_STRVAR(RefdbBackend_rename__doc__, - "rename(old_name: str, new_name: str, force: bool, who: Signature, message: str) -> Reference\n" + "rename(old_name: str, new_name: str, force: bool, who: Signature, message: str | None) -> Reference\n" "\n" "Renames a reference."); @@ -643,7 +659,7 @@ RefdbBackend_rename(RefdbBackend *self, PyObject *args) return Py_NotImplemented; } - if (!PyArg_ParseTuple(args, "sspO!s", &old_name, &new_name, + if (!PyArg_ParseTuple(args, "sspO!z", &old_name, &new_name, &force, &SignatureType, &who, &message)) return NULL; diff --git a/test/test_refdb_backend.py b/test/test_refdb_backend.py index 4c9402f7b..fda2c8683 100644 --- a/test/test_refdb_backend.py +++ b/test/test_refdb_backend.py @@ -41,8 +41,15 @@ # design. class ProxyRefdbBackend(pygit2.RefdbBackend): def __init__(self, source: pygit2.RefdbBackend) -> None: + super().__init__() self.source = source + def __iter__(self) -> 'ProxyRefdbBackend': + return self + + def __next__(self) -> Reference: + raise StopIteration + def exists(self, ref: str) -> bool: return self.source.exists(ref) @@ -61,7 +68,12 @@ def write( return self.source.write(ref, force, who, message, old, old_target) def rename( - self, old_name: str, new_name: str, force: bool, who: Signature, message: str + self, + old_name: str, + new_name: str, + force: bool, + who: Signature, + message: str | None, ) -> Reference: return self.source.rename(old_name, new_name, force, who, message) @@ -112,6 +124,18 @@ def test_rename(repo: Repository) -> None: assert repo.backend.lookup('refs/heads/intl').target == target.id +def test_rename_callback(repo: Repository) -> None: + # Exercise the custom backend's rename callback through libgit2's + # git_reference_rename; calling repo.backend.rename() directly bypasses it. + refdb = pygit2.Refdb.new(repo) + refdb.set_backend(repo.backend) + repo.set_refdb(refdb) + ref = repo.references['refs/heads/i18n'] + target = ref.target + ref.rename('refs/heads/intl') + assert repo.references['refs/heads/intl'].target == target + + def test_delete(repo: Repository) -> None: old = repo.backend.lookup('refs/heads/i18n') repo.backend.delete('refs/heads/i18n', old.target, None) From cc48ea6479cd73fda01b2a20ede27860bca5deb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Fri, 7 Aug 2026 16:06:54 +0200 Subject: [PATCH 205/225] Fix ownership and error handling bugs in refdb backend callbacks pygit2_refdb_backend_write() wrapped libgit2's reference and signature pointers directly, so the Python objects freed memory that libgit2 frees again on return, a double free. Pass git_reference_dup() and git_signature_dup() copies instead. It also fed the _old OID pointer straight into git_oid_to_python(), crashing when libgit2 passes NULL for newly created references; map NULL to None, which RefdbBackend.write() already accepts. pygit2_refdb_backend_lookup() treated a None result as a type error, but None is how RefdbBackend.lookup() signals 'not found'; translate it to GIT_ENOTFOUND. Both lookup() and rename() leaked the Python Reference returned by the callback, because libgit2 takes ownership of the underlying git_reference while Reference_dealloc() would free it too. Detach the pointer from the Python object before releasing it. Add test_write_callback and test_write_callback_create, driving the write callback through libgit2's git_reference_set_target() and git_reference_create(). Follow-up to the fix for #1474 Assisted-by: Kimi Code --- src/refdb_backend.c | 69 +++++++++++++++++++++++++++++--------- test/test_refdb_backend.py | 24 +++++++++++++ 2 files changed, 77 insertions(+), 16 deletions(-) diff --git a/src/refdb_backend.c b/src/refdb_backend.c index 6df480eb0..cad323497 100644 --- a/src/refdb_backend.c +++ b/src/refdb_backend.c @@ -190,18 +190,30 @@ pygit2_refdb_backend_lookup(git_reference **out, result = (Reference *)PyObject_CallObject(be->lookup, args); Py_DECREF(args); - if ((err = git_error_for_exc()) != 0) - goto out; + if ((err = git_error_for_exc()) != 0) { + Py_XDECREF(result); + return err; + } + + // A lookup that finds nothing returns None, per RefdbBackend.lookup() + if ((PyObject *)result == Py_None) { + Py_DECREF(result); + return GIT_ENOTFOUND; + } if (!PyObject_IsInstance((PyObject *)result, (PyObject *)&ReferenceType)) { PyErr_SetString(PyExc_TypeError, "Expected object of type pygit2.Reference"); - err = GIT_EUSER; - goto out; + Py_DECREF(result); + return GIT_EUSER; } + // Ownership of the underlying git_reference is transferred to libgit2, + // which sets its db field itself once the callback returns; detach it + // from the Python object before releasing the object. *out = result->reference; -out: - return err; + result->reference = NULL; + Py_DECREF(result); + return 0; } static int @@ -212,24 +224,46 @@ pygit2_refdb_backend_write(git_refdb_backend *_be, { struct pygit2_refdb_backend *be = (struct pygit2_refdb_backend *)_be; - PyObject *ref = wrap_reference((git_reference *)_ref, NULL); // XXX: Drops const + // The Python objects take ownership of the reference and the signature, + // so pass them copies; _ref and _who belong to the caller (libgit2). + git_reference *reference; + int err = git_reference_dup(&reference, (git_reference *)_ref); // XXX: Drops const + if (err != 0) { + return err; + } + + PyObject *ref = wrap_reference(reference, NULL); if (ref == NULL) { + git_reference_free(reference); return GIT_EUSER; } - PyObject *who = build_signature(NULL, _who, "utf-8"); - if (who == NULL) { + git_signature *signature; + err = git_signature_dup(&signature, _who); + if (err != 0) { Py_DECREF(ref); - return GIT_EUSER; + return err; } - PyObject *old = git_oid_to_python(_old); - if (old == NULL) { + PyObject *who = build_signature(NULL, signature, "utf-8"); + if (who == NULL) { Py_DECREF(ref); - Py_DECREF(who); return GIT_EUSER; } + PyObject *old; + if (_old == NULL) { + old = Py_None; + Py_INCREF(old); + } else { + old = git_oid_to_python(_old); + if (old == NULL) { + Py_DECREF(ref); + Py_DECREF(who); + return GIT_EUSER; + } + } + // Py_BuildValue takes ownership of ref, who and old (N format), even on failure, so // they must not be decref'd past this point. PyObject *args = Py_BuildValue("(NNNsNs)", ref, PyBool_FromLong(force), who, message, @@ -239,7 +273,7 @@ pygit2_refdb_backend_write(git_refdb_backend *_be, } PyObject_CallObject(be->write, args); - int err = git_error_for_exc(); + err = git_error_for_exc(); Py_DECREF(args); return err; } @@ -277,6 +311,7 @@ pygit2_refdb_backend_rename(git_reference **out, git_refdb_backend *_be, err = git_error_for_exc(); if (err != 0) { + Py_XDECREF(ref); return err; } @@ -287,9 +322,11 @@ pygit2_refdb_backend_rename(git_reference **out, git_refdb_backend *_be, } // Ownership of the underlying git_reference is transferred to libgit2, - // which sets its db field itself once the callback returns, so the Python - // object must not be decref'd; same as in pygit2_refdb_backend_lookup. + // which sets its db field itself once the callback returns; detach it + // from the Python object before releasing the object. *out = ref->reference; + ref->reference = NULL; + Py_DECREF(ref); return 0; } diff --git a/test/test_refdb_backend.py b/test/test_refdb_backend.py index fda2c8683..9a0bc9fca 100644 --- a/test/test_refdb_backend.py +++ b/test/test_refdb_backend.py @@ -136,6 +136,30 @@ def test_rename_callback(repo: Repository) -> None: assert repo.references['refs/heads/intl'].target == target +def test_write_callback(repo: Repository) -> None: + # Exercise the custom backend's write callback through libgit2's + # git_reference_set_target; calling repo.backend.write() directly + # bypasses it. + refdb = pygit2.Refdb.new(repo) + refdb.set_backend(repo.backend) + repo.set_refdb(refdb) + master = repo.references['refs/heads/master'] + i18n = repo.references['refs/heads/i18n'] + i18n.set_target(master.target) + assert repo.references['refs/heads/i18n'].target == master.target + + +def test_write_callback_create(repo: Repository) -> None: + # Exercise the custom backend's write callback through libgit2's + # git_reference_create, which passes old=NULL for new references. + refdb = pygit2.Refdb.new(repo) + refdb.set_backend(repo.backend) + repo.set_refdb(refdb) + master = repo.references['refs/heads/master'] + repo.references.create('refs/heads/test-write', master.target) + assert repo.references['refs/heads/test-write'].target == master.target + + def test_delete(repo: Repository) -> None: old = repo.backend.lookup('refs/heads/i18n') repo.backend.delete('refs/heads/i18n', old.target, None) From 2efb7062685f95678cedc1b8523f3df9febb0880 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Fri, 7 Aug 2026 16:19:43 +0200 Subject: [PATCH 206/225] Update wheels to OpenSSL 3.5.7 And update changelog --- AGENTS.md | 2 +- CHANGELOG.md | 24 +++++++++++++----------- Makefile | 2 +- pyproject.toml | 2 +- 4 files changed, 16 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3bbc87c4e..86f6159f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -252,7 +252,7 @@ The `cibuildwheel` configuration in `pyproject.toml` pins: - `LIBGIT2_VERSION="1.9.6"` - `LIBSSH2_VERSION="1.11.1"` -- `OPENSSL_VERSION="3.5.4"` +- `OPENSSL_VERSION="3.5.7"` and skips `*musllinux_ppc64le` plus testing on `*-*linux_ppc64le`, `*-*linux_riscv64` and `pp*-macosx_arm64`. On Windows it uses the diff --git a/CHANGELOG.md b/CHANGELOG.md index 4242d5596..37e046825 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ - New `RemoteCallbacks.custom_headers()` [#1465](https://github.com/libgit2/pygit2/pull/1465) +- New `Repository.rebase_init(...)` and `Repository.rebase_open(...)` + [#1483](https://github.com/libgit2/pygit2/pull/1483) + - Fix `Config.snapshot()` for non-repository configs, allow `PathLike` in `Config.__init__()`, and improve `Config` documentation [#1468](https://github.com/libgit2/pygit2/pull/1468) @@ -12,20 +15,19 @@ [#1451](https://github.com/libgit2/pygit2/issues/1451) [#1469](https://github.com/libgit2/pygit2/pull/1469) -- Add riscv64 wheels - [#1463](https://github.com/libgit2/pygit2/pull/1463) - -- Update wheels to libgit2 1.9.6 - -- Wrap libgit2's rebase API: add `Repository.rebase_init(...)` and - `Repository.rebase_open(...)` returning a `Rebase` object, plus - `RebaseOperation` and `enums.RebaseOperationType` - [#1483](https://github.com/libgit2/pygit2/pull/1483) - - Fix `enums.CheckoutStrategy.CONFLICT_STYLE_ZDIFF3`, which was mistakenly bound to the `DIFF3` constant [#1483](https://github.com/libgit2/pygit2/pull/1483) +- Fix memory issues + [#1471](https://github.com/libgit2/pygit2/issues/1471) + [#1474](https://github.com/libgit2/pygit2/issues/1474) + +- Update wheels to libgit2 1.9.6 and OpenSSL 3.5.7 + +- Add riscv64 wheels + [#1463](https://github.com/libgit2/pygit2/pull/1463) + Breaking changes: - Remove deprecated `pygit2.legacyenums` module and `GIT_*` constants, @@ -41,7 +43,7 @@ Breaking changes: - Remove deprecated `Remote.ls_remotes(...)`, use `Remote.list_heads(...)` instead -- Remove `pygit2.to_bytes` and `pygit2.to_str`; they were undocumented +- Remove `pygit2.to_bytes(...)` and `pygit2.to_str(...)`; they were undocumented accidental public APIs diff --git a/Makefile b/Makefile index 876d5cb2b..681925394 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ .PHONY: build html build: - OPENSSL_VERSION=3.5.4 LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.6 sh build.sh + OPENSSL_VERSION=3.5.7 LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.6 sh build.sh html: build make -C docs html diff --git a/pyproject.toml b/pyproject.toml index 6ee10527a..c9503929e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ skip = "*musllinux_ppc64le" archs = ["native"] build-frontend = "default" dependency-versions = "pinned" -environment = {LIBGIT2="$(pwd)/ci", LIBGIT2_VERSION="1.9.6", LIBSSH2_VERSION="1.11.1", OPENSSL_VERSION="3.5.4"} +environment = {LIBGIT2="$(pwd)/ci", LIBGIT2_VERSION="1.9.6", LIBSSH2_VERSION="1.11.1", OPENSSL_VERSION="3.5.7"} before-all = "sh build.sh" test-command = "pytest" From 5878864c271dbcf724a6d4e533678f19a20d372f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Fri, 7 Aug 2026 17:28:37 +0200 Subject: [PATCH 207/225] Fix ownership and lifetime bugs in refdb backend callbacks The refdb backend callbacks that hand a reference to libgit2 all had the same bug class: they leaked the Python Reference returned or yielded by user code, and borrowed the inner git_reference pointer from it, so releasing the object without leaking would leave libgit2 with a dangling pointer. git_reference_dup() cannot be used to make a safe copy, because it requires the db field to be set, which libgit2 only does after the callback returns. Instead, detach the pointer when the callback returned a fresh object (refcount 1), and copy it with git_reference__alloc()/git_reference__alloc_symbolic() when it returned a shared object, e.g. one the backend caches and returns on every call; the copy loses the cached peel, which is only a hint. Fixed callbacks: - lookup() and rename() now handle a cached Reference instead of invalidating it on first use (the second lookup handed libgit2 a NULL pointer). - The iterator next() callback leaked every yielded Reference and borrowed its pointer, a double free waiting for the object to be collected; the glob filter also leaked every non-matching yield. The next_name() callback leaked as well, and now keeps the last yielded Reference alive so the borrowed name stays valid until the next call. - The iterator constructor crashed on a NULL glob, which is exactly what git_reference_iterator_new() passes; it had never been exercised through libgit2 before. Also fix Reference(name, oid, None) passing an uninitialized peel OID to git_reference__alloc(), so every backend-constructed direct reference carried a garbage peel into libgit2. Add test_lookup_cached_callback, test_iterator_callback and test_iterator_callback_no_leak, driving cached Reference objects through libgit2; each crashes or fails against the old code. Fixes #1475 Assisted-by: Kimi Code --- CHANGELOG.md | 3 +- src/refdb_backend.c | 115 +++++++++++++++++++++++++++---------- src/reference.c | 3 +- test/test_refdb_backend.py | 89 +++++++++++++++++++++++++++- 4 files changed, 176 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37e046825..10ac78fb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,9 +19,10 @@ bound to the `DIFF3` constant [#1483](https://github.com/libgit2/pygit2/pull/1483) -- Fix memory issues +- Fix memory issues in refdb backend [#1471](https://github.com/libgit2/pygit2/issues/1471) [#1474](https://github.com/libgit2/pygit2/issues/1474) + [#1475](https://github.com/libgit2/pygit2/issues/1475) - Update wheels to libgit2 1.9.6 and OpenSSL 3.5.7 diff --git a/src/refdb_backend.c b/src/refdb_backend.c index cad323497..0df623075 100644 --- a/src/refdb_backend.c +++ b/src/refdb_backend.c @@ -36,6 +36,7 @@ #include "wildmatch.h" #include #include +#include #include extern PyTypeObject ReferenceType; @@ -66,21 +67,82 @@ struct pygit2_refdb_backend struct pygit2_refdb_iterator { struct git_reference_iterator base; PyObject *iterator; + PyObject *current; /* keeps the last Reference yielded by next_name alive */ char *glob; }; +// Copy a reference that does not belong to a refdb yet. git_reference_dup() +// cannot be used here because it requires the db field to be set, which +// libgit2 only does after the backend callback returns. The copy does not +// preserve the cached peel of direct references, which is only a hint. +static git_reference * +copy_reference(const git_reference *ref) +{ + if (git_reference_type(ref) == GIT_REF_SYMBOLIC) { + return git_reference__alloc_symbolic(git_reference_name(ref), + git_reference_symbolic_target(ref)); + } + return git_reference__alloc(git_reference_name(ref), + git_reference_target(ref), NULL); +} + +// Transfer the reference returned by a backend callback to libgit2, which +// takes ownership and sets its db field itself once the callback returns. +// If the callback returned a fresh object (refcount 1), detach the pointer +// from the Python object before releasing it; if it returned a shared +// object (e.g. one the backend caches and returns on every call), leave it +// intact and give libgit2 a copy instead. +// Consumes the reference to result; returns 0 or a libgit2 error code. +static int +transfer_reference(git_reference **out, Reference *result) +{ + if (result->reference == NULL) { + PyErr_SetString(PyExc_ValueError, "Reference object is no longer valid"); + Py_DECREF(result); + return GIT_EUSER; + } + + if (Py_REFCNT((PyObject *)result) == 1) { + *out = result->reference; + result->reference = NULL; + Py_DECREF(result); + return 0; + } + + *out = copy_reference(result->reference); + Py_DECREF(result); + if (*out == NULL) { + git_error_set(GIT_ERROR_NOMEMORY, "out of memory"); + return GIT_ERROR; + } + return 0; +} + +// Returns the next valid Reference from the Python iterator, or NULL when +// the iterator is exhausted or an error occurred; check PyErr_Occurred() +// to tell the two cases apart. static Reference * iterator_get_next(struct pygit2_refdb_iterator *iter) { Reference *ref; while ((ref = (Reference *)PyIter_Next(iter->iterator)) != NULL) { - if (!iter->glob) { - return ref; + if (!PyObject_IsInstance((PyObject *)ref, (PyObject *)&ReferenceType)) { + PyErr_SetString(PyExc_TypeError, + "RefdbBackend iterator must yield References"); + Py_DECREF(ref); + return NULL; + } + if (ref->reference == NULL) { + PyErr_SetString(PyExc_ValueError, + "Reference object is no longer valid"); + Py_DECREF(ref); + return NULL; } - const char *name = git_reference_name(ref->reference); - if (wildmatch(iter->glob, name, 0) != WM_NOMATCH) { + if (!iter->glob || + wildmatch(iter->glob, git_reference_name(ref->reference), 0) != WM_NOMATCH) { return ref; } + Py_DECREF(ref); } return NULL; } @@ -91,16 +153,12 @@ pygit2_refdb_iterator_next(git_reference **out, git_reference_iterator *_iter) struct pygit2_refdb_iterator *iter = (struct pygit2_refdb_iterator *)_iter; Reference *ref = iterator_get_next(iter); if (ref == NULL) { + if (PyErr_Occurred()) + return GIT_EUSER; *out = NULL; return GIT_ITEROVER; } - if (!PyObject_IsInstance((PyObject *)ref, (PyObject *)&ReferenceType)) { - PyErr_SetString(PyExc_TypeError, - "RefdbBackend iterator must yield References"); - return GIT_EUSER; - } - *out = ref->reference; - return 0; + return transfer_reference(out, ref); } static int @@ -109,14 +167,15 @@ pygit2_refdb_iterator_next_name(const char **ref_name, git_reference_iterator *_ struct pygit2_refdb_iterator *iter = (struct pygit2_refdb_iterator *)_iter; Reference *ref = iterator_get_next(iter); if (ref == NULL) { + if (PyErr_Occurred()) + return GIT_EUSER; *ref_name = NULL; return GIT_ITEROVER; } - if (!PyObject_IsInstance((PyObject *)ref, (PyObject *)&ReferenceType)) { - PyErr_SetString(PyExc_TypeError, - "RefdbBackend iterator must yield References"); - return GIT_EUSER; - } + // The name is borrowed from the Reference; keep the object alive until + // the next call or until the iterator is freed. + Py_XDECREF(iter->current); + iter->current = (PyObject *)ref; *ref_name = git_reference_name(ref->reference); return 0; } @@ -125,6 +184,7 @@ static void pygit2_refdb_iterator_free(git_reference_iterator *_iter) { struct pygit2_refdb_iterator *iter = (struct pygit2_refdb_iterator *)_iter; + Py_CLEAR(iter->current); Py_DECREF(iter->iterator); free(iter->glob); } @@ -144,12 +204,17 @@ pygit2_refdb_backend_iterator(git_reference_iterator **iter, git_error_set(GIT_ERROR_NOMEMORY, "out of memory"); return GIT_ERROR; } + if (glob && (pyiter->glob = strdup(glob)) == NULL) { + Py_DECREF(iterator); + free(pyiter); + git_error_set(GIT_ERROR_NOMEMORY, "out of memory"); + return GIT_ERROR; + } *iter = (git_reference_iterator *)pyiter; pyiter->iterator = iterator; pyiter->base.next = pygit2_refdb_iterator_next; pyiter->base.next_name = pygit2_refdb_iterator_next_name; pyiter->base.free = pygit2_refdb_iterator_free; - pyiter->glob = strdup(glob); return 0; } @@ -207,13 +272,7 @@ pygit2_refdb_backend_lookup(git_reference **out, return GIT_EUSER; } - // Ownership of the underlying git_reference is transferred to libgit2, - // which sets its db field itself once the callback returns; detach it - // from the Python object before releasing the object. - *out = result->reference; - result->reference = NULL; - Py_DECREF(result); - return 0; + return transfer_reference(out, result); } static int @@ -321,13 +380,7 @@ pygit2_refdb_backend_rename(git_reference **out, git_refdb_backend *_be, return GIT_EUSER; } - // Ownership of the underlying git_reference is transferred to libgit2, - // which sets its db field itself once the callback returns; detach it - // from the Python object before releasing the object. - *out = ref->reference; - ref->reference = NULL; - Py_DECREF(ref); - return 0; + return transfer_reference(out, ref); } static int diff --git a/src/reference.c b/src/reference.c index fbce62d1a..38135f925 100644 --- a/src/reference.c +++ b/src/reference.c @@ -170,7 +170,8 @@ Reference_init(Reference *self, PyObject *args, PyObject *kwds) py_oid_to_git_oid(py_peel, &peel); } - self->reference = git_reference__alloc(name, &oid, &peel); + self->reference = git_reference__alloc(name, &oid, + py_peel == Py_None ? NULL : &peel); return 0; } diff --git a/test/test_refdb_backend.py b/test/test_refdb_backend.py index 9a0bc9fca..ec05d1da2 100644 --- a/test/test_refdb_backend.py +++ b/test/test_refdb_backend.py @@ -25,7 +25,8 @@ """Tests for Refdb objects.""" -from collections.abc import Generator +import sys +from collections.abc import Generator, Iterator from pathlib import Path import pytest @@ -33,6 +34,8 @@ import pygit2 from pygit2 import Commit, Oid, Reference, Repository, Signature +from . import utils + # Note: the refdb abstraction from libgit2 is meant to provide information # which libgit2 transforms into something more useful, and in general YMMV by @@ -96,6 +99,41 @@ def repo(testrepo: Repository) -> Generator[Repository, None, None]: yield testrepo +class CachedRefdbBackend(ProxyRefdbBackend): + """A backend that caches and reuses the Reference objects it returns.""" + + def __init__(self, source: pygit2.RefdbBackend) -> None: + super().__init__(source) + self.cache: dict[str, Reference] = {} + + def lookup(self, ref: str) -> Reference: + if ref not in self.cache: + self.cache[ref] = self.source.lookup(ref) + return self.cache[ref] + + +class IterRefdbBackend(ProxyRefdbBackend): + """A backend whose iterator yields cached Reference objects.""" + + def __init__(self, source: pygit2.RefdbBackend) -> None: + super().__init__(source) + self.cache: list[Reference] | None = None + self.refs: Iterator[Reference] = iter([]) + + def __iter__(self) -> 'IterRefdbBackend': + if self.cache is None: + self.cache = [ + self.source.lookup('refs/heads/master'), + self.source.lookup('refs/heads/i18n'), + Reference('refs/heads/symbolic', 'refs/heads/master'), + ] + self.refs = iter(self.cache) + return self + + def __next__(self) -> Reference: + return next(self.refs) + + def test_exists(repo: Repository) -> None: assert not repo.backend.exists('refs/heads/does-not-exist') assert repo.backend.exists('refs/heads/master') @@ -106,6 +144,55 @@ def test_lookup(repo: Repository) -> None: assert repo.backend.lookup('refs/heads/master').name == 'refs/heads/master' +def test_lookup_cached_callback(testrepo: Repository) -> None: + # Regression test: a backend may cache and return the same Reference + # object on every lookup; the callback must not invalidate it, and + # repeated lookups through libgit2 must keep working. + backend = CachedRefdbBackend(pygit2.RefdbFsBackend(testrepo)) + refdb = pygit2.Refdb.new(testrepo) + refdb.set_backend(backend) + testrepo.set_refdb(refdb) + + target = testrepo.references['refs/heads/master'].target + assert testrepo.references['refs/heads/master'].target == target + assert backend.cache['refs/heads/master'].name == 'refs/heads/master' + + +def test_iterator_callback(testrepo: Repository) -> None: + # Exercise the custom backend's iterator callback through libgit2's + # git_reference_iterator; the Python attribute alone doesn't install it. + backend = IterRefdbBackend(pygit2.RefdbFsBackend(testrepo)) + refdb = pygit2.Refdb.new(testrepo) + refdb.set_backend(backend) + testrepo.set_refdb(refdb) + + names = sorted(ref.name for ref in testrepo.references.iterator()) + assert names == ['refs/heads/i18n', 'refs/heads/master', 'refs/heads/symbolic'] + + # The backend's cached objects must still be usable after iteration. + assert backend.cache is not None + assert [ref.name for ref in backend.cache] == [ + 'refs/heads/master', + 'refs/heads/i18n', + 'refs/heads/symbolic', + ] + + +@utils.requires_refcount +def test_iterator_callback_no_leak(testrepo: Repository) -> None: + # Iterating must not leak the Reference objects the backend yields. + backend = IterRefdbBackend(pygit2.RefdbFsBackend(testrepo)) + refdb = pygit2.Refdb.new(testrepo) + refdb.set_backend(backend) + testrepo.set_refdb(refdb) + + list(testrepo.references.iterator()) + assert backend.cache is not None + refcount = sys.getrefcount(backend.cache[0]) + list(testrepo.references.iterator()) + assert sys.getrefcount(backend.cache[0]) == refcount + + def test_write(repo: Repository) -> None: master = repo.backend.lookup('refs/heads/master') commit = repo[master.target] From 007c5629f55533aced365ec2f25aaf0550122ce5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Fri, 7 Aug 2026 22:16:49 +0200 Subject: [PATCH 208/225] Fix NULL dereference in refdb backend exists callback When the Python exists callback raises, PyObject_CallObject returns NULL and pygit2_refdb_backend_exists jumped to its cleanup block, where Py_DECREF(result) dereferenced a NULL pointer, crashing the interpreter. It also returned 0 on the error path, swallowing the exception. Use Py_XDECREF and propagate the error instead. Add defensive Py_XDECREF to the has_log and ensure_log callbacks as well. Fixes #1476 Assisted-by: Kimi Code --- CHANGELOG.md | 1 + src/refdb_backend.c | 6 ++++-- test/test_refdb_backend.py | 19 +++++++++++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10ac78fb0..fd77e6d3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ [#1471](https://github.com/libgit2/pygit2/issues/1471) [#1474](https://github.com/libgit2/pygit2/issues/1474) [#1475](https://github.com/libgit2/pygit2/issues/1475) + [#1476](https://github.com/libgit2/pygit2/issues/1476) - Update wheels to libgit2 1.9.6 and OpenSSL 3.5.7 diff --git a/src/refdb_backend.c b/src/refdb_backend.c index 0df623075..d8617b055 100644 --- a/src/refdb_backend.c +++ b/src/refdb_backend.c @@ -237,8 +237,8 @@ pygit2_refdb_backend_exists(int *exists, *exists = PyObject_IsTrue(result); out: - Py_DECREF(result); - return 0; + Py_XDECREF(result); + return err; } static int @@ -423,6 +423,7 @@ pygit2_refdb_backend_has_log(git_refdb_backend *_be, const char *refname) Py_DECREF(args); if ((err = git_error_for_exc()) != 0) { + Py_XDECREF(result); return err; } @@ -449,6 +450,7 @@ pygit2_refdb_backend_ensure_log(git_refdb_backend *_be, const char *refname) Py_DECREF(args); if ((err = git_error_for_exc()) != 0) { + Py_XDECREF(result); return err; } diff --git a/test/test_refdb_backend.py b/test/test_refdb_backend.py index ec05d1da2..fc79fed31 100644 --- a/test/test_refdb_backend.py +++ b/test/test_refdb_backend.py @@ -139,6 +139,25 @@ def test_exists(repo: Repository) -> None: assert repo.backend.exists('refs/heads/master') +class RaisingRefdbBackend(ProxyRefdbBackend): + """A backend whose exists callback always raises.""" + + def exists(self, ref: str) -> bool: + raise RuntimeError('boom') + + +def test_exists_callback_raises(testrepo: Repository) -> None: + # Regression test (issue #1476): when the exists callback raises, the C + # wrapper must not crash on a NULL result, and must propagate the error. + backend = RaisingRefdbBackend(pygit2.RefdbFsBackend(testrepo)) + # Call the unbound C method so the call goes through the C wrapper + # (pygit2_refdb_backend_exists), not directly to the Python override. + # The error propagates as GitError, or as OSError/ValueError when a stale + # libgit2 error (with a matching class) is left over from an earlier call. + with pytest.raises((pygit2.GitError, OSError)): + pygit2.RefdbBackend.exists(backend, 'refs/heads/master') + + def test_lookup(repo: Repository) -> None: assert repo.backend.lookup('refs/heads/does-not-exist') is None assert repo.backend.lookup('refs/heads/master').name == 'refs/heads/master' From 28be4b8683dd426ffae3ad3a049daeee8df9891d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Fri, 7 Aug 2026 22:51:30 +0200 Subject: [PATCH 209/225] Fix refcount test failing under pytest assertion rewriting test_iterator_callback_no_leak fails on Python 3.11 in CI: pytest's assertion rewriting explodes the assert expression into frame temporaries, and one of them holds backend.cache[0] while sys.getrefcount runs, inflating the count from 2 to 3. On 3.14 the temporary does not survive, so the test passes there. Keep the getrefcount call out of the assert so no rewritten temporary can hold the object. Assisted-by: Kimi Code --- test/test_refdb_backend.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/test_refdb_backend.py b/test/test_refdb_backend.py index fc79fed31..07c454463 100644 --- a/test/test_refdb_backend.py +++ b/test/test_refdb_backend.py @@ -209,7 +209,11 @@ def test_iterator_callback_no_leak(testrepo: Repository) -> None: assert backend.cache is not None refcount = sys.getrefcount(backend.cache[0]) list(testrepo.references.iterator()) - assert sys.getrefcount(backend.cache[0]) == refcount + # Keep the getrefcount call out of the assert: pytest's assertion + # rewriting holds the subscript result in a frame temporary, which + # inflates the refcount on some Python versions (e.g. 3.11). + new_refcount = sys.getrefcount(backend.cache[0]) + assert new_refcount == refcount def test_write(repo: Repository) -> None: From ba21cf58151ec7fb5d15152e952040cef21a4009 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Sat, 8 Aug 2026 12:52:23 +0200 Subject: [PATCH 210/225] Release 1.20.0 And update copyright yers --- .mailmap | 1 + AUTHORS.md | 6 ++++-- CHANGELOG.md | 15 ++++++++------- docs/conf.py | 4 ++-- docs/install.rst | 2 +- pygit2/__init__.py | 2 +- pygit2/_build.py | 4 ++-- pygit2/_libgit2/ffi.pyi | 2 +- pygit2/_run.py | 2 +- pygit2/blame.py | 2 +- pygit2/branches.py | 2 +- pygit2/callbacks.py | 2 +- pygit2/config.py | 2 +- pygit2/credentials.py | 2 +- pygit2/enums.py | 2 +- pygit2/errors.py | 2 +- pygit2/ffi.py | 2 +- pygit2/filter.py | 2 +- pygit2/index.py | 2 +- pygit2/options.py | 2 +- pygit2/packbuilder.py | 2 +- pygit2/rebase.py | 2 +- pygit2/references.py | 2 +- pygit2/refspec.py | 2 +- pygit2/remotes.py | 2 +- pygit2/repository.py | 2 +- pygit2/settings.py | 2 +- pygit2/submodules.py | 2 +- pygit2/transaction.py | 2 +- pygit2/utils.py | 2 +- setup.py | 2 +- src/blob.c | 2 +- src/branch.c | 2 +- src/branch.h | 2 +- src/commit.c | 2 +- src/diff.c | 2 +- src/diff.h | 2 +- src/error.c | 2 +- src/error.h | 2 +- src/filter.c | 2 +- src/filter.h | 2 +- src/mailmap.c | 2 +- src/mailmap.h | 2 +- src/note.c | 2 +- src/note.h | 2 +- src/object.c | 2 +- src/object.h | 2 +- src/odb.c | 2 +- src/odb.h | 2 +- src/odb_backend.c | 2 +- src/odb_backend.h | 2 +- src/oid.c | 2 +- src/oid.h | 2 +- src/patch.c | 2 +- src/patch.h | 2 +- src/pygit2.c | 2 +- src/refdb.c | 2 +- src/refdb.h | 2 +- src/refdb_backend.c | 2 +- src/refdb_backend.h | 2 +- src/reference.c | 2 +- src/reference.h | 2 +- src/repository.c | 2 +- src/repository.h | 2 +- src/revspec.c | 2 +- src/revspec.h | 2 +- src/signature.c | 2 +- src/signature.h | 2 +- src/stash.c | 2 +- src/tag.c | 2 +- src/tree.c | 2 +- src/tree.h | 2 +- src/treebuilder.c | 2 +- src/treebuilder.h | 2 +- src/types.h | 2 +- src/utils.c | 2 +- src/utils.h | 2 +- src/walker.c | 2 +- src/walker.h | 2 +- src/worktree.c | 2 +- src/worktree.h | 2 +- test/__init__.py | 2 +- test/test_apply_diff.py | 2 +- test/test_archive.py | 2 +- test/test_attributes.py | 2 +- test/test_blame.py | 2 +- test/test_blob.py | 2 +- test/test_branch.py | 2 +- test/test_branch_empty.py | 2 +- test/test_cherrypick.py | 2 +- test/test_commit.py | 2 +- test/test_commit_gpg.py | 2 +- test/test_commit_trailer.py | 2 +- test/test_config.py | 2 +- test/test_credentials.py | 2 +- test/test_describe.py | 2 +- test/test_diff.py | 2 +- test/test_diff_binary.py | 2 +- test/test_index.py | 2 +- test/test_mailmap.py | 2 +- test/test_merge.py | 2 +- test/test_nonunicode.py | 2 +- test/test_note.py | 2 +- test/test_object.py | 2 +- test/test_odb.py | 2 +- test/test_odb_backend.py | 2 +- test/test_oid.py | 2 +- test/test_options.py | 2 +- test/test_packbuilder.py | 2 +- test/test_patch.py | 2 +- test/test_patch_encoding.py | 2 +- test/test_rebase.py | 2 +- test/test_refdb_backend.py | 2 +- test/test_refs.py | 2 +- test/test_remote.py | 2 +- test/test_remote_prune.py | 2 +- test/test_remote_utf8.py | 2 +- test/test_repository.py | 2 +- test/test_repository_bare.py | 2 +- test/test_repository_custom.py | 2 +- test/test_repository_empty.py | 2 +- test/test_revparse.py | 2 +- test/test_revwalk.py | 2 +- test/test_settings.py | 2 +- test/test_signature.py | 2 +- test/test_status.py | 2 +- test/test_submodule.py | 2 +- test/test_tag.py | 2 +- test/test_transaction.py | 2 +- test/test_tree.py | 2 +- test/test_treebuilder.py | 2 +- test/utils.py | 2 +- 132 files changed, 144 insertions(+), 140 deletions(-) diff --git a/.mailmap b/.mailmap index ec2a26cd6..e47d815f1 100644 --- a/.mailmap +++ b/.mailmap @@ -39,6 +39,7 @@ Tamir Bahar Victor Florea Victor Garcia Vlad Temian +William Bowers William Schueller Wim Jeantine-Glenn Xavier Delannoy diff --git a/AUTHORS.md b/AUTHORS.md index 0868ce4a1..04c7fbb85 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -21,6 +21,7 @@ Authors: Nick Hynes Richard Möhn Xu Tao + Łukasz Langa Konstantin Baikov Matthew Duggan Matthew Gamble @@ -30,7 +31,6 @@ Authors: Sriram Raghu Victor Garcia Yonggang Luo - Łukasz Langa Patrick Steinhardt Petr Hosek Tamir Bahar @@ -44,6 +44,7 @@ Authors: Nabijacz Leweli Simon Cozens Vlad Temian + WANG Xuerui Brodie Rao Chad Dombrova Lukas Fleischer @@ -65,7 +66,6 @@ Authors: Santiago Perez De Rosso Sebastian Thiel Thom Wiggers - WANG Xuerui William Manley Alexander Linne Alok Singhal @@ -127,6 +127,7 @@ Authors: Masud Rahman Michael Sondergaard Natanael Arndt + Nick Williams Ondřej Nový Sarath Lakshman Steve Kieffer @@ -242,6 +243,7 @@ Authors: Victor Florea Vladimir Rutsky Vruyr Gyolchanyan + William Bowers William Schueller Wim Jeantine-Glenn Yu Jianjian diff --git a/CHANGELOG.md b/CHANGELOG.md index fd77e6d3d..ff8767577 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,14 @@ -# 1.20.0 (UNRELEASED) +# 1.20.0 (2026-08-08) - New `RemoteCallbacks.custom_headers()` [#1465](https://github.com/libgit2/pygit2/pull/1465) -- New `Repository.rebase_init(...)` and `Repository.rebase_open(...)` +- New rebase API: `Repository.rebase_init(...)`, `Repository.rebase_open(...)`, + `Rebase`, and `RebaseOperation` [#1483](https://github.com/libgit2/pygit2/pull/1483) - Fix `Config.snapshot()` for non-repository configs, allow `PathLike` in - `Config.__init__()`, and improve `Config` documentation + `Config.__init__()`, and improve config documentation [#1468](https://github.com/libgit2/pygit2/pull/1468) - Fix `UnicodeDecodeError` with non-UTF-8 file paths in `Repository.status()`, @@ -19,7 +20,7 @@ bound to the `DIFF3` constant [#1483](https://github.com/libgit2/pygit2/pull/1483) -- Fix memory issues in refdb backend +- Fix crashes and reference-lifetime bugs in custom refdb backends [#1471](https://github.com/libgit2/pygit2/issues/1471) [#1474](https://github.com/libgit2/pygit2/issues/1474) [#1475](https://github.com/libgit2/pygit2/issues/1475) @@ -38,9 +39,9 @@ Breaking changes: - Remove deprecated support for passing `str` to `Repository.merge(...)`, pass a `Commit`, `Oid`, or `Reference` object instead -- `Repository.merge_file_from_index(...)` now returns `MergeFileResult` - by default. The previous string-returning behavior is still available - with `use_deprecated=True`, but is deprecated. +- `Repository.merge_file_from_index(...)` now returns `MergeFileResult` by + default; pass `use_deprecated=True` for the previous string return, now + deprecated. - Remove deprecated `Remote.ls_remotes(...)`, use `Remote.list_heads(...)` instead diff --git a/docs/conf.py b/docs/conf.py index fdae4b2a8..6c4a9bb58 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -19,11 +19,11 @@ # -- Project information ----------------------------------------------------- project = 'pygit2' -copyright = '2010-2025 The pygit2 contributors' +copyright = '2010-2026 The pygit2 contributors' # author = '' # The full version, including alpha/beta/rc tags -release = '1.19.3' +release = '1.20.0' # -- General configuration --------------------------------------------------- diff --git a/docs/install.rst b/docs/install.rst index f2c083733..a8d186510 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -83,7 +83,7 @@ of Python and the required libgit2 version. +-------------+----------------+------------+ | pygit2 | Python | libgit2 | +-------------+----------------+------------+ -| 1.19 | 3.11 - 3.14(t) | 1.9 | +| 1.19 - 1.20 | 3.11 - 3.14(t) | 1.9 | +-------------+----------------+------------+ | 1.17 - 1.18 | 3.10 - 3.13 | 1.9 | +-------------+----------------+------------+ diff --git a/pygit2/__init__.py b/pygit2/__init__.py index 4a69fe044..3a2a4f5b7 100644 --- a/pygit2/__init__.py +++ b/pygit2/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/pygit2/_build.py b/pygit2/_build.py index 3a5908b86..0cb6f928f 100644 --- a/pygit2/_build.py +++ b/pygit2/_build.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -34,7 +34,7 @@ # # The version number of pygit2 # -__version__ = '1.19.3' +__version__ = '1.20.0' # diff --git a/pygit2/_libgit2/ffi.pyi b/pygit2/_libgit2/ffi.pyi index a784feb69..599fe415e 100644 --- a/pygit2/_libgit2/ffi.pyi +++ b/pygit2/_libgit2/ffi.pyi @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/pygit2/_run.py b/pygit2/_run.py index 9aace11cf..7cee2c8e5 100644 --- a/pygit2/_run.py +++ b/pygit2/_run.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/pygit2/blame.py b/pygit2/blame.py index d7d54150c..aefb5c85d 100644 --- a/pygit2/blame.py +++ b/pygit2/blame.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/pygit2/branches.py b/pygit2/branches.py index b729a21e5..77cabfc1b 100644 --- a/pygit2/branches.py +++ b/pygit2/branches.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/pygit2/callbacks.py b/pygit2/callbacks.py index da78e8faf..06a53ba24 100644 --- a/pygit2/callbacks.py +++ b/pygit2/callbacks.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/pygit2/config.py b/pygit2/config.py index 8016e9b46..66f4df9ce 100644 --- a/pygit2/config.py +++ b/pygit2/config.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/pygit2/credentials.py b/pygit2/credentials.py index 52edc8ce4..855490e1a 100644 --- a/pygit2/credentials.py +++ b/pygit2/credentials.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/pygit2/enums.py b/pygit2/enums.py index b3e4e41aa..9248d1085 100644 --- a/pygit2/enums.py +++ b/pygit2/enums.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/pygit2/errors.py b/pygit2/errors.py index 02278ddb6..81636fbbd 100644 --- a/pygit2/errors.py +++ b/pygit2/errors.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/pygit2/ffi.py b/pygit2/ffi.py index 2a5d86042..51cc153d8 100644 --- a/pygit2/ffi.py +++ b/pygit2/ffi.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/pygit2/filter.py b/pygit2/filter.py index a9980005d..4e79a1c32 100644 --- a/pygit2/filter.py +++ b/pygit2/filter.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/pygit2/index.py b/pygit2/index.py index 6af3b933c..611940d10 100644 --- a/pygit2/index.py +++ b/pygit2/index.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/pygit2/options.py b/pygit2/options.py index 80f20b124..86261567e 100644 --- a/pygit2/options.py +++ b/pygit2/options.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/pygit2/packbuilder.py b/pygit2/packbuilder.py index f9b77c5d3..fdaea1bb8 100644 --- a/pygit2/packbuilder.py +++ b/pygit2/packbuilder.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/pygit2/rebase.py b/pygit2/rebase.py index 68428d05d..46726d3c9 100644 --- a/pygit2/rebase.py +++ b/pygit2/rebase.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/pygit2/references.py b/pygit2/references.py index 93c370eab..533e90ab5 100644 --- a/pygit2/references.py +++ b/pygit2/references.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/pygit2/refspec.py b/pygit2/refspec.py index 9246782fe..7d937bfab 100644 --- a/pygit2/refspec.py +++ b/pygit2/refspec.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/pygit2/remotes.py b/pygit2/remotes.py index 17ff08110..df4931132 100644 --- a/pygit2/remotes.py +++ b/pygit2/remotes.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/pygit2/repository.py b/pygit2/repository.py index 7628dd5b0..2c5637992 100644 --- a/pygit2/repository.py +++ b/pygit2/repository.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/pygit2/settings.py b/pygit2/settings.py index 91b5d3191..6f3141400 100644 --- a/pygit2/settings.py +++ b/pygit2/settings.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/pygit2/submodules.py b/pygit2/submodules.py index 93f70d0ce..facf1c274 100644 --- a/pygit2/submodules.py +++ b/pygit2/submodules.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/pygit2/transaction.py b/pygit2/transaction.py index f8348cb7d..5c33b2356 100644 --- a/pygit2/transaction.py +++ b/pygit2/transaction.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/pygit2/utils.py b/pygit2/utils.py index 4e6e25617..95e382c32 100644 --- a/pygit2/utils.py +++ b/pygit2/utils.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/setup.py b/setup.py index d960cb8ce..778a6c0e8 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/src/blob.c b/src/blob.c index 93e7dbe47..c8fad9a6a 100644 --- a/src/blob.c +++ b/src/blob.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/branch.c b/src/branch.c index 51705ab92..334431382 100644 --- a/src/branch.c +++ b/src/branch.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/branch.h b/src/branch.h index 5ee6de7e2..c108e3a32 100644 --- a/src/branch.h +++ b/src/branch.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/commit.c b/src/commit.c index f758bdd87..2c2533d78 100644 --- a/src/commit.c +++ b/src/commit.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/diff.c b/src/diff.c index b580737f1..adab1ba49 100644 --- a/src/diff.c +++ b/src/diff.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/diff.h b/src/diff.h index 17b64df8a..6b32b0f98 100644 --- a/src/diff.h +++ b/src/diff.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/error.c b/src/error.c index d264e6196..a6371dbac 100644 --- a/src/error.c +++ b/src/error.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/error.h b/src/error.h index f08f3a998..e38bb8a4a 100644 --- a/src/error.h +++ b/src/error.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/filter.c b/src/filter.c index 3362441f5..be19b7027 100644 --- a/src/filter.c +++ b/src/filter.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/filter.h b/src/filter.h index 04bbf7c0b..5328c1e22 100644 --- a/src/filter.h +++ b/src/filter.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/mailmap.c b/src/mailmap.c index f79d2b93e..0671562cc 100644 --- a/src/mailmap.c +++ b/src/mailmap.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/mailmap.h b/src/mailmap.h index 0f61d96b6..d75c4b9f0 100644 --- a/src/mailmap.h +++ b/src/mailmap.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/note.c b/src/note.c index 0c014578b..b0ab6bac7 100644 --- a/src/note.c +++ b/src/note.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/note.h b/src/note.h index 2e87b8942..98e091c2e 100644 --- a/src/note.h +++ b/src/note.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/object.c b/src/object.c index f8cba2ea9..bd16f848a 100644 --- a/src/object.c +++ b/src/object.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/object.h b/src/object.h index 9fc41526e..0f2e01e95 100644 --- a/src/object.h +++ b/src/object.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/odb.c b/src/odb.c index 85c07b804..a42f6bb6a 100644 --- a/src/odb.c +++ b/src/odb.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/odb.h b/src/odb.h index 7a69a46c0..29e0610a4 100644 --- a/src/odb.h +++ b/src/odb.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/odb_backend.c b/src/odb_backend.c index 5b079add3..06e908820 100644 --- a/src/odb_backend.c +++ b/src/odb_backend.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/odb_backend.h b/src/odb_backend.h index 15ec46c72..9a650ade5 100644 --- a/src/odb_backend.h +++ b/src/odb_backend.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/oid.c b/src/oid.c index ffce36a25..6183acd4e 100644 --- a/src/oid.c +++ b/src/oid.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/oid.h b/src/oid.h index 99ed3b868..9947e885a 100644 --- a/src/oid.h +++ b/src/oid.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/patch.c b/src/patch.c index 478364c68..d1b0cbe44 100644 --- a/src/patch.c +++ b/src/patch.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/patch.h b/src/patch.h index 3c0ad759f..691b13f21 100644 --- a/src/patch.h +++ b/src/patch.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/pygit2.c b/src/pygit2.c index 9ddf6e764..0dd47097b 100644 --- a/src/pygit2.c +++ b/src/pygit2.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/refdb.c b/src/refdb.c index 70c687473..8036a49d2 100644 --- a/src/refdb.c +++ b/src/refdb.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/refdb.h b/src/refdb.h index 984423542..57df647d0 100644 --- a/src/refdb.h +++ b/src/refdb.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/refdb_backend.c b/src/refdb_backend.c index d8617b055..5f52eee18 100644 --- a/src/refdb_backend.c +++ b/src/refdb_backend.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/refdb_backend.h b/src/refdb_backend.h index c6a5ff366..b976de3e7 100644 --- a/src/refdb_backend.h +++ b/src/refdb_backend.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/reference.c b/src/reference.c index 38135f925..4285a9d25 100644 --- a/src/reference.c +++ b/src/reference.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/reference.h b/src/reference.h index 909cbf30a..dc754742d 100644 --- a/src/reference.h +++ b/src/reference.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/repository.c b/src/repository.c index 3613b0108..b97c6bde8 100644 --- a/src/repository.c +++ b/src/repository.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/repository.h b/src/repository.h index 059d774a5..4af7dd819 100755 --- a/src/repository.h +++ b/src/repository.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/revspec.c b/src/revspec.c index 64e462bda..d7365f2f8 100644 --- a/src/revspec.c +++ b/src/revspec.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/revspec.h b/src/revspec.h index 2f80af913..b769b6c0d 100644 --- a/src/revspec.h +++ b/src/revspec.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/signature.c b/src/signature.c index f384bd7d0..4e1e75b5c 100644 --- a/src/signature.c +++ b/src/signature.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/signature.h b/src/signature.h index 9c646d86c..5366c560d 100644 --- a/src/signature.h +++ b/src/signature.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/stash.c b/src/stash.c index e60dcb8b5..75932f5b7 100644 --- a/src/stash.c +++ b/src/stash.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/tag.c b/src/tag.c index d384171a9..6601a35c9 100644 --- a/src/tag.c +++ b/src/tag.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/tree.c b/src/tree.c index 2e7906aaa..9aca5c73f 100644 --- a/src/tree.c +++ b/src/tree.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/tree.h b/src/tree.h index f7866695a..d4b241403 100644 --- a/src/tree.h +++ b/src/tree.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/treebuilder.c b/src/treebuilder.c index 629442bc4..ddcb67572 100644 --- a/src/treebuilder.c +++ b/src/treebuilder.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/treebuilder.h b/src/treebuilder.h index 5a6c82427..cd775a61f 100644 --- a/src/treebuilder.h +++ b/src/treebuilder.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/types.h b/src/types.h index 24a66aa10..77964f847 100644 --- a/src/types.h +++ b/src/types.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/utils.c b/src/utils.c index be90dba4d..f013183a3 100644 --- a/src/utils.c +++ b/src/utils.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/utils.h b/src/utils.h index e906b1129..17334a39c 100644 --- a/src/utils.h +++ b/src/utils.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/walker.c b/src/walker.c index 7b1e9d8d5..e09482709 100644 --- a/src/walker.c +++ b/src/walker.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/walker.h b/src/walker.h index 75b3afc92..3d0d296aa 100644 --- a/src/walker.h +++ b/src/walker.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/worktree.c b/src/worktree.c index b403485a1..2168d2844 100644 --- a/src/worktree.c +++ b/src/worktree.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/worktree.h b/src/worktree.h index 198f7b076..1f407b1b6 100644 --- a/src/worktree.h +++ b/src/worktree.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/test/__init__.py b/test/__init__.py index 53a1b2306..137e4a942 100644 --- a/test/__init__.py +++ b/test/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_apply_diff.py b/test/test_apply_diff.py index 9277d7eb1..b1761fbe1 100644 --- a/test/test_apply_diff.py +++ b/test/test_apply_diff.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_archive.py b/test/test_archive.py index 092fdd63f..be0fb9cba 100644 --- a/test/test_archive.py +++ b/test/test_archive.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_attributes.py b/test/test_attributes.py index 12f9106ba..cb2564a5a 100644 --- a/test/test_attributes.py +++ b/test/test_attributes.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_blame.py b/test/test_blame.py index cb1223954..929da2523 100644 --- a/test/test_blame.py +++ b/test/test_blame.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_blob.py b/test/test_blob.py index dcce71f4c..ccffffdb7 100644 --- a/test/test_blob.py +++ b/test/test_blob.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_branch.py b/test/test_branch.py index 63923cd66..14cb965f7 100644 --- a/test/test_branch.py +++ b/test/test_branch.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_branch_empty.py b/test/test_branch_empty.py index 5b2beabd8..b0ef793cf 100644 --- a/test/test_branch_empty.py +++ b/test/test_branch_empty.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_cherrypick.py b/test/test_cherrypick.py index 6c003223b..d7417ab75 100644 --- a/test/test_cherrypick.py +++ b/test/test_cherrypick.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_commit.py b/test/test_commit.py index 214340262..c4d38d7a9 100644 --- a/test/test_commit.py +++ b/test/test_commit.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_commit_gpg.py b/test/test_commit_gpg.py index d20f584ef..16cb2f129 100644 --- a/test/test_commit_gpg.py +++ b/test/test_commit_gpg.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_commit_trailer.py b/test/test_commit_trailer.py index efe5434f2..5bbd39b88 100644 --- a/test/test_commit_trailer.py +++ b/test/test_commit_trailer.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_config.py b/test/test_config.py index 88c7d0c44..34aade599 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_credentials.py b/test/test_credentials.py index dbc98823a..5f1c1e1f7 100644 --- a/test/test_credentials.py +++ b/test/test_credentials.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_describe.py b/test/test_describe.py index 963649b4a..6ea5bb028 100644 --- a/test/test_describe.py +++ b/test/test_describe.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_diff.py b/test/test_diff.py index cdd596884..2671f822d 100644 --- a/test/test_diff.py +++ b/test/test_diff.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_diff_binary.py b/test/test_diff_binary.py index 2947e403d..f0c02958f 100644 --- a/test/test_diff_binary.py +++ b/test/test_diff_binary.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_index.py b/test/test_index.py index 01b04417c..92d979026 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_mailmap.py b/test/test_mailmap.py index 44da270f4..141f92830 100644 --- a/test/test_mailmap.py +++ b/test/test_mailmap.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_merge.py b/test/test_merge.py index 1c9174dcc..a10d28512 100644 --- a/test/test_merge.py +++ b/test/test_merge.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_nonunicode.py b/test/test_nonunicode.py index 76cfb7a0f..5b0d89520 100644 --- a/test/test_nonunicode.py +++ b/test/test_nonunicode.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_note.py b/test/test_note.py index 6a1846c07..a3b488fa5 100644 --- a/test/test_note.py +++ b/test/test_note.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_object.py b/test/test_object.py index fa1a83119..f97c7e4f3 100644 --- a/test/test_object.py +++ b/test/test_object.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_odb.py b/test/test_odb.py index f0657af54..df03ece47 100644 --- a/test/test_odb.py +++ b/test/test_odb.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_odb_backend.py b/test/test_odb_backend.py index 89c215726..5acf8dfce 100644 --- a/test/test_odb_backend.py +++ b/test/test_odb_backend.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_oid.py b/test/test_oid.py index d57432bff..d806fd727 100644 --- a/test/test_oid.py +++ b/test/test_oid.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_options.py b/test/test_options.py index 6a1c1ac08..e6baaa911 100644 --- a/test/test_options.py +++ b/test/test_options.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_packbuilder.py b/test/test_packbuilder.py index 5309f3f13..06410699e 100644 --- a/test/test_packbuilder.py +++ b/test/test_packbuilder.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_patch.py b/test/test_patch.py index 4b74dd572..1426cf6b8 100644 --- a/test/test_patch.py +++ b/test/test_patch.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_patch_encoding.py b/test/test_patch_encoding.py index 4a151a70d..b70a9b8b8 100644 --- a/test/test_patch_encoding.py +++ b/test/test_patch_encoding.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_rebase.py b/test/test_rebase.py index 80342924e..9b422e374 100644 --- a/test/test_rebase.py +++ b/test/test_rebase.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_refdb_backend.py b/test/test_refdb_backend.py index 07c454463..73a2c9a5d 100644 --- a/test/test_refdb_backend.py +++ b/test/test_refdb_backend.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_refs.py b/test/test_refs.py index 276a0cdb2..80d0eb791 100644 --- a/test/test_refs.py +++ b/test/test_refs.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_remote.py b/test/test_remote.py index 5f4e59e74..98b83bf8e 100644 --- a/test/test_remote.py +++ b/test/test_remote.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_remote_prune.py b/test/test_remote_prune.py index 2fdaab0dd..2c57e0aa8 100644 --- a/test/test_remote_prune.py +++ b/test/test_remote_prune.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_remote_utf8.py b/test/test_remote_utf8.py index 03edd6b4c..29dfb6fcf 100644 --- a/test/test_remote_utf8.py +++ b/test/test_remote_utf8.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_repository.py b/test/test_repository.py index 30496e84a..03ba80811 100644 --- a/test/test_repository.py +++ b/test/test_repository.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_repository_bare.py b/test/test_repository_bare.py index 86cb15711..9a5028728 100644 --- a/test/test_repository_bare.py +++ b/test/test_repository_bare.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_repository_custom.py b/test/test_repository_custom.py index 40698df05..779ddc620 100644 --- a/test/test_repository_custom.py +++ b/test/test_repository_custom.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_repository_empty.py b/test/test_repository_empty.py index be8a8d342..5e3595bf8 100644 --- a/test/test_repository_empty.py +++ b/test/test_repository_empty.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_revparse.py b/test/test_revparse.py index d61df77d2..9e93fb043 100644 --- a/test/test_revparse.py +++ b/test/test_revparse.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_revwalk.py b/test/test_revwalk.py index 28cfc4061..fbdb30932 100644 --- a/test/test_revwalk.py +++ b/test/test_revwalk.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_settings.py b/test/test_settings.py index 5c5211019..da482078c 100644 --- a/test/test_settings.py +++ b/test/test_settings.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_signature.py b/test/test_signature.py index e90f55392..20dae2064 100644 --- a/test/test_signature.py +++ b/test/test_signature.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_status.py b/test/test_status.py index 653ed93d3..82226c575 100644 --- a/test/test_status.py +++ b/test/test_status.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_submodule.py b/test/test_submodule.py index 90bdc7586..5c26578ac 100644 --- a/test/test_submodule.py +++ b/test/test_submodule.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_tag.py b/test/test_tag.py index a3be72c28..ddaca735b 100644 --- a/test/test_tag.py +++ b/test/test_tag.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_transaction.py b/test/test_transaction.py index 5a6e97ed5..226f1f9ed 100644 --- a/test/test_transaction.py +++ b/test/test_transaction.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_tree.py b/test/test_tree.py index 556c3751a..f7ab2c8d8 100644 --- a/test/test_tree.py +++ b/test/test_tree.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/test_treebuilder.py b/test/test_treebuilder.py index 99e4d6b3b..beac1ce07 100644 --- a/test/test_treebuilder.py +++ b/test/test_treebuilder.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, diff --git a/test/utils.py b/test/utils.py index 48dc7c329..3b56eff55 100644 --- a/test/utils.py +++ b/test/utils.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, From 608c8252a487687f5c314a56e6c6de12abeb9fa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Mon, 10 Aug 2026 16:50:58 +0200 Subject: [PATCH 211/225] Add specific exception classes for libgit2 error codes The following libgit2 errors are now raised as specific pygit2 exceptions instead of plain ValueError or KeyError: - AlreadyExistsError (also ValueError) - InvalidSpecError (also ValueError) - InvalidError (also ValueError) - NotFoundError (also KeyError) - AmbiguousError (also ValueError) - AuthError - CertificateError All new exceptions inherit from GitError, so existing code catching GitError, ValueError, KeyError, or Exception continues to work. Fixes #830 Closes #998 Assisted-by: Kimi Code --- CHANGELOG.md | 9 ++++ docs/general.rst | 82 ++++++++++++++++++++++++++++ pygit2/__init__.py | 21 ++++++-- pygit2/_pygit2.pyi | 11 +++- pygit2/errors.py | 58 +++++++++++++++++--- src/error.c | 27 ++++++++-- src/pygit2.c | 14 ++++- src/utils.h | 13 +++++ test/test_errors.py | 126 ++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 340 insertions(+), 21 deletions(-) create mode 100644 test/test_errors.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ff8767577..2450440de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +# 1.20.1 (UNRELEASED) + +- New exception hierarchy: `AlreadyExistsError`, `InvalidSpecError`, + `InvalidError`, `NotFoundError`, `AmbiguousError`, `AuthError`, and + `CertificateError` all inherit from `GitError` and the appropriate Python + built-in exception (`ValueError`/`KeyError`) for backward compatibility + [#830](https://github.com/libgit2/pygit2/issues/830). + + # 1.20.0 (2026-08-08) - New `RemoteCallbacks.custom_headers()` diff --git a/docs/general.rst b/docs/general.rst index a155357b6..e2f73d667 100644 --- a/docs/general.rst +++ b/docs/general.rst @@ -81,6 +81,41 @@ Exception when trying to create an object (reference, etc) that already exists. Exception when an input specification such as a reference name is invalid. +.. autoexception:: pygit2.InvalidError + :members: + :show-inheritance: + :undoc-members: + +Exception when an operation or input is invalid. + +.. autoexception:: pygit2.NotFoundError + :members: + :show-inheritance: + :undoc-members: + +Exception when a requested object could not be found. + +.. autoexception:: pygit2.AmbiguousError + :members: + :show-inheritance: + :undoc-members: + +Exception when more than one object matches. + +.. autoexception:: pygit2.AuthError + :members: + :show-inheritance: + :undoc-members: + +Exception when an authentication error occurs. + +.. autoexception:: pygit2.CertificateError + :members: + :show-inheritance: + :undoc-members: + +Exception when a server certificate is invalid. + .. autoexception:: pygit2.Passthrough :members: :show-inheritance: @@ -88,3 +123,50 @@ Exception when an input specification such as a reference name is invalid. Exception that can be raised from a callback to tell libgit2 to behave as if that callback had not been set. See :doc:`callbacks` for details. + +Error mapping +============= + +The following table shows how libgit2 error codes map to pygit2 exceptions. +The new exception classes inherit from :py:exc:`pygit2.GitError` and, where +noted, from a Python built-in exception for backward compatibility. + +.. list-table:: + :header-rows: 1 + :widths: 35 35 30 + + * - pygit2 exception + - libgit2 code / class + - Built-in base + + * - :py:exc:`AlreadyExistsError` + - ``GIT_EEXISTS`` + - ``ValueError`` + + * - :py:exc:`InvalidSpecError` + - ``GIT_EINVALIDSPEC`` + - ``ValueError`` + + * - :py:exc:`InvalidError` + - ``GIT_EINVALID``, ``GIT_ERROR_INVALID`` + - ``ValueError`` + + * - :py:exc:`NotFoundError` + - ``GIT_ENOTFOUND`` + - ``KeyError`` + + * - :py:exc:`AmbiguousError` + - ``GIT_EAMBIGUOUS`` + - ``ValueError`` + + * - :py:exc:`AuthError` + - ``GIT_EAUTH`` + - + + * - :py:exc:`CertificateError` + - ``GIT_ECERTIFICATE`` + - + + * - :py:exc:`GitError` + - generic / other errors + - diff --git a/pygit2/__init__.py b/pygit2/__init__.py index 3a2a4f5b7..785ffe958 100644 --- a/pygit2/__init__.py +++ b/pygit2/__init__.py @@ -248,7 +248,6 @@ LIBGIT2_VER_MINOR, LIBGIT2_VER_REVISION, LIBGIT2_VERSION, - AlreadyExistsError, Blob, Branch, Commit, @@ -259,8 +258,6 @@ DiffLine, DiffStats, FilterSource, - GitError, - InvalidSpecError, Mailmap, Note, Object, @@ -306,7 +303,18 @@ ) from .config import Config from .credentials import * -from .errors import Passthrough, check_error +from .errors import ( + AlreadyExistsError, + AmbiguousError, + AuthError, + CertificateError, + GitError, + InvalidError, + InvalidSpecError, + NotFoundError, + Passthrough, + check_error, +) from .ffi import C, ffi from .filter import Filter from .index import Index, IndexEntry @@ -592,8 +600,11 @@ def filter_unregister(name: str) -> None: 'Object', 'Reference', 'AlreadyExistsError', + 'AmbiguousError', + 'AuthError', 'Blob', 'Branch', + 'CertificateError', 'Commit', 'Diff', 'DiffDelta', @@ -602,9 +613,11 @@ def filter_unregister(name: str) -> None: 'DiffLine', 'DiffStats', 'GitError', + 'InvalidError', 'InvalidSpecError', 'Mailmap', 'Note', + 'NotFoundError', 'Odb', 'OdbBackend', 'OdbBackendLoose', diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index aac8ccde1..7142f0382 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -331,7 +331,10 @@ class Reference: def __lt__(self, other, /) -> bool: ... def __ne__(self, other, /) -> bool: ... -class AlreadyExistsError(ValueError): ... +class AlreadyExistsError(GitError, ValueError): ... +class AmbiguousError(GitError, ValueError): ... +class AuthError(GitError): ... +class InvalidError(GitError, ValueError): ... @final class Blob(Object): @@ -376,6 +379,8 @@ class Branch(Reference): # type: ignore[misc] def is_head(self) -> bool: ... def rename(self, name: str, force: bool = False) -> 'Branch': ... # type: ignore[override] +class CertificateError(GitError): ... + @final class Commit(_ObjectBase[GitCommitC]): _pointer: _Pointer[GitCommitC] @@ -476,7 +481,7 @@ class FilterSource: flags: int class GitError(Exception): ... -class InvalidSpecError(ValueError): ... +class InvalidSpecError(GitError, ValueError): ... @final class Mailmap: @@ -505,6 +510,8 @@ class Note: self, author: Signature, committer: Signature, ref: str = 'refs/notes/commits' ) -> None: ... +class NotFoundError(GitError, KeyError): ... + @final class Odb: backends: Iterator[OdbBackend] diff --git a/pygit2/errors.py b/pygit2/errors.py index 81636fbbd..0921ab535 100644 --- a/pygit2/errors.py +++ b/pygit2/errors.py @@ -24,12 +24,39 @@ # Boston, MA 02110-1301, USA. # Import from pygit2 -from ._pygit2 import GitError +from ._pygit2 import ( + AlreadyExistsError, + AmbiguousError, + AuthError, + CertificateError, + GitError, + InvalidError, + InvalidSpecError, + NotFoundError, +) from .ffi import C, ffi -__all__ = ['GitError'] +__all__ = [ + 'AlreadyExistsError', + 'AmbiguousError', + 'AuthError', + 'CertificateError', + 'GitError', + 'InvalidError', + 'InvalidSpecError', + 'NotFoundError', + 'Passthrough', +] -value_errors = set([C.GIT_EEXISTS, C.GIT_EINVALIDSPEC, C.GIT_EAMBIGUOUS]) +# Docstrings for C-defined exception classes +GitError.__doc__ = 'Generic libgit2 error.' +AlreadyExistsError.__doc__ = 'Object already exists.' +InvalidSpecError.__doc__ = 'Invalid name/ref spec.' +NotFoundError.__doc__ = 'Requested object could not be found.' +AmbiguousError.__doc__ = 'More than one object matches.' +AuthError.__doc__ = 'Authentication error.' +CertificateError.__doc__ = 'Server certificate is invalid.' +InvalidError.__doc__ = 'Invalid operation or input.' def check_error(err: int, io: bool = False) -> None: @@ -48,17 +75,32 @@ def check_error(err: int, io: bool = False) -> None: message = f'err {err} (no message provided)' # Translate to Python errors - if err in value_errors: + if err == C.GIT_EEXISTS: + raise AlreadyExistsError(message) + + if err == C.GIT_EINVALIDSPEC: + raise InvalidSpecError(message) + + if err == C.GIT_EINVALID: + raise InvalidError(message) + + if err == C.GIT_EAMBIGUOUS: + raise AmbiguousError(message) + + if err == C.GIT_EBUFS: raise ValueError(message) + if err == C.GIT_EAUTH: + raise AuthError(message) + + if err == C.GIT_ECERTIFICATE: + raise CertificateError(message) + if err == C.GIT_ENOTFOUND: if io: raise IOError(message) - raise KeyError(message) - - if err == C.GIT_EINVALIDSPEC: - raise ValueError(message) + raise NotFoundError(message) if err == C.GIT_ITEROVER: raise StopIteration() diff --git a/src/error.c b/src/error.c index a6371dbac..9b1247eee 100644 --- a/src/error.c +++ b/src/error.c @@ -30,6 +30,11 @@ extern PyObject *GitError; extern PyObject *AlreadyExistsError; extern PyObject *InvalidSpecError; +extern PyObject *InvalidError; +extern PyObject *NotFoundError; +extern PyObject *AmbiguousError; +extern PyObject *AuthError; +extern PyObject *CertificateError; PyObject * Error_type(int type) @@ -39,7 +44,7 @@ Error_type(int type) switch (type) { /* Input does not exist in the scope searched. */ case GIT_ENOTFOUND: - return PyExc_KeyError; + return NotFoundError; /* A reference with this name already exists */ case GIT_EEXISTS: @@ -47,7 +52,7 @@ Error_type(int type) /* The given short oid is ambiguous */ case GIT_EAMBIGUOUS: - return PyExc_ValueError; + return AmbiguousError; /* The buffer is too short to satisfy the request */ case GIT_EBUFS: @@ -57,6 +62,18 @@ Error_type(int type) case GIT_EINVALIDSPEC: return InvalidSpecError; + /* Invalid operation or input */ + case GIT_EINVALID: + return InvalidError; + + /* Authentication error */ + case GIT_EAUTH: + return AuthError; + + /* Server certificate is invalid */ + case GIT_ECERTIFICATE: + return CertificateError; + /* Skip and passthrough the given ODB backend */ case GIT_PASSTHROUGH: return GitError; @@ -75,7 +92,7 @@ Error_type(int type) case GITERR_OS: return PyExc_OSError; case GITERR_INVALID: - return PyExc_ValueError; + return InvalidError; } } return GitError; @@ -106,8 +123,8 @@ PyObject * Error_set_str(int err, const char *str) { if (err == GIT_ENOTFOUND) { - /* KeyError expects the arg to be the missing key. */ - PyErr_SetString(PyExc_KeyError, str); + /* NotFoundError inherits from KeyError; the argument is the missing key. */ + PyErr_SetString(NotFoundError, str); return NULL; } diff --git a/src/pygit2.c b/src/pygit2.c index 0dd47097b..ea5c36acc 100644 --- a/src/pygit2.c +++ b/src/pygit2.c @@ -40,6 +40,11 @@ PyObject *GitError; PyObject *AlreadyExistsError; PyObject *InvalidSpecError; +PyObject *InvalidError; +PyObject *NotFoundError; +PyObject *AmbiguousError; +PyObject *AuthError; +PyObject *CertificateError; PyObject *DeltaStatusEnum; PyObject *DiffFlagEnum; @@ -461,8 +466,13 @@ PyInit__pygit2(void) /* Exceptions */ ADD_EXC(m, GitError, NULL); - ADD_EXC(m, AlreadyExistsError, PyExc_ValueError); - ADD_EXC(m, InvalidSpecError, PyExc_ValueError); + ADD_EXC2(m, AlreadyExistsError, GitError, PyExc_ValueError); + ADD_EXC2(m, InvalidSpecError, GitError, PyExc_ValueError); + ADD_EXC2(m, InvalidError, GitError, PyExc_ValueError); + ADD_EXC2(m, NotFoundError, GitError, PyExc_KeyError); + ADD_EXC2(m, AmbiguousError, GitError, PyExc_ValueError); + ADD_EXC(m, AuthError, GitError); + ADD_EXC(m, CertificateError, GitError); /* Repository */ INIT_TYPE(RepositoryType, NULL, PyType_GenericNew) diff --git a/src/utils.h b/src/utils.h index 17334a39c..560a84677 100644 --- a/src/utils.h +++ b/src/utils.h @@ -148,6 +148,19 @@ PyObject *pygit2_enum(PyObject *enum_type, int value); goto fail;\ } +#define ADD_EXC2(m, name, base1, base2) {\ + PyObject *bases = PyTuple_Pack(2, base1, base2);\ + if (bases == NULL) goto fail;\ + name = PyErr_NewException("_pygit2." #name, bases, NULL);\ + Py_DECREF(bases);\ + if (name == NULL) goto fail;\ + Py_INCREF(name);\ + if (PyModule_AddObject(m, #name, name)) {\ + Py_DECREF(name);\ + goto fail;\ + }\ +} + #define ADD_CONSTANT_INT(m, name) \ if (PyModule_AddIntConstant(m, #name, name) == -1) return NULL; diff --git a/test/test_errors.py b/test/test_errors.py new file mode 100644 index 000000000..6bc111b1e --- /dev/null +++ b/test/test_errors.py @@ -0,0 +1,126 @@ +# Copyright 2010-2026 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file 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; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +"""Tests for the exception hierarchy.""" + +import pytest + +from pygit2 import ( + AlreadyExistsError, + AmbiguousError, + AuthError, + CertificateError, + GitError, + InvalidError, + InvalidSpecError, + NotFoundError, + Repository, +) + + +def test_already_exists_error_inheritance() -> None: + assert issubclass(AlreadyExistsError, GitError) + assert issubclass(AlreadyExistsError, ValueError) + + +def test_invalid_spec_error_inheritance() -> None: + assert issubclass(InvalidSpecError, GitError) + assert issubclass(InvalidSpecError, ValueError) + + +def test_invalid_error_inheritance() -> None: + assert issubclass(InvalidError, GitError) + assert issubclass(InvalidError, ValueError) + + +def test_not_found_error_inheritance() -> None: + assert issubclass(NotFoundError, GitError) + assert issubclass(NotFoundError, KeyError) + + +def test_ambiguous_error_inheritance() -> None: + assert issubclass(AmbiguousError, GitError) + assert issubclass(AmbiguousError, ValueError) + + +def test_auth_error_inheritance() -> None: + assert issubclass(AuthError, GitError) + + +def test_certificate_error_inheritance() -> None: + assert issubclass(CertificateError, GitError) + + +def test_create_reference_already_exists(testrepo: Repository) -> None: + target = testrepo.head.target + testrepo.create_reference_direct('refs/heads/foo', target, False) + + with pytest.raises(AlreadyExistsError) as excinfo: + testrepo.create_reference_direct('refs/heads/foo', target, False) + + assert isinstance(excinfo.value, GitError) + assert isinstance(excinfo.value, ValueError) + + +def test_create_reference_invalid_spec(testrepo: Repository) -> None: + target = testrepo.head.target + + with pytest.raises(InvalidSpecError) as excinfo: + testrepo.create_reference_direct('invalid ref name', target, False) + + assert isinstance(excinfo.value, GitError) + assert isinstance(excinfo.value, ValueError) + + +def test_remote_lookup_not_found(emptyrepo: Repository) -> None: + with pytest.raises(NotFoundError) as excinfo: + emptyrepo.remotes['nonexistent'] + + assert isinstance(excinfo.value, GitError) + assert isinstance(excinfo.value, KeyError) + + +def test_revparse_single_not_found(testrepo: Repository) -> None: + with pytest.raises(NotFoundError) as excinfo: + testrepo.revparse_single('nonexistent-ref-12345') + + assert isinstance(excinfo.value, GitError) + assert isinstance(excinfo.value, KeyError) + + +def test_oid_parse_invalid_error(testrepo: Repository) -> None: + with pytest.raises(InvalidError) as excinfo: + testrepo['notahexoid'] + + assert isinstance(excinfo.value, GitError) + assert isinstance(excinfo.value, ValueError) + + +def test_lookup_short_oid_ambiguous(testrepo: Repository) -> None: + with pytest.raises(AmbiguousError) as excinfo: + testrepo['5fe'] + + assert isinstance(excinfo.value, GitError) + assert isinstance(excinfo.value, ValueError) From b9f62a3572d4d51008e7c27982e82f009043357b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Tue, 11 Aug 2026 10:18:36 +0200 Subject: [PATCH 212/225] Load DiffDelta binary flags lazily Fixes #962 Closes #1100 Assisted-by: Kimi Code --- CHANGELOG.md | 5 ++++ pygit2/_pygit2.pyi | 2 +- src/diff.c | 56 +++++++++++++++++++++++++++++++++++++--- src/diff.h | 2 +- src/patch.c | 2 +- src/types.h | 2 ++ test/test_diff.py | 4 +-- test/test_diff_binary.py | 6 +++++ 8 files changed, 69 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2450440de..dbf41499a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ built-in exception (`ValueError`/`KeyError`) for backward compatibility [#830](https://github.com/libgit2/pygit2/issues/830). +- Fix `DiffDelta.is_binary` and `DiffDelta.flags` returning stale values for + deltas obtained from `Diff.deltas`; flags are now loaded lazily + [#962](https://github.com/libgit2/pygit2/issues/962) + [#1100](https://github.com/libgit2/pygit2/pull/1100). + # 1.20.0 (2026-08-08) diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi index 7142f0382..f8ca06bd1 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -426,7 +426,7 @@ class Diff: @final class DiffDelta: flags: DiffFlag - is_binary: bool + is_binary: bool | None nfiles: int new_file: DiffFile old_file: DiffFile diff --git a/src/diff.c b/src/diff.c index adab1ba49..c7e366b1b 100644 --- a/src/diff.c +++ b/src/diff.c @@ -93,7 +93,7 @@ wrap_diff_file(const git_diff_file *file) } PyObject * -wrap_diff_delta(const git_diff_delta *delta) +wrap_diff_delta(const git_diff_delta *delta, Diff *diff, size_t idx) { DiffDelta *py_delta; @@ -108,6 +108,10 @@ wrap_diff_delta(const git_diff_delta *delta) py_delta->nfiles = delta->nfiles; py_delta->old_file = wrap_diff_file(&delta->old_file); py_delta->new_file = wrap_diff_file(&delta->new_file); + py_delta->diff = diff; + if (diff) + Py_INCREF(diff); + py_delta->idx = idx; } return (PyObject *) py_delta; @@ -315,6 +319,37 @@ DiffDelta_status_char(DiffDelta *self) return Py_BuildValue("C", status); } +/* Load the file data so delta->flags is up to date. This is a no-op if the + flags have already been loaded or if the delta was not created from a Diff + object (e.g. it came from a Patch, whose flags are already loaded). */ +static int +DiffDelta_ensure_flags(DiffDelta *self) +{ + git_patch *patch = NULL; + const git_diff_delta *delta; + int err; + + if (self->diff == NULL) + return 0; + + if (self->flags & (GIT_DIFF_FLAG_BINARY | GIT_DIFF_FLAG_NOT_BINARY)) + return 0; + + err = git_patch_from_diff(&patch, self->diff->diff, self->idx); + if (err < 0) + return err; + + delta = git_diff_get_delta(self->diff->diff, self->idx); + if (delta == NULL) { + git_patch_free(patch); + return GIT_ENOTFOUND; + } + + self->flags = delta->flags; + git_patch_free(patch); + return 0; +} + PyDoc_STRVAR(DiffDelta_is_binary__doc__, "True if binary data, False if text, None if not (yet) known." ); @@ -322,6 +357,12 @@ PyDoc_STRVAR(DiffDelta_is_binary__doc__, PyObject * DiffDelta_is_binary__get__(DiffDelta *self) { + int err; + + err = DiffDelta_ensure_flags(self); + if (err < 0) + return Error_set(err); + if (self->flags & GIT_DIFF_FLAG_BINARY) Py_RETURN_TRUE; @@ -350,12 +391,19 @@ PyDoc_STRVAR(DiffDelta_flags__doc__, PyObject * DiffDelta_flags__get__(DiffDelta *self) { + int err; + + err = DiffDelta_ensure_flags(self); + if (err < 0) + return Error_set(err); + return pygit2_enum(DiffFlagEnum, self->flags); } static void DiffDelta_dealloc(DiffDelta *self) { + Py_CLEAR(self->diff); Py_CLEAR(self->old_file); Py_CLEAR(self->new_file); PyObject_Del(self); @@ -603,7 +651,7 @@ PyTypeObject DiffIterType = { }; PyObject * -diff_get_delta_byindex(git_diff *diff, size_t idx) +diff_get_delta_byindex(git_diff *diff, size_t idx, Diff *parent) { const git_diff_delta *delta = git_diff_get_delta(diff, idx); if (delta == NULL) { @@ -611,14 +659,14 @@ diff_get_delta_byindex(git_diff *diff, size_t idx) return NULL; } - return (PyObject*) wrap_diff_delta(delta); + return (PyObject*) wrap_diff_delta(delta, parent, idx); } PyObject * DeltasIter_iternext(DeltasIter *self) { if (self->i < self->n) - return diff_get_delta_byindex(self->diff->diff, self->i++); + return diff_get_delta_byindex(self->diff->diff, self->i++, self->diff); PyErr_SetNone(PyExc_StopIteration); return NULL; diff --git a/src/diff.h b/src/diff.h index 6b32b0f98..8a2c2196c 100644 --- a/src/diff.h +++ b/src/diff.h @@ -34,7 +34,7 @@ #include "types.h" PyObject* wrap_diff(git_diff *diff, Repository *repo); -PyObject* wrap_diff_delta(const git_diff_delta *delta); +PyObject* wrap_diff_delta(const git_diff_delta *delta, Diff *diff, size_t idx); PyObject* wrap_diff_file(const git_diff_file *file); PyObject* wrap_diff_hunk(Patch *patch, size_t idx); PyObject* wrap_diff_line(const git_diff_line *line, DiffHunk *hunk); diff --git a/src/patch.c b/src/patch.c index d1b0cbe44..bf818f645 100644 --- a/src/patch.c +++ b/src/patch.c @@ -76,7 +76,7 @@ PyObject * Patch_delta__get__(Patch *self) { assert(self->patch); - return wrap_diff_delta(git_patch_get_delta(self->patch)); + return wrap_diff_delta(git_patch_get_delta(self->patch), NULL, 0); } PyDoc_STRVAR(Patch_line_stats__doc__, diff --git a/src/types.h b/src/types.h index 77964f847..afdf8d096 100644 --- a/src/types.h +++ b/src/types.h @@ -182,6 +182,8 @@ typedef struct { uint16_t nfiles; PyObject *old_file; PyObject *new_file; + Diff *diff; + size_t idx; } DiffDelta; typedef struct { diff --git a/test/test_diff.py b/test/test_diff.py index 2671f822d..29d4649c6 100644 --- a/test/test_diff.py +++ b/test/test_diff.py @@ -426,9 +426,7 @@ def test_deltas(barerepo: Repository) -> None: assert delta.new_file.id == patch_delta.new_file.id assert delta.old_file.mode == patch_delta.old_file.mode assert delta.new_file.mode == patch_delta.new_file.mode - - # As explained in the libgit2 documentation, flags are not set - # assert delta.flags == patch_delta.flags + assert delta.flags == patch_delta.flags def test_diff_parse(barerepo: Repository) -> None: diff --git a/test/test_diff_binary.py b/test/test_diff_binary.py index f0c02958f..bb2f36e2b 100644 --- a/test/test_diff_binary.py +++ b/test/test_diff_binary.py @@ -67,3 +67,9 @@ def test_binary_diff(repo: Repository) -> None: assert PATCH_BINARY == diff.patch diff = repo.diff(b'HEAD', b'HEAD^', flags=DiffOption.SHOW_BINARY) assert PATCH_BINARY_SHOW == diff.patch + + +def test_binary_delta_is_binary(repo: Repository) -> None: + diff = repo.diff('HEAD', 'HEAD^') + for delta in diff.deltas: + assert delta.is_binary From e6204dc42ccd24754f79abf9fc54cc4552be5bb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Tue, 11 Aug 2026 12:33:29 +0200 Subject: [PATCH 213/225] Fix invalid oid handling in BlobIO, Reference, RefdbBackend and ODB backends Invalid oid arguments were silently ignored or left a Python exception set, which could produce wrong results, SystemError, or crashes. Now: - Blob__write_to_queue, Reference_init, RefdbBackend_write/delete check py_oid_to_git_oid return value and propagate the Python exception. - Custom ODB backend callbacks return GIT_EUSER on invalid oids. - Odb_as_iter and OdbBackend_as_iter propagate GIT_EUSER errors without goto. - OdbBackend_read_prefix/exists_prefix preserve Python exceptions from custom backends. Regression tests added. Fixes #1478 Assisted-by: Kimi Code --- CHANGELOG.md | 6 ++++ src/blob.c | 8 +++-- src/odb.c | 21 +++++++------ src/odb_backend.c | 63 ++++++++++++++++++++++++++++---------- src/refdb_backend.c | 6 ++-- src/reference.c | 6 ++-- test/test_blob.py | 42 ++++++++++++++++++++++++- test/test_odb_backend.py | 47 ++++++++++++++++++++++++++++ test/test_refdb_backend.py | 36 ++++++++++++++++++++++ test/test_refs.py | 22 +++++++++++++ 10 files changed, 223 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dbf41499a..6b3a9808f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,12 @@ [#962](https://github.com/libgit2/pygit2/issues/962) [#1100](https://github.com/libgit2/pygit2/pull/1100). +- Fix invalid oid arguments being silently ignored in `BlobIO`, the + `Reference` constructor, `RefdbBackend.write()`/`delete()`, and custom + `OdbBackend` callbacks; they now raise the appropriate Python exception + instead of producing wrong results, `SystemError`, or crashes + [#1478](https://github.com/libgit2/pygit2/issues/1478). + # 1.20.0 (2026-08-08) diff --git a/src/blob.c b/src/blob.c index c8fad9a6a..63b70dd42 100644 --- a/src/blob.c +++ b/src/blob.c @@ -316,9 +316,11 @@ Blob__write_to_queue(Blob *self, PyObject *args, PyObject *kwds) { if (py_oid != NULL && py_oid != Py_None) { - err = py_oid_to_git_oid(py_oid, &opts.attr_commit_id); - if (err < 0) - return Error_set(err); + size_t len = py_oid_to_git_oid(py_oid, &opts.attr_commit_id); + if (len == 0) { + git_blob_free(blob); + return NULL; + } } if ((opts.flags & GIT_BLOB_FILTER_NO_SYSTEM_ATTRIBUTES) != 0) diff --git a/src/odb.c b/src/odb.c index a42f6bb6a..51ae3aa59 100644 --- a/src/odb.c +++ b/src/odb.c @@ -113,21 +113,24 @@ Odb_build_as_iter(const git_oid *oid, void *accum) PyObject * Odb_as_iter(Odb *self) { - int err; PyObject *accum = PyList_New(0); - PyObject *ret = NULL; + if (accum == NULL) + return NULL; - err = git_odb_foreach(self->odb, Odb_build_as_iter, (void*)accum); + int err = git_odb_foreach(self->odb, Odb_build_as_iter, (void*)accum); + if (err == GIT_EUSER && PyErr_Occurred()) { + Py_DECREF(accum); + return NULL; + } if (err == GIT_EUSER) - goto exit; + err = GIT_ERROR; + if (err < 0) { - ret = Error_set(err); - goto exit; + Py_DECREF(accum); + return Error_set(err); } - ret = PyObject_GetIter(accum); - -exit: + PyObject *ret = PyObject_GetIter(accum); Py_DECREF(accum); return ret; } diff --git a/src/odb_backend.c b/src/odb_backend.c index 06e908820..47d0a54e9 100644 --- a/src/odb_backend.c +++ b/src/odb_backend.c @@ -124,8 +124,10 @@ pgit_odb_backend_read_prefix(git_oid *oid_out, void **ptr, size_t *sz, git_objec } memcpy(*ptr, bytes, *sz); - py_oid_to_git_oid(py_oid_out, oid_out); + size_t oid_len = py_oid_to_git_oid(py_oid_out, oid_out); Py_DECREF(result); + if (oid_len == 0) + return GIT_EUSER; return 0; } @@ -204,8 +206,10 @@ pgit_odb_backend_exists_prefix(git_oid *out, git_odb_backend *_be, if (py_oid == NULL) return git_error_for_exc(); - py_oid_to_git_oid(py_oid, out); + size_t oid_len = py_oid_to_git_oid(py_oid, out); Py_DECREF(py_oid); + if (oid_len == 0) + return GIT_EUSER; return 0; } @@ -224,15 +228,33 @@ pgit_odb_backend_foreach(git_odb_backend *_be, PyObject *item; git_oid oid; pgit_odb_backend *be = (pgit_odb_backend *)_be; - PyObject *iterator = PyObject_GetIter((PyObject *)be->py_backend); - assert(iterator); + + /* Call the Python __iter__ method directly. PyObject_GetIter would invoke + * the C tp_iter slot (OdbBackend_as_iter), which calls this function back + * and causes infinite recursion for Python backends. */ + PyObject *iter_method = PyObject_GetAttrString((PyObject *)be->py_backend, "__iter__"); + if (iter_method == NULL) + return git_error_for_exc(); + + PyObject *iterator = PyObject_CallObject(iter_method, NULL); + Py_DECREF(iter_method); + if (iterator == NULL) + return git_error_for_exc(); while ((item = PyIter_Next(iterator))) { - py_oid_to_git_oid(item, &oid); - cb(&oid, payload); + size_t len = py_oid_to_git_oid(item, &oid); Py_DECREF(item); + if (len == 0) { + Py_DECREF(iterator); + return GIT_EUSER; + } + if (cb(&oid, payload) != 0) { + Py_DECREF(iterator); + return GIT_EUSER; + } } + Py_DECREF(iterator); return git_error_for_exc(); } @@ -278,7 +300,7 @@ OdbBackend_init(OdbBackend *self, PyObject *args, PyObject *kwds) // custom_backend->backend.freshen = pgit_odb_backend_freshen; // custom_backend->backend.writestream = pgit_odb_backend_writestream; // custom_backend->backend.readstream = pgit_odb_backend_readstream; - if (PyIter_Check((PyObject *)self)) + if (PyObject_HasAttrString((PyObject *)self, "__iter__")) custom_backend->backend.foreach = pgit_odb_backend_foreach; // Cross reference (don't incref because it's something internal) @@ -321,20 +343,23 @@ PyObject * OdbBackend_as_iter(OdbBackend *self) { PyObject *accum = PyList_New(0); - PyObject *iter = NULL; + if (accum == NULL) + return NULL; int err = self->odb_backend->foreach(self->odb_backend, OdbBackend_build_as_iter, (void*)accum); + if (err == GIT_EUSER && PyErr_Occurred()) { + Py_DECREF(accum); + return NULL; + } if (err == GIT_EUSER) - goto exit; + err = GIT_ERROR; if (err < 0) { - Error_set(err); - goto exit; + Py_DECREF(accum); + return Error_set(err); } - iter = PyObject_GetIter(accum); - -exit: + PyObject *iter = PyObject_GetIter(accum); Py_DECREF(accum); return iter; } @@ -397,8 +422,9 @@ OdbBackend_read_prefix(OdbBackend *self, PyObject *py_hex) err = self->odb_backend->read_prefix(&oid_out, &data, &size, &type, self->odb_backend, &oid, len); if (err != 0) { - Error_set_oid(err, &oid, len); - return NULL; + if (err == GIT_EUSER && PyErr_Occurred()) + return NULL; + return Error_set_oid(err, &oid, len); } PyObject *py_oid_out = git_oid_to_python(&oid_out); @@ -492,8 +518,11 @@ OdbBackend_exists_prefix(OdbBackend *self, PyObject *py_hex) git_oid out; result = self->odb_backend->exists_prefix(&out, self->odb_backend, &oid, len); - if (result < 0) + if (result < 0) { + if (result == GIT_EUSER && PyErr_Occurred()) + return NULL; return Error_set(result); + } return git_oid_to_python(&out); } diff --git a/src/refdb_backend.c b/src/refdb_backend.c index 5f52eee18..28cba7396 100644 --- a/src/refdb_backend.c +++ b/src/refdb_backend.c @@ -711,7 +711,8 @@ RefdbBackend_write(RefdbBackend *self, PyObject *args) return NULL; if ((PyObject *)py_old != Py_None) { - py_oid_to_git_oid(py_old, &_old); + if (py_oid_to_git_oid(py_old, &_old) == 0) + return NULL; old = &_old; } @@ -785,7 +786,8 @@ RefdbBackend_delete(RefdbBackend *self, PyObject *args) return NULL; if (py_old_id != Py_None) { - py_oid_to_git_oid(py_old_id, &old_id); + if (py_oid_to_git_oid(py_old_id, &old_id) == 0) + return NULL; err = self->refdb_backend->del(self->refdb_backend, ref_name, &old_id, old_target); } else { diff --git a/src/reference.c b/src/reference.c index 4285a9d25..be1cae2bc 100644 --- a/src/reference.c +++ b/src/reference.c @@ -165,9 +165,11 @@ Reference_init(Reference *self, PyObject *args, PyObject *kwds) return -1; } - py_oid_to_git_oid(py_oid, &oid); + if (py_oid_to_git_oid(py_oid, &oid) == 0) + return -1; if (py_peel != Py_None) { - py_oid_to_git_oid(py_peel, &peel); + if (py_oid_to_git_oid(py_peel, &peel) == 0) + return -1; } self->reference = git_reference__alloc(name, &oid, diff --git a/test/test_blob.py b/test/test_blob.py index ccffffdb7..fdc78a2ac 100644 --- a/test/test_blob.py +++ b/test/test_blob.py @@ -34,7 +34,7 @@ import pygit2 from pygit2 import Repository -from pygit2.enums import ObjectType +from pygit2.enums import BlobFilter, ObjectType from . import utils @@ -266,3 +266,43 @@ def test_blobio_filtered(testrepo: Repository) -> None: with pygit2.BlobIO(blob, as_path='bye.txt') as reader: assert b'bye world\n' == reader.read() assert not reader.raw._thread.is_alive() # type: ignore[attr-defined] + + +def test_blob_write_to_queue_invalid_commit_id_type(testrepo: Repository) -> None: + # Regression test (issue #1478): an invalid commit_id type must raise + # TypeError instead of being ignored and leaving an exception set. + queue: Queue[bytes] = Queue() + ready = Event() + done = Event() + blob_oid = testrepo.create_blob_fromworkdir('bye.txt') + blob = testrepo[blob_oid] + assert isinstance(blob, pygit2.Blob) + with pytest.raises(TypeError): + blob._write_to_queue( + queue, + ready, + done, + as_path='bye.txt', + flags=BlobFilter.ATTRIBUTES_FROM_COMMIT, + commit_id=1234, # type: ignore + ) + + +def test_blob_write_to_queue_invalid_commit_id_str(testrepo: Repository) -> None: + # Regression test (issue #1478): a malformed commit_id string must raise + # InvalidError instead of being ignored and leaving an exception set. + queue: Queue[bytes] = Queue() + ready = Event() + done = Event() + blob_oid = testrepo.create_blob_fromworkdir('bye.txt') + blob = testrepo[blob_oid] + assert isinstance(blob, pygit2.Blob) + with pytest.raises(pygit2.InvalidError): + blob._write_to_queue( + queue, + ready, + done, + as_path='bye.txt', + flags=BlobFilter.ATTRIBUTES_FROM_COMMIT, + commit_id='not-a-valid-oid', # type: ignore[arg-type] + ) diff --git a/test/test_odb_backend.py b/test/test_odb_backend.py index 5acf8dfce..7088f0e28 100644 --- a/test/test_odb_backend.py +++ b/test/test_odb_backend.py @@ -169,3 +169,50 @@ def test_repo_read(repo: Repository) -> None: ab = repo[BLOB_OID] a = repo[BLOB_HEX] assert ab == a + + +class BadOidReadPrefixBackend(ProxyBackend): + def read_prefix_cb(self, oid: Oid | str) -> tuple[int, bytes, Oid | str]: # type: ignore[override] + return (ObjectType.BLOB, b'bad', 'not-a-valid-oid') + + +class BadOidExistsPrefixBackend(ProxyBackend): + def exists_prefix_cb(self, oid: Oid | str) -> Oid | str: # type: ignore[override] + return 'not-a-valid-oid' + + +class BadOidIterBackend(ProxyBackend): + def __iter__(self) -> Iterator[Oid | str]: # type: ignore[override] + yield 'not-a-valid-oid' + + +def test_read_prefix_cb_bad_oid(barerepo: Repository) -> None: + # Regression test (issue #1478): an ODB backend returning an invalid oid + # from read_prefix_cb must raise InvalidError instead of silently returning + # garbage data. + path = Path(barerepo.path) / 'objects' + backend = BadOidReadPrefixBackend(pygit2.OdbBackendPack(path)) + with pytest.raises(pygit2.InvalidError): + backend.read_prefix(BLOB_HEX[:4]) + + +def test_exists_prefix_cb_bad_oid(barerepo: Repository) -> None: + # Regression test (issue #1478): an ODB backend returning an invalid oid + # from exists_prefix_cb must raise InvalidError instead of silently returning + # garbage data. + path = Path(barerepo.path) / 'objects' + backend = BadOidExistsPrefixBackend(pygit2.OdbBackendPack(path)) + with pytest.raises(pygit2.InvalidError): + backend.exists_prefix(BLOB_HEX[:4]) + + +def test_foreach_cb_bad_oid(barerepo: Repository) -> None: + # Regression test (issue #1478): an ODB backend yielding an invalid oid + # during iteration must raise InvalidError instead of crashing or returning + # garbage data. + path = Path(barerepo.path) / 'objects' + backend = BadOidIterBackend(pygit2.OdbBackendPack(path)) + odb = pygit2.Odb() + odb.add_backend(backend, 1) + with pytest.raises(pygit2.InvalidError): + next(iter(odb)) diff --git a/test/test_refdb_backend.py b/test/test_refdb_backend.py index 73a2c9a5d..3940c5fc9 100644 --- a/test/test_refdb_backend.py +++ b/test/test_refdb_backend.py @@ -224,6 +224,42 @@ def test_write(repo: Repository) -> None: assert repo.backend.lookup('refs/heads/test-write').target == master.target +def test_write_invalid_old_type(repo: Repository) -> None: + # Regression test (issue #1478): RefdbBackend.write must raise TypeError + # when old is not a valid oid, not silently ignore the bad argument. + master = repo.backend.lookup('refs/heads/master') + commit = repo[master.target] + ref = pygit2.Reference('refs/heads/test-write', master.target, None) + with pytest.raises(TypeError): + repo.backend.write(ref, False, commit.author, 'Create test-write', 1234, None) # type: ignore + + +def test_write_invalid_old_str(repo: Repository) -> None: + # Regression test (issue #1478): RefdbBackend.write must raise InvalidError + # when old is a malformed oid string, not silently ignore the bad argument. + master = repo.backend.lookup('refs/heads/master') + commit = repo[master.target] + ref = pygit2.Reference('refs/heads/test-write', master.target, None) + with pytest.raises(pygit2.InvalidError): + repo.backend.write( + ref, False, commit.author, 'Create test-write', 'not-a-valid-oid', None + ) + + +def test_delete_invalid_old_type(repo: Repository) -> None: + # Regression test (issue #1478): RefdbBackend.delete must raise TypeError + # when old_id is not a valid oid, not silently ignore the bad argument. + with pytest.raises(TypeError): + repo.backend.delete('refs/heads/master', 1234, None) # type: ignore + + +def test_delete_invalid_old_str(repo: Repository) -> None: + # Regression test (issue #1478): RefdbBackend.delete must raise InvalidError + # when old_id is a malformed oid string, not silently ignore the bad argument. + with pytest.raises(pygit2.InvalidError): + repo.backend.delete('refs/heads/master', 'not-a-valid-oid', None) + + def test_rename(repo: Repository) -> None: old_ref = repo.backend.lookup('refs/heads/i18n') target = repo.get(old_ref.target) diff --git a/test/test_refs.py b/test/test_refs.py index 80d0eb791..80c59fc9b 100644 --- a/test/test_refs.py +++ b/test/test_refs.py @@ -33,6 +33,7 @@ AlreadyExistsError, Commit, GitError, + InvalidError, InvalidSpecError, Oid, Reference, @@ -790,3 +791,24 @@ def test_invalid_arguments() -> None: reference_is_valid_name(1) # type: ignore with pytest.raises(TypeError): reference_is_valid_name('too', 'many') # type: ignore + + +def test_reference_init_invalid_target_type() -> None: + # Regression test (issue #1478): Reference constructor must raise TypeError + # for a non-oid target, not silently create a reference with garbage data. + with pytest.raises(TypeError): + Reference('refs/heads/test', 1234, None) + + +def test_reference_init_invalid_target_str() -> None: + # Regression test (issue #1478): Reference constructor must raise InvalidError + # for a malformed oid string, not silently create a reference with garbage data. + with pytest.raises(InvalidError): + Reference('refs/heads/test', 'not-a-valid-oid', None) + + +def test_reference_init_invalid_peel() -> None: + # Regression test (issue #1478): Reference constructor must raise TypeError + # for a malformed peel oid, not silently ignore the error. + with pytest.raises(TypeError): + Reference('refs/heads/test', LAST_COMMIT, 1234) From 794b7e1fc36bf8d12711b9024286bed40df16922 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Tue, 11 Aug 2026 12:41:23 +0200 Subject: [PATCH 214/225] Fix crash and leak in DiffHunk.lines and Patch.hunks On allocation failure PyList_New() returns NULL, and the previous code dereferenced it while setting items. Also, the partially built list was leaked on every mid-loop early return. Fixes #1479 Assisted-by: Kimi Code --- CHANGELOG.md | 4 ++++ src/diff.c | 11 +++++++++-- src/patch.c | 7 ++++++- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b3a9808f..3f65aaea8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,10 @@ instead of producing wrong results, `SystemError`, or crashes [#1478](https://github.com/libgit2/pygit2/issues/1478). +- Fix potential crash and memory leak in `DiffHunk.lines` and `Patch.hunks` + on allocation or per-item failure + [#1479](https://github.com/libgit2/pygit2/issues/1479). + # 1.20.0 (2026-08-08) diff --git a/src/diff.c b/src/diff.c index c7e366b1b..0f1650e67 100644 --- a/src/diff.c +++ b/src/diff.c @@ -832,14 +832,21 @@ DiffHunk_lines__get__(DiffHunk *self) // TODO Replace by an iterator py_lines = PyList_New(self->n_lines); + if (py_lines == NULL) + return NULL; + for (i = 0; i < self->n_lines; ++i) { err = git_patch_get_line_in_hunk(&line, self->patch->patch, self->idx, i); - if (err < 0) + if (err < 0) { + Py_DECREF(py_lines); return Error_set(err); + } py_line = wrap_diff_line(line, self); - if (py_line == NULL) + if (py_line == NULL) { + Py_DECREF(py_lines); return NULL; + } PyList_SetItem(py_lines, i, py_line); } diff --git a/src/patch.c b/src/patch.c index bf818f645..0f10abfac 100644 --- a/src/patch.c +++ b/src/patch.c @@ -223,10 +223,15 @@ Patch_hunks__get__(Patch *self) hunk_amounts = git_patch_num_hunks(self->patch); py_hunks = PyList_New(hunk_amounts); + if (py_hunks == NULL) + return NULL; + for (i = 0; i < hunk_amounts; i++) { py_hunk = wrap_diff_hunk(self, i); - if (py_hunk == NULL) + if (py_hunk == NULL) { + Py_DECREF(py_hunks); return NULL; + } PyList_SET_ITEM((PyObject*) py_hunks, i, py_hunk); } From 0ec0130c4d805e1b8048dc88a7fb936291ffca8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Tue, 11 Aug 2026 12:48:33 +0200 Subject: [PATCH 215/225] Fix memory leaks on error paths in listall_branches and diff_to_index Fix Repository.listall_branches() leaking the result list when git_branch_iterator_new() fails. Fix Tree.diff_to_index() leaking the _pointer object when lazy-loading the tree fails. Fixes #1480 Assisted-by: Kimi Code --- CHANGELOG.md | 4 ++++ src/repository.c | 4 +++- src/tree.c | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f65aaea8..a083394cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,10 @@ on allocation or per-item failure [#1479](https://github.com/libgit2/pygit2/issues/1479). +- Fix memory leaks in `Repository.listall_branches()` and + `Tree.diff_to_index()` on early error paths + [#1480](https://github.com/libgit2/pygit2/issues/1480). + # 1.20.0 (2026-08-08) diff --git a/src/repository.c b/src/repository.c index b97c6bde8..d3e50fce7 100644 --- a/src/repository.c +++ b/src/repository.c @@ -1438,8 +1438,10 @@ Repository_listall_branches_impl(Repository *self, PyObject *args, PyObject *(*i if (list == NULL) return NULL; - if ((err = git_branch_iterator_new(&iter, self->repo, list_flags)) < 0) + if ((err = git_branch_iterator_new(&iter, self->repo, list_flags)) < 0) { + Py_DECREF(list); return Error_set(err); + } while ((err = git_branch_next(&ref, &type, iter)) == 0) { PyObject *py_branch_name = item_trans(git_reference_shorthand(ref)); diff --git a/src/tree.c b/src/tree.c index 9aca5c73f..4b574fc69 100644 --- a/src/tree.c +++ b/src/tree.c @@ -308,7 +308,7 @@ Tree_diff_to_index(Tree *self, PyObject *args, PyObject *kwds) index = *((git_index **) buffer); /* the "buffer" contains the pointer */ /* Call git_diff_tree_to_index */ - if (Object__load((Object*)self) == NULL) { return NULL; } // Lazy load + if (Object__load((Object*)self) == NULL) { goto error; } // Lazy load err = git_diff_tree_to_index(&diff, self->repo->repo, self->tree, index, &opts); Py_DECREF(py_idx_ptr); From 490bd886952c308d1dc3638c071d9977e76511fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Tue, 11 Aug 2026 13:03:39 +0200 Subject: [PATCH 216/225] Fix reference leaks in blob filter stream and _cache_enums Fixes #1481. Assisted-by: Kimi Code --- CHANGELOG.md | 4 ++++ src/blob.c | 5 ++++- src/pygit2.c | 1 + 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a083394cc..5a4008254 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,10 @@ `Tree.diff_to_index()` on early error paths [#1480](https://github.com/libgit2/pygit2/issues/1480). +- Fix reference leaks in the blob filter stream callbacks and in + `_cache_enums()` + [#1481](https://github.com/libgit2/pygit2/issues/1481). + # 1.20.0 (2026-08-08) diff --git a/src/blob.c b/src/blob.c index 63b70dd42..68d3271bf 100644 --- a/src/blob.c +++ b/src/blob.c @@ -190,6 +190,7 @@ static int blob_filter_stream_write( err = GIT_ERROR; goto done; } + Py_DECREF(result); pos += chunk_size; } @@ -202,7 +203,7 @@ static int blob_filter_stream_close(git_writestream *s) { struct blob_filter_stream *stream = (struct blob_filter_stream *)s; PyGILState_STATE gil = PyGILState_Ensure(); - PyObject *result; + PyObject *result = NULL; int err = 0; /* Signal closed and then ready in that order so consumers can block on @@ -214,6 +215,7 @@ static int blob_filter_stream_close(git_writestream *s) git_error_set(GIT_ERROR_OS, "failed to signal writer closed"); err = GIT_ERROR; } + Py_XDECREF(result); result = PyObject_CallMethod(stream->py_ready, "set", NULL); if (result == NULL) { @@ -221,6 +223,7 @@ static int blob_filter_stream_close(git_writestream *s) git_error_set(GIT_ERROR_OS, "failed to signal queue ready"); err = GIT_ERROR; } + Py_XDECREF(result); PyGILState_Release(gil); return err; diff --git a/src/pygit2.c b/src/pygit2.c index ea5c36acc..7cc22f393 100644 --- a/src/pygit2.c +++ b/src/pygit2.c @@ -407,6 +407,7 @@ _cache_enums(PyObject *self, PyObject *args) #undef CACHE_PYGIT2_ENUM + Py_DECREF(enums); Py_RETURN_NONE; fail: From 7f503ff8a098676c46059530a3e8e346a7cc9be7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Tue, 11 Aug 2026 13:31:18 +0200 Subject: [PATCH 217/225] Fix stale libgit2 errors clobbering Python backend exceptions When a custom ODB or refdb callback raised an arbitrary Python exception, git_error_for_exc() returned GIT_EUSER and left the real exception pending. The entry points then called Error_set/err/str/oid, which read git_error_last() and overwrote the pending exception with whatever stale text happened to be in libgit2's buffer (commonly an OSError about .git/shallow). Preserve the pending Python exception when err == GIT_EUSER in the three Error_set* helpers, so the callback's original exception propagates. This does not affect the KeyError -> GIT_ENOTFOUND or ValueError -> GIT_EAMBIGUOUS mappings, which return different error codes. Add regression tests for refdb exists/lookup and ODB read/read_prefix/ read_header/exists/exists_prefix callbacks, all expecting the original RuntimeError to propagate. Assisted-by: Kimi Code --- CHANGELOG.md | 3 ++ src/error.c | 13 ++++++++ test/test_odb_backend.py | 61 ++++++++++++++++++++++++++++++++++++++ test/test_refdb_backend.py | 38 ++++++++++++++++-------- 4 files changed, 102 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a4008254..63fd2f48c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ built-in exception (`ValueError`/`KeyError`) for backward compatibility [#830](https://github.com/libgit2/pygit2/issues/830). +- Fix custom ODB and refdb backend callbacks overwriting a pending Python + exception (e.g. `RuntimeError`) with a stale libgit2 error message. + - Fix `DiffDelta.is_binary` and `DiffDelta.flags` returning stale values for deltas obtained from `Diff.deltas`; flags are now loaded lazily [#962](https://github.com/libgit2/pygit2/issues/962) diff --git a/src/error.c b/src/error.c index 9b1247eee..8700bb715 100644 --- a/src/error.c +++ b/src/error.c @@ -104,6 +104,11 @@ Error_set(int err) { assert(err < 0); + /* GIT_EUSER means a Python callback raised an exception. Preserve that + * exception instead of overwriting it with a stale libgit2 error message. */ + if (err == GIT_EUSER && PyErr_Occurred()) + return NULL; + return Error_set_exc(Error_type(err)); } @@ -122,6 +127,10 @@ Error_set_exc(PyObject* exception) PyObject * Error_set_str(int err, const char *str) { + /* GIT_EUSER means a Python callback raised an exception. Preserve it. */ + if (err == GIT_EUSER && PyErr_Occurred()) + return NULL; + if (err == GIT_ENOTFOUND) { /* NotFoundError inherits from KeyError; the argument is the missing key. */ PyErr_SetString(NotFoundError, str); @@ -138,6 +147,10 @@ Error_set_str(int err, const char *str) PyObject * Error_set_oid(int err, const git_oid *oid, size_t len) { + /* GIT_EUSER means a Python callback raised an exception. Preserve it. */ + if (err == GIT_EUSER && PyErr_Occurred()) + return NULL; + char hex[GIT_OID_HEXSZ + 1]; git_oid_fmt(hex, oid); diff --git a/test/test_odb_backend.py b/test/test_odb_backend.py index 7088f0e28..43a01d0cb 100644 --- a/test/test_odb_backend.py +++ b/test/test_odb_backend.py @@ -95,6 +95,29 @@ def __iter__(self) -> Iterator[Oid]: return iter(self.source) +class RaisingOdbBackend(pygit2.OdbBackend): + """A backend whose callbacks always raise a configurable exception.""" + + def __init__(self, exc: Exception) -> None: + super().__init__() + self.exc = exc + + def read_cb(self, oid: Oid | str) -> tuple[int, bytes]: + raise self.exc + + def read_prefix_cb(self, oid: Oid | str) -> tuple[int, bytes, Oid]: + raise self.exc + + def read_header_cb(self, oid: Oid | str) -> tuple[int, int]: + raise self.exc + + def exists_cb(self, oid: Oid | str) -> bool: + raise self.exc + + def exists_prefix_cb(self, oid: Oid | str) -> Oid: + raise self.exc + + # # Test a custom object backend alone (without adding it to an ODB) # This doesn't make much sense, but it's possible. @@ -141,6 +164,44 @@ def test_exists_prefix(proxy: ProxyBackend) -> None: assert BLOB_HEX == proxy.exists_prefix(a_hex_prefix) +@pytest.fixture +def raising_backend() -> Generator[RaisingOdbBackend, None, None]: + yield RaisingOdbBackend(RuntimeError('boom')) + + +def test_read_cb_raises_runtime_error(raising_backend: RaisingOdbBackend) -> None: + # Regression test: a RuntimeError in read_cb must propagate as RuntimeError, + # not be overwritten by a stale libgit2 error message. + with pytest.raises(RuntimeError, match='boom'): + pygit2.OdbBackend.read(raising_backend, BLOB_OID) + + +def test_read_prefix_cb_raises_runtime_error( + raising_backend: RaisingOdbBackend, +) -> None: + with pytest.raises(RuntimeError, match='boom'): + pygit2.OdbBackend.read_prefix(raising_backend, BLOB_HEX[:4]) + + +def test_read_header_cb_raises_runtime_error( + raising_backend: RaisingOdbBackend, +) -> None: + with pytest.raises(RuntimeError, match='boom'): + pygit2.OdbBackend.read_header(raising_backend, BLOB_OID) + + +def test_exists_cb_raises_runtime_error(raising_backend: RaisingOdbBackend) -> None: + with pytest.raises(RuntimeError, match='boom'): + pygit2.OdbBackend.exists(raising_backend, BLOB_OID) + + +def test_exists_prefix_cb_raises_runtime_error( + raising_backend: RaisingOdbBackend, +) -> None: + with pytest.raises(RuntimeError, match='boom'): + pygit2.OdbBackend.exists_prefix(raising_backend, BLOB_HEX[:4]) + + # # Test a custom object backend, through a Repository. # diff --git a/test/test_refdb_backend.py b/test/test_refdb_backend.py index 3940c5fc9..063c38049 100644 --- a/test/test_refdb_backend.py +++ b/test/test_refdb_backend.py @@ -140,24 +140,36 @@ def test_exists(repo: Repository) -> None: class RaisingRefdbBackend(ProxyRefdbBackend): - """A backend whose exists callback always raises.""" + """A backend whose callbacks always raise RuntimeError.""" + + def __init__(self, source: pygit2.RefdbBackend, exc: Exception) -> None: + super().__init__(source) + self.exc = exc def exists(self, ref: str) -> bool: - raise RuntimeError('boom') - - -def test_exists_callback_raises(testrepo: Repository) -> None: - # Regression test (issue #1476): when the exists callback raises, the C - # wrapper must not crash on a NULL result, and must propagate the error. - backend = RaisingRefdbBackend(pygit2.RefdbFsBackend(testrepo)) - # Call the unbound C method so the call goes through the C wrapper - # (pygit2_refdb_backend_exists), not directly to the Python override. - # The error propagates as GitError, or as OSError/ValueError when a stale - # libgit2 error (with a matching class) is left over from an earlier call. - with pytest.raises((pygit2.GitError, OSError)): + raise self.exc + + def lookup(self, ref: str) -> Reference: + raise self.exc + + +def test_exists_callback_raises_runtime_error(testrepo: Repository) -> None: + # Regression test: when the exists callback raises RuntimeError, the C + # wrapper must propagate the original Python exception, not overwrite it + # with a stale libgit2 error message. + backend = RaisingRefdbBackend(pygit2.RefdbFsBackend(testrepo), RuntimeError('boom')) + with pytest.raises(RuntimeError, match='boom'): pygit2.RefdbBackend.exists(backend, 'refs/heads/master') +def test_lookup_callback_raises_runtime_error(testrepo: Repository) -> None: + # Regression test: when the lookup callback raises RuntimeError, the C + # wrapper must propagate the original Python exception. + backend = RaisingRefdbBackend(pygit2.RefdbFsBackend(testrepo), RuntimeError('boom')) + with pytest.raises(RuntimeError, match='boom'): + pygit2.RefdbBackend.lookup(backend, 'refs/heads/master') + + def test_lookup(repo: Repository) -> None: assert repo.backend.lookup('refs/heads/does-not-exist') is None assert repo.backend.lookup('refs/heads/master').name == 'refs/heads/master' From 6a4b8abefa071aa4231e4345c5bbec2a280f84ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Wed, 12 Aug 2026 10:04:05 +0200 Subject: [PATCH 218/225] CI (macOS): build architecture-specific dependencies Stop building universal OpenSSL/libssh2/libgit2 binaries for macOS wheels. Each runner already targets a single architecture, so build only for the host arch. This makes wheels smaller and simplifies build.sh. Assisted-by: Kimi Code --- AGENTS.md | 4 ++-- CHANGELOG.md | 3 +++ build.sh | 57 +++++++++++++++++++--------------------------------- 3 files changed, 26 insertions(+), 38 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 86f6159f3..f977934d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -139,8 +139,8 @@ Variables consumed by `build.sh`: - `LIBGIT2_VERSION` — If set, download and build this libgit2 version. - `LIBSSH2_VERSION` — If set, download and build libssh2 with SSH support. -- `OPENSSL_VERSION` — If set, download and build OpenSSL (mainly used for - macOS universal builds on CI). +- `OPENSSL_VERSION` — If set, download and build OpenSSL (used on Linux and + macOS CI builds). - `ZLIB_VERSION` — If set, download and build zlib. - `BUILD_TYPE` — CMake build type (default: `Debug`). - `PYTHON` — Python interpreter to use (default: `python3`). diff --git a/CHANGELOG.md b/CHANGELOG.md index 63fd2f48c..0558ba794 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # 1.20.1 (UNRELEASED) +- CI (macOS): build architecture-specific OpenSSL/libssh2/libgit2 + dependencies instead of universal binaries, producing smaller wheels. + - New exception hierarchy: `AlreadyExistsError`, `InvalidSpecError`, `InvalidError`, `NotFoundError`, `AmbiguousError`, `AuthError`, and `CertificateError` all inherit from `GitError` and the appropriate Python diff --git a/build.sh b/build.sh index 14c9c1cd3..38c632e73 100644 --- a/build.sh +++ b/build.sh @@ -14,7 +14,7 @@ # LIBSSH2_VERSION= - Build the given version of libssh2 # LIBGIT2_VERSION= - Build the given version of libgit2 # OPENSSL_VERSION= - Build the given version of OpenSSL -# (only needed for Mac universal on CI) +# (used on Linux and macOS CI builds) # # Examples. # @@ -110,31 +110,24 @@ if [ -n "$OPENSSL_VERSION" ]; then wget https://www.openssl.org/source/$FILENAME.tar.gz -N --no-check-certificate if [ "$KERNEL" = "Darwin" ]; then + # Build OpenSSL for the host architecture only. tar xf $FILENAME.tar.gz - mv $FILENAME openssl-x86 - - tar xf $FILENAME.tar.gz - mv $FILENAME openssl-arm - - cd openssl-x86 - ./Configure darwin64-x86_64-cc shared - make - cd ../openssl-arm - ./Configure enable-rc5 zlib darwin64-arm64-cc no-asm + cd $FILENAME + if [ "$ARCH" = "arm64" ]; then + ./Configure enable-rc5 zlib darwin64-arm64-cc no-asm shared --prefix=$PREFIX --libdir=$PREFIX/lib + else + ./Configure darwin64-x86_64-cc shared --prefix=$PREFIX --libdir=$PREFIX/lib + fi make - cd .. - - mkdir openssl-universal - - LIBSSL=$(basename openssl-x86/libssl.*.dylib) - lipo -create openssl-x86/libssl.*.dylib openssl-arm/libssl.*.dylib -output openssl-universal/$LIBSSL - LIBCRYPTO=$(basename openssl-x86/libcrypto.*.dylib) - lipo -create openssl-x86/libcrypto.*.dylib openssl-arm/libcrypto.*.dylib -output openssl-universal/$LIBCRYPTO - cd openssl-universal - install_name_tool -id "@rpath/$LIBSSL" $LIBSSL - install_name_tool -id "@rpath/$LIBCRYPTO" $LIBCRYPTO - OPENSSL_PREFIX=$(pwd) - cd .. + make install + OPENSSL_PREFIX=$PREFIX + # Set install names so delocate can bundle the libraries. + cd $PREFIX/lib + LIBSSL=$(find . -maxdepth 1 -name 'libssl.*.dylib' -type f | head -n1 | sed 's|^\./||') + LIBCRYPTO=$(find . -maxdepth 1 -name 'libcrypto.*.dylib' -type f | head -n1 | sed 's|^\./||') + install_name_tool -id "@rpath/$LIBSSL" "$LIBSSL" + install_name_tool -id "@rpath/$LIBCRYPTO" "$LIBCRYPTO" + cd ../.. else # Linux tar xf $FILENAME.tar.gz @@ -154,14 +147,11 @@ if [ -n "$LIBSSH2_VERSION" ]; then tar xf $FILENAME.tar.gz cd $FILENAME if [ "$KERNEL" = "Darwin" ] && [ "$CIBUILDWHEEL" = "1" ]; then - cmake . \ + CMAKE_PREFIX_PATH=$PREFIX cmake . \ -DCMAKE_INSTALL_PREFIX=$PREFIX \ -DBUILD_SHARED_LIBS=ON \ -DBUILD_EXAMPLES=OFF \ - -DCMAKE_OSX_ARCHITECTURES="arm64;x86_64" \ - -DOPENSSL_CRYPTO_LIBRARY="../openssl-universal/$LIBCRYPTO" \ - -DOPENSSL_SSL_LIBRARY="../openssl-universal/$LIBSSL" \ - -DOPENSSL_INCLUDE_DIR="../openssl-x86/include" \ + -DCMAKE_OSX_ARCHITECTURES="$ARCH" \ -DBUILD_TESTING=OFF else cmake . \ @@ -186,14 +176,11 @@ if [ -n "$LIBGIT2_VERSION" ]; then mkdir -p build cd build if [ "$KERNEL" = "Darwin" ] && [ "$CIBUILDWHEEL" = "1" ]; then - CMAKE_PREFIX_PATH=$OPENSSL_PREFIX:$PREFIX cmake .. \ + CMAKE_PREFIX_PATH=$PREFIX cmake .. \ -DBUILD_SHARED_LIBS=ON \ -DBUILD_TESTS=OFF \ -DCMAKE_BUILD_TYPE=$BUILD_TYPE \ - -DCMAKE_OSX_ARCHITECTURES="arm64;x86_64" \ - -DOPENSSL_CRYPTO_LIBRARY="../openssl-universal/$LIBCRYPTO" \ - -DOPENSSL_SSL_LIBRARY="../openssl-universal/$LIBSSL" \ - -DOPENSSL_INCLUDE_DIR="../openssl-x86/include" \ + -DCMAKE_OSX_ARCHITECTURES="$ARCH" \ -DCMAKE_INSTALL_PREFIX=$PREFIX \ -DUSE_SSH=$USE_SSH else @@ -213,8 +200,6 @@ fi if [ "$CIBUILDWHEEL" = "1" ]; then if [ "$KERNEL" = "Darwin" ]; then - cp $OPENSSL_PREFIX/*.dylib $PREFIX/lib/ - cp $OPENSSL_PREFIX/*.dylib $PREFIX/lib/ echo "PREFIX " $PREFIX echo "OPENSSL_PREFIX" $OPENSSL_PREFIX ls -l $PREFIX From c6da9eb687e4fa8d6dd06616b146ffaf64555dc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Wed, 12 Aug 2026 11:00:53 +0200 Subject: [PATCH 219/225] CI: cache compiled dependencies on Linux, macOS and Windows Add GitHub Actions caching of compiled dependencies for Linux, macOS and Windows wheel jobs. build.sh and build.ps1 now write a versions.txt stamp and skip rebuilding when the cached dependencies match the requested versions. Assisted-by: Kimi Code --- .github/workflows/wheels.yml | 32 +++++++++++++++++++++++++++-- AGENTS.md | 3 ++- CHANGELOG.md | 3 +++ build.ps1 | 40 ++++++++++++++++++++++++++++++------ build.sh | 16 +++++++++++++++ 5 files changed, 85 insertions(+), 9 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index cf32a338e..1d02ea41f 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -48,7 +48,7 @@ jobs: path: ReleaseNotes.md build_wheels: - name: Wheels for ${{ matrix.name }} + name: ${{ matrix.name }} runs-on: ${{ matrix.os }} needs: sdist strategy: @@ -66,17 +66,38 @@ jobs: os: macos-15 - name: windows-x64 os: windows-latest + libgit2_prefix: C:/libgit2_install_x86_64 - name: windows-x86 os: windows-latest + libgit2_prefix: C:/libgit2_install_x86 - name: windows-arm64 # https://github.com/actions/partner-runner-images#available-images os: windows-11-arm + libgit2_prefix: C:/libgit2_install_arm64 steps: - uses: actions/setup-python@v7 with: python-version: '3.13' + - name: Cache compiled dependencies + if: ${{ !startsWith(matrix.name, 'windows-') }} + uses: actions/cache@v4 + with: + path: ci + key: deps-ci-${{ matrix.name }}-${{ hashFiles('pyproject.toml') }} + restore-keys: deps-ci-${{ matrix.name }}- + + - name: Cache compiled dependencies (Windows) + if: ${{ startsWith(matrix.name, 'windows-') }} + uses: actions/cache@v4 + with: + path: | + build/libgit2_src + ${{ matrix.libgit2_prefix }} + key: deps-ci-${{ matrix.name }}-${{ hashFiles('pyproject.toml') }} + restore-keys: deps-ci-${{ matrix.name }}- + - name: Download sdist uses: actions/download-artifact@v8 with: @@ -98,7 +119,7 @@ jobs: path: ./wheelhouse/*.whl build_wheels_emulated_linux: - name: Wheels for ${{ matrix.name }} + name: ${{ matrix.name }} runs-on: ubuntu-24.04 needs: sdist strategy: @@ -121,6 +142,13 @@ jobs: with: platforms: ${{ matrix.qemu_platform }} + - name: Cache compiled dependencies + uses: actions/cache@v4 + with: + path: ci + key: deps-ci-${{ matrix.name }}-${{ hashFiles('pyproject.toml') }} + restore-keys: deps-ci-${{ matrix.name }}- + - name: Download sdist uses: actions/download-artifact@v8 with: diff --git a/AGENTS.md b/AGENTS.md index f977934d0..3785afc70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -242,7 +242,8 @@ GitHub Actions workflows live in `.github/workflows/`: `sh build.sh mypy`. - **`wheels.yml`** — Uses `cibuildwheel` (`~=3.3`) to build wheels for Linux (amd64, arm64, ppc64le and riscv64 via QEMU, musl), macOS (intel, arm64, - PyPy), and Windows (x64, x86, arm64). It also builds an sdist, runs a + PyPy), and Windows (x64, x86, arm64). Linux, macOS and Windows jobs cache + the compiled dependencies between runs. It also builds an sdist, runs a `twine check` (skipped on version tags), publishes to PyPI, and creates a GitHub Release on version tags (`v*`), with release notes parsed from `CHANGELOG.md` by `.github/workflows/parse_release_notes.py`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0558ba794..dd7fcca7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # 1.20.1 (UNRELEASED) +- CI (Linux, macOS, Windows): cache compiled dependencies between wheel + builds, keyed by platform and dependency versions. + - CI (macOS): build architecture-specific OpenSSL/libssh2/libgit2 dependencies instead of universal binaries, producing smaller wheels. diff --git a/build.ps1 b/build.ps1 index ce40959bf..360eefaf4 100644 --- a/build.ps1 +++ b/build.ps1 @@ -1,11 +1,36 @@ $ErrorActionPreference = 'Stop' +$LIBGIT2_VERSION = $env:LIBGIT2_VERSION +$LIBGIT2_SRC = $env:LIBGIT2_SRC +if (-not $LIBGIT2_SRC) { + $LIBGIT2_SRC = "build/libgit2_src" +} + +# Prefer CMAKE_INSTALL_PREFIX, then LIBGIT2, then the default Program Files location. +$INSTALL_PREFIX = $env:CMAKE_INSTALL_PREFIX +if (-not $INSTALL_PREFIX) { + $INSTALL_PREFIX = $env:LIBGIT2 +} +if (-not $INSTALL_PREFIX) { + $INSTALL_PREFIX = "$env:ProgramFiles\libgit2" +} + +# Use cached dependencies if they match the requested version. +if ((Test-Path -Path "$INSTALL_PREFIX\versions.txt") -and $LIBGIT2_VERSION) { + $cached = Get-Content "$INSTALL_PREFIX\versions.txt" + $matches = $cached | Select-String "^LIBGIT2_VERSION=$LIBGIT2_VERSION$" + if ($matches) { + Write-Host "Using cached dependencies" + exit 0 + } +} + if (!(Test-Path -Path "build")) { # in case the pygit2 package build/ workspace has not been created by cibuildwheel yet mkdir build } -if (Test-Path -Path "$env:LIBGIT2_SRC") { - Set-Location "$env:LIBGIT2_SRC" +if (Test-Path -Path "$LIBGIT2_SRC") { + Set-Location "$LIBGIT2_SRC" # for local runs, reuse build/libgit_src if it exists if (Test-Path -Path build) { # purge previous build env (likely for a different arch type) @@ -13,11 +38,14 @@ if (Test-Path -Path "$env:LIBGIT2_SRC") { } # ensure we are checked out to the right version git fetch --depth=1 --tags - git checkout "v$env:LIBGIT2_VERSION" + git checkout "v$LIBGIT2_VERSION" } else { # from a fresh run (like in CI) - git clone --depth=1 -b "v$env:LIBGIT2_VERSION" https://github.com/libgit2/libgit2.git $env:LIBGIT2_SRC - Set-Location "$env:LIBGIT2_SRC" + git clone --depth=1 -b "v$LIBGIT2_VERSION" https://github.com/libgit2/libgit2.git $LIBGIT2_SRC + Set-Location "$LIBGIT2_SRC" } -cmake -B build -S . -DBUILD_TESTS=OFF +cmake -B build -S . -DBUILD_TESTS=OFF -DCMAKE_INSTALL_PREFIX="$INSTALL_PREFIX" cmake --build build/ --config=Release --target install + +# Record version so the cache can be reused. +"LIBGIT2_VERSION=$LIBGIT2_VERSION" | Set-Content "$INSTALL_PREFIX\versions.txt" -NoNewline diff --git a/build.sh b/build.sh index 38c632e73..0e3a156a7 100644 --- a/build.sh +++ b/build.sh @@ -82,6 +82,16 @@ if [ "$CIBUILDWHEEL" = "1" ]; then apk add --no-cache perl fi fi + + # Use cached dependencies if they match the requested versions. + if [ -f ci/versions.txt ] && \ + grep -q "^LIBGIT2_VERSION=$LIBGIT2_VERSION$" ci/versions.txt && \ + grep -q "^LIBSSH2_VERSION=$LIBSSH2_VERSION$" ci/versions.txt && \ + grep -q "^OPENSSL_VERSION=$OPENSSL_VERSION$" ci/versions.txt; then + echo "Using cached dependencies" + exit 0 + fi + rm -rf ci mkdir ci || true cd ci @@ -199,6 +209,12 @@ if [ -n "$LIBGIT2_VERSION" ]; then fi if [ "$CIBUILDWHEEL" = "1" ]; then + # Record versions so the cache can be reused. + cat > $PREFIX/versions.txt < Date: Wed, 12 Aug 2026 14:03:52 +0200 Subject: [PATCH 220/225] tests: add regression tests for status_file with non-ASCII paths Cover paths with accented characters, non-breaking space, and both NFC/NFD normalization forms. Related to #687 Assisted-by: Kimi Code --- test/test_status.py | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/test/test_status.py b/test/test_status.py index 82226c575..5ebb4f89c 100644 --- a/test/test_status.py +++ b/test/test_status.py @@ -23,8 +23,11 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. +from pathlib import Path + import pytest +import pygit2 from pygit2 import Repository from pygit2.enums import FileStatus @@ -85,3 +88,39 @@ def test_status_ignored( assert { file for file, status in git_status.items() if status & FileStatus.IGNORED } == expected + + +def test_status_file_non_ascii(tmp_path: Path) -> None: + """status_file must round-trip non-ASCII path names.""" + repo = pygit2.init_repository(str(tmp_path / 'repo')) + path = 'täst_é.txt' + (Path(repo.workdir) / path).write_text('hello') + repo.index.add(path) + repo.index.write() + assert repo.status_file(path) == FileStatus.INDEX_NEW + + +def test_status_file_non_breaking_space(tmp_path: Path) -> None: + """status_file must handle U+00A0 in the path.""" + repo = pygit2.init_repository(str(tmp_path / 'repo')) + path = 'file\u00a0name.txt' + (Path(repo.workdir) / path).write_text('hello') + repo.index.add(path) + repo.index.write() + assert repo.status_file(path) == FileStatus.INDEX_NEW + + +@pytest.mark.parametrize( + 'path', + [ + 'café.txt', # NFC + 'cafe\u0301.txt', # NFD + ], +) +def test_status_file_unicode_normalization(tmp_path: Path, path: str) -> None: + """status_file must work for both NFC and NFD forms of a path.""" + repo = pygit2.init_repository(str(tmp_path / 'repo')) + (Path(repo.workdir) / path).write_text('hello') + repo.index.add(path) + repo.index.write() + assert repo.status_file(path) == FileStatus.INDEX_NEW From cabc0df34ee06639a70519ae59b6a8ac7d77d8c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Wed, 12 Aug 2026 14:51:10 +0200 Subject: [PATCH 221/225] CI: checkout repo before dependency cache step actions/cache's hashFiles('pyproject.toml') was returning empty because pyproject.toml was not present in the workspace. Add actions/checkout before the cache steps in build_wheels and build_wheels_emulated_linux so the cache key includes the dependency version hash. Assisted-by: Kimi Code --- .github/workflows/wheels.yml | 8 ++++++++ AGENTS.md | 1 + 2 files changed, 9 insertions(+) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 1d02ea41f..b889e5b3a 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -80,6 +80,10 @@ jobs: with: python-version: '3.13' + - uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Cache compiled dependencies if: ${{ !startsWith(matrix.name, 'windows-') }} uses: actions/cache@v4 @@ -138,6 +142,10 @@ jobs: with: python-version: '3.13' + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: docker/setup-qemu-action@v4 with: platforms: ${{ matrix.qemu_platform }} diff --git a/AGENTS.md b/AGENTS.md index 3785afc70..a65dae154 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -273,6 +273,7 @@ and skips `*musllinux_ppc64le` plus testing on `*-*linux_ppc64le`, ## Git Commits +- Do not run `git commit` or create commits unless the user explicitly asks for it. - Commits created by an AI agent must end with the `Assisted-by:` trailer. Human-authored commits do not need it. From d39183b18dc8fa24c56de8abfc59808dc6f1a7a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Thu, 13 Aug 2026 10:20:14 +0200 Subject: [PATCH 222/225] ci: split Linux wheel builds by libc family Split linux-amd, linux-arm and linux-riscv64 into separate glibc and musllinux jobs. Rename linux-ppc64le to linux-ppc64le-glibc for consistency. Each job now skips the other libc family via CIBW_SKIP, giving distinct cache keys and artifact names per libc. Assisted-by: Kimi Code --- .github/workflows/wheels.yml | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index b889e5b3a..6303a8c9e 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -56,10 +56,18 @@ jobs: fail-fast: false matrix: include: - - name: linux-amd + - name: linux-amd-glibc os: ubuntu-24.04 - - name: linux-arm + cibw_skip: '*musllinux*' + - name: linux-amd-musl + os: ubuntu-24.04 + cibw_skip: '*manylinux*' + - name: linux-arm-glibc + os: ubuntu-24.04-arm + cibw_skip: '*musllinux*' + - name: linux-arm-musl os: ubuntu-24.04-arm + cibw_skip: '*manylinux*' - name: macos-intel os: macos-15-intel - name: macos-arm @@ -115,6 +123,7 @@ jobs: shell: bash env: CIBW_ARCHS_WINDOWS: ${{ matrix.name == 'windows-x86' && 'auto32' || 'native' }} + CIBW_SKIP: ${{ matrix.cibw_skip }} run: python -m cibuildwheel dist/*.tar.gz --output-dir wheelhouse - uses: actions/upload-artifact@v7 @@ -130,12 +139,18 @@ jobs: fail-fast: true matrix: include: - - name: linux-ppc64le + - name: linux-ppc64le-glibc qemu_platform: linux/ppc64le cibw_arch: ppc64le - - name: linux-riscv64 + cibw_skip: '*musllinux*' + - name: linux-riscv64-glibc + qemu_platform: linux/riscv64 + cibw_arch: riscv64 + cibw_skip: '*musllinux*' + - name: linux-riscv64-musl qemu_platform: linux/riscv64 cibw_arch: riscv64 + cibw_skip: '*manylinux*' steps: - uses: actions/setup-python@v7 @@ -171,6 +186,7 @@ jobs: run: python -m cibuildwheel dist/*.tar.gz --output-dir wheelhouse env: CIBW_ARCHS: ${{ matrix.cibw_arch }} + CIBW_SKIP: ${{ matrix.cibw_skip }} CIBW_ENVIRONMENT: LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.6 LIBGIT2=/project/ci - uses: actions/upload-artifact@v7 From ae382271748185aa6fbb32d3a3a8f40036ee6404 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Thu, 13 Aug 2026 10:26:12 +0200 Subject: [PATCH 223/225] Fix dependency caching for emulated Linux jobs cibuildwheel copies the project into an ephemeral Linux container, so build.sh writes compiled dependencies to /project/ci inside the container, but that directory never reaches the host. The actions/cache step on the host therefore always missed and could never save the ci/ folder. Bind-mount the host ci/ directory into the container at /project/ci using CIBW_CONTAINER_ENGINE create-args. This makes the cache persist across runs for all Linux container builds (native amd64/arm64 and emulated ppc64le/riscv64). Also include build.sh in the dependency cache key, since the cache contents depend on how dependencies are compiled, not just the versions in pyproject.toml. Assisted-by: Kimi Code --- .github/workflows/wheels.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 6303a8c9e..51a00e948 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -97,7 +97,7 @@ jobs: uses: actions/cache@v4 with: path: ci - key: deps-ci-${{ matrix.name }}-${{ hashFiles('pyproject.toml') }} + key: deps-ci-${{ matrix.name }}-${{ hashFiles('pyproject.toml', 'build.sh') }} restore-keys: deps-ci-${{ matrix.name }}- - name: Cache compiled dependencies (Windows) @@ -124,6 +124,7 @@ jobs: env: CIBW_ARCHS_WINDOWS: ${{ matrix.name == 'windows-x86' && 'auto32' || 'native' }} CIBW_SKIP: ${{ matrix.cibw_skip }} + CIBW_CONTAINER_ENGINE: 'docker; create_args: -v ${{ github.workspace }}/ci:/project/ci' run: python -m cibuildwheel dist/*.tar.gz --output-dir wheelhouse - uses: actions/upload-artifact@v7 @@ -169,7 +170,7 @@ jobs: uses: actions/cache@v4 with: path: ci - key: deps-ci-${{ matrix.name }}-${{ hashFiles('pyproject.toml') }} + key: deps-ci-${{ matrix.name }}-${{ hashFiles('pyproject.toml', 'build.sh') }} restore-keys: deps-ci-${{ matrix.name }}- - name: Download sdist @@ -187,6 +188,7 @@ jobs: env: CIBW_ARCHS: ${{ matrix.cibw_arch }} CIBW_SKIP: ${{ matrix.cibw_skip }} + CIBW_CONTAINER_ENGINE: 'docker; create_args: -v ${{ github.workspace }}/ci:/project/ci' CIBW_ENVIRONMENT: LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.6 LIBGIT2=/project/ci - uses: actions/upload-artifact@v7 From 1fa0dfebeff0adca16b44b59f8bce3e9c7040ccc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Thu, 13 Aug 2026 10:41:03 +0200 Subject: [PATCH 224/225] fix(build.sh): clear ci directory without removing mount point When ci/ is bind-mounted into cibuildwheel's container at /project/ci, rm -rf ci fails with "Resource busy" because the mount point cannot be removed from inside the container. Clear the directory contents instead, and use mkdir -p so the command succeeds whether or not the directory already exists. Assisted-by: Kimi Code --- build.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/build.sh b/build.sh index 0e3a156a7..54b9337d7 100644 --- a/build.sh +++ b/build.sh @@ -92,8 +92,10 @@ if [ "$CIBUILDWHEEL" = "1" ]; then exit 0 fi - rm -rf ci - mkdir ci || true + # The ci directory may be a bind-mount (e.g. inside cibuildwheel), so + # remove its contents but keep the directory itself. + rm -rf ci/* ci/.[!.]* ci/..?* 2>/dev/null || true + mkdir -p ci cd ci else # Create a virtual environment From d532da79846d3c17eb27df43eb1783b163f5a9ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20David=20Ib=C3=A1=C3=B1ez?= Date: Fri, 14 Aug 2026 20:46:34 +0200 Subject: [PATCH 225/225] Update wheels to libgit2 1.9.7 --- .github/workflows/lint.yml | 2 +- .github/workflows/tests.yml | 2 +- .github/workflows/wheels.yml | 2 +- AGENTS.md | 6 +++--- CHANGELOG.md | 2 ++ Makefile | 2 +- build.sh | 8 ++++---- docs/general.rst | 10 +++++----- docs/install.rst | 14 +++++++------- pyproject.toml | 4 ++-- 10 files changed, 27 insertions(+), 25 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 68c8dff84..e54ec0ae5 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -31,4 +31,4 @@ jobs: run: ruff check - name: Check typing with mypy - run: LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.6 /bin/sh build.sh mypy + run: LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.7 /bin/sh build.sh mypy diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2cd0c6c03..1a069281d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -25,5 +25,5 @@ jobs: apt-get update -q -y apt-get install -q -y cmake libssl-dev python3-dev python3-venv wget run: | - LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.6 /bin/sh build.sh test + LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.7 /bin/sh build.sh test continue-on-error: true # Tests are expected to fail, see issue #812 diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 51a00e948..78bbfb25a 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -189,7 +189,7 @@ jobs: CIBW_ARCHS: ${{ matrix.cibw_arch }} CIBW_SKIP: ${{ matrix.cibw_skip }} CIBW_CONTAINER_ENGINE: 'docker; create_args: -v ${{ github.workspace }}/ci:/project/ci' - CIBW_ENVIRONMENT: LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.6 LIBGIT2=/project/ci + CIBW_ENVIRONMENT: LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.7 LIBGIT2=/project/ci - uses: actions/upload-artifact@v7 with: diff --git a/AGENTS.md b/AGENTS.md index a65dae154..cfeab3f25 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ and a high-level, Pythonic API for repository manipulation. - **License**: GPLv2 with linking exception (see `COPYING`) - **Maintainer**: J. David Ibáñez - **Python Support**: 3.11 – 3.14 and PyPy3 7.3+ -- **libgit2 Version**: 1.9.6 +- **libgit2 Version**: 1.9.7 - **Homepage**: - **Repository**: @@ -107,7 +107,7 @@ handles libgit2 compilation via CMake. make # Or manually: -LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.6 sh build.sh +LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.7 sh build.sh # Build a wheel and bundle the shared libraries into it sh build.sh wheel @@ -251,7 +251,7 @@ GitHub Actions workflows live in `.github/workflows/`: The `cibuildwheel` configuration in `pyproject.toml` pins: -- `LIBGIT2_VERSION="1.9.6"` +- `LIBGIT2_VERSION="1.9.7"` - `LIBSSH2_VERSION="1.11.1"` - `OPENSSL_VERSION="3.5.7"` diff --git a/CHANGELOG.md b/CHANGELOG.md index dd7fcca7b..aa90c76fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # 1.20.1 (UNRELEASED) +- Update wheels to libgit2 1.9.7 + - CI (Linux, macOS, Windows): cache compiled dependencies between wheel builds, keyed by platform and dependency versions. diff --git a/Makefile b/Makefile index 681925394..6a0f14df5 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ .PHONY: build html build: - OPENSSL_VERSION=3.5.7 LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.6 sh build.sh + OPENSSL_VERSION=3.5.7 LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.7 sh build.sh html: build make -C docs html diff --git a/build.sh b/build.sh index 54b9337d7..8f10af14c 100644 --- a/build.sh +++ b/build.sh @@ -22,14 +22,14 @@ # # sh build.sh # -# Build libgit2 1.9.6 (will use libssh2 if available), then build pygit2 +# Build libgit2 1.9.7 (will use libssh2 if available), then build pygit2 # inplace: # -# LIBGIT2_VERSION=1.9.6 sh build.sh +# LIBGIT2_VERSION=1.9.7 sh build.sh # -# Build libssh2 1.11.1 and libgit2 1.9.6, then build pygit2 inplace: +# Build libssh2 1.11.1 and libgit2 1.9.7, then build pygit2 inplace: # -# LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.6 sh build.sh +# LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.7 sh build.sh # # Build inplace and run the tests: # diff --git a/docs/general.rst b/docs/general.rst index e2f73d667..9e0222c3e 100644 --- a/docs/general.rst +++ b/docs/general.rst @@ -18,7 +18,7 @@ library that has been built against. The version number has a .. py:data:: LIBGIT2_VER_MAJOR Integer value of the major version number. For example, for the version - ``1.9.6``:: + ``1.9.7``:: >>> print(pygit2.LIBGIT2_VER_MAJOR) 1 @@ -26,7 +26,7 @@ library that has been built against. The version number has a .. py:data:: LIBGIT2_VER_MINOR Integer value of the minor version number. For example, for the version - ``1.9.6``:: + ``1.9.7``:: >>> print(pygit2.LIBGIT2_VER_MINOR) 9 @@ -34,7 +34,7 @@ library that has been built against. The version number has a .. py:data:: LIBGIT2_VER_REVISION Integer value of the revision version number. For example, for the version - ``1.9.6``:: + ``1.9.7``:: >>> print(pygit2.LIBGIT2_VER_REVISION) 6 @@ -42,7 +42,7 @@ library that has been built against. The version number has a .. py:data:: LIBGIT2_VER Tuple value of the revision version numbers. For example, for the version - ``1.9.6``:: + ``1.9.7``:: >>> print(pygit2.LIBGIT2_VER) (1, 9, 6) @@ -52,7 +52,7 @@ library that has been built against. The version number has a The libgit2 version number as a string:: >>> print(pygit2.LIBGIT2_VERSION) - '1.9.6' + '1.9.7' Options ========= diff --git a/docs/install.rst b/docs/install.rst index a8d186510..54bb07dc8 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -158,9 +158,9 @@ directory, do: .. code-block:: sh :caption: On Linux using bash - wget https://github.com/libgit2/libgit2/archive/refs/tags/v1.9.6.tar.gz -O libgit2-1.9.6.tar.gz - tar -xzf libgit2-1.9.6.tar.gz - cd libgit2-1.9.6/ + wget https://github.com/libgit2/libgit2/archive/refs/tags/v1.9.7.tar.gz -O libgit2-1.9.7.tar.gz + tar -xzf libgit2-1.9.7.tar.gz + cd libgit2-1.9.7/ cmake . make sudo make install @@ -245,9 +245,9 @@ Install libgit2 (see we define the installation prefix): .. code-block:: sh :caption: On Linux using bash - wget https://github.com/libgit2/libgit2/archive/refs/tags/v1.9.6.tar.gz -O libgit2-1.9.6.tar.gz - tar xzf libgit2-1.9.6.tar.gz - cd libgit2-1.9.6/ + wget https://github.com/libgit2/libgit2/archive/refs/tags/v1.9.7.tar.gz -O libgit2-1.9.7.tar.gz + tar xzf libgit2-1.9.7.tar.gz + cd libgit2-1.9.7/ cmake . -DCMAKE_INSTALL_PREFIX=$LIBGIT2 cmake --build . --target install @@ -307,7 +307,7 @@ variable. The following recipe shows you how to do it: .. code-block:: pwsh :caption: On Windows using PowerShell (and CMake v3.21 or newer) - git clone --depth=1 -b v1.9.6 https://github.com/libgit2/libgit2.git + git clone --depth=1 -b v1.9.7 https://github.com/libgit2/libgit2.git $env:CMAKE_INSTALL_PREFIX = "C:/Dev/libgit2" $env:CMAKE_GENERATOR = "Visual Studio 17 2022" # or "Visual Studio 18 2026" $env:CMAKE_GENERATOR_PLATFORM = "x64" # or "Win32" or "ARM64" diff --git a/pyproject.toml b/pyproject.toml index c9503929e..3829aa5c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ skip = "*musllinux_ppc64le" archs = ["native"] build-frontend = "default" dependency-versions = "pinned" -environment = {LIBGIT2="$(pwd)/ci", LIBGIT2_VERSION="1.9.6", LIBSSH2_VERSION="1.11.1", OPENSSL_VERSION="3.5.7"} +environment = {LIBGIT2="$(pwd)/ci", LIBGIT2_VERSION="1.9.7", LIBSSH2_VERSION="1.11.1", OPENSSL_VERSION="3.5.7"} before-all = "sh build.sh" test-command = "pytest" @@ -32,7 +32,7 @@ repair-wheel-command = "DYLD_LIBRARY_PATH={package}/ci/lib delocate-wheel --requ [tool.cibuildwheel.windows] environment.LIBGIT2_SRC = "build/libgit2_src" -environment.LIBGIT2_VERSION = "1.9.6" +environment.LIBGIT2_VERSION = "1.9.7" before-all = "powershell -File build.ps1" before-build = ""