diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..a3d627a7 --- /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: + - "*" diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml new file mode 100644 index 00000000..58f0813a --- /dev/null +++ b/.github/workflows/codespell.yml @@ -0,0 +1,25 @@ +# Codespell configuration is within pyproject.toml +--- +name: Codespell + +on: + pull_request: + branches: [master] + push: + branches: [master] + +permissions: + contents: read + +jobs: + codespell: + name: Check for spelling errors + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Annotate locations with typos + uses: codespell-project/codespell-problem-matcher@v1 + - name: Codespell + uses: codespell-project/actions-codespell@v2 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 00f5f719..0b5290c9 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' @@ -12,15 +14,21 @@ jobs: steps: - name: Checkout pygit2 - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: - python-version: '3.12' + python-version: '3.13' - 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.2 /bin/sh build.sh mypy diff --git a/.github/workflows/parse_release_notes.py b/.github/workflows/parse_release_notes.py new file mode 100644 index 00000000..a2b25cda --- /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 9a8eaa78..1c02cb84 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,73 +1,23 @@ -name: Tests +name: Tests (s390x) on: pull_request: + branches: [master] push: + branches: [master] paths-ignore: - '**.rst' jobs: - x86_64: - 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: macos-latest - python-version: '3.10' - - steps: - - name: Checkout pygit2 - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Linux - if: runner.os == 'Linux' - run: | - sudo apt install tinyproxy - LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.8.4 /bin/sh build.sh test - - - name: macOS - if: runner.os == 'macOS' - run: | - export OPENSSL_PREFIX=`brew --prefix openssl@1.1` - LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.8.4 /bin/sh build.sh test - - aarch64: - runs-on: ubuntu-24.04 - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Build & test - uses: uraimo/run-on-arch-action@v2 - with: - arch: aarch64 - distro: ubuntu22.04 - install: | - 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.8.4 /bin/sh build.sh test - - s390x: + linux-s390x: runs-on: ubuntu-24.04 if: github.ref == 'refs/heads/master' steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Build & test - uses: uraimo/run-on-arch-action@v2 + uses: uraimo/run-on-arch-action@v3 with: arch: s390x distro: ubuntu22.04 @@ -75,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.8.4 /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 dd14bde6..06028447 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -1,54 +1,158 @@ name: Wheels +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref_name != 'master' }} + on: push: - branches: - - master + branches: [master] tags: - - 'v*' + - 'v*' + pull_request: + branches: [master] + paths-ignore: + - 'docs/**' jobs: build_wheels: - name: Build wheels on ${{ matrix.os }} + 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 + - name: linux-amd os: ubuntu-24.04 - - name: macos - os: macos-12 + - name: linux-arm + os: ubuntu-24.04-arm + - name: macos-intel + os: macos-15-intel + - name: macos-arm + os: macos-15 + - 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@v4 + - uses: actions/checkout@v6 + with: + # avoid leaking credentials in uploaded artifacts + persist-credentials: false - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: - python-version: '3.10' + python-version: '3.13' + + - name: Install cibuildwheel + run: python -m pip install cibuildwheel~=3.3 + + - name: Build wheels + env: + CIBW_ARCHS_WINDOWS: ${{ matrix.name == 'windows-x86' && 'auto32' || 'native' }} + run: python -m cibuildwheel --output-dir wheelhouse + + - uses: actions/upload-artifact@v7 + with: + name: wheels-${{ matrix.name }} + path: ./wheelhouse/*.whl + + build_wheels_ppc: + name: Wheels for linux-ppc + runs-on: ubuntu-24.04 + + 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' - uses: docker/setup-qemu-action@v3 - if: runner.os == 'Linux' with: - platforms: all + platforms: linux/ppc64le - name: Install cibuildwheel - run: python -m pip install cibuildwheel==2.21.3 + run: python -m pip install -r requirements-wheel.txt - name: Build wheels run: python -m cibuildwheel --output-dir wheelhouse + env: + CIBW_ARCHS: ppc64le + CIBW_ENVIRONMENT: LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.2 LIBGIT2=/project/ci - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: - name: wheels-${{ matrix.name }} + 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. + # 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@v8 + 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] + needs: [build_wheels, build_wheels_ppc, sdist] + permissions: + contents: write # to create GitHub Release runs-on: ubuntu-24.04 steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: path: dist pattern: wheels-* @@ -61,3 +165,21 @@ jobs: with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} + skip-existing: true + + - uses: actions/download-artifact@v8 + 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/.gitignore b/.gitignore index 60e8a550..a9106ffb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,245 @@ -/.cache/ -/.coverage -/.eggs/ -/.envrc -/.tox/ -/build/ +# 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/ -/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 diff --git a/.mailmap b/.mailmap index 6445697e..9ee281b1 100644 --- a/.mailmap +++ b/.mailmap @@ -3,6 +3,8 @@ 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 Grégory Herrero @@ -12,6 +14,8 @@ J. David Ibáñez Jeremy Westwood Jose Plana Kaarel Kitsemets +Karl Malmros <44969574+ktpa@users.noreply.github.com> +Konstantin Baikov Lukas Fleischer Martin Lenders Matthew Duggan @@ -19,9 +23,11 @@ Matthew Gamble Matthias Bartelmeß Mikhail Yushkovskiy Nabijacz Leweli +Nicolas Rybowski Óscar San José Petr Hosek Phil Schleihauf +Raphael Medaer Richo Healey Robert Hölzl Saugat Pachhai @@ -29,9 +35,10 @@ Sriram Raghu Sukhman Bhuller Tamir Bahar Tamir Bahar -Victor Garcia Victor Florea +Victor Garcia Vlad Temian +William Schueller Wim Jeantine-Glenn Xavier Delannoy Xu Tao diff --git a/.vimrc b/.vimrc new file mode 100644 index 00000000..66868d60 --- /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/AUTHORS.md b/AUTHORS.md index 75bd1ebc..8a04c1be 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 @@ -12,6 +13,7 @@ Authors: Dave Borowitz Brandon Milton Daniel Rodríguez Troitiño + Brendan Doherty Peter Rowlands Richo Healey Christian Boos @@ -19,6 +21,7 @@ Authors: Nick Hynes Richard Möhn Xu Tao + Konstantin Baikov Matthew Duggan Matthew Gamble Jeremy Westwood @@ -27,6 +30,7 @@ Authors: Sriram Raghu Victor Garcia Yonggang Luo + Łukasz Langa Patrick Steinhardt Petr Hosek Tamir Bahar @@ -34,6 +38,7 @@ Authors: Xavier Delannoy Michael Jones Saugat Pachhai + Andrej730 Bernardo Heynemann John Szakmeister Nabijacz Leweli @@ -43,14 +48,16 @@ Authors: Chad Dombrova Lukas Fleischer Mathias Leppich + Mathieu Parent + Michał Kępień Nicolas Dandrimont - Raphael Medaer (Escaux) + Raphael Medaer + Yaroslav Halchenko Anatoly Techtonik Andrew Olsen Dan Sully David Versmisse Grégory Herrero - Michał Kępień Mikhail Yushkovskiy Robin Stocker Rohit Sanjay @@ -58,12 +65,14 @@ Authors: Santiago Perez De Rosso Sebastian Thiel Thom Wiggers + WANG Xuerui William Manley Alexander Linne Alok Singhal Assaf Nativ Bob Carroll Christian Häggström + Edmundo Carmona Antoranz Erik Johnson Filip Rindler Fraser Tweedale @@ -82,14 +91,15 @@ Authors: Sukhman Bhuller Thomas Kluyver Tyler Cipriani - WANG Xuerui Alex Chamberlain Alexander Bayandin Amit Bakshi Andrey Devyatkin Arno van Lumig Ben Davis + CJ Steiner Colin Watson + Craig de Stigter Dan Yeaw Dustin Raimondi Eric Schrijver @@ -111,9 +121,9 @@ Authors: Kaarel Kitsemets Ken Dreyer Kevin KIN-FOO + Kyle Gottfried Marcel Waldvogel Masud Rahman - Mathieu Parent Michael Sondergaard Natanael Arndt Ondřej Nový @@ -126,9 +136,12 @@ Authors: nikitalita Adam Gausmann Adam Spiers + Adrien Nader Albin Söderström + Alexander Shadchin Alexandru Fikl Andrew Chin + Andrew McNulty Andrey Trubachev András Veres-Szentkirályi Ash Berlin @@ -146,7 +159,6 @@ Authors: Chris Rebert Christopher Hunt Claudio Jolowicz - Craig de Stigter Cristian Hotea Cyril Jouve Dan Cecile @@ -171,13 +183,17 @@ Authors: Hugh Cole-Baker Isabella Stephens Jacob Swanson + Jah-yee Jasper Lievisse Adriaanse Jimisola Laursen Jiri Benc + Johann Miller Jonathan Robson Josh Bleecher Snyder Julia Evans Justin Clift + Karl Malmros + Kevin Valk Konstantinos Smanis Kyriakos Oikonomakos Lance Eftink @@ -187,9 +203,11 @@ Authors: Mathieu Bridon Mathieu Pillard Matthaus Woolard + Matěj Cepl Maxwell G Michał Górny Na'aman Hirschfeld + Nicolas Rybowski Nicolás Sanguinetti Nikita Kartashov Nikolai Zujev @@ -208,8 +226,10 @@ Authors: Rodrigo Bistolfi Ross Nicoll Rui Abreu Ferreira + Rui Chen Sandro Jäckel Saul Pwanson + Sebastian Hamann Shane Turner Sheeo Simone Mosciatti @@ -219,6 +239,8 @@ Authors: Timo Röhling Victor Florea Vladimir Rutsky + Vruyr Gyolchanyan + William Schueller Wim Jeantine-Glenn Yu Jianjian buhl diff --git a/CHANGELOG.md b/CHANGELOG.md index 45d91990..754bb14e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,245 @@ +# 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) + +- New `Odb.read_header(...)` and now `Odb.read(...)` returns `enums.ObjectType` instead of int + [#1450](https://github.com/libgit2/pygit2/pull/1450) + +- 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) + +- 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 (macOS): arm, intel and pypy wheels (instead of universal) + [#1441](https://github.com/libgit2/pygit2/pull/1441) + +- CI (pypy): fix tests + [#1437](https://github.com/libgit2/pygit2/pull/1437) + + +# 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 + +- 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: 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) + +- Documentation improvements and other changes + [#1426](https://github.com/libgit2/pygit2/pull/1426) + [#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 + [#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) + +- 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) + +- 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) + [#1407](https://github.com/libgit2/pygit2/pull/1407) + [#1408](https://github.com/libgit2/pygit2/pull/1408) + +Deprecations: + +- `Remote.ls_remotes(...)` is deprecated, use `Remote.list_heads(...)`: + + # Before + for head in remote.ls_remotes(): + head['name'] + head['oid'] + head['loid'] # None when local is False + head['local'] + head['symref_target'] + + # Now + for head in remote.list_heads(): + head.name + head.oid + head.loid # The zero oid when local is False + head.local + head.symref_target + + +# 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) + +- Typing improvements + [#1369](https://github.com/libgit2/pygit2/pull/1369) + [#1370](https://github.com/libgit2/pygit2/pull/1370) + [#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: + +- Update your code: + + # Before + contents = Repository.merge_file_from_index(...) + + # Now + result = Repository.merge_file_from_index(..., use_deprecated=False) + contents = result.contents + + At some point in the future `use_deprecated=False` will be the default. + + +# 1.18.0 (2025-04-24) + +- Upgrade Linux Glibc wheels to `manylinux_2_28` + +- Add `RemoteCallbacks.push_transfer_progress(...)` callback + [#1345](https://github.com/libgit2/pygit2/pull/1345) + +- New `bool(oid)` + [#1347](https://github.com/libgit2/pygit2/pull/1347) + +- Now `Repository.merge(...)` accepts a commit or reference object + [#1348](https://github.com/libgit2/pygit2/pull/1348) + +- New `threads` optional argument in `Remote.push(...)` + [#1352](https://github.com/libgit2/pygit2/pull/1352) + +- New `proxy` optional argument in `clone_repository(...)` + [#1354](https://github.com/libgit2/pygit2/pull/1354) + +- New optional arguments `context_lines` and `interhunk_lines` in `Blob.diff(...)` ; and + now `Repository.diff(...)` honors these two arguments when the objects diffed are blobs. + [#1360](https://github.com/libgit2/pygit2/pull/1360) + +- Now `Tree.diff_to_workdir(...)` accepts keyword arguments, not just positional. + +- Fix when a reference name has non UTF-8 chars + [#1329](https://github.com/libgit2/pygit2/pull/1329) + +- Fix condition check in `Repository.remotes.rename(...)` + [#1342](https://github.com/libgit2/pygit2/pull/1342) + +- Add codespell workflow, fix a number of typos + [#1344](https://github.com/libgit2/pygit2/pull/1344) + +- Documentation and typing + [#1343](https://github.com/libgit2/pygit2/pull/1343) + [#1347](https://github.com/libgit2/pygit2/pull/1347) + [#1356](https://github.com/libgit2/pygit2/pull/1356) + +- CI: Use ARM runner for tests and wheels + [#1346](https://github.com/libgit2/pygit2/pull/1346) + +- Build and CI updates + [#1363](https://github.com/libgit2/pygit2/pull/1363) + [#1365](https://github.com/libgit2/pygit2/pull/1365) + +Deprecations: + +- Passing str to `Repository.merge(...)` is deprecated, + instead pass an oid object (or a commit, or a reference) + [#1349](https://github.com/libgit2/pygit2/pull/1349) + +Breaking changes: + +- Keyword argument `flag` has been renamed to `flags` in `Blob.diff(...)` and + `Blob.diff_to_buffer(...)` + + +# 1.17.0 (2025-01-08) + +- Upgrade to libgit2 1.9 + +- Add `certificate_check` callback to `Remote.ls_remotes(...)` + [#1326](https://github.com/libgit2/pygit2/pull/1326) + +- Fix build with GCC 14 + [#1324](https://github.com/libgit2/pygit2/pull/1324) + +- Release wheels for PyPy + [#1336](https://github.com/libgit2/pygit2/pull/1336) + [#1339](https://github.com/libgit2/pygit2/pull/1339) + +- CI: update tests for macOS to use OpenSSL 3 + [#1335](https://github.com/libgit2/pygit2/pull/1335) + +- Documentation: fix typo in `Repository.status(...)` docstring + [#1327](https://github.com/libgit2/pygit2/pull/1327) + + # 1.16.0 (2024-10-11) - Add support for Python 3.13 @@ -124,31 +366,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: @@ -238,7 +488,7 @@ Deprecations: - New `keep_all` and `paths` optional arguments for `Repository.stash(...)` [#1202](https://github.com/libgit2/pygit2/pull/1202) -- New `Respository.state()` +- New `Repository.state()` [#1204](https://github.com/libgit2/pygit2/pull/1204) - Improve `Repository.write_archive(...)` performance [#1183](https://github.com/libgit2/pygit2/pull/1183) @@ -418,7 +668,7 @@ Breaking changes: Breaking changes: -- Remove deprecated `GIT_CREDTYPE_XXX` contants, use +- Remove deprecated `GIT_CREDTYPE_XXX` constants, use `GIT_CREDENTIAL_XXX` instead. - Remove deprecated `Patch.patch` getter, use `Patch.text` instead. @@ -515,7 +765,7 @@ Deprecations: - Deprecate `Repository.create_remote(...)`, use instead `Repository.remotes.create(...)` -- Deprecate `GIT_CREDTYPE_XXX` contants, use `GIT_CREDENTIAL_XXX` +- Deprecate `GIT_CREDTYPE_XXX` constants, use `GIT_CREDENTIAL_XXX` instead. # 1.2.0 (2020-04-05) @@ -636,7 +886,7 @@ Breaking changes: Breaking changes: -- Now the Repository has a new attribue `odb` for object database: +- Now the Repository has a new attribute `odb` for object database: # Before repository.read(...) @@ -841,7 +1091,7 @@ Other changes: [#610](https://github.com/libgit2/pygit2/issues/610) - Fix tests failing in some cases [#795](https://github.com/libgit2/pygit2/issues/795) -- Automatize wheels upload to pypi +- Automate wheels upload to pypi [#563](https://github.com/libgit2/pygit2/issues/563) # 0.27.0 (2018-03-30) diff --git a/Makefile b/Makefile index b8a4cfc1..80107aae 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ .PHONY: build html build: - OPENSSL_VERSION=3.2.3 LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.8.4 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/README.md b/README.md index 0182486f..3d0891e9 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,24 @@ # 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+ -[![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/appveyor.yml b/appveyor.yml deleted file mode 100644 index 76a1f4fa..00000000 --- a/appveyor.yml +++ /dev/null @@ -1,68 +0,0 @@ -version: 1.16.{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.8.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/build.ps1 b/build.ps1 new file mode 100644 index 00000000..204a201b --- /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/build.sh b/build.sh index 944ed052..c2d654d8 100644 --- a/build.sh +++ b/build.sh @@ -22,14 +22,14 @@ # # sh build.sh # -# Build libgit2 1.8.4 (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.8.4 sh build.sh +# LIBGIT2_VERSION=1.9.2 sh build.sh # -# Build libssh2 1.11.1 and libgit2 1.8.4, 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.8.4 sh build.sh +# LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.2 sh build.sh # # Build inplace and run the tests: # @@ -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 @@ -69,11 +71,15 @@ if [ "$CIBUILDWHEEL" = "1" ]; then yum install openssl-devel -y 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 + apk add --no-cache openssl-dev + else + apk add --no-cache perl fi fi rm -rf ci @@ -133,7 +139,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) @@ -177,7 +183,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 .. \ @@ -261,6 +267,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 -r requirements-typing.txt + $PREFIX/bin/mypy pygit2 test +fi + # Test .pyi stub file if [ "$1" = "stubtest" ]; then shift diff --git a/build_tag.py b/build_tag.py index 66c61b68..5051f997 100644 --- a/build_tag.py +++ b/build_tag.py @@ -1,4 +1,5 @@ -import platform, sys +import platform +import sys py = {'CPython': 'cp', 'PyPy': 'pp'}[platform.python_implementation()] print(f'{py}{sys.version_info.major}{sys.version_info.minor}') diff --git a/docs/branches.rst b/docs/branches.rst index f98d9baa..5afe6673 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') diff --git a/docs/conf.py b/docs/conf.py index 732c2ee4..4c21e4f3 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -4,7 +4,8 @@ # list see the documentation: # http://www.sphinx-doc.org/en/master/config -import os, sys +import os +import sys # -- Path setup -------------------------------------------------------------- @@ -18,11 +19,11 @@ # -- Project information ----------------------------------------------------- project = 'pygit2' -copyright = '2010-2024 The pygit2 contributors' +copyright = '2010-2025 The pygit2 contributors' # author = '' # The full version, including alpha/beta/rc tags -release = '1.16.0' +release = '1.19.2' # -- General configuration --------------------------------------------------- diff --git a/docs/development.rst b/docs/development.rst index 771a7080..79d7e6bf 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: @@ -82,7 +82,7 @@ Step 3. Build pygit2 with debug symbols:: Step 4. Install requirements:: $ $PYTHONBIN/python3 setup.py install - $ pip insall pytest + $ pip install pytest Step 4. Run valgrind:: diff --git a/docs/index.rst b/docs/index.rst index d728af53..bc922c54 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 ===================================== @@ -74,6 +74,7 @@ Table of Contents oid packing references + transactions remotes repository revparse diff --git a/docs/index_file.rst b/docs/index_file.rst index f7cd158a..86c70f56 100644 --- a/docs/index_file.rst +++ b/docs/index_file.rst @@ -18,9 +18,10 @@ Iterate over all entries of the index:: Index write:: - >>> index.add('path/to/file') # git add - >>> index.remove('path/to/file') # git rm - >>> index.write() # don't forget to save the changes + >>> index.add('path/to/file') # git add + >>> index.remove('path/to/file') # git rm + >>> index.remove_directory('path/to/directory/') # git rm -r + >>> index.write() # don't forget to save the changes Custom entries:: >>> entry = pygit2.IndexEntry('README.md', blob_id, blob_filemode) diff --git a/docs/install.rst b/docs/install.rst index c0fe7087..1ba7efc9 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: @@ -50,20 +46,20 @@ 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``): -- cffi 1.17.0 or later +- cffi 2.0 or later -Libgit2 **v1.8.x**; binary wheels already include libgit2, so you only need to -worry about this if you install the source package. +Libgit2 **v1.9.x**; binary wheels already include libgit2, so you only need to +worry about this if you `install from the source distribution`_. -Optional libgit2 dependecies to support ssh and https: +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.10.0 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 @@ -83,35 +80,41 @@ The version number of pygit2 is composed of three numbers separated by dots The table below summarizes the latest pygit2 versions with the supported versions of Python and the required libgit2 version. -+-----------+----------------+------------+ -| pygit2 | Python | libgit2 | -+-----------+----------------+------------+ -| 1.15 | 3.9 - 3.12 | 1.8 | -+-----------+----------------+------------+ -| 1.14 | 3.9 - 3.12 | 1.7 | -+-----------+----------------+------------+ -| 1.13 | 3.8 - 3.12 | 1.7 | -+-----------+----------------+------------+ -| 1.12 | 3.8 - 3.11 | 1.6 | -+-----------+----------------+------------+ -| 1.11 | 3.8 - 3.11 | 1.5 | -+-----------+----------------+------------+ -| 1.10 | 3.7 - 3.10 | 1.5 | -+-----------+----------------+------------+ -| 1.9 | 3.7 - 3.10 | 1.4 | -+-----------+----------------+------------+ -| 1.7 - 1.8 | 3.7 - 3.10 | 1.3 | -+-----------+----------------+------------+ -| 1.4 - 1.6 | 3.6 - 3.9 | 1.1 | -+-----------+----------------+------------+ -| 1.2 - 1.3 | 3.6 - 3.8 | 1.0 | -+-----------+----------------+------------+ -| 1.1 | 3.5 - 3.8 | 0.99 - 1.0 | -+-----------+----------------+------------+ -| 1.0 | 3.5 - 3.8 | 0.28 | -+-----------+----------------+------------+ -| 0.28.2 | 2.7, 3.4 - 3.7 | 0.28 | -+-----------+----------------+------------+ ++-------------+----------------+------------+ +| pygit2 | Python | libgit2 | ++-------------+----------------+------------+ +| 1.19 | 3.11 - 3.14(t) | 1.9 | ++-------------+----------------+------------+ +| 1.17 - 1.18 | 3.10 - 3.13 | 1.9 | ++-------------+----------------+------------+ +| 1.16 | 3.10 - 3.13 | 1.8 | ++-------------+----------------+------------+ +| 1.15 | 3.9 - 3.12 | 1.8 | ++-------------+----------------+------------+ +| 1.14 | 3.9 - 3.12 | 1.7 | ++-------------+----------------+------------+ +| 1.13 | 3.8 - 3.12 | 1.7 | ++-------------+----------------+------------+ +| 1.12 | 3.8 - 3.11 | 1.6 | ++-------------+----------------+------------+ +| 1.11 | 3.8 - 3.11 | 1.5 | ++-------------+----------------+------------+ +| 1.10 | 3.7 - 3.10 | 1.5 | ++-------------+----------------+------------+ +| 1.9 | 3.7 - 3.10 | 1.4 | ++-------------+----------------+------------+ +| 1.7 - 1.8 | 3.7 - 3.10 | 1.3 | ++-------------+----------------+------------+ +| 1.4 - 1.6 | 3.6 - 3.9 | 1.1 | ++-------------+----------------+------------+ +| 1.2 - 1.3 | 3.6 - 3.8 | 1.0 | ++-------------+----------------+------------+ +| 1.1 | 3.5 - 3.8 | 0.99 - 1.0 | ++-------------+----------------+------------+ +| 1.0 | 3.5 - 3.8 | 0.28 | ++-------------+----------------+------------+ +| 0.28.2 | 2.7, 3.4 - 3.7 | 0.28 | ++-------------+----------------+------------+ .. warning:: @@ -124,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 ----------------------- @@ -135,33 +142,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.8.1.tar.gz -O libgit2-1.8.1.tar.gz - $ tar xzf libgit2-1.8.1.tar.gz - $ cd libgit2-1.8.1/ - $ cmake . - $ make - $ sudo make install + 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 .. 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 @@ -170,9 +185,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 @@ -184,9 +199,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 @@ -210,7 +226,7 @@ libgit2 within a virtual environment This is how to install both libgit2 and pygit2 within a virtual environment. -This is useful if you don't have root acces to install libgit2 system wide. +This is useful if you don't have root access to install libgit2 system wide. Or if you wish to have different versions of libgit2/pygit2 installed in different virtual environments, isolated from each other. @@ -218,29 +234,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.8.1.tar.gz -O libgit2-1.8.1.tar.gz - $ tar xzf libgit2-1.8.1.tar.gz - $ cd libgit2-1.8.1/ - $ cmake . -DCMAKE_INSTALL_PREFIX=$LIBGIT2 - $ cmake --build . --target install + 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 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 @@ -254,43 +273,49 @@ 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 the pygit2 extension modules, so you don't need to set ``LD_LIBRARY_PATH`` -everytime. Verify yourself if curious: +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.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" + 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.8.1 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. @@ -313,12 +338,12 @@ source package. The easiest way is to first install libgit2 with the `Homebrew `_ package manager and then use pip3 for pygit2. The following example assumes that -XCode and Hombrew are already installed. +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/merge.rst b/docs/merge.rst index 43c99d48..dcc8c528 100644 --- a/docs/merge.rst +++ b/docs/merge.rst @@ -66,7 +66,7 @@ The following methods perform the calculation for a base to an n-way merge. .. automethod:: pygit2.Repository.merge_base_many .. automethod:: pygit2.Repository.merge_base_octopus -With this base at hand one can do repeated invokations of +With this base at hand one can do repeated invocations of :py:meth:`.Repository.merge_commits` and :py:meth:`.Repository.merge_trees` to perform the actual merge into one tree (and deal with conflicts along the way). \ No newline at end of file diff --git a/docs/objects.rst b/docs/objects.rst index 97ef286a..9aed0282 100644 --- a/docs/objects.rst +++ b/docs/objects.rst @@ -16,7 +16,7 @@ Object lookup In the previous chapter we learnt about Object IDs. With an Oid we can ask the repository to get the associated object. To do that the ``Repository`` class -implementes a subset of the mapping interface. +implements a subset of the mapping interface. .. autoclass:: pygit2.Repository :noindex: @@ -33,7 +33,7 @@ implementes 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) @@ -90,7 +90,7 @@ Blobs ================= A blob is just a raw byte string. They are the Git equivalent to files in -a filesytem. +a filesystem. This is their API: @@ -221,7 +221,7 @@ Creating trees Commits ================= -A commit is a snapshot of the working dir with meta informations like author, +A commit is a snapshot of the working dir with meta information like author, committer and others. .. autoclass:: pygit2.Commit diff --git a/docs/oid.rst b/docs/oid.rst index 2ad8ed4e..97de2688 100644 --- a/docs/oid.rst +++ b/docs/oid.rst @@ -56,9 +56,9 @@ The Oid type >>> raw = unhexlify("cff3ceaefc955f0dbe1957017db181bc49913781") >>> oid2 = Oid(raw=raw) -And the other way around, from an Oid object we can get the hexadecimal and raw -forms. You can use the built-in `str()` (or `unicode()` in python 2) to get the -hexadecimal representation of the Oid. +And the other way around, from an Oid object we can get the raw form via +`oid.raw`. You can use `str(oid)` to get the hexadecimal representation of the +Oid. .. method:: Oid.__str__() .. autoattribute:: pygit2.Oid.raw @@ -68,8 +68,11 @@ The Oid type supports: - rich comparisons, not just for equality, also: lesser-than, lesser-or-equal, etc. -- hashing, so Oid objects can be used as keys in a dictionary. +- `hash(oid)`, so Oid objects can be used as keys in a dictionary. +- `bool(oid)`, returning False if the Oid is a null SHA-1 (all zeros). + +- `str(oid)`, returning the hexadecimal representation of the Oid. Constants ========= diff --git a/docs/references.rst b/docs/references.rst index 0d06c3c3..56387855 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/repository.rst b/docs/repository.rst index 546c75a3..0e22178d 100644 --- a/docs/repository.rst +++ b/docs/repository.rst @@ -29,6 +29,7 @@ Functions >>> repo_path = '/path/to/create/repository' >>> repo = clone_repository(repo_url, repo_path) # Clones a non-bare repository >>> repo = clone_repository(repo_url, repo_path, bare=True) # Clones a bare repository + >>> repo = clone_repository(repo_url, repo_path, proxy=True) # Enable automatic proxy detection .. autofunction:: pygit2.discover_repository diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 00000000..cbf1e365 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,2 @@ +sphinx +sphinx-rtd-theme diff --git a/docs/transactions.rst b/docs/transactions.rst new file mode 100644 index 00000000..4645320e --- /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/mypy.ini b/mypy.ini new file mode 100644 index 00000000..ea5a4ae1 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,12 @@ +[mypy] + +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 +disallow_untyped_calls = True diff --git a/pygit2/__init__.py b/pygit2/__init__.py index 48857f96..52ff9638 100644 --- a/pygit2/__init__.py +++ b/pygit2/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -23,37 +23,351 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. +# ruff: noqa: F401 F403 F405 + # Standard Library import functools -from os import PathLike +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 ( + 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_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, + RevSpec, + Signature, + Stash, + Tag, + Tree, + TreeBuilder, + Walker, + Worktree, + _cache_enums, + discover_repository, + filter_register, + hash, + hashfile, + init_file_backend, + reference_is_valid_name, + tree_entry_cmp, +) from .blame import Blame, BlameHunk from .blob import BlobIO -from .callbacks import Payload, RemoteCallbacks, CheckoutCallbacks, StashApplyCallbacks -from .callbacks import git_clone_options, git_fetch_options, get_credentials +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 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 * +from .options import ( + 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, + option, +) from .packbuilder import PackBuilder from .remotes import Remote from .repository import Repository from .settings import Settings from .submodules import Submodule +from .transaction import ReferenceTransaction from .utils import to_bytes, to_str - # Features features = enums.Feature(C.git_libgit2_features()) @@ -66,12 +380,10 @@ def init_repository( - path: typing.Union[str, bytes, PathLike, None], + path: str | bytes | os.PathLike[str] | os.PathLike[bytes] | 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, @@ -86,7 +398,7 @@ def init_repository( The *flags* may be a combination of enums.RepositoryInitFlag constants: - - BARE (overriden by the *bare* parameter) + - BARE (overridden by the *bare* parameter) - NO_REINIT - NO_DOTGIT_DIR - MKDIR @@ -99,6 +411,10 @@ def init_repository( The *workdir_path*, *description*, *template_path*, *initial_head* and *origin_url* are all strings. + If a repository already exists at *path*, it may be opened successfully but + you must not rely on that behavior and should use the Repository + constructor directly instead. + See libgit2's documentation on git_repository_init_ext for further details. """ # Pre-process input parameters @@ -144,15 +460,16 @@ def init_repository( def clone_repository( - url, - path, - bare=False, - repository=None, - remote=None, - checkout_branch=None, - callbacks=None, - depth=0, -): + 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, + checkout_branch: str | bytes | None = None, + callbacks: RemoteCallbacks | None = None, + depth: int = 0, + proxy: None | bool | str = None, +) -> Repository: """ Clones a new Git repository from *url* in the given *path*. @@ -160,9 +477,9 @@ def clone_repository( Parameters: - url : str + url : str or bytes or pathlike object URL of the repository to clone. - path : str + path : str or bytes or pathlike object Local path to clone into. bare : bool Whether the local repository should be bare. @@ -178,7 +495,7 @@ def clone_repository( The repository callback has `(path, bare) -> Repository` as a signature. The Repository it returns will be used instead of creating a new one. - checkout_branch : str + checkout_branch : str or bytes Branch to checkout after the clone. The default is to use the remote's default branch. callbacks : RemoteCallbacks @@ -192,6 +509,12 @@ def clone_repository( If greater than 0, creates a shallow clone with a history truncated to the specified number of commits. The default is 0 (full commit history). + proxy : None or True or str + Proxy configuration. Can be one of: + + * `None` (the default) to disable proxy usage + * `True` to enable automatic proxy detection + * an url to a proxy (`http://proxy.example.org:3128/`) """ if callbacks is None: @@ -212,14 +535,471 @@ def clone_repository( opts.checkout_branch = checkout_branch_ref with git_fetch_options(payload, opts=opts.fetch_opts): - crepo = ffi.new('git_repository **') - err = C.git_clone(crepo, to_bytes(url), to_bytes(path), opts) - payload.check_error(err) + 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) + payload.check_error(err) # Ok 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() + +__all__ = ( + # Standard Library + 'functools', + 'os', + 'typing', + # Standard Library symbols + 'TYPE_CHECKING', + 'annotations', + # Low level API + 'GIT_OID_HEX_ZERO', + 'GIT_OID_HEXSZ', + 'GIT_OID_MINPREFIXLEN', + 'GIT_OID_RAWSZ', + 'LIBGIT2_VER_MAJOR', + 'LIBGIT2_VER_MINOR', + 'LIBGIT2_VER_REVISION', + 'LIBGIT2_VERSION', + 'Object', + 'Reference', + 'AlreadyExistsError', + 'Blob', + 'Branch', + 'Commit', + 'Diff', + 'DiffDelta', + 'DiffFile', + 'DiffHunk', + 'DiffLine', + 'DiffStats', + 'GitError', + 'InvalidSpecError', + 'Mailmap', + 'Note', + 'Odb', + 'OdbBackend', + 'OdbBackendLoose', + 'OdbBackendPack', + 'Oid', + 'Patch', + 'RefLogEntry', + 'Refdb', + 'RefdbBackend', + 'RefdbFsBackend', + 'RevSpec', + 'Signature', + 'Stash', + 'Tag', + 'Tree', + 'TreeBuilder', + 'Walker', + 'Worktree', + 'discover_repository', + 'hash', + 'hashfile', + 'init_file_backend', + 'option', + 'reference_is_valid_name', + 'tree_entry_cmp', + # Low Level API (not present in .pyi) + 'FilterSource', + 'filter_register', + '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_FOR_RENAMES_ONLY', + 'GIT_DIFF_BREAK_REWRITES', + '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_FROM_UNMODIFIED', + 'GIT_DIFF_FIND_COPIES', + '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_FROM_REWRITES', + 'GIT_DIFF_FIND_RENAMES', + '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_CHANGE', + 'GIT_DIFF_IGNORE_WHITESPACE_EOL', + 'GIT_DIFF_IGNORE_WHITESPACE', + 'GIT_DIFF_INCLUDE_CASECHANGE', + 'GIT_DIFF_INCLUDE_IGNORED', + 'GIT_DIFF_INCLUDE_TYPECHANGE_TREES', + 'GIT_DIFF_INCLUDE_TYPECHANGE', + 'GIT_DIFF_INCLUDE_UNMODIFIED', + 'GIT_DIFF_INCLUDE_UNREADABLE_AS_UNTRACKED', + 'GIT_DIFF_INCLUDE_UNREADABLE', + '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_EXECUTABLE', + 'GIT_FILEMODE_BLOB', + '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_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', + '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', + # High level API. + 'enums', + 'blame', + 'Blame', + 'BlameHunk', + 'blob', + 'BlobIO', + 'callbacks', + 'Payload', + 'RemoteCallbacks', + 'CheckoutCallbacks', + 'StashApplyCallbacks', + 'git_clone_options', + 'git_fetch_options', + 'git_proxy_options', + 'get_credentials', + 'config', + 'Config', + 'credentials', + 'CredentialType', + 'Username', + 'UserPass', + 'Keypair', + 'KeypairFromAgent', + 'KeypairFromMemory', + 'errors', + 'check_error', + 'Passthrough', + 'ffi', + 'C', + 'filter', + 'Filter', + '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', + 'remotes', + 'Remote', + 'repository', + 'Repository', + 'branches', + 'references', + 'settings', + 'Settings', + 'submodules', + 'Submodule', + 'transaction', + 'ReferenceTransaction', + 'utils', + 'to_bytes', + 'to_str', + # __init__ module defined symbols + 'features', + 'LIBGIT2_VER', + 'init_repository', + 'clone_repository', + 'tree_entry_key', +) diff --git a/pygit2/_build.py b/pygit2/_build.py index 1e7272a9..976c98f4 100644 --- a/pygit2/_build.py +++ b/pygit2/_build.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -34,13 +34,13 @@ # # The version number of pygit2 # -__version__ = '1.16.0' +__version__ = '1.19.2' # -# Utility functions to get the paths required for bulding extensions +# 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/_libgit2/ffi.pyi b/pygit2/_libgit2/ffi.pyi new file mode 100644 index 00000000..a0710cae --- /dev/null +++ b/pygit2/_libgit2/ffi.pyi @@ -0,0 +1,385 @@ +# 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 Any, Generic, Literal, NewType, SupportsIndex, TypeVar, overload + +T = TypeVar('T') + +NULL_TYPE = NewType('NULL_TYPE', object) +NULL: NULL_TYPE = ... + +char = NewType('char', object) +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 ssize_t: + def __getitem__(self, item: Literal[0]) -> int: ... + +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 _MultiPointer(Generic[T]): + def __getitem__(self, item: int) -> T: ... + +class ArrayC(Generic[T]): + # incomplete! + # def _len(self, ?) -> ?: ... + def __getitem__(self, index: int) -> T: ... + def __setitem__(self, index: int, value: T) -> None: ... + +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': ... + pass + +class GitFetchOptionsC: + # TODO: FetchOptions exist in _pygit2.pyi + # incomplete + depth: int + +class GitSubmoduleC: + pass + +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: ... + +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 GitBlobC: + # 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: + # incomplete + pass + +class GitConfigIteratorC: + # incomplete + pass + +class GitConfigEntryC: + # incomplete + name: char_pointer + value: char_pointer + level: int + +class GitDescribeFormatOptionsC: + version: int + abbreviated_size: int + always_use_long_format: int + dirty_suffix: ArrayC[char] + +class GitDescribeOptionsC: + version: int + max_candidates_tags: int + describe_strategy: int + pattern: ArrayC[char] + only_follow_first_parent: int + show_commit_oid_as_fallback: int + +class GitDescribeResultC: + pass + +class GitFilterListC: + # opaque struct + pass + +class GitIndexC: + pass + +class GitIndexEntryC: + # incomplete? + mode: int + path: ArrayC[char] + +class GitMergeFileResultC: + pass + +class GitObjectC: + pass + +class GitStashSaveOptionsC: + version: int + flags: int + stasher: GitSignatureC + message: ArrayC[char] + paths: GitStrrayC + +class GitStrrayC: + # incomplete? + strings: NULL_TYPE | ArrayC[char_pointer] + count: int + +class GitTreeC: + pass + +class GitRepositoryInitOptionsC: + version: int + flags: int + mode: int + workdir_path: ArrayC[char] + description: ArrayC[char] + template_path: ArrayC[char] + initial_head: ArrayC[char] + origin_url: ArrayC[char] + +class GitCloneOptionsC: + pass + +class GitPackbuilderC: + pass + +class GitProxyTC: + pass + +class GitProxyOptionsC: + version: int + type: GitProxyTC + url: char_pointer + # credentials + # certificate_check + # payload + +class GitRemoteC: + pass + +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: ... +@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_blob **']) -> _Pointer[GitBlobC]: ... +@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['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]: ... +@overload +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: ... +@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_index_entry *']) -> GitIndexEntryC: ... +@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_packbuilder **']) -> _Pointer[GitPackbuilderC]: ... +@overload +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: ... +@overload +def new( + a: Literal['git_remote_head ***'], +) -> _Pointer[_MultiPointer[GitRemoteHeadC]]: ... +@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: ... +@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['void **'], b: bytes) -> _Pointer[bytes]: ... +@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 +@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): + 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: ... + +@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/_pygit2.pyi b/pygit2/_pygit2.pyi index a73da2a3..19dd61ff 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -1,8 +1,29 @@ -from typing import Iterator, Literal, Optional, overload -from io import IOBase +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 ( # noqa: UP035 + Generic, + Literal, + Optional, + Type, + TypedDict, + TypeVar, + overload, +) + from . import Index +from ._libgit2.ffi import ( + GitCommitC, + GitObjectC, + GitProxyOptionsC, + GitSignatureC, + _Pointer, +) from .enums import ( ApplyLocation, + BlobFilter, BranchType, DeltaStatus, DiffFind, @@ -13,47 +34,263 @@ from .enums import ( MergeAnalysis, MergePreference, ObjectType, - Option, ReferenceFilter, ReferenceType, ResetMode, SortMode, ) +from .filter import Filter + +GIT_OBJ_BLOB = Literal[3] +GIT_OBJ_COMMIT = Literal[1] +GIT_OBJ_TAG = Literal[4] +GIT_OBJ_TREE = Literal[2] -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 - -class Object: - _pointer: bytes +LIBGIT2_VERSION: str +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 + +T = TypeVar('T') + +class _ObjectBase(Generic[T]): + _pointer: _Pointer[T] filemode: FileMode - hex: str id: Oid name: str | None - oid: Oid 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_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]') -> 'Commit': ... + def peel( + self, target_type: 'Literal[GIT_OBJ_COMMIT, ObjectType.COMMIT] | Type[Commit]' + ) -> 'Commit': ... @overload - def peel(self, target_type: 'Literal[GIT_OBJ_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]') -> '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]') -> 'Blob': ... + def peel( + self, target_type: 'Literal[GIT_OBJ_BLOB, ObjectType.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: ... @@ -63,6 +300,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 @@ -75,15 +315,15 @@ class Reference: def delete(self) -> None: ... def log(self) -> Iterator[RefLogEntry]: ... @overload - def peel(self, type: 'Literal[GIT_OBJ_COMMIT]') -> 'Commit': ... + def peel(self, type: 'Literal[GIT_OBJ_COMMIT] | Type[Commit]') -> 'Commit': ... @overload - def peel(self, type: 'Literal[GIT_OBJ_TREE]') -> 'Tree': ... + def peel(self, type: 'Literal[GIT_OBJ_TREE] | Type[Tree]') -> 'Tree': ... @overload - def peel(self, type: 'Literal[GIT_OBJ_TAG]') -> 'Tag': ... + def peel(self, type: 'Literal[GIT_OBJ_TAG] | Type[Tag]') -> 'Tag': ... @overload - def peel(self, type: 'Literal[GIT_OBJ_BLOB]') -> 'Blob': ... + def peel(self, type: 'Literal[GIT_OBJ_BLOB] | Type[Blob]') -> 'Blob': ... @overload - def peel(self, type: 'None') -> 'Commit|Tree|Blob': ... + def peel(self, type: 'None' = None) -> 'Commit|Tree|Tag|Blob': ... def rename(self, new_name: str) -> None: ... def resolve(self) -> Reference: ... def set_target(self, target: _OidArg, message: str = ...) -> None: ... @@ -109,11 +349,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[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 @@ -124,9 +376,28 @@ class Branch(Reference): def delete(self) -> None: ... def is_checked_out(self) -> bool: ... def is_head(self) -> bool: ... - def rename(self, name: str, force: bool = False) -> None: ... - -class Commit(Object): + 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 + +class Commit(_ObjectBase[GitCommitC]): + _pointer: _Pointer[GitCommitC] author: Signature commit_time: int commit_time_offset: int @@ -146,6 +417,7 @@ class Diff: patch: str | None patchid: Oid stats: DiffStats + text: str def find_similar( self, flags: DiffFind = DiffFind.FIND_BY_CONFIG, @@ -160,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: @@ -207,6 +479,11 @@ class DiffStats: insertions: int def format(self, format: DiffStatsFormat, width: int) -> str: ... +class FilterSource: + # probably incomplete + repo: object + pass + class GitError(Exception): ... class InvalidSpecError(ValueError): ... @@ -214,9 +491,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 @@ -230,6 +507,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: ... @@ -238,10 +516,11 @@ 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 write(self, type: int, data: bytes) -> Oid: ... + 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 @@ -262,7 +541,6 @@ class OdbBackendPack(OdbBackend): def __init__(self, *args, **kwargs) -> None: ... class Oid: - hex: str raw: bytes def __init__(self, raw: bytes = ..., hex: str = ...) -> None: ... def __eq__(self, other) -> bool: ... @@ -272,6 +550,7 @@ class Oid: def __le__(self, other) -> bool: ... def __lt__(self, other) -> bool: ... def __ne__(self, other) -> bool: ... + def __bool__(self) -> bool: ... class Patch: data: bytes @@ -310,7 +589,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: ... @@ -324,31 +605,42 @@ 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: ... +_Proxy = None | Literal[True] | str + +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 + loid: Oid | None + name: str | None + symref_target: str | None + oid: Oid + class Repository: - _pointer: bytes - 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 - 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 add_worktree(self, name: str, path: str, ref: Reference = ...) -> Worktree: ... + def add_worktree( + self, name: str, path: str | Path, ref: Reference = ... + ) -> Worktree: ... def applies( self, diff: Diff, @@ -360,10 +652,10 @@ class Repository: ) -> None: ... def cherrypick(self, id: _OidArg) -> None: ... def compress_references(self) -> None: ... - 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) -> 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, @@ -372,7 +664,7 @@ class Repository: committer: Signature, message: str | bytes, tree: _OidArg, - parents: list[_OidArg], + parents: Sequence[_OidArg], encoding: str = ..., ) -> Oid: ... def create_commit_string( @@ -415,7 +707,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' @@ -438,7 +730,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: ... @@ -462,7 +754,7 @@ class RevSpec: class Signature: _encoding: str | None - _pointer: bytes + _pointer: _Pointer[GitSignatureC] email: str name: str offset: int @@ -471,7 +763,7 @@ class Signature: time: int def __init__( self, - name: str, + name: str | bytes, email: str, time: int = -1, offset: int = 0, @@ -527,11 +819,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: ... @@ -557,13 +849,14 @@ 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) -> Oid: ... +def hash(data: bytes | str) -> Oid: ... def hashfile(path: str) -> Oid: ... 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: ... +def filter_register(name: str, filter: type[Filter]) -> None: ... _OidArg = str | Oid diff --git a/pygit2/_run.py b/pygit2/_run.py index 78b52a29..85d31f69 100644 --- a/pygit2/_run.py +++ b/pygit2/_run.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -29,15 +29,15 @@ # Import from the Standard Library import codecs -from pathlib import Path import sys +from pathlib import Path # Import from cffi from cffi import FFI # 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 @@ -77,15 +77,18 @@ 'net.h', 'refspec.h', 'repository.h', + 'filter.h', 'commit.h', 'revert.h', 'stash.h', 'submodule.h', + 'transaction.h', + 'options.h', 'callbacks.h', # Bridge from libgit2 to Python ] 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()) @@ -94,6 +97,7 @@ C_PREAMBLE = """\ #include #include +#include """ # ffi diff --git a/pygit2/blame.py b/pygit2/blame.py index 3b7ef748..9854f3af 100644 --- a/pygit2/blame.py +++ b/pygit2/blame.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -23,13 +23,20 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. +from collections.abc import Iterator +from typing import TYPE_CHECKING + +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 + +if TYPE_CHECKING: + from ._libgit2.ffi import GitBlameC, GitHunkC, GitSignatureC -def wrap_signature(csig): +def wrap_signature(csig: 'GitSignatureC') -> None | Signature: if not csig: return None @@ -43,58 +50,61 @@ 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: @@ -104,27 +114,30 @@ def orig_path(self): 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): + def __len__(self) -> int: return C.git_blame_get_hunk_count(self._blame) - 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 +156,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/blob.py b/pygit2/blob.py index d9f4de89..1ee6a9eb 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 @@ -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): @@ -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/branches.py b/pygit2/branches.py index 99630d31..b729a21e 100644 --- a/pygit2/branches.py +++ b/pygit2/branches.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -24,10 +24,12 @@ # Boston, MA 02110-1301, USA. from __future__ import annotations + +from collections.abc import Iterator from typing import TYPE_CHECKING +from ._pygit2 import Branch, Commit, Oid from .enums import BranchType, ReferenceType -from ._pygit2 import Commit, Oid # Need BaseRepository for type hints, but don't let it cause a circular dependency if TYPE_CHECKING: @@ -35,9 +37,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: @@ -51,7 +59,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) @@ -64,36 +72,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/callbacks.py b/pygit2/callbacks.py index 68008bf6..adcfca52 100644 --- a/pygit2/callbacks.py +++ b/pygit2/callbacks.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -63,18 +63,26 @@ """ # Standard Library +from collections.abc import Callable, Generator from contextlib import contextmanager from functools import wraps -from typing import Optional, Union +from typing import TYPE_CHECKING, Optional, ParamSpec, TypeVar # 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 .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 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 # libgit2, to the Python callbacks. And back. @@ -82,12 +90,16 @@ class Payload: - def __init__(self, **kw): + 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) self._stored_exception = None - def check_error(self, error_code): + def check_error(self, error_code: int) -> None: if error_code == C.GIT_EUSER: assert self._stored_exception is not None raise self._stored_exception @@ -113,14 +125,20 @@ class RemoteCallbacks(Payload): RemoteCallbacks(certificate=certificate). """ - def __init__(self, credentials=None, certificate_check=None): + push_options: 'PushOptions' + + def __init__( + self, + credentials: _Credentials | None = None, + certificate_check: Callable[[None, bool, bytes], bool] | None = None, + ) -> None: super().__init__() if credentials is not None: - self.credentials = credentials + self.credentials = credentials # type: ignore[method-assign, assignment] if certificate_check is not None: - self.certificate_check = certificate_check + self.certificate_check = certificate_check # type: ignore[method-assign, assignment] - def sideband_progress(self, string): + def sideband_progress(self, string: str) -> None: """ Progress output callback. Override this function with your own progress reporting function @@ -134,9 +152,9 @@ def sideband_progress(self, string): def credentials( self, url: str, - username_from_url: Union[str, None], + username_from_url: str | None, allowed_types: CredentialType, - ): + ) -> _Credentials: """ Credentials callback. If the remote server requires authentication, this function will be called and its return value used for @@ -159,7 +177,7 @@ def credentials( """ raise Passthrough - def certificate_check(self, certificate, valid, host): + def certificate_check(self, certificate: None, valid: bool, host: bytes) -> bool: """ Certificate callback. Override with your own function to determine whether to accept the server's certificate. @@ -181,10 +199,21 @@ def certificate_check(self, certificate, valid, host): raise Passthrough - def transfer_progress(self, stats): + 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: """ - Transfer progress callback. Override with your own function to report - transfer progress. + During the download of new data, this will be regularly called with + the indexer's progress. + + Override with your own function to report transfer progress. Parameters: @@ -192,7 +221,20 @@ def transfer_progress(self, stats): The progress up to now. """ - def update_tips(self, refname, old, new): + def push_transfer_progress( + self, objects_pushed: int, total_objects: int, bytes_pushed: int + ) -> None: + """ + During the upload portion of a push, this will be regularly called + with progress information. + + Be aware that this is called inline with pack building operations, + so performance may be affected. + + Override with your own function to report push transfer progress. + """ + + def update_tips(self, refname: str, old: Oid, new: Oid) -> None: """ Update tips callback. Override with your own function to report reference updates. @@ -209,7 +251,7 @@ def update_tips(self, refname, old, new): The reference's new value. """ - def push_update_reference(self, refname, message): + def push_update_reference(self, refname: str, message: str) -> None: """ Push update reference callback. Override with your own function to report the remote's acceptance or rejection of reference updates. @@ -229,7 +271,7 @@ class CheckoutCallbacks(Payload): in your class, which you can then pass to checkout operations. """ - def __init__(self): + def __init__(self) -> None: super().__init__() def checkout_notify_flags(self) -> CheckoutNotify: @@ -260,7 +302,7 @@ def checkout_notify( baseline: Optional[DiffFile], target: Optional[DiffFile], workdir: Optional[DiffFile], - ): + ) -> None: """ Checkout will invoke an optional notification callback for certain cases - you pick which ones via `checkout_notify_flags`. @@ -275,7 +317,9 @@ def checkout_notify( """ pass - 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: """ Optional callback to notify the consumer of checkout progress. """ @@ -289,7 +333,7 @@ class StashApplyCallbacks(CheckoutCallbacks): in your class, which you can then pass to stash apply or pop operations. """ - def stash_apply_progress(self, progress: StashApplyProgress): + def stash_apply_progress(self, progress: StashApplyProgress) -> None: """ Stash application progress notification function. @@ -355,6 +399,29 @@ def git_fetch_options(payload, opts=None): yield payload +@contextmanager +def git_proxy_options( + payload: object, + opts: Optional['GitProxyOptionsC'] = None, + proxy: None | bool | str = 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) + if proxy is None: + opts.type = C.GIT_PROXY_NONE + elif proxy is True: + opts.type = C.GIT_PROXY_AUTO + 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] + else: + raise TypeError('Proxy must be None, True, or a string') + yield opts + + @contextmanager def git_push_options(payload, opts=None): if payload is None: @@ -370,6 +437,14 @@ 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 ( + type(payload).push_transfer_progress + is not RemoteCallbacks.push_transfer_progress + ): + opts.callbacks.push_transfer_progress = C._push_transfer_progress_cb # Payload handle = ffi.new_handle(payload) opts.callbacks.payload = handle @@ -391,6 +466,7 @@ def git_remote_callbacks(payload): # Plug callbacks cdata.credentials = C._credentials_cb cdata.update_tips = C._update_tips_cb + cdata.certificate_check = C._certificate_check_cb # Payload handle = ffi.new_handle(payload) cdata.payload = handle @@ -404,18 +480,21 @@ def git_remote_callbacks(payload): # # C callbacks # -# These functions are called by libgit2. They cannot raise execptions, since +# These functions are called by libgit2. They cannot raise exceptions, since # they return to libgit2, they can only send back error codes. # -# They cannot be overriden, but sometimes the only thing these functions do is +# They cannot be overridden, but sometimes the only thing these functions do is # to proxy the call to a user defined function. If user defined functions # raises an exception, the callback must store it somewhere and return # GIT_EUSER to libgit2, then the outer Python code will be able to reraise the # 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]) @@ -433,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]) @@ -453,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 @@ -494,6 +573,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) @@ -553,6 +645,16 @@ def _transfer_progress_cb(stats_ptr, data): return 0 +@libgit2_callback +def _push_transfer_progress_cb(current, total, bytes_pushed, payload): + push_transfer_progress = getattr(payload, 'push_transfer_progress', None) + if not push_transfer_progress: + return 0 + + push_transfer_progress(current, total, bytes_pushed) + return 0 + + @libgit2_callback def _update_tips_cb(refname, a, b, data): update_tips = getattr(data, 'update_tips', None) @@ -643,7 +745,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 @@ -658,7 +760,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/config.py b/pygit2/config.py index 8143a1f1..5ffb6896 100644 --- a/pygit2/config.py +++ b/pygit2/config.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -23,18 +23,27 @@ # 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 + 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 -from .ffi import ffi, C +from .ffi import C, ffi from .utils import to_bytes +if TYPE_CHECKING: + from ._libgit2.ffi import GitConfigC, GitConfigEntryC + from .repository import BaseRepository + -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') @@ -42,32 +51,29 @@ def str_to_bytes(value, name): 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_entry(self): + def __next__(self) -> 'ConfigEntry': + return self._next_entry() + + def _next_entry(self) -> 'ConfigEntry': centry = ffi.new('git_config_entry **') err = C.git_config_next(centry, self._iter) check_error(err) return ConfigEntry._from_c(centry[0], self) - def next(self): - return self.__next__() - - def __next__(self): - return self._next_entry() - class ConfigMultivarIterator(ConfigIterator): - def __next__(self): + def __next__(self) -> str | None: # type: ignore[override] entry = self._next_entry() return entry.value @@ -75,33 +81,36 @@ def __next__(self): class Config: """Git configuration management.""" - def __init__(self, path=None): + _repo: 'BaseRepository' + _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: 'BaseRepository', 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[int, 'ConfigEntry']: key = str_to_bytes(key, 'key') entry = ffi.new('git_config_entry **') @@ -109,7 +118,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: @@ -118,7 +127,7 @@ def _get_entry(self, key): 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: @@ -128,7 +137,7 @@ def __contains__(self, key): return True - def __getitem__(self, key): + 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 @@ -138,7 +147,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 @@ -151,13 +160,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 @@ -170,22 +179,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. """ @@ -196,7 +209,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. """ @@ -206,7 +219,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). @@ -221,7 +234,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. @@ -236,7 +249,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( @@ -244,7 +257,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 @@ -261,7 +274,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) @@ -269,7 +282,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) @@ -281,7 +294,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) @@ -291,26 +304,31 @@ 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) class ConfigEntry: - """An entry in a configuation object.""" + """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 @@ -324,7 +342,7 @@ def _from_c(cls, ptr, iterator=None): # git_config_iterator_free when we've deleted all ConfigEntry objects. # But it's not, to reproduce the error comment the lines below and run # the script in https://github.com/libgit2/pygit2/issues/970 - # So instead we load the Python object immmediately. Ideally we should + # So instead we load the Python object immediately. Ideally we should # investigate libgit2 source code. if iterator is not None: entry.raw_name = entry.raw_name @@ -333,34 +351,34 @@ 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) @property - def c_value(self): + def c_value(self) -> 'ffi.char_pointer': """The raw ``cData`` entry value.""" 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): - 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): + 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 | 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/pygit2/credentials.py b/pygit2/credentials.py index fb5ae81e..52edc8ce 100644 --- a/pygit2/credentials.py +++ b/pygit2/credentials.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -23,10 +23,15 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. -from .ffi import C +from __future__ import annotations + +from typing import TYPE_CHECKING from .enums import CredentialType +if TYPE_CHECKING: + from pathlib import Path + class Username: """Username credentials @@ -35,7 +40,7 @@ class Username: callback and for returning from said callback. """ - def __init__(self, username): + def __init__(self, username: str): self._username = username @property @@ -43,10 +48,12 @@ def credential_type(self) -> CredentialType: return CredentialType.USERNAME @property - def credential_tuple(self): + def credential_tuple(self) -> tuple[str]: return (self._username,) - def __call__(self, _url, _username, _allowed): + def __call__( + self, _url: str, _username: str | None, _allowed: CredentialType + ) -> Username: return self @@ -57,7 +64,7 @@ class UserPass: callback and for returning from said callback. """ - def __init__(self, username, password): + def __init__(self, username: str, password: str): self._username = username self._password = password @@ -66,10 +73,12 @@ def credential_type(self) -> CredentialType: return CredentialType.USERPASS_PLAINTEXT @property - def credential_tuple(self): + def credential_tuple(self) -> tuple[str, str]: return (self._username, self._password) - def __call__(self, _url, _username, _allowed): + def __call__( + self, _url: str, _username: str | None, _allowed: CredentialType + ) -> UserPass: return self @@ -96,7 +105,13 @@ class Keypair: no passphrase is required. """ - def __init__(self, username, pubkey, privkey, passphrase): + def __init__( + self, + username: str, + pubkey: str | Path | None, + privkey: str | Path | None, + passphrase: str | None, + ): self._username = username self._pubkey = pubkey self._privkey = privkey @@ -107,15 +122,19 @@ def credential_type(self) -> CredentialType: return CredentialType.SSH_KEY @property - def credential_tuple(self): + def credential_tuple( + self, + ) -> tuple[str, str | Path | None, str | Path | None, str | None]: return (self._username, self._pubkey, self._privkey, self._passphrase) - def __call__(self, _url, _username, _allowed): + def __call__( + self, _url: str, _username: str | None, _allowed: CredentialType + ) -> Keypair: return self class KeypairFromAgent(Keypair): - def __init__(self, username): + def __init__(self, username: str): super().__init__(username, None, None, None) diff --git a/pygit2/decl/blame.h b/pygit2/decl/blame.h index 5ccc8dd3..36205840 100644 --- a/pygit2/decl/blame.h +++ b/pygit2/decl/blame.h @@ -18,12 +18,15 @@ typedef struct git_blame_hunk { git_oid final_commit_id; size_t final_start_line_number; git_signature *final_signature; + git_signature *final_committer; git_oid orig_commit_id; const char *orig_path; size_t orig_start_line_number; git_signature *orig_signature; + git_signature *orig_committer; + const char *summary; char boundary; } git_blame_hunk; diff --git a/pygit2/decl/callbacks.h b/pygit2/decl/callbacks.h index f6991a5b..64582718 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, @@ -38,6 +43,12 @@ extern "Python" int _transfer_progress_cb( const git_indexer_progress *stats, void *payload); +extern "Python" int _push_transfer_progress_cb( + unsigned int objects_pushed, + unsigned int total_objects, + size_t bytes_pushed, + void *payload); + extern "Python" int _update_tips_cb( const char *refname, const git_oid *a, diff --git a/pygit2/decl/commit.h b/pygit2/decl/commit.h index bc1dd6b1..fc83c6b1 100644 --- a/pygit2/decl/commit.h +++ b/pygit2/decl/commit.h @@ -13,4 +13,9 @@ int git_annotated_commit_lookup( git_repository *repo, const git_oid *id); +int git_annotated_commit_from_ref( + git_annotated_commit **out, + git_repository *repo, + const struct git_reference *ref); + void git_annotated_commit_free(git_annotated_commit *commit); diff --git a/pygit2/decl/config.h b/pygit2/decl/config.h index 5cba6f7f..82003d73 100644 --- a/pygit2/decl/config.h +++ b/pygit2/decl/config.h @@ -18,7 +18,6 @@ typedef struct git_config_entry { const char *origin_path; unsigned int include_depth; git_config_level_t level; - void (*free)(struct git_config_entry *entry); } git_config_entry; void git_config_entry_free(git_config_entry *); diff --git a/pygit2/decl/filter.h b/pygit2/decl/filter.h new file mode 100644 index 00000000..51734687 --- /dev/null +++ b/pygit2/decl/filter.h @@ -0,0 +1,49 @@ +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_unregister( + const char *name); + +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); + +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); + +void git_filter_list_free(git_filter_list *filters); diff --git a/pygit2/decl/index.h b/pygit2/decl/index.h index 746e6a8d..11a498cb 100644 --- a/pygit2/decl/index.h +++ b/pygit2/decl/index.h @@ -34,6 +34,7 @@ int git_index_find(size_t *at_pos, git_index *index, const char *path); int git_index_add_bypath(git_index *index, const char *path); int git_index_add(git_index *index, const git_index_entry *source_entry); int git_index_remove(git_index *index, const char *path, int stage); +int git_index_remove_directory(git_index *index, const char *path, int stage); int git_index_read_tree(git_index *index, const git_tree *tree); int git_index_clear(git_index *index); int git_index_write_tree(git_oid *out, git_index *index); @@ -59,6 +60,11 @@ void git_index_conflict_iterator_free( int git_index_conflict_iterator_new( git_index_conflict_iterator **iterator_out, git_index *index); +int git_index_conflict_add( + git_index *index, + const git_index_entry *ancestor_entry, + const git_index_entry *our_entry, + const git_index_entry *their_entry); int git_index_conflict_get( const git_index_entry **ancestor_out, const git_index_entry **our_out, diff --git a/pygit2/decl/options.h b/pygit2/decl/options.h new file mode 100644 index 00000000..f6556d5e --- /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/decl/remote.h b/pygit2/decl/remote.h index 64b96442..d0e2c141 100644 --- a/pygit2/decl/remote.h +++ b/pygit2/decl/remote.h @@ -48,6 +48,7 @@ struct git_remote_callbacks { git_remote_ready_cb remote_ready; void *payload; git_url_resolve_cb resolve_url; + int (*update_refs)(const char *refname, const git_oid *a, const git_oid *b, git_refspec *spec, void *data); }; typedef struct { diff --git a/pygit2/decl/repository.h b/pygit2/decl/repository.h index cf646816..297d8710 100644 --- a/pygit2/decl/repository.h +++ b/pygit2/decl/repository.h @@ -81,7 +81,7 @@ int git_repository_set_head( int git_repository_set_head_detached( git_repository* repo, - const git_oid* commitish); + const git_oid* committish); int git_repository_hashfile(git_oid *out, git_repository *repo, const char *path, git_object_t type, const char *as_path); int git_repository_ident(const char **name, const char **email, const git_repository *repo); diff --git a/pygit2/decl/submodule.h b/pygit2/decl/submodule.h index fda915a5..b16f4b03 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/decl/transaction.h b/pygit2/decl/transaction.h new file mode 100644 index 00000000..20ac98de --- /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 8bb8fd29..64d7ce0a 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; @@ -12,6 +14,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/enums.py b/pygit2/enums.py index e5bd54d9..d690e73a 100644 --- a/pygit2/enums.py +++ b/pygit2/enums.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -25,7 +25,7 @@ from enum import IntEnum, IntFlag -from . import _pygit2 +from . import _pygit2, options from .ffi import C @@ -65,16 +65,16 @@ class BlameFlag(IntFlag): 'Normal blame, the default' TRACK_COPIES_SAME_FILE = _pygit2.GIT_BLAME_TRACK_COPIES_SAME_FILE - 'Not yet implemented and reserved for future use (as of libgit2 1.8.0).' + 'Not yet implemented and reserved for future use (as of libgit2 1.9.0).' TRACK_COPIES_SAME_COMMIT_MOVES = _pygit2.GIT_BLAME_TRACK_COPIES_SAME_COMMIT_MOVES - 'Not yet implemented and reserved for future use (as of libgit2 1.8.0).' + 'Not yet implemented and reserved for future use (as of libgit2 1.9.0).' TRACK_COPIES_SAME_COMMIT_COPIES = _pygit2.GIT_BLAME_TRACK_COPIES_SAME_COMMIT_COPIES - 'Not yet implemented and reserved for future use (as of libgit2 1.8.0).' + 'Not yet implemented and reserved for future use (as of libgit2 1.9.0).' TRACK_COPIES_ANY_COMMIT_COPIES = _pygit2.GIT_BLAME_TRACK_COPIES_ANY_COMMIT_COPIES - 'Not yet implemented and reserved for future use (as of libgit2 1.8.0).' + 'Not yet implemented and reserved for future use (as of libgit2 1.9.0).' FIRST_PARENT = _pygit2.GIT_BLAME_FIRST_PARENT 'Restrict the search of commits to those reachable following only the first parents.' @@ -948,51 +948,54 @@ 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 - # 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 + 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 = 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/errors.py b/pygit2/errors.py index d95bfaa6..02278ddb 100644 --- a/pygit2/errors.py +++ b/pygit2/errors.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -24,14 +24,15 @@ # Boston, MA 02110-1301, USA. # Import from pygit2 -from .ffi import ffi, C from ._pygit2 import GitError +from .ffi import C, ffi +__all__ = ['GitError'] value_errors = set([C.GIT_EEXISTS, C.GIT_EINVALIDSPEC, C.GIT_EAMBIGUOUS]) -def check_error(err, io=False): +def check_error(err: int, io: bool = False) -> None: if err >= 0: return @@ -42,7 +43,7 @@ def check_error(err, io=False): # Error message giterr = C.git_error_last() if giterr != ffi.NULL: - message = ffi.string(giterr.message).decode('utf8') + message = ffi.string(giterr.message).decode('utf8', errors='surrogateescape') else: message = f'err {err} (no message provided)' @@ -68,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/ffi.py b/pygit2/ffi.py index 7a60faf7..2a5d8604 100644 --- a/pygit2/ffi.py +++ b/pygit2/ffi.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -24,4 +24,7 @@ # Boston, MA 02110-1301, USA. # Import from pygit2 -from ._libgit2 import ffi, lib as C +from ._libgit2 import ffi # noqa: F401 +from ._libgit2 import lib as C # type: ignore # noqa: F401 + +__all__ = ['C', 'ffi'] diff --git a/pygit2/filter.py b/pygit2/filter.py index 2a5b8e07..6fc953e3 100644 --- a/pygit2/filter.py +++ b/pygit2/filter.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -23,9 +23,20 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. -from typing import Callable, List, Optional +from __future__ import annotations -from ._pygit2 import FilterSource +import weakref +from collections.abc import Callable +from typing import TYPE_CHECKING + +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: @@ -58,7 +69,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[str | None]) -> None: """ Check whether this filter should be applied to the given source. @@ -77,7 +88,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 +106,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. @@ -107,3 +118,90 @@ def close(self, write_next: Callable[[bytes], None]): Any remaining filtered output data must be written to `write_next` before returning. """ + + +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') + c_name = to_bytes(name) + 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 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/pygit2/index.py b/pygit2/index.py index b06ae684..6ce8a39c 100644 --- a/pygit2/index.py +++ b/pygit2/index.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -23,16 +23,20 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. +import typing import warnings -import weakref +from dataclasses import dataclass +from os import PathLike # 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 + +if typing.TYPE_CHECKING: + from .repository import Repository class Index: @@ -41,7 +45,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 | PathLike[str] | None = None) -> None: """Create a new Index If path is supplied, the read and write methods will use that path @@ -68,13 +72,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 @@ -82,7 +86,7 @@ def __contains__(self, path): 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) @@ -102,7 +106,7 @@ def __getitem__(self, key): 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. @@ -116,16 +120,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 +138,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 +148,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,18 +178,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_all(self, pathspecs): + 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: 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 @@ -194,7 +205,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 @@ -212,7 +223,46 @@ 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: '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. + + Parameters: + + ancestor + ancestor of the conflict + ours + ours side of the conflict + theirs + their side of the conflict + """ + + if ancestor and not isinstance(ancestor, IndexEntry): + raise TypeError('ancestor has to be an instance of IndexEntry or None') + if ours and not isinstance(ours, IndexEntry): + raise TypeError('ours has to be an instance of IndexEntry or None') + if theirs and not isinstance(theirs, IndexEntry): + raise TypeError('theirs has to be an instance of IndexEntry or None') + + 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: + centry_ours, _ = ours._to_c() + if theirs is not None: + centry_theirs, _ = theirs._to_c() + err = C.git_index_conflict_add( + self._index, centry_ancestor, centry_ours, centry_theirs + ) check_error(err, io=True) @@ -311,7 +361,6 @@ def diff_to_tree( # # Conflicts # - _conflicts = None @property def conflicts(self): @@ -333,19 +382,53 @@ def conflicts(self): the particular conflict. """ if not C.git_index_has_conflicts(self._index): - self._conflicts = None return None - if self._conflicts is None or self._conflicts() is None: - conflicts = ConflictCollection(self) - self._conflicts = weakref.ref(conflicts) - return conflicts + return ConflictCollection(self) + + +@dataclass +class MergeFileResult: + automergeable: bool + 'True if the output was automerged, false if the output contains conflict markers' + + path: str | None | PathLike[str] + 'The path that the resultant merge file should use, or None if a filename conflict would occur' + + mode: FileMode + 'The mode that the resultant merge file should use' + + contents: str + 'Contents of the file, which might include conflict markers' + + def __repr__(self): + t = type(self) + contents = ( + self.contents if len(self.contents) <= 20 else f'{self.contents[:20]}...' + ) + return ( + f'<{t.__module__}.{t.__qualname__} "' + f'automergeable={self.automergeable} "' + f'path={self.path} ' + f'mode={self.mode} ' + f'contents={contents}>' + ) + + @classmethod + def _from_c(cls, centry): + if centry == ffi.NULL: + return None + + automergeable = centry.automergeable != 0 + path = to_str(ffi.string(centry.path)) if centry.path else None + mode = FileMode(centry.mode) + contents = ffi.string(centry.ptr, centry.len).decode('utf-8') - return self._conflicts() + return MergeFileResult(automergeable, path, mode, contents) class IndexEntry: - path: str + path: str | PathLike[str] 'The path of this entry' id: Oid @@ -354,22 +437,18 @@ 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 @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'' @@ -386,7 +465,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 @@ -467,8 +546,8 @@ def __init__(self, index): def __del__(self): C.git_index_conflict_iterator_free(self._iter) - def next(self): - return self.__next__() + def __iter__(self): + return self def __next__(self): cancestor = ffi.new('git_index_entry **') @@ -486,6 +565,3 @@ def __next__(self): theirs = IndexEntry._from_c(ctheirs[0]) return ancestor, ours, theirs - - def __iter__(self): - return self diff --git a/pygit2/legacyenums.py b/pygit2/legacyenums.py index 245f8afc..176534a6 100644 --- a/pygit2/legacyenums.py +++ b/pygit2/legacyenums.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, diff --git a/pygit2/options.py b/pygit2/options.py new file mode 100644 index 00000000..ad96253b --- /dev/null +++ b/pygit2/options.py @@ -0,0 +1,805 @@ +# 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 __future__ import annotations + +# Import only for type checking to avoid circular imports +from typing import TYPE_CHECKING, Any, Literal, cast, overload + +from .errors import check_error +from .ffi import C, ffi +from .utils import to_bytes, to_str + +if TYPE_CHECKING: + from ._libgit2.ffi import NULL_TYPE, ArrayC, char, char_pointer + from .enums import ConfigLevel, ObjectType, Option + +# 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 +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() + + +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: Literal[ + Option.GET_MWINDOW_SIZE, + Option.GET_MWINDOW_MAPPED_LIMIT, + Option.GET_MWINDOW_FILE_LIMIT, + ], +) -> int: ... + + +@overload +def option( + 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: ... + + +@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: str | bytes | None, # cert_file + arg2: str | bytes | None, # cert_dir +) -> None: ... + + +@overload +def option( + 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: ... + + +@overload +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: ... + + +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. + + 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. + """ + + result: str | None | list[str] + + 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] + + 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 + + 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 + + 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 + + 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 + + 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 + + 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]) + + 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 + + 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]) + + 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 + + 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 + + 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 + + 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 + + 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 + + # 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: + 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') + + else: + raise ValueError(f'Invalid option {option_type}') diff --git a/pygit2/packbuilder.py b/pygit2/packbuilder.py index 0bee41ac..baa54630 100644 --- a/pygit2/packbuilder.py +++ b/pygit2/packbuilder.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -23,15 +23,21 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. +from os import PathLike +from typing import TYPE_CHECKING # Import from pygit2 from .errors import check_error -from .ffi import ffi, C +from .ffi import C, ffi from .utils import to_bytes +if TYPE_CHECKING: + from pygit2 import Oid, Repository + from pygit2.repository import BaseRepository + class PackBuilder: - def __init__(self, repo): + def __init__(self, repo: 'Repository | BaseRepository') -> None: cpackbuilder = ffi.new('git_packbuilder **') err = C.git_packbuilder_new(cpackbuilder, repo._repo) check_error(err) @@ -41,39 +47,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/pygit2/references.py b/pygit2/references.py index f36d7be2..93c370ea 100644 --- a/pygit2/references.py +++ b/pygit2/references.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -24,29 +24,34 @@ # Boston, MA 02110-1301, USA. from __future__ import annotations + +from collections.abc import Iterator from typing import TYPE_CHECKING +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' | None: try: return self[key] except KeyError: return None - def __iter__(self): + def __iter__(self) -> Iterator[str]: iter = self._repository.references_iterator_init() while True: ref = self._repository.references_iterator_next(iter) @@ -55,7 +60,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. @@ -87,18 +94,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/refspec.py b/pygit2/refspec.py index 423d820a..794e8d13 100644 --- a/pygit2/refspec.py +++ b/pygit2/refspec.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -23,36 +23,38 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. +from collections.abc import Callable + # Import from pygit2 from .errors import check_error -from .ffi import ffi, C +from .ffi import C, ffi from .utils import to_bytes 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 @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): - """Destinaton or rhs of the refspec""" + 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 02a4dbe2..0603a6f9 100644 --- a/pygit2/remotes.py +++ b/pygit2/remotes.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -24,27 +24,103 @@ # Boston, MA 02110-1301, USA. from __future__ import annotations -from typing import TYPE_CHECKING + +import warnings +from collections.abc import Generator, Iterator +from typing import TYPE_CHECKING, Any, Literal # Import from pygit2 +from pygit2 import RemoteCallbacks + +from . import utils from ._pygit2 import Oid -from .callbacks import git_fetch_options, git_push_options, git_remote_callbacks +from .callbacks import ( + git_fetch_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: + from ._libgit2.ffi import GitRemoteC, char_pointer from .repository import BaseRepository +class RemoteHead: + """ + Description of a reference advertised by a remote server, + given out on `Remote.list_heads` calls. + """ + + local: bool + """Available locally""" + + oid: Oid + + loid: Oid + + name: str | None + + symref_target: str | None + """ + 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) + 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) + + +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.""" - def __init__(self, tp): + total_objects: int + indexed_objects: int + received_objects: int + local_objects: int + total_deltas: int + indexed_deltas: int + received_bytes: int + + def __init__(self, tp: Any) -> None: self.total_objects = tp.total_objects """Total number of objects to download""" @@ -68,34 +144,39 @@ def __init__(self, tp): 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: @@ -107,24 +188,26 @@ def connect(self, callbacks=None, direction=C.GIT_DIRECTION_FETCH, proxy=None): * `True` to enable automatic proxy detection * an url to a proxy (`http://proxy.example.org:3128/`) """ - proxy_opts = ffi.new('git_proxy_options *') - C.git_proxy_options_init(proxy_opts, C.GIT_PROXY_OPTIONS_VERSION) - self.__set_proxy(proxy_opts, proxy) - 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_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) 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. @@ -154,73 +237,93 @@ def fetch( opts = payload.fetch_options opts.prune = prune opts.depth = depth - self.__set_proxy(opts.proxy_opts, proxy) - with StrArray(refspecs) as arr: - err = C.git_remote_fetch(self._remote, arr.ptr, opts, to_bytes(message)) - payload.check_error(err) + 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) + ) + payload.check_error(err) return TransferProgress(C.git_remote_stats(self._remote)) - def ls_remotes(self, callbacks=None, proxy=None): + def list_heads( + self, + callbacks: RemoteCallbacks | None = None, + proxy: str | None | bool = None, + connect: bool = True, + ) -> 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: 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 *') + 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 = { - '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 - def prune(self, callbacks=None): + 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: err = C.git_remote_prune(self._remote, payload.remote_callbacks) 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) - 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) @property - def fetch_refspecs(self): + def fetch_refspecs(self) -> list[str]: """Refspecs that will be used for fetching""" specs = ffi.new('git_strarray *') @@ -229,7 +332,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 *') @@ -237,7 +340,14 @@ def push_refspecs(self): check_error(err) return strarray_to_strings(specs) - def push(self, specs, callbacks=None, proxy=None, push_options=None): + 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. @@ -246,13 +356,15 @@ def push(self, specs, callbacks=None, proxy=None, push_options=None): function will return successfully. Thus it is strongly recommended to install a callback, that implements :py:meth:`RemoteCallbacks.push_update_reference` and check the passed - parameters for successfull operations. + parameters for successful operations. Parameters: specs : [str] Push refspecs to use. + callbacks : + proxy : None or True or str Proxy configuration. Can be one of: @@ -263,27 +375,24 @@ def push(self, specs, callbacks=None, proxy=None, push_options=None): push_options : [str] Push options to send to the server, which passes them to the pre-receive as well as the post-receive hook. + + threads : int + If the transport being used to push to the remote requires the + creation of a pack file, this controls the number of worker threads + used by the packbuilder when creating that pack file to be sent to + the remote. + + If set to 0, the packbuilder will auto-detect the number of threads + to create. The default value is 1. """ with git_push_options(callbacks) as payload: opts = payload.push_options - self.__set_proxy(opts.proxy_opts, proxy) - with StrArray(specs) as refspecs, StrArray(push_options) as pushopts: - pushopts.assign_to(opts.remote_push_options) - err = C.git_remote_push(self._remote, refspecs.ptr, opts) - payload.check_error(err) - - def __set_proxy(self, proxy_opts, proxy): - if proxy is None: - proxy_opts.type = C.GIT_PROXY_NONE - elif proxy is True: - proxy_opts.type = C.GIT_PROXY_AUTO - elif type(proxy) is str: - proxy_opts.type = C.GIT_PROXY_SPECIFIED - # Keep url in memory, otherwise memory is freed and bad things happen - self.__url = ffi.new('char[]', to_bytes(proxy)) - proxy_opts.url = self.__url - else: - raise TypeError('Proxy must be None, True, or a string') + opts.pb_parallelism = threads + with git_proxy_options(self, payload.push_options.proxy_opts, proxy): + with StrArray(specs) as refspecs, StrArray(push_options) as pushopts: + pushopts.assign_to(opts.remote_push_options) + err = C.git_remote_push(self._remote, refspecs.ptr, opts) + payload.check_error(err) class RemoteCollection: @@ -296,16 +405,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) @@ -313,7 +422,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] @@ -323,19 +432,19 @@ def __getitem__(self, name): 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): + 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): + 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. @@ -344,31 +453,31 @@ def create(self, name, url, fetch=None): """ 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. @@ -376,7 +485,7 @@ def rename(self, name, new_name): the standard format and thus could not be remapped. """ - if not new_name: + if not name: raise ValueError('Current remote name must be a non-empty string') if not new_name: @@ -389,7 +498,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. @@ -397,17 +506,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( @@ -415,7 +524,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)) diff --git a/pygit2/repository.py b/pygit2/repository.py index 38a969d3..2605eee3 100644 --- a/pygit2/repository.py +++ b/pygit2/repository.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -23,31 +23,48 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. +import tarfile +import warnings +from collections.abc import Callable, Iterator from io import BytesIO -from os import PathLike +from pathlib import Path from string import hexdigits from time import time -import tarfile -import typing +from typing import TYPE_CHECKING, Optional, overload # 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 Reference, Tree, Commit, Blob, Signature -from ._pygit2 import InvalidSpecError - +from ._pygit2 import ( + GIT_OID_HEXSZ, + GIT_OID_MINPREFIXLEN, + Blob, + Commit, + Diff, + InvalidSpecError, + Object, + Oid, + Patch, + Reference, + Signature, + Tree, + init_file_backend, +) +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, BlameFlag, - BranchType, CheckoutStrategy, DescribeStrategy, DiffOption, FileMode, + FilterMode, MergeFavor, MergeFileFlag, MergeFlag, @@ -56,25 +73,57 @@ RepositoryState, ) from .errors import check_error -from .ffi import ffi, C -from .index import Index, IndexEntry +from .ffi import C, ffi +from .filter import FilterList +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 .transaction import ReferenceTransaction +from .utils import StrArray, to_bytes + +if TYPE_CHECKING: + 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) self.submodules = SubmoduleCollection(self) + self._active_transaction = None # Get the pointer as the contents of a buffer and store it for # later access @@ -83,22 +132,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. @@ -134,8 +188,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, @@ -167,6 +221,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: @@ -182,33 +237,58 @@ def hashfile( oid = Oid(raw=bytes(ffi.buffer(c_oid.id)[:])) return oid - def __iter__(self): + 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) # # Mapping interface # - def get(self, key, default=None): + 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 - 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)})' # # 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 @@ -237,7 +317,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. @@ -259,25 +345,26 @@ 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) - 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. @@ -297,9 +384,25 @@ 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) # type: ignore + + def transaction(self) -> ReferenceTransaction: + """Create a new reference transaction. - return (commit, reference) + 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 @@ -338,7 +441,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. @@ -406,7 +513,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. @@ -453,15 +560,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 @@ -522,27 +649,30 @@ def diff( a = self.__whatever_to_tree_or_blob(a) b = self.__whatever_to_tree_or_blob(b) - opt_keys = ['flags', 'context_lines', 'interhunk_lines'] - opt_values = [int(flags), context_lines, interhunk_lines] + options = { + 'flags': int(flags), + 'context_lines': context_lines, + 'interhunk_lines': interhunk_lines, + } # Case 1: Diff tree to tree if isinstance(a, Tree) and isinstance(b, Tree): - return a.diff_to_tree(b, **dict(zip(opt_keys, opt_values))) + 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(*opt_values) + return self.index.diff_to_workdir(**options) # 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, *opt_values) + return a.diff_to_index(self.index, **options) # type: ignore[arg-type] else: - return a.diff_to_workdir(*opt_values) + 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) + return a.diff(b, **options) # type: ignore[arg-type] raise ValueError('Only blobs and treeish can be diffed') @@ -557,9 +687,9 @@ 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): + 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. @@ -571,14 +701,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. @@ -612,6 +742,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) @@ -652,7 +783,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 + ) -> 'GitMergeOptionsC': """Return a 'git_merge_opts *'""" # Check arguments type @@ -677,12 +810,16 @@ def _merge_options(favor: MergeFavor, flags: MergeFlag, file_flags: MergeFileFla def merge_file_from_index( self, - ancestor: typing.Union[None, IndexEntry], - ours: typing.Union[None, IndexEntry], - theirs: typing.Union[None, IndexEntry], - ) -> str: - """Merge files from index. Return a string with the merge result - containing possible conflicts. + ancestor: 'IndexEntry | None', + ours: 'IndexEntry | None', + theirs: 'IndexEntry | None', + use_deprecated: bool = True, + ) -> 'str | MergeFileResult | 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. ancestor The index entry which will be used as a common @@ -691,6 +828,10 @@ def merge_file_from_index( The index entry to take as "ours" or base. 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. """ cmergeresult = ffi.new('git_merge_file_result *') @@ -707,19 +848,28 @@ def merge_file_from_index( ) check_error(err) - ret = ffi.string(cmergeresult.ptr, cmergeresult.len).decode('utf-8') + mergeFileResult = MergeFileResult._from_c(cmergeresult) C.git_merge_file_result_free(cmergeresult) - return ret + if use_deprecated: + warnings.warn( + 'Getting an str from Repository.merge_file_from_index is deprecated. ' + 'The method will later return an instance of MergeFileResult by default, instead. ' + 'Check parameter use_deprecated.', + DeprecationWarning, + ) + return mergeFileResult.contents if mergeFileResult else '' + + return mergeFileResult 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. @@ -751,9 +901,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) @@ -770,13 +926,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. @@ -808,16 +964,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) @@ -834,23 +983,28 @@ def merge_trees( def merge( self, - id: typing.Union[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 id into HEAD. + Merges the given Reference or Commit into HEAD. - Merges the given commit(s) into HEAD, writing the results into the working directory. + Merges the given commit into HEAD, writing the results into the working directory. Any changes are staged for commit and any conflicts are written to the index. Callers should inspect the repository's index after this completes, resolve any conflicts and prepare a commit. Parameters: - id - The id to merge into HEAD + source + The Reference, Commit, or commit Oid to merge into HEAD. + 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. @@ -862,12 +1016,35 @@ def merge( file_flags A combination of enums.MergeFileFlag constants. """ - if not isinstance(id, (str, Oid)): - raise TypeError(f'expected oid (string or ) got {type(id)}') - id = self[id].id - c_id = ffi.new('git_oid *') - ffi.buffer(c_id)[:] = id.raw[:] + 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, 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): + 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) merge_opts = self._merge_options(favor, flags, file_flags) @@ -877,10 +1054,6 @@ def merge( CheckoutStrategy.SAFE | CheckoutStrategy.RECREATE_MISSING ) - commit_ptr = ffi.new('git_annotated_commit **') - err = C.git_annotated_commit_lookup(commit_ptr, self._repo, c_id) - check_error(err) - err = C.git_merge(self._repo, commit_ptr, 1, merge_opts, checkout_opts) C.git_annotated_commit_free(commit_ptr[0]) check_error(err) @@ -923,7 +1096,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. """ @@ -935,16 +1108,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. @@ -987,7 +1160,7 @@ def describe( always_use_long_format : bool Always output the long format (the nearest tag, the number of - commits, and the abbrevated commit name) even when the committish + commits, and the abbreviated commit name) even when the committish matches a tag. dirty_suffix : str @@ -1018,10 +1191,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[:] @@ -1064,13 +1240,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. @@ -1125,7 +1301,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) @@ -1134,7 +1310,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. @@ -1168,11 +1350,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. @@ -1184,19 +1370,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. @@ -1268,7 +1470,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. @@ -1308,11 +1510,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. @@ -1385,7 +1587,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 @@ -1396,7 +1598,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. @@ -1409,7 +1611,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. @@ -1450,14 +1654,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. @@ -1506,24 +1710,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): @@ -1553,9 +1757,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( @@ -1572,11 +1778,16 @@ 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__( self, - path: typing.Optional[str] = None, + path: str | bytes | None | Path = None, flags: RepositoryOpenFlag = RepositoryOpenFlag.DEFAULT, ): """ @@ -1610,10 +1821,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/settings.py b/pygit2/settings.py index 24d01aae..91b5d319 100644 --- a/pygit2/settings.py +++ b/pygit2/settings.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -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 +from .options import option 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,27 @@ 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 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]: """ 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 +132,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 +140,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 +150,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 +163,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. @@ -191,3 +216,133 @@ def set_ssl_cert_locations(self, cert_file, cert_dir): 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 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/pygit2/submodules.py b/pygit2/submodules.py index 08b1a9a5..4ff00980 100644 --- a/pygit2/submodules.py +++ b/pygit2/submodules.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -24,23 +24,32 @@ # Boston, MA 02110-1301, USA. from __future__ import annotations -from typing import TYPE_CHECKING, Iterable, Iterator, Optional, Union + +from collections.abc import Iterable, Iterator +from pathlib import Path +from typing import TYPE_CHECKING, Optional 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 pygit2 import Repository + from pygit2._libgit2.ffi import GitSubmoduleC + from .repository import BaseRepository class Submodule: + _repo: BaseRepository + _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 @@ -48,18 +57,18 @@ 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) 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): + def init(self, overwrite: bool = False) -> None: """ Just like "git submodule init", this copies information about the submodule into ".git/config". @@ -74,8 +83,11 @@ 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, + ) -> None: """ Update a submodule. This will clone a missing submodule and checkout the subrepository to the commit specified in the index of the @@ -108,7 +120,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. @@ -136,11 +148,19 @@ 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) + @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.""" @@ -148,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). @@ -167,7 +187,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. @@ -186,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. @@ -261,7 +281,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". @@ -289,7 +311,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 @@ -346,7 +368,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. @@ -359,7 +381,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/pygit2/transaction.py b/pygit2/transaction.py new file mode 100644 index 00000000..358b40a9 --- /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/pygit2/utils.py b/pygit2/utils.py index 0660cc42..4a36a06f 100644 --- a/pygit2/utils.py +++ b/pygit2/utils.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -25,19 +25,49 @@ import contextlib import os +from collections.abc import Generator, Iterator, Sequence +from types import TracebackType +from typing import ( + TYPE_CHECKING, + Generic, + Optional, + Protocol, + TypeVar, + Union, + overload, +) # Import from pygit2 -from .ffi import ffi, C +from .ffi import C, ffi +if TYPE_CHECKING: + from ._libgit2.ffi import ArrayC, GitStrrayC, char, char_pointer -def maybe_string(ptr): + +def maybe_string(ptr: 'char_pointer | None') -> str | None: if not ptr: return None - return ffi.string(ptr).decode('utf8') - - -def to_bytes(s, encoding='utf-8', errors='strict'): + return ffi.string(ptr).decode('utf8', errors='surrogateescape') + + +@overload +def to_bytes( + s: str | bytes | os.PathLike[str] | os.PathLike[bytes], + 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], os.PathLike[bytes], None], + encoding: str = 'utf-8', + errors: str = 'strict', +) -> Union[bytes, 'ffi.NULL_TYPE']: if s == ffi.NULL or s is None: return ffi.NULL @@ -47,10 +77,10 @@ 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): +def to_str(s: str | bytes | os.PathLike[str] | os.PathLike[bytes]) -> str: if hasattr(s, '__fspath__'): s = os.fspath(s) @@ -63,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. @@ -74,13 +104,13 @@ 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) -def strarray_to_strings(arr): +def strarray_to_strings(arr) -> list[str]: """ Return a list of strings from a git_strarray pointer. @@ -113,18 +143,22 @@ class StrArray: contents of 'struct' only remain valid within the StrArray context. """ - def __init__(self, l): + __array: 'GitStrrayC | ffi.NULL_TYPE' + __strings: list['None | 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 - if l is None: + if lst is None: self.__array = ffi.NULL return - if not isinstance(l, (list, tuple)): + if not isinstance(lst, (list, tuple)): raise TypeError('Value must be a list') - strings = [None] * len(l) - for i in range(len(l)): - li = l[i] + 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__'): raise TypeError('Value must be a string or PathLike object') @@ -132,19 +166,24 @@ def __init__(self, l): self.__arr = ffi.new('char *[]', strings) 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 @@ -153,22 +192,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 next(self): - return self.__next__() + 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/pyproject.toml b/pyproject.toml index 20d4b61a..7d008a96 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,38 +1,78 @@ [build-system] -requires = ["setuptools", "wheel"] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" [tool.cibuildwheel] -skip = "pp3* *musllinux_aarch64 *musllinux_ppc64le" +enable = ["pypy"] +skip = "*musllinux_ppc64le" -archs = ["auto"] +archs = ["native"] build-frontend = "default" dependency-versions = "pinned" -environment = {LIBGIT2_VERSION="1.8.4", LIBSSH2_VERSION="1.11.1", OPENSSL_VERSION="3.2.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" +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 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}" -archs = ["x86_64", "aarch64", "ppc64le"] [[tool.cibuildwheel.overrides]] 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.8.4", LIBSSH2_VERSION="1.11.1", OPENSSL_VERSION="3.2.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.2" +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] -target-version = "py310" # oldest supported Python version -fix = true -extend-exclude = [ - ".cache", - ".coverage", - "build", - "venv*", -] +extend-exclude = [ ".cache", ".coverage", "build", "site-packages", "venv*"] +target-version = "py311" # oldest supported Python version + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F", "I", "UP035", "UP007"] [tool.ruff.format] quote-style = "single" + +[tool.codespell] +# Ref: https://github.com/codespell-project/codespell#using-a-config-file +skip = '.git*' +check-hidden = true +# ignore-regex = '' +ignore-words-list = 'devault,claus' diff --git a/requirements-typing.txt b/requirements-typing.txt new file mode 100644 index 00000000..c16ede75 --- /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 00000000..e14d9f0a --- /dev/null +++ b/requirements-wheel.txt @@ -0,0 +1 @@ +cibuildwheel ~= 3.3 diff --git a/requirements.txt b/requirements.txt index e06cd8ab..df6e4d8c 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 7c158eff..d92087cd 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -23,16 +23,18 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. -# Import setuptools before distutils to avoid user warning -from setuptools import setup, Extension +# mypy: disable-error-code="import-not-found, import-untyped" +# Import setuptools before distutils to avoid user warning +import os +import sys +from distutils import log # type: ignore[attr-defined] 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') @@ -45,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 ) @@ -54,8 +56,8 @@ def get_file_list(self): print(stderrdata) sys.exit() - def exclude(line): - for prefix in ['.', 'appveyor.yml', 'docs/', 'misc/']: + def exclude(line: str) -> bool: + for prefix in ['.', 'docs/', 'misc/']: if line.startswith(prefix): return True return False @@ -75,10 +77,10 @@ def exclude(line): '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', @@ -95,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') @@ -119,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) @@ -127,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'] @@ -151,9 +153,9 @@ def run(self): cffi_modules=['pygit2/_run.py:ffi'], ext_modules=ext_modules, # Requirements - python_requires='>=3.10', - setup_requires=['cffi>=1.17.0'], - install_requires=['cffi>=1.17.0'], + python_requires='>=3.11', + setup_requires=['cffi>=2.0'], + install_requires=['cffi>=2.0'], # URLs url='https://github.com/libgit2/pygit2', project_urls={ diff --git a/src/blob.c b/src/blob.c index a1f40dfa..93e7dbe4 100644 --- a/src/blob.c +++ b/src/blob.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2024 The pygit2 contributors + * 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, @@ -41,7 +41,7 @@ extern PyObject *GitError; extern PyTypeObject BlobType; PyDoc_STRVAR(Blob_diff__doc__, - "diff([blob: Blob, flag: int = GIT_DIFF_NORMAL, old_as_path: str, new_as_path: str]) -> Patch\n" + "diff([blob: Blob, flags: int = GIT_DIFF_NORMAL, old_as_path: str, new_as_path: str]) -> Patch\n" "\n" "Directly generate a :py:class:`pygit2.Patch` from the difference\n" "between two blobs.\n" @@ -53,14 +53,22 @@ PyDoc_STRVAR(Blob_diff__doc__, "blob : Blob\n" " The :py:class:`~pygit2.Blob` to diff.\n" "\n" - "flag\n" - " A GIT_DIFF_* constant.\n" + "flags\n" + " A combination of GIT_DIFF_* constant.\n" "\n" "old_as_path : str\n" " Treat old blob as if it had this filename.\n" "\n" "new_as_path : str\n" - " Treat new blob as if it had this filename.\n"); + " Treat new blob as if it had this filename.\n" + "\n" + "context_lines: int\n" + " Number of unchanged lines that define the boundary of a hunk\n" + " (and to display before and after).\n" + "\n" + "interhunk_lines: int\n" + " Maximum number of unchanged lines between hunk boundaries\n" + " before the hunks will be merged into one.\n"); PyObject * Blob_diff(Blob *self, PyObject *args, PyObject *kwds) @@ -70,11 +78,12 @@ Blob_diff(Blob *self, PyObject *args, PyObject *kwds) char *old_as_path = NULL, *new_as_path = NULL; Blob *other = NULL; int err; - char *keywords[] = {"blob", "flag", "old_as_path", "new_as_path", NULL}; + char *keywords[] = {"blob", "flags", "old_as_path", "new_as_path", "context_lines", "interhunk_lines", NULL}; - if (!PyArg_ParseTupleAndKeywords(args, kwds, "|O!Iss", keywords, + if (!PyArg_ParseTupleAndKeywords(args, kwds, "|O!IssHH", keywords, &BlobType, &other, &opts.flags, - &old_as_path, &new_as_path)) + &old_as_path, &new_as_path, + &opts.context_lines, &opts.interhunk_lines)) return NULL; if (Object__load((Object*)self) == NULL) { return NULL; } // Lazy load @@ -91,7 +100,7 @@ Blob_diff(Blob *self, PyObject *args, PyObject *kwds) PyDoc_STRVAR(Blob_diff_to_buffer__doc__, - "diff_to_buffer(buffer: bytes = None, flag: int = GIT_DIFF_NORMAL[, old_as_path: str, buffer_as_path: str]) -> Patch\n" + "diff_to_buffer(buffer: bytes = None, flags: int = GIT_DIFF_NORMAL[, old_as_path: str, buffer_as_path: str]) -> Patch\n" "\n" "Directly generate a :py:class:`~pygit2.Patch` from the difference\n" "between a blob and a buffer.\n" @@ -103,8 +112,8 @@ PyDoc_STRVAR(Blob_diff_to_buffer__doc__, "buffer : bytes\n" " Raw data for new side of diff.\n" "\n" - "flag\n" - " A GIT_DIFF_* constant.\n" + "flags\n" + " A combination of GIT_DIFF_* constants.\n" "\n" "old_as_path : str\n" " Treat old blob as if it had this filename.\n" @@ -121,8 +130,7 @@ Blob_diff_to_buffer(Blob *self, PyObject *args, PyObject *kwds) const char *buffer = NULL; Py_ssize_t buffer_len; int err; - char *keywords[] = {"buffer", "flag", "old_as_path", "buffer_as_path", - NULL}; + char *keywords[] = {"buffer", "flags", "old_as_path", "buffer_as_path", NULL}; if (!PyArg_ParseTupleAndKeywords(args, kwds, "|z#Iss", keywords, &buffer, &buffer_len, &opts.flags, @@ -227,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/src/branch.c b/src/branch.c index e518f179..51705ab9 100644 --- a/src/branch.c +++ b/src/branch.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2024 The pygit2 contributors + * 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, @@ -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/branch.h b/src/branch.h index 4761ffd0..5ee6de7e 100644 --- a/src/branch.h +++ b/src/branch.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2024 The pygit2 contributors + * 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, diff --git a/src/commit.c b/src/commit.c index 941589c5..f758bdd8 100644 --- a/src/commit.c +++ b/src/commit.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2024 The pygit2 contributors + * 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, diff --git a/src/diff.c b/src/diff.c index 60e8a109..2ae542a7 100644 --- a/src/diff.c +++ b/src/diff.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2024 The pygit2 contributors + * 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, @@ -236,7 +236,7 @@ PyMemberDef DiffFile_members[] = { {NULL} }; -PyMethodDef DiffFile_methods[] = { +static PyMethodDef DiffFile_methods[] = { METHOD(DiffFile, from_c, METH_STATIC | METH_O), {NULL}, }; @@ -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); } @@ -909,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/diff.h b/src/diff.h index 995047e7..17b64df8 100644 --- a/src/diff.h +++ b/src/diff.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2024 The pygit2 contributors + * 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, diff --git a/src/error.c b/src/error.c index d7ad951f..d264e619 100644 --- a/src/error.c +++ b/src/error.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2024 The pygit2 contributors + * 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, diff --git a/src/error.h b/src/error.h index 22938026..f08f3a99 100644 --- a/src/error.h +++ b/src/error.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2024 The pygit2 contributors + * 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, diff --git a/src/filter.c b/src/filter.c index 5e51a202..3362441f 100644 --- a/src/filter.c +++ b/src/filter.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2024 The pygit2 contributors + * 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, @@ -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/filter.h b/src/filter.h index cba899f7..04bbf7c0 100644 --- a/src/filter.h +++ b/src/filter.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2024 The pygit2 contributors + * 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, diff --git a/src/mailmap.c b/src/mailmap.c index 500d8ab9..f79d2b93 100644 --- a/src/mailmap.c +++ b/src/mailmap.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2024 The pygit2 contributors + * 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, @@ -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/mailmap.h b/src/mailmap.h index 8209599a..0f61d96b 100644 --- a/src/mailmap.h +++ b/src/mailmap.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2024 The pygit2 contributors + * 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, diff --git a/src/note.c b/src/note.c index 19a506d2..0c014578 100644 --- a/src/note.c +++ b/src/note.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2024 The pygit2 contributors + * 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, @@ -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/note.h b/src/note.h index 49ee50fe..2e87b894 100644 --- a/src/note.h +++ b/src/note.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2024 The pygit2 contributors + * 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, diff --git a/src/object.c b/src/object.c index 0c74bfb5..f8cba2ea 100644 --- a/src/object.c +++ b/src/object.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2024 The pygit2 contributors + * 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, @@ -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/object.h b/src/object.h index 4dcdd40d..9fc41526 100644 --- a/src/object.h +++ b/src/object.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2024 The pygit2 contributors + * 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, diff --git a/src/odb.c b/src/odb.c index c687aa6e..e8720fc0 100644 --- a/src/odb.c +++ b/src/odb.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2024 The pygit2 contributors + * 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, @@ -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, size\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)); @@ -200,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" @@ -298,9 +344,10 @@ 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), METHOD(Odb, write, METH_VARARGS), METHOD(Odb, exists, METH_O), METHOD(Odb, add_backend, METH_VARARGS), diff --git a/src/odb.h b/src/odb.h index 788a29da..7a69a46c 100644 --- a/src/odb.h +++ b/src/odb.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2024 The pygit2 contributors + * 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, diff --git a/src/odb_backend.c b/src/odb_backend.c index 05df7145..2139f220 100644 --- a/src/odb_backend.c +++ b/src/odb_backend.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2024 The pygit2 contributors + * 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, @@ -103,7 +103,7 @@ pgit_odb_backend_read_prefix(git_oid *oid_out, void **ptr, size_t *sz, git_objec if (result == NULL) return git_error_for_exc(); - // Parse output from calback + // Parse output from callback PyObject *py_oid_out; Py_ssize_t type_value; const char *bytes; @@ -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/odb_backend.h b/src/odb_backend.h index 0d9cc929..15ec46c7 100644 --- a/src/odb_backend.h +++ b/src/odb_backend.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2024 The pygit2 contributors + * 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, diff --git a/src/oid.c b/src/oid.c index ca1e1c31..ffce36a2 100644 --- a/src/oid.c +++ b/src/oid.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2024 The pygit2 contributors + * 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, @@ -34,6 +34,8 @@ PyTypeObject OidType; +static const git_oid oid_zero = GIT_OID_SHA1_ZERO; + PyObject * git_oid_to_python(const git_oid *oid) @@ -255,6 +257,13 @@ Oid__str__(Oid *self) return git_oid_to_py_str(&self->oid); } +int +Oid__bool(PyObject *self) +{ + git_oid *oid = &((Oid*)self)->oid; + return !git_oid_equal(oid, &oid_zero); +} + PyDoc_STRVAR(Oid_raw__doc__, "Raw oid, a 20 bytes string."); PyObject * @@ -269,6 +278,45 @@ PyGetSetDef Oid_getseters[] = { {NULL}, }; +PyNumberMethods Oid_as_number = { + 0, /* nb_add */ + 0, /* nb_subtract */ + 0, /* nb_multiply */ + 0, /* nb_remainder */ + 0, /* nb_divmod */ + 0, /* nb_power */ + 0, /* nb_negative */ + 0, /* nb_positive */ + 0, /* nb_absolute */ + Oid__bool, /* nb_bool */ + 0, /* nb_invert */ + 0, /* nb_lshift */ + 0, /* nb_rshift */ + 0, /* nb_and */ + 0, /* nb_xor */ + 0, /* nb_or */ + 0, /* nb_int */ + 0, /* nb_reserved */ + 0, /* nb_float */ + 0, /* nb_inplace_add */ + 0, /* nb_inplace_subtract */ + 0, /* nb_inplace_multiply */ + 0, /* nb_inplace_remainder */ + 0, /* nb_inplace_power */ + 0, /* nb_inplace_lshift */ + 0, /* nb_inplace_rshift */ + 0, /* nb_inplace_and */ + 0, /* nb_inplace_xor */ + 0, /* nb_inplace_or */ + 0, /* nb_floor_divide */ + 0, /* nb_true_divide */ + 0, /* nb_inplace_floor_divide */ + 0, /* nb_inplace_true_divide */ + 0, /* nb_index */ + 0, /* nb_matrix_multiply */ + 0, /* nb_inplace_matrix_multiply */ +}; + PyDoc_STRVAR(Oid__doc__, "Object id."); PyTypeObject OidType = { @@ -282,7 +330,7 @@ PyTypeObject OidType = { 0, /* tp_setattr */ 0, /* tp_compare */ (reprfunc)Oid__str__, /* tp_repr */ - 0, /* tp_as_number */ + &Oid_as_number, /* tp_as_number */ 0, /* tp_as_sequence */ 0, /* tp_as_mapping */ (hashfunc)Oid_hash, /* tp_hash */ diff --git a/src/oid.h b/src/oid.h index 613e525b..99ed3b86 100644 --- a/src/oid.h +++ b/src/oid.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2024 The pygit2 contributors + * 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, diff --git a/src/options.c b/src/options.c deleted file mode 100644 index ebb82c11..00000000 --- a/src/options.c +++ /dev/null @@ -1,309 +0,0 @@ -/* - * Copyright 2010-2024 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 90b91d9e..00000000 --- a/src/options.h +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright 2010-2024 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/patch.c b/src/patch.c index 3e55eeb8..478364c6 100644 --- a/src/patch.c +++ b/src/patch.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2024 The pygit2 contributors + * 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, @@ -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/patch.h b/src/patch.h index a18f2b77..3c0ad759 100644 --- a/src/patch.h +++ b/src/patch.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2024 The pygit2 contributors + * 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, diff --git a/src/pygit2.c b/src/pygit2.c index 3c661773..9ddf6e76 100644 --- a/src/pygit2.c +++ b/src/pygit2.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2024 The pygit2 contributors + * 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, @@ -35,7 +35,6 @@ #include "utils.h" #include "repository.h" #include "oid.h" -#include "options.h" #include "filter.h" PyObject *GitError; @@ -48,6 +47,7 @@ PyObject *FileModeEnum; PyObject *FileStatusEnum; PyObject *MergeAnalysisEnum; PyObject *MergePreferenceEnum; +PyObject *ObjectTypeEnum; PyObject *ReferenceTypeEnum; extern PyTypeObject RepositoryType; @@ -290,7 +290,7 @@ PyDoc_STRVAR(filter_register__doc__, "\n" "`priority` defaults to GIT_FILTER_DRIVER_PRIORITY which imitates a core\n" "Git filter driver that will be run last on checkout (smudge) and first \n" - "on checkin (clean).\n" + "on check-in (clean).\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" @@ -305,72 +305,53 @@ 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; - goto done; - -error: - Py_DECREF(py_filter_cls); - free(filter); -done: - Py_DECREF(py_attrs); - 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; + /* keep reference to Python filter */ + filter->py_filter_cls = py_filter_cls; - if (!PyArg_ParseTuple(args, "s#", &name, &size)) - return NULL; - if ((err = git_filter_unregister(name)) < 0) + /* git register filter */ + if ((err = git_filter_register(name, &filter->filter, priority)) < 0) { + free(attrs); + free(filter); return Error_set(err); + } + Py_INCREF(py_filter_cls); /* libgit2 now owns this reference, will decref in shutdown */ Py_RETURN_NONE; } - static void forget_enums(void) { @@ -380,6 +361,7 @@ forget_enums(void) Py_CLEAR(FileStatusEnum); Py_CLEAR(MergeAnalysisEnum); Py_CLEAR(MergePreferenceEnum); + Py_CLEAR(ObjectTypeEnum); Py_CLEAR(ReferenceTypeEnum); } @@ -415,6 +397,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 @@ -434,16 +417,14 @@ 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__}, {"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__}, - {"filter_unregister", filter_unregister, METH_VARARGS, filter_unregister__doc__}, {"_cache_enums", _cache_enums, METH_NOARGS, _cache_enums__doc__}, {NULL} }; @@ -467,45 +448,16 @@ 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) 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/src/refdb.c b/src/refdb.c index f2273f81..70c68747 100644 --- a/src/refdb.c +++ b/src/refdb.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2019 The pygit2 contributors + * 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, @@ -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.h b/src/refdb.h index 7e1a0f95..98442354 100644 --- a/src/refdb.h +++ b/src/refdb.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2019 The pygit2 contributors + * 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, diff --git a/src/refdb_backend.c b/src/refdb_backend.c index 7e8571f5..ea351dc2 100644 --- a/src/refdb_backend.c +++ b/src/refdb_backend.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2019 The pygit2 contributors + * 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, @@ -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/refdb_backend.h b/src/refdb_backend.h index 1f92fcac..c6a5ff36 100644 --- a/src/refdb_backend.h +++ b/src/refdb_backend.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2019 The pygit2 contributors + * 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, diff --git a/src/reference.c b/src/reference.c index 0cd1bfb8..fbce62d1 100644 --- a/src/reference.c +++ b/src/reference.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2024 The pygit2 contributors + * 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, @@ -230,7 +230,7 @@ Reference_rename(Reference *self, PyObject *py_name) if (err) return Error_set(err); - // Upadate reference + // Update reference git_reference_free(self->reference); self->reference = new_reference; @@ -440,6 +440,14 @@ Reference_type__get__(Reference *self) return pygit2_enum(ReferenceTypeEnum, c_type); } +PyDoc_STRVAR(Reference__pointer__doc__, "Get the reference's pointer. For internal use only."); + +PyObject * +Reference__pointer__get__(Reference *self) +{ + /* Bytes means a raw buffer */ + return PyBytes_FromStringAndSize((char *) &self->reference, sizeof(git_reference *)); +} PyDoc_STRVAR(Reference_log__doc__, "log() -> RefLogIter\n" @@ -650,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), @@ -668,6 +676,7 @@ PyGetSetDef Reference_getseters[] = { GETTER(Reference, target), GETTER(Reference, raw_target), GETTER(Reference, type), + GETTER(Reference, _pointer), {NULL} }; diff --git a/src/reference.h b/src/reference.h index f4af1def..909cbf30 100644 --- a/src/reference.h +++ b/src/reference.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2024 The pygit2 contributors + * 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, diff --git a/src/repository.c b/src/repository.c index d1d42ecf..24a54848 100644 --- a/src/repository.c +++ b/src/repository.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2024 The pygit2 contributors + * 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, @@ -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; @@ -69,7 +80,7 @@ extern PyObject *FileStatusEnum; extern PyObject *MergeAnalysisEnum; extern PyObject *MergePreferenceEnum; -/* forward-declaration for Repsository._from_c() */ +/* forward-declaration for Repository._from_c() */ PyTypeObject RepositoryType; PyObject * @@ -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); @@ -1065,7 +1082,8 @@ Repository_create_commit(Repository *self, PyObject *args) err = git_commit_create(&oid, self->repo, update_ref, py_author->signature, py_committer->signature, - encoding, message, tree, parent_count, parents); + encoding, message, tree, parent_count, + (const git_commit **)parents); if (err < 0) { Error_set(err); goto out; @@ -1134,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); @@ -1147,7 +1168,8 @@ Repository_create_commit_string(Repository *self, PyObject *args) err = git_commit_create_buffer(&buf, self->repo, py_author->signature, py_committer->signature, - encoding, message, tree, parent_count, parents); + encoding, message, tree, parent_count, + (const git_commit **)parents); if (err < 0) { Error_set(err); goto out; @@ -1701,7 +1723,7 @@ PyDoc_STRVAR(Repository_status__doc__, " How to handle untracked files, defaults to \"all\":\n" "\n" " - \"no\": do not return untracked files\n" - " - \"normal\": include untracked files/directories but no dot recurse subdirectories\n" + " - \"normal\": include untracked files/directories but do not recurse subdirectories\n" " - \"all\": include all files in untracked directories\n" "\n" " Using `untracked_files=\"no\"` or \"normal\"can be faster than \"all\" when the worktree\n" @@ -2076,7 +2098,7 @@ Repository_free(Repository *self) PyDoc_STRVAR(Repository_expand_id__doc__, "expand_id(hex: str) -> Oid\n" "\n" - "Expand a string into a full Oid according to the objects in this repsitory.\n"); + "Expand a string into a full Oid according to the objects in this repository.\n"); PyObject * Repository_expand_id(Repository *self, PyObject *py_hex) @@ -2401,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/repository.h b/src/repository.h index d369b517..059d774a 100755 --- a/src/repository.h +++ b/src/repository.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2024 The pygit2 contributors + * 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, diff --git a/src/revspec.c b/src/revspec.c index d3f3cda0..64e462bd 100644 --- a/src/revspec.c +++ b/src/revspec.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2024 The pygit2 contributors + * 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, diff --git a/src/revspec.h b/src/revspec.h index 9ba91bec..2f80af91 100644 --- a/src/revspec.h +++ b/src/revspec.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2024 The pygit2 contributors + * 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, diff --git a/src/signature.c b/src/signature.c index f65cf5fc..f384bd7d 100644 --- a/src/signature.c +++ b/src/signature.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2024 The pygit2 contributors + * 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, @@ -81,7 +81,7 @@ Signature_init(Signature *self, PyObject *args, PyObject *kwds) void Signature_dealloc(Signature *self) { - /* self->obj is the owner of the git_signature C structure, so we musn't free it */ + /* self->obj is the owner of the git_signature C structure, so we mustn't free it */ if (self->obj) { Py_CLEAR(self->obj); } else { diff --git a/src/signature.h b/src/signature.h index 354127b0..9c646d86 100644 --- a/src/signature.h +++ b/src/signature.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2024 The pygit2 contributors + * 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, diff --git a/src/stash.c b/src/stash.c index faf276d9..e60dcb8b 100644 --- a/src/stash.c +++ b/src/stash.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2022 The pygit2 contributors + * 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, diff --git a/src/tag.c b/src/tag.c index 7179c8f6..d384171a 100644 --- a/src/tag.c +++ b/src/tag.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2024 The pygit2 contributors + * 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, @@ -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 bbdae604..2e7906aa 100644 --- a/src/tree.c +++ b/src/tree.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2024 The pygit2 contributors + * 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, @@ -222,14 +222,16 @@ PyDoc_STRVAR(Tree_diff_to_workdir__doc__, " the hunks will be merged into a one.\n"); PyObject * -Tree_diff_to_workdir(Tree *self, PyObject *args) +Tree_diff_to_workdir(Tree *self, PyObject *args, PyObject *kwds) { git_diff_options opts = GIT_DIFF_OPTIONS_INIT; git_diff *diff; int err; - if (!PyArg_ParseTuple(args, "|IHH", &opts.flags, &opts.context_lines, - &opts.interhunk_lines)) + char *keywords[] = {"flags", "context_lines", "interhunk_lines", NULL}; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "|IHH", keywords, &opts.flags, + &opts.context_lines, &opts.interhunk_lines)) return NULL; if (Object__load((Object*)self) == NULL) { return NULL; } // Lazy load @@ -354,8 +356,7 @@ Tree_diff_to_tree(Tree *self, PyObject *args, PyObject *kwds) git_diff *diff; git_tree *from, *to = NULL, *tmp; int err, swap = 0; - char *keywords[] = {"obj", "flags", "context_lines", "interhunk_lines", - "swap", NULL}; + char *keywords[] = {"obj", "flags", "context_lines", "interhunk_lines", "swap", NULL}; Tree *other = NULL; @@ -404,9 +405,9 @@ 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), + METHOD(Tree, diff_to_workdir, METH_VARARGS | METH_KEYWORDS), METHOD(Tree, diff_to_index, METH_VARARGS | METH_KEYWORDS), {NULL} }; diff --git a/src/tree.h b/src/tree.h index 6af553bd..f7866695 100644 --- a/src/tree.h +++ b/src/tree.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2024 The pygit2 contributors + * 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, diff --git a/src/treebuilder.c b/src/treebuilder.c index 149218c7..629442bc 100644 --- a/src/treebuilder.c +++ b/src/treebuilder.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2024 The pygit2 contributors + * 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, @@ -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/treebuilder.h b/src/treebuilder.h index 9a6d246a..5a6c8242 100644 --- a/src/treebuilder.h +++ b/src/treebuilder.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2024 The pygit2 contributors + * 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, diff --git a/src/types.h b/src/types.h index c70c7eb7..24a66aa1 100644 --- a/src/types.h +++ b/src/types.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2024 The pygit2 contributors + * 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, @@ -33,8 +33,8 @@ #include #include -#if !(LIBGIT2_VER_MAJOR == 1 && LIBGIT2_VER_MINOR == 8) -#error You need a compatible libgit2 version (1.8.x) +#if !(LIBGIT2_VER_MAJOR == 1 && LIBGIT2_VER_MINOR == 9) +#error You need a compatible libgit2 version (1.9.x) #endif /* diff --git a/src/utils.c b/src/utils.c index 42426ccc..be90dba4 100644 --- a/src/utils.c +++ b/src/utils.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2024 The pygit2 contributors + * 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, @@ -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 4e3c3fc4..2ea28ec2 100644 --- a/src/utils.h +++ b/src/utils.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2024 The pygit2 contributors + * 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, @@ -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 91f5fc0b..7b1e9d8d 100644 --- a/src/walker.c +++ b/src/walker.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2024 The pygit2 contributors + * 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, @@ -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/walker.h b/src/walker.h index 464811c3..75b3afc9 100644 --- a/src/walker.h +++ b/src/walker.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2024 The pygit2 contributors + * 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, diff --git a/src/worktree.c b/src/worktree.c index 6671957e..b403485a 100644 --- a/src/worktree.c +++ b/src/worktree.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2024 The pygit2 contributors + * 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, @@ -93,7 +93,7 @@ Worktree_dealloc(Worktree *self) } -PyMethodDef Worktree_methods[] = { +static PyMethodDef Worktree_methods[] = { METHOD(Worktree, prune, METH_VARARGS), {NULL} }; diff --git a/src/worktree.h b/src/worktree.h index 371e8dd2..198f7b07 100644 --- a/src/worktree.h +++ b/src/worktree.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2024 The pygit2 contributors + * 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, diff --git a/test/__init__.py b/test/__init__.py index 793dc1df..53a1b230 100644 --- a/test/__init__.py +++ b/test/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -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 1c6d7b8f..69d3e638 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,13 +1,16 @@ +from collections.abc import Generator from pathlib import Path -import platform 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 = [ @@ -18,43 +21,41 @@ def global_git_config(): for level in levels: pygit2.settings.search_path[level] = '' - # Fix tests running in AppVeyor - if platform.system() == 'Windows': - pygit2.option(pygit2.enums.Option.SET_OWNER_VALIDATION, 0) - @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) @@ -62,36 +63,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_apply_diff.py b/test/test_apply_diff.py index 915d4368..9277d7eb 100644 --- a/test/test_apply_diff.py +++ b/test/test_apply_diff.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -23,39 +23,42 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. -import pygit2 -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): +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 +68,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 +80,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 +96,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 +106,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 +116,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 +133,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 +150,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 +166,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): diff --git a/test/test_archive.py b/test/test_archive.py index b6977714..bd8ef864 100644 --- a/test/test_archive.py +++ b/test/test_archive.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -23,17 +23,18 @@ # 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 - +from pygit2 import Index, Object, Oid, Repository, Tree 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 +56,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) diff --git a/test/test_attributes.py b/test/test_attributes.py index 73b0cbe4..12f9106b 100644 --- a/test/test_attributes.py +++ b/test/test_attributes.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -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) diff --git a/test/test_blame.py b/test/test_blame.py index 8bd7fca3..cb122395 100644 --- a/test/test_blame.py +++ b/test/test_blame.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -27,10 +27,9 @@ import pytest -from pygit2 import Signature, Oid +from pygit2 import Oid, Repository, Signature from pygit2.enums import BlameFlag - PATH = 'hello.txt' HUNKS = [ @@ -61,7 +60,7 @@ ] -def test_blame_index(testrepo): +def test_blame_index(testrepo: Repository) -> None: blame = testrepo.blame(PATH) assert len(blame) == 3 @@ -78,7 +77,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,18 +94,17 @@ 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(): + 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): +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 +121,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), diff --git a/test/test_blob.py b/test/test_blob.py index 78cec628..dcce71f4 100644 --- a/test/test_blob.py +++ b/test/test_blob.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -27,15 +27,16 @@ import io from pathlib import Path -from threading import Event from queue import Queue +from threading import Event import pytest import pygit2 +from pygit2 import Repository from pygit2.enums import ObjectType -from . import utils +from . import utils BLOB_SHA = 'a520c24d85fbfc815d385957eed41406ca5a860b' BLOB_CONTENT = """hello world @@ -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] @@ -109,14 +110,14 @@ def test_create_blob(testrepo): 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): 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] diff --git a/test/test_branch.py b/test/test_branch.py index 4f4903a4..63923cd6 100644 --- a/test/test_branch.py +++ b/test/test_branch.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -25,11 +25,13 @@ """Tests for branch methods.""" -import pygit2 -import pytest import os -from pygit2.enums import BranchType +import pytest + +import pygit2 +from pygit2 import Commit, Repository +from pygit2.enums import BranchType LAST_COMMIT = '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98' I18N_LAST_COMMIT = '5470a671a80ac3789f1a6a8cefbcf43ce7af0563' @@ -38,7 +40,7 @@ SHARED_COMMIT = '4ec4389a8068641da2d6578db0419484972284c8' -def test_branches_getitem(testrepo): +def test_branches_getitem(testrepo: Repository) -> None: branch = testrepo.branches['master'] assert branch.target == LAST_COMMIT @@ -49,13 +51,14 @@ def test_branches_getitem(testrepo): testrepo.branches['not-exists'] -def test_branches(testrepo): +def test_branches(testrepo: Repository) -> None: branches = sorted(testrepo.branches) assert branches == ['i18n', 'master'] -def test_branches_create(testrepo): +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'] @@ -70,27 +73,27 @@ def test_branches_create(testrepo): assert reference.target == LAST_COMMIT -def test_branches_delete(testrepo): +def test_branches_delete(testrepo: Repository) -> None: testrepo.branches.delete('i18n') assert testrepo.branches.get('i18n') is None -def test_branches_delete_error(testrepo): +def test_branches_delete_error(testrepo: Repository) -> None: with pytest.raises(pygit2.GitError): testrepo.branches.delete('master') -def test_branches_is_head(testrepo): +def test_branches_is_head(testrepo: Repository) -> None: branch = testrepo.branches.get('master') assert branch.is_head() -def test_branches_is_not_head(testrepo): +def test_branches_is_not_head(testrepo: Repository) -> None: branch = testrepo.branches.get('i18n') assert not branch.is_head() -def test_branches_rename(testrepo): +def test_branches_rename(testrepo: Repository) -> None: new_branch = testrepo.branches['i18n'].rename('new-branch') assert new_branch.target == I18N_LAST_COMMIT @@ -98,25 +101,25 @@ def test_branches_rename(testrepo): assert new_branch_2.target == I18N_LAST_COMMIT -def test_branches_rename_error(testrepo): +def test_branches_rename_error(testrepo: Repository) -> None: original_branch = testrepo.branches.get('i18n') with pytest.raises(ValueError): original_branch.rename('master') -def test_branches_rename_force(testrepo): +def test_branches_rename_force(testrepo: Repository) -> None: original_branch = testrepo.branches.get('master') new_branch = original_branch.rename('i18n', True) assert new_branch.target == LAST_COMMIT -def test_branches_rename_invalid(testrepo): +def test_branches_rename_invalid(testrepo: Repository) -> None: original_branch = testrepo.branches.get('i18n') with pytest.raises(ValueError): original_branch.rename('abc@{123') -def test_branches_name(testrepo): +def test_branches_name(testrepo: Repository) -> None: branch = testrepo.branches.get('master') assert branch.branch_name == 'master' assert branch.name == 'refs/heads/master' @@ -128,7 +131,7 @@ def test_branches_name(testrepo): assert branch.raw_branch_name == branch.branch_name.encode('utf-8') -def test_branches_with_commit(testrepo): +def test_branches_with_commit(testrepo: Repository) -> None: branches = testrepo.branches.with_commit(EXCLUSIVE_MASTER_COMMIT) assert sorted(branches) == ['master'] assert branches.get('i18n') is None @@ -140,7 +143,9 @@ def test_branches_with_commit(testrepo): 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) @@ -152,7 +157,7 @@ def test_branches_with_commit(testrepo): # -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 @@ -165,16 +170,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 @@ -189,64 +195,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' diff --git a/test/test_branch_empty.py b/test/test_branch_empty.py index 2afd749f..5b2beabd 100644 --- a/test/test_branch_empty.py +++ b/test/test_branch_empty.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -23,39 +23,44 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. +from collections.abc import Generator + import pytest -from pygit2.enums import BranchType +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]) + commit = repo[remote_master.target] + assert isinstance(commit, Commit) + master = repo.branches.create('master', commit) assert master.upstream is None master.upstream = remote_master @@ -71,9 +76,11 @@ 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]) + 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' @@ -84,28 +91,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 +130,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' diff --git a/test/test_cherrypick.py b/test/test_cherrypick.py index a5e5c558..6c003223 100644 --- a/test/test_cherrypick.py +++ b/test/test_cherrypick.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -26,26 +26,30 @@ """Tests for merging and information about it.""" from pathlib import Path + 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') @@ -53,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 diff --git a/test/test_commit.py b/test/test_commit.py index 521059b9..21434026 100644 --- a/test/test_commit.py +++ b/test/test_commit.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -29,10 +29,10 @@ import pytest -from pygit2 import Signature, Oid, GitError +from pygit2 import Commit, GitError, Oid, Repository, Signature, Tree from pygit2.enums import ObjectType -from . import utils +from . import utils COMMIT_SHA = '5fe808e8953c12735680c257f56600cb0de44b10' COMMIT_SHA_TO_AMEND = ( @@ -40,8 +40,8 @@ ) -@utils.refcount -def test_commit_refcount(barerepo): +@utils.requires_refcount +def test_commit_refcount(barerepo: Repository) -> None: commit = barerepo[COMMIT_SHA] start = sys.getrefcount(commit) tree = commit.tree @@ -50,15 +50,16 @@ 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) assert 'c2792cfa289ae6321ecf2cd5806c2194b0fd070c' == parents[0].id assert commit.message_encoding is None assert commit.message == ( - 'Second test data commit.\n\n' 'This commit has some additional text.\n' + 'Second test data commit.\n\nThis commit has some additional text.\n' ) commit_time = 1288481576 assert commit_time == commit.commit_time @@ -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 diff --git a/test/test_commit_gpg.py b/test/test_commit_gpg.py index cb1c812e..d20f584e 100644 --- a/test/test_commit_gpg.py +++ b/test/test_commit_gpg.py @@ -1,4 +1,4 @@ -# Copyright 2010-2022 The pygit2 contributors +# 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, @@ -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 diff --git a/test/test_commit_trailer.py b/test/test_commit_trailer.py index 7f07825e..efe5434f 100644 --- a/test/test_commit_trailer.py +++ b/test/test_commit_trailer.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -23,25 +23,31 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. -import pygit2 +from collections.abc import Generator +from pathlib import Path + 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'] diff --git a/test/test_config.py b/test/test_config.py index c89c7d38..2d1a2a11 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -23,19 +23,20 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. +from collections.abc import Generator from pathlib import Path import pytest -from pygit2 import Config -from . import utils +from pygit2 import Config, Repository +from . import utils CONFIG_FILENAME = 'test_config' @pytest.fixture -def config(testrepo): +def config(testrepo: Repository) -> Generator[object, None, None]: yield testrepo.config try: Path(CONFIG_FILENAME).unlink() @@ -43,11 +44,11 @@ def config(testrepo): 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: @@ -55,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: @@ -63,7 +64,7 @@ def test_system_config(): pass -def test_new(): +def test_new() -> None: # Touch file open(CONFIG_FILENAME, 'w').close() @@ -80,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') @@ -93,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') @@ -102,11 +103,11 @@ def test_add_aspath(): assert 'this.that' in config -def test_read(config): +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'] ) @@ -120,9 +121,9 @@ 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') + config.__setitem__((), 'This should not work') # type: ignore assert 'core.dummy1' not in config config['core.dummy1'] = 42 @@ -147,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') @@ -174,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 @@ -184,7 +185,36 @@ def test_iterator(config): assert lst['core.bare'] -def test_parsing(): +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') diff --git a/test/test_credentials.py b/test/test_credentials.py index 0e9adf66..dbc98823 100644 --- a/test/test_credentials.py +++ b/test/test_credentials.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -23,18 +23,23 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. -"""Tests for credentials""" - -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, + Repository, + Username, + UserPass, +) from pygit2.enums import CredentialType -from . import utils +from . import utils REMOTE_NAME = 'origin' REMOTE_URL = 'git://github.com/libgit2/pygit2.git' @@ -46,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' @@ -60,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' @@ -70,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') @@ -80,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' @@ -99,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) @@ -113,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) @@ -130,10 +137,15 @@ 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) -> 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' @@ -143,10 +155,15 @@ def credentials(testrepo, url, username, allowed): @utils.requires_network -def test_bad_cred_type(testrepo): +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' @@ -156,9 +173,11 @@ def credentials(testrepo, url, username, allowed): @utils.requires_network -def test_fetch_certificate_check(testrepo): +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' @@ -181,7 +200,7 @@ def certificate_check(testrepo, certificate, valid, host): @utils.requires_network -def test_user_pass(testrepo): +def test_user_pass(testrepo: Repository) -> None: credentials = UserPass('libgit2', 'libgit2') callbacks = pygit2.RemoteCallbacks(credentials=credentials) @@ -193,7 +212,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) -> None: credentials = UserPass('libgit2', 'libgit2') callbacks = pygit2.RemoteCallbacks(credentials=credentials) diff --git a/test/test_describe.py b/test/test_describe.py index 7ae41c28..963649b4 100644 --- a/test/test_describe.py +++ b/test/test_describe.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -27,11 +27,12 @@ import pytest -from pygit2.enums import DescribeStrategy, ObjectType 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') diff --git a/test/test_diff.py b/test/test_diff.py index b2138526..cdd59688 100644 --- a/test/test_diff.py +++ b/test/test_diff.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -25,14 +25,18 @@ """Tests for Diff objects.""" -from itertools import chain import textwrap +from collections.abc import Iterator +from itertools import chain +from pathlib import Path import pytest import pygit2 +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' @@ -107,74 +111,137 @@ delete mode 100644 c/d """ +TEXT_BLOB1 = """Common header of the file +Blob 1 line 1 +Common middle line 1 +Common middle line 2 +Common middle line 3 +Blob 1 line 2 +Common footer of the file +""" + +TEXT_BLOB2 = """Common header of the file +Blob 2 line 1 +Common middle line 1 +Common middle line 2 +Common middle line 3 +Blob 2 line 2 +Common footer of the file +""" + +PATCH_BLOBS_DEFAULT = """diff --git a/file b/file +index 0b5ac93..ddfdbcc 100644 +--- a/file ++++ b/file +@@ -1,7 +1,7 @@ + Common header of the file +-Blob 1 line 1 ++Blob 2 line 1 + Common middle line 1 + Common middle line 2 + Common middle line 3 +-Blob 1 line 2 ++Blob 2 line 2 + Common footer of the file +""" + +PATCH_BLOBS_NO_LEEWAY = """diff --git a/file b/file +index 0b5ac93..ddfdbcc 100644 +--- a/file ++++ b/file +@@ -2 +2 @@ Common header of the file +-Blob 1 line 1 ++Blob 2 line 1 +@@ -6 +6 @@ Common middle line 3 +-Blob 1 line 2 ++Blob 2 line 2 +""" + +PATCH_BLOBS_ONE_CONTEXT_LINE = """diff --git a/file b/file +index 0b5ac93..ddfdbcc 100644 +--- a/file ++++ b/file +@@ -1,3 +1,3 @@ + Common header of the file +-Blob 1 line 1 ++Blob 2 line 1 + Common middle line 1 +@@ -5,3 +5,3 @@ Common middle line 2 + Common middle line 3 +-Blob 1 line 2 ++Blob 2 line 2 + Common footer of the file +""" + -def test_diff_empty_index(dirtyrepo): +def test_diff_empty_index(dirtyrepo: Repository) -> None: repo = dirtyrepo 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 -def test_workdir_to_tree(dirtyrepo): +def test_workdir_to_tree(dirtyrepo: Repository) -> None: repo = dirtyrepo 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): +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 -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] 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 -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)) + 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 @@ -199,45 +266,49 @@ 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): - hunks = chain.from_iterable(map(lambda x: x.hunks, diff)) + def get_context_for_lines(diff: Diff) -> Iterator[str]: + 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)) -def test_diff_revparse(barerepo): +def test_diff_revparse(barerepo: Repository) -> None: diff = barerepo.diff('HEAD', 'HEAD~6') - assert type(diff) == pygit2.Diff + 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] 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): +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] @@ -247,13 +318,14 @@ def test_diff_merge(barerepo): 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 @@ -264,7 +336,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] @@ -273,16 +345,17 @@ 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] + assert patch is not None delta = patch.delta assert delta.old_file.id == '7f129fd57e31e935c6d60a0c794efe4e6927664b' 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) @@ -290,10 +363,11 @@ 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] + 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) @@ -301,21 +375,25 @@ 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] # ~ 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): +def test_diff_stats(barerepo: Repository) -> None: commit_a = barerepo[COMMIT_SHA1_1] commit_b = barerepo[COMMIT_SHA1_2] @@ -330,12 +408,12 @@ 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) 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 @@ -353,7 +431,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 @@ -365,12 +443,12 @@ 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(): +def test_parse_diff_bad() -> None: diff = textwrap.dedent( """ diff --git a/file1 b/file1 @@ -382,3 +460,47 @@ def test_parse_diff_bad(): ) with pytest.raises(pygit2.GitError): pygit2.Diff.parse_diff(diff) + + +def test_diff_blobs(emptyrepo: Repository) -> None: + repo = emptyrepo + blob1 = repo.create_blob(TEXT_BLOB1.encode()) + blob2 = repo.create_blob(TEXT_BLOB2.encode()) + diff_default = repo.diff(blob1, blob2) + assert diff_default.text == PATCH_BLOBS_DEFAULT + diff_no_leeway = repo.diff(blob1, blob2, context_lines=0) + assert diff_no_leeway.text == PATCH_BLOBS_NO_LEEWAY + diff_one_context_line = repo.diff(blob1, blob2, context_lines=1) + 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: Repository) -> 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' diff --git a/test/test_diff_binary.py b/test/test_diff_binary.py index 17eceaac..2947e403 100644 --- a/test/test_diff_binary.py +++ b/test/test_diff_binary.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -23,16 +23,20 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. +from collections.abc import Generator +from pathlib import Path + 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) diff --git a/test/test_filter.py b/test/test_filter.py index f37f9e1c..c4338489 100644 --- a/test/test_filter.py +++ b/test/test_filter.py @@ -1,39 +1,53 @@ -from io import BytesIO import codecs +import gc +from collections.abc import Callable, Generator +from io import BytesIO + import pytest import pygit2 -from pygit2.enums import BlobFilter +from pygit2 import Blob, Filter, FilterSource, Repository +from pygit2.enums import BlobFilter, FilterMode 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 @@ -43,37 +57,40 @@ class _UnmatchedFilter(_Rot13Filter): attributes = 'filter=rot13' -@pytest.fixture -def rot13_filter(): - pygit2.filter_register('rot13', _Rot13Filter) +def _filter_fixture(name: str, filter: type[Filter]) -> Generator[None, None, None]: + pygit2.filter_register(name, filter) yield - pygit2.filter_unregister('rot13') + + # Collect any FilterLists that may use this filter before unregistering it + gc.collect() + + pygit2.filter_unregister(name) @pytest.fixture -def passthrough_filter(): - pygit2.filter_register('passthrough-rot13', _PassthroughFilter) - yield - pygit2.filter_unregister('passthrough-rot13') +def rot13_filter() -> Generator[None, None, None]: + yield from _filter_fixture('rot13', _Rot13Filter) @pytest.fixture -def buffered_filter(): - pygit2.filter_register('buffered-rot13', _BufferedFilter) - yield - pygit2.filter_unregister('buffered-rot13') +def passthrough_filter() -> Generator[None, None, None]: + yield from _filter_fixture('passthrough-rot13', _PassthroughFilter) @pytest.fixture -def unmatched_filter(): - pygit2.filter_register('unmatched-rot13', _UnmatchedFilter) - yield - pygit2.filter_unregister('unmatched-rot13') +def buffered_filter() -> Generator[None, None, None]: + yield from _filter_fixture('buffered-rot13', _BufferedFilter) -def test_filter(testrepo, rot13_filter): +@pytest.fixture +def unmatched_filter() -> Generator[None, None, None]: + yield from _filter_fixture('unmatched-rot13', _UnmatchedFilter) + + +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: @@ -82,9 +99,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: @@ -93,9 +111,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: @@ -104,9 +123,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: @@ -115,7 +135,98 @@ 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() + + +def test_filterlist_none(testrepo: Repository) -> None: + fl = testrepo.load_filter_list('hello.txt') + assert fl is None + + +def test_filterlist_apply_to_buffer_crlf_clean(testrepo: Repository) -> None: + testrepo.config['core.autocrlf'] = True + + 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_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 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: + 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') + + +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' diff --git a/test/test_index.py b/test/test_index.py index cf10eefd..01b04417 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -30,20 +30,21 @@ import pytest import pygit2 -from pygit2 import Repository, Index, Oid +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 @@ -59,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' @@ -70,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 @@ -78,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' @@ -112,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 @@ -121,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() @@ -139,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 @@ -153,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 @@ -167,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. """ @@ -177,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] @@ -185,14 +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_all(testrepo): +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: Repository) -> None: index = testrepo.index assert 'hello.txt' in index index.remove_all(['*.txt']) @@ -201,21 +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_all_aspath(testrepo): +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: 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'] @@ -229,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) @@ -237,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) @@ -245,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) @@ -253,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) @@ -270,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 ( @@ -283,17 +298,42 @@ def test_entry_repr(testrepo): ) -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): - index('read_tree', 'fd937514cb799514d4b81bb24c5fcfeb6472b245') + index('read_tree', 'fd937514cb799514d4b81bb24c5fcfeb6472b245') # type: ignore + + +def test_create_empty_read_tree(testrepo: Repository) -> None: + index = Index() + tree = testrepo['fd937514cb799514d4b81bb24c5fcfeb6472b245'] + assert isinstance(tree, Tree) + index.read_tree(tree) + + +@utils.fails_in_macos +def test_add_conflict(testrepo: Repository) -> None: + ancestor_blob_id = testrepo.create_blob('ancestor') + ancestor = IndexEntry('conflict.txt', ancestor_blob_id, FileMode.BLOB_EXECUTABLE) + ours_blob_id = testrepo.create_blob('ours') + ours = IndexEntry('conflict.txt', ours_blob_id, FileMode.BLOB) -def test_create_empty_read_tree(testrepo): index = Index() - index.read_tree(testrepo['fd937514cb799514d4b81bb24c5fcfeb6472b245']) + assert index.conflicts is None + + index.add_conflict(ancestor, ours, None) + + assert index.conflicts is not None + assert 'conflict.txt' in index.conflicts + conflict_ancestor, conflict_ours, conflict_theirs = index.conflicts['conflict.txt'] + assert conflict_ancestor.id == ancestor_blob_id + assert conflict_ancestor.mode == FileMode.BLOB_EXECUTABLE + assert conflict_ours.id == ours_blob_id + assert conflict_ours.mode == FileMode.BLOB + assert conflict_theirs is None diff --git a/test/test_mailmap.py b/test/test_mailmap.py index 0adeaa63..44da270f 100644 --- a/test/test_mailmap.py +++ b/test/test_mailmap.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -27,7 +27,6 @@ from pygit2 import Mailmap - TEST_MAILMAP = """\ # Simple Comment line @@ -63,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 @@ -81,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: diff --git a/test/test_merge.py b/test/test_merge.py index cadc1657..492c0034 100644 --- a/test/test_merge.py +++ b/test/test_merge.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -30,18 +30,29 @@ import pytest import pygit2 -from pygit2.enums import FileStatus, MergeAnalysis, MergeFavor, MergeFlag, MergeFileFlag +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 -def test_merge_analysis_uptodate(mergerepo): +# 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' - branch_id = mergerepo.get(branch_head_hex).id + + 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 analysis, preference = mergerepo.merge_analysis(branch_id) assert analysis & MergeAnalysis.UP_TO_DATE @@ -54,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 @@ -69,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 @@ -80,15 +91,18 @@ 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): + 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): +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') @@ -96,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 @@ -131,8 +145,8 @@ def test_merge_no_fastforward_conflicts(mergerepo): assert {'.gitignore': FileStatus.INDEX_MODIFIED} == mergerepo.status() -def test_merge_remove_conflicts(mergerepo): - other_branch_tip = '1b2bae55ac95a4be3f8983b86cd579226d0eb247' +def test_merge_remove_conflicts(mergerepo: Repository) -> None: + other_branch_tip = pygit2.Oid(hex='1b2bae55ac95a4be3f8983b86cd579226d0eb247') mergerepo.merge(other_branch_tip) idx = mergerepo.index conflicts = idx.conflicts @@ -141,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') @@ -157,31 +171,30 @@ def test_merge_remove_conflicts(mergerepo): MergeFavor.UNION, ], ) -def test_merge_favor(mergerepo, favor): - branch_head_hex = '1b2bae55ac95a4be3f8983b86cd579226d0eb247' - mergerepo.merge(branch_head_hex, favor=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): - branch_head_hex = '1b2bae55ac95a4be3f8983b86cd579226d0eb247' +def test_merge_fail_on_conflict(mergerepo: Repository) -> None: + branch_head = pygit2.Oid(hex='1b2bae55ac95a4be3f8983b86cd579226d0eb247') - with pytest.raises(pygit2.GitError): + with pytest.raises(pygit2.GitError, match=r'merge conflicts exist'): mergerepo.merge( - branch_head_hex, flags=MergeFlag.FIND_RENAMES | MergeFlag.FAIL_ON_CONFLICT + branch_head, flags=MergeFlag.FIND_RENAMES | MergeFlag.FAIL_ON_CONFLICT ) -def test_merge_commits(mergerepo): - branch_head_hex = '03490f16b15a09913edb3a067a3dc67fbb8d41f1' - branch_id = mergerepo.get(branch_head_hex).id +def test_merge_commits(mergerepo: Repository) -> None: + branch_head = pygit2.Oid(hex='03490f16b15a09913edb3a067a3dc67fbb8d41f1') - merge_index = mergerepo.merge_commits(mergerepo.head.target, branch_head_hex) + merge_index = mergerepo.merge_commits(mergerepo.head.target, branch_head) assert merge_index.conflicts is None merge_commits_tree = merge_index.write_tree(mergerepo) - mergerepo.merge(branch_id) + mergerepo.merge(branch_head) index = mergerepo.index assert index.conflicts is None merge_tree = index.write_tree() @@ -189,27 +202,24 @@ def test_merge_commits(mergerepo): assert merge_tree == merge_commits_tree -def test_merge_commits_favor(mergerepo): - branch_head_hex = '1b2bae55ac95a4be3f8983b86cd579226d0eb247' +def test_merge_commits_favor(mergerepo: Repository) -> None: + branch_head = pygit2.Oid(hex='1b2bae55ac95a4be3f8983b86cd579226d0eb247') merge_index = mergerepo.merge_commits( - mergerepo.head.target, branch_head_hex, favor=MergeFavor.OURS + mergerepo.head.target, branch_head, favor=MergeFavor.OURS ) assert merge_index.conflicts is None # Incorrect favor value - with pytest.raises(TypeError): - mergerepo.merge_commits(mergerepo.head.target, branch_head_hex, favor='foo') + with pytest.raises(TypeError, match=r'favor argument must be MergeFavor'): + mergerepo.merge_commits(mergerepo.head.target, branch_head, favor='foo') # type: ignore -def test_merge_trees(mergerepo): - branch_head_hex = '03490f16b15a09913edb3a067a3dc67fbb8d41f1' - branch_id = mergerepo.get(branch_head_hex).id +def test_merge_trees(mergerepo: Repository) -> None: + branch_id = pygit2.Oid(hex='03490f16b15a09913edb3a067a3dc67fbb8d41f1') ancestor_id = mergerepo.merge_base(mergerepo.head.target, branch_id) - merge_index = mergerepo.merge_trees( - ancestor_id, mergerepo.head.target, branch_head_hex - ) + merge_index = mergerepo.merge_trees(ancestor_id, mergerepo.head.target, branch_id) assert merge_index.conflicts is None merge_commits_tree = merge_index.write_tree(mergerepo) @@ -221,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( @@ -231,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 ) @@ -271,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( @@ -290,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( @@ -309,38 +324,56 @@ 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_hex = '1b2bae55ac95a4be3f8983b86cd579226d0eb247' - mergerepo.merge(branch_head_hex) + branch_head = pygit2.Oid(hex='1b2bae55ac95a4be3f8983b86cd579226d0eb247') + mergerepo.merge(branch_head) - assert mergerepo.listall_mergeheads() == [pygit2.Oid(hex=branch_head_hex)] + assert mergerepo.listall_mergeheads() == [branch_head] mergerepo.state_cleanup() - assert ( - mergerepo.listall_mergeheads() == [] - ), 'state_cleanup() should wipe the mergeheads' + assert mergerepo.listall_mergeheads() == [], ( + 'state_cleanup() should wipe the mergeheads' + ) -def test_merge_message(mergerepo): +def test_merge_message(mergerepo: Repository) -> None: assert not mergerepo.message assert not mergerepo.raw_message - branch_head_hex = '1b2bae55ac95a4be3f8983b86cd579226d0eb247' - mergerepo.merge(branch_head_hex) + branch_head = pygit2.Oid(hex='1b2bae55ac95a4be3f8983b86cd579226d0eb247') + mergerepo.merge(branch_head) - assert mergerepo.message.startswith(f"Merge commit '{branch_head_hex}'") + assert mergerepo.message.startswith(f"Merge commit '{branch_head}'") assert mergerepo.message.encode('utf-8') == mergerepo.raw_message mergerepo.state_cleanup() assert not mergerepo.message -def test_merge_remove_message(mergerepo): - branch_head_hex = '1b2bae55ac95a4be3f8983b86cd579226d0eb247' - mergerepo.merge(branch_head_hex) +def test_merge_remove_message(mergerepo: Repository) -> None: + branch_head = pygit2.Oid(hex='1b2bae55ac95a4be3f8983b86cd579226d0eb247') + mergerepo.merge(branch_head) - assert mergerepo.message.startswith(f"Merge commit '{branch_head_hex}'") + assert mergerepo.message.startswith(f"Merge commit '{branch_head}'") mergerepo.remove_message() assert not mergerepo.message + + +def test_merge_commit(mergerepo: Repository) -> None: + commit = mergerepo['1b2bae55ac95a4be3f8983b86cd579226d0eb247'] + assert isinstance(commit, pygit2.Commit) + mergerepo.merge(commit) + + assert mergerepo.message.startswith(f"Merge commit '{str(commit.id)}'") + assert mergerepo.listall_mergeheads() == [commit.id] + + +def test_merge_reference(mergerepo: Repository) -> None: + branch = mergerepo.branches.local['branch-conflicts'] + branch_head_hex = '1b2bae55ac95a4be3f8983b86cd579226d0eb247' + mergerepo.merge(branch) + + assert mergerepo.message.startswith("Merge branch 'branch-conflicts'") + assert mergerepo.listall_mergeheads() == [pygit2.Oid(hex=branch_head_hex)] diff --git a/test/test_nonunicode.py b/test/test_nonunicode.py new file mode 100644 index 00000000..cdf1ce16 --- /dev/null +++ b/test/test_nonunicode.py @@ -0,0 +1,59 @@ +# Copyright 2010-2024 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 non unicode byte strings""" + +import os +import shutil +import sys + +import pytest + +import pygit2 +from pygit2 import Repository + +from . import utils + +# FIXME Detect the filesystem rather than the operating system +works_in_linux = pytest.mark.xfail( + sys.platform != 'linux', + reason='fails in macOS/Windows, and also in Linux with the FAT filesystem', +) + + +@utils.requires_network +@works_in_linux +def test_nonunicode_branchname(testrepo: Repository) -> None: + folderpath = 'temp_repo_nonutf' + if os.path.exists(folderpath): + shutil.rmtree(folderpath) + newrepo = pygit2.clone_repository( + path=folderpath, url='https://github.com/pygit2/test_branch_notutf.git' + ) + bstring = b'\xc3master' + assert bstring in [ + (ref.split('/')[-1]).encode('utf8', 'surrogateescape') + for ref in newrepo.listall_references() + ] # Remote branch among references: 'refs/remotes/origin/\udcc3master' diff --git a/test/test_note.py b/test/test_note.py index dbd85ec9..6a1846c0 100644 --- a/test/test_note.py +++ b/test/test_note.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -25,9 +25,9 @@ """Tests for note objects.""" -from pygit2 import Signature import pytest +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 diff --git a/test/test_object.py b/test/test_object.py index 40c01d7a..fa1a8311 100644 --- a/test/test_object.py +++ b/test/test_object.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -27,10 +27,9 @@ import pytest -from pygit2 import Tree, Tag +from pygit2 import Commit, Object, Oid, Repository, Tag, Tree from pygit2.enums import ObjectType - BLOB_SHA = 'a520c24d85fbfc815d385957eed41406ca5a860b' BLOB_CONTENT = """hello world hola mundo @@ -40,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] @@ -51,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] @@ -81,27 +80,27 @@ 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] # and peel to the tree tree = commit.peel(ObjectType.TREE) - assert type(tree) == Tree + assert type(tree) is Tree 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) - assert type(tree) == Tree + assert type(tree) is Tree 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] @@ -109,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] @@ -117,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) @@ -139,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 diff --git a/test/test_odb.py b/test/test_odb.py index fe91c957..f0657af5 100644 --- a/test/test_odb.py +++ b/test/test_odb.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -27,22 +27,24 @@ # Standard Library import binascii +from collections.abc import Generator from pathlib import Path import pytest # pygit2 -from pygit2 import Odb, Oid +from pygit2 import Odb, Oid, Repository from pygit2.enums import ObjectType -from . import utils +from . import utils 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): +def test_emptyodb(barerepo: Repository) -> None: odb = Odb() assert len(list(odb)) == 0 @@ -53,42 +55,57 @@ 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) 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') 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 (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): +def test_write(odb: Odb) -> None: data = b'hello world' # invalid object type with pytest.raises(ValueError): odb.write(ObjectType.ANY, data) oid = odb.write(ObjectType.BLOB, data) - assert type(oid) == Oid + assert type(oid) is Oid diff --git a/test/test_odb_backend.py b/test/test_odb_backend.py index c99d47f4..89c21572 100644 --- a/test/test_odb_backend.py +++ b/test/test_odb_backend.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -27,15 +27,17 @@ # Standard Library import binascii +from collections.abc import Generator, Iterator from pathlib import Path import pytest # pygit2 import pygit2 +from pygit2 import Odb, Oid, Repository from pygit2.enums import ObjectType -from . import utils +from . import utils BLOB_HEX = 'af431f20fc541ed6d5afede3e2dc7160f6f01f16' BLOB_RAW = binascii.unhexlify(BLOB_HEX.encode('ascii')) @@ -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) diff --git a/test/test_oid.py b/test/test_oid.py index c16d688f..d57432bf 100644 --- a/test/test_oid.py +++ b/test/test_oid.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -28,50 +28,50 @@ # Standard Library from binascii import unhexlify -from pygit2 import Oid import pytest +from pygit2 import Oid HEX = '15b648aec6ed045b5ca6f57f8b7831a8b4757298' 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)) @@ -99,3 +99,10 @@ def test_hash(): s.add(Oid(hex='0000000000000000000000000000000000000000')) s.add(Oid(hex='0000000000000000000000000000000000000001')) assert len(s) == 3 + + +def test_bool() -> None: + assert Oid(raw=RAW) + assert Oid(hex=HEX) + assert not Oid(raw=b'') + assert not Oid(hex='0000000000000000000000000000000000000000') diff --git a/test/test_options.py b/test/test_options.py index 5913f67b..6a1c1ac0 100644 --- a/test/test_options.py +++ b/test/test_options.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -23,12 +23,16 @@ # 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 -def __option(getter, setter, value): +def __option(getter: Option, setter: Option, value: object) -> None: old_value = option(getter) option(setter, value) assert value == option(getter) @@ -36,7 +40,7 @@ def __option(getter, setter, value): option(setter, old_value) -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 +48,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 +93,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 +101,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 +120,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 +132,131 @@ 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) + + +def test_template_path() -> None: + original_path = option(Option.GET_TEMPLATE_PATH) + + test_path = '/tmp/test_templates' + option(Option.SET_TEMPLATE_PATH, test_path) + assert option(Option.GET_TEMPLATE_PATH) == test_path + + if original_path: + option(Option.SET_TEMPLATE_PATH, original_path) + else: + option(Option.SET_TEMPLATE_PATH, None) + + +def test_user_agent() -> None: + original_agent = option(Option.GET_USER_AGENT) + + test_agent = 'test-agent/1.0' + option(Option.SET_USER_AGENT, test_agent) + assert option(Option.GET_USER_AGENT) == test_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) + + +@pytest.mark.skipif(sys.platform != 'win32', reason='Windows-specific feature') +def test_windows_sharemode() -> None: + __option(Option.GET_WINDOWS_SHAREMODE, Option.SET_WINDOWS_SHAREMODE, 1) + + +def test_ssl_ciphers() -> None: + # Setting SSL ciphers (no getter available) + try: + option(Option.SET_SSL_CIPHERS, 'DEFAULT') + except pygit2.GitError as e: + if "TLS backend doesn't support custom ciphers" in str(e): + pytest.skip(str(e)) + raise + + +def test_enable_http_expect_continue() -> None: + option(Option.ENABLE_HTTP_EXPECT_CONTINUE, True) + option(Option.ENABLE_HTTP_EXPECT_CONTINUE, False) + + +def test_odb_priorities() -> None: + option(Option.SET_ODB_PACKED_PRIORITY, 1) + option(Option.SET_ODB_LOOSE_PRIORITY, 2) + + +def test_extensions() -> None: + original_extensions = option(Option.GET_EXTENSIONS) + assert isinstance(original_extensions, list) + + test_extensions = ['objectformat', 'worktreeconfig'] + option(Option.SET_EXTENSIONS, test_extensions, len(test_extensions)) + + new_extensions = option(Option.GET_EXTENSIONS) + assert isinstance(new_extensions, list) + + # 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}" + + option(Option.SET_EXTENSIONS, [], 0) + empty_extensions = option(Option.GET_EXTENSIONS) + assert isinstance(empty_extensions, list) + + 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 + + if original_extensions: + option(Option.SET_EXTENSIONS, original_extensions, len(original_extensions)) + else: + option(Option.SET_EXTENSIONS, [], 0) + + final_extensions = option(Option.GET_EXTENSIONS) + assert set(final_extensions) == set(original_extensions) + + +def test_homedir() -> None: + original_homedir = option(Option.GET_HOMEDIR) + + test_homedir = '/tmp/test_home' + option(Option.SET_HOMEDIR, test_homedir) + assert option(Option.GET_HOMEDIR) == test_homedir + + if original_homedir: + option(Option.SET_HOMEDIR, original_homedir) + else: + option(Option.SET_HOMEDIR, None) + + +def test_server_timeouts() -> None: + 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) + + 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: + original_product = option(Option.GET_USER_AGENT_PRODUCT) + + test_product = 'test-product' + option(Option.SET_USER_AGENT_PRODUCT, test_product) + assert option(Option.GET_USER_AGENT_PRODUCT) == test_product + + if original_product: + option(Option.SET_USER_AGENT_PRODUCT, original_product) + + +def test_mwindow_file_limit() -> None: + __option(Option.GET_MWINDOW_FILE_LIMIT, Option.SET_MWINDOW_FILE_LIMIT, 100) diff --git a/test/test_packbuilder.py b/test/test_packbuilder.py index f61d475f..5309f3f1 100644 --- a/test/test_packbuilder.py +++ b/test/test_packbuilder.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -25,20 +25,22 @@ """Tests for Index files.""" +from collections.abc import Callable from pathlib import Path 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] @@ -48,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: @@ -62,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(): @@ -78,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: @@ -86,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/test_patch.py b/test/test_patch.py index d15a2e53..4b74dd57 100644 --- a/test/test_patch.py +++ b/test/test_patch.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -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 pygit2 import Blob, Repository BLOB_OLD_SHA = 'a520c24d85fbfc815d385957eed41406ca5a860b' BLOB_NEW_SHA = '3b18e512dba79e4c8300dd08aeb37f8e728b8dad' @@ -80,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, @@ -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 diff --git a/test/test_patch_encoding.py b/test/test_patch_encoding.py index 12f8b514..4a151a70 100644 --- a/test/test_patch_encoding.py +++ b/test/test_patch_encoding.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -24,7 +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 @@ -36,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' @@ -55,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', ) diff --git a/test/test_refdb_backend.py b/test/test_refdb_backend.py index 19a944b1..bfc37551 100644 --- a/test/test_refdb_backend.py +++ b/test/test_refdb_backend.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -25,11 +25,14 @@ """Tests for Refdb objects.""" +from collections.abc import Generator, Iterator from pathlib import Path -import pygit2 import pytest +import pygit2 +from pygit2 import Commit, Oid, Reference, Repository, Signature + # Note: the refdb abstraction from libgit2 is meant to provide information # which libgit2 transforms into something more useful, and in general YMMV by @@ -37,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() @@ -115,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') diff --git a/test/test_refs.py b/test/test_refs.py index a6caa103..276a0cdb 100644 --- a/test/test_refs.py +++ b/test/test_refs.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -29,15 +29,24 @@ import pytest -from pygit2 import Commit, Signature, Tree, reference_is_valid_name -from pygit2 import AlreadyExistsError, GitError, InvalidSpecError -from pygit2.enums import ReferenceType - +from pygit2 import ( + AlreadyExistsError, + Commit, + GitError, + InvalidSpecError, + Oid, + Reference, + Repository, + Signature, + Tree, + reference_is_valid_name, +) +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'), @@ -45,7 +54,7 @@ def test_refs_list_objects(testrepo): ] -def test_refs_list(testrepo): +def test_refs_list(testrepo: Repository) -> None: # Without argument assert sorted(testrepo.references) == ['refs/heads/i18n', 'refs/heads/master'] @@ -58,13 +67,14 @@ def test_refs_list(testrepo): ] -def test_head(testrepo): +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 -def test_refs_getitem(testrepo): +def test_refs_getitem(testrepo: Repository) -> None: refname = 'refs/foo' # Raise KeyError ? with pytest.raises(KeyError): @@ -75,41 +85,48 @@ def test_refs_getitem(testrepo): # 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): +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): +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 -def test_refs_set_sha_prefix(testrepo): +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): +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): +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): +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') @@ -117,15 +134,17 @@ def test_refs_set_target(testrepo): assert reference.raw_target == b'refs/heads/i18n' -def test_refs_get_shorthand(testrepo): +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' -def test_refs_set_target_with_message(testrepo): +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') @@ -139,7 +158,7 @@ def test_refs_set_target_with_message(testrepo): assert first.committer == sig -def test_refs_delete(testrepo): +def test_refs_delete(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) assert 'refs/tags/version1' in testrepo.references @@ -163,7 +182,7 @@ def test_refs_delete(testrepo): reference.rename('refs/tags/version2') -def test_refs_rename(testrepo): +def test_refs_rename(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) assert reference.name == 'refs/tags/version1' @@ -177,7 +196,7 @@ def test_refs_rename(testrepo): reference.rename('b1') -# def test_reload(testrepo): +# def test_reload(testrepo: Repository) -> None: # name = 'refs/tags/version1' # ref = testrepo.create_reference(name, "refs/heads/master", symbolic=True) # ref2 = testrepo.lookup_reference(name) @@ -187,26 +206,31 @@ def test_refs_rename(testrepo): # with pytest.raises(GitError): getattr(ref2, 'name') -def test_refs_resolve(testrepo): +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 assert reference.target == LAST_COMMIT -def test_refs_resolve_identity(testrepo): +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): +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 @@ -220,7 +244,7 @@ def test_refs_create(testrepo): assert reference.target == LAST_COMMIT -def test_refs_create_symbolic(testrepo): +def test_refs_create_symbolic(testrepo: Repository) -> None: # We add a tag as a new symbolic reference that always points to # "refs/heads/master" reference = testrepo.references.create('refs/tags/beta', 'refs/heads/master') @@ -241,20 +265,22 @@ def test_refs_create_symbolic(testrepo): assert reference.raw_target == b'refs/heads/master' -# def test_packall_references(testrepo): +# def test_packall_references(testrepo: Repository) -> None: # testrepo.packall_references() -def test_refs_peel(testrepo): +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 commit = ref.peel(Commit) assert commit.tree.id == ref.peel(Tree).id -def test_refs_equality(testrepo): +def test_refs_equality(testrepo: Repository) -> None: ref1 = testrepo.references.get('refs/heads/master') ref2 = testrepo.references.get('refs/heads/master') ref3 = testrepo.references.get('refs/heads/i18n') @@ -267,7 +293,7 @@ def test_refs_equality(testrepo): assert not ref1 == ref3 -def test_refs_compress(testrepo): +def test_refs_compress(testrepo: Repository) -> None: packed_refs_file = Path(testrepo.path) / 'packed-refs' assert not packed_refs_file.exists() old_refs = [(ref.name, ref.target) for ref in testrepo.references.objects] @@ -283,7 +309,7 @@ def test_refs_compress(testrepo): # -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()] @@ -293,7 +319,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 @@ -317,14 +343,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' @@ -350,7 +376,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)) @@ -362,7 +390,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)) @@ -372,7 +400,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' @@ -389,41 +417,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 ? @@ -435,7 +465,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 @@ -465,7 +495,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 @@ -507,37 +537,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' @@ -546,14 +576,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' @@ -565,10 +595,13 @@ def test_set_target_with_message(testrepo): 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): +def test_delete(testrepo: Repository) -> None: repo = testrepo # We add a tag as a new reference that points to "origin/master" @@ -596,7 +629,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' @@ -604,7 +637,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 @@ -616,7 +649,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() @@ -624,13 +657,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() @@ -651,7 +684,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' @@ -663,7 +696,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" @@ -684,7 +717,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' @@ -696,7 +729,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 @@ -705,21 +738,22 @@ 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) 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') @@ -727,12 +761,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') @@ -747,12 +781,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_remote.py b/test/test_remote.py index 1f3fcca9..d4c3bea6 100644 --- a/test/test_remote.py +++ b/test/test_remote.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -23,18 +23,17 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. -"""Tests for Remote objects.""" - -from unittest.mock import patch import sys +from collections.abc import Generator +from pathlib import Path import pytest import pygit2 -from pygit2 import Oid -from pygit2.ffi import ffi -from . import utils +from pygit2 import Remote, Repository +from pygit2.remotes import PushUpdate, TransferProgress +from . import utils REMOTE_NAME = 'origin' REMOTE_URL = 'https://github.com/libgit2/pygit2.git' @@ -48,13 +47,13 @@ ORIGIN_REFSPEC = '+refs/heads/*:refs/remotes/origin/*' -def test_remote_create(testrepo): +def test_remote_create(testrepo: Repository) -> None: name = 'upstream' url = 'https://github.com/libgit2/pygit2.git' remote = testrepo.remotes.create(name, url) - assert type(remote) == pygit2.Remote + assert type(remote) is pygit2.Remote assert name == remote.name assert url == remote.url assert remote.push_url is None @@ -63,21 +62,21 @@ def test_remote_create(testrepo): testrepo.remotes.create(*(name, url)) -def test_remote_create_with_refspec(testrepo): +def test_remote_create_with_refspec(testrepo: Repository) -> None: name = 'upstream' url = 'https://github.com/libgit2/pygit2.git' fetch = '+refs/*:refs/*' remote = testrepo.remotes.create(name, url, fetch) - assert type(remote) == pygit2.Remote + assert type(remote) is pygit2.Remote assert name == remote.name assert url == remote.url assert [fetch] == remote.fetch_refspecs assert remote.push_url is None -def test_remote_create_anonymous(testrepo): +def test_remote_create_anonymous(testrepo: Repository) -> None: url = 'https://github.com/libgit2/pygit2.git' remote = testrepo.remotes.create_anonymous(url) @@ -88,7 +87,7 @@ def test_remote_create_anonymous(testrepo): assert [] == remote.push_refspecs -def test_remote_delete(testrepo): +def test_remote_delete(testrepo: Repository) -> None: name = 'upstream' url = 'https://github.com/libgit2/pygit2.git' @@ -101,7 +100,7 @@ def test_remote_delete(testrepo): assert 1 == len(testrepo.remotes) -def test_remote_rename(testrepo): +def test_remote_rename(testrepo: Repository) -> None: remote = testrepo.remotes[0] assert REMOTE_NAME == remote.name @@ -112,10 +111,10 @@ def test_remote_rename(testrepo): with pytest.raises(ValueError): testrepo.remotes.rename('', '') with pytest.raises(ValueError): - testrepo.remotes.rename(None, None) + testrepo.remotes.rename(None, None) # type: ignore -def test_remote_set_url(testrepo): +def test_remote_set_url(testrepo: Repository) -> None: remote = testrepo.remotes['origin'] assert REMOTE_URL == remote.url @@ -134,7 +133,7 @@ def test_remote_set_url(testrepo): testrepo.remotes.set_push_url('origin', '') -def test_refspec(testrepo): +def test_refspec(testrepo: Repository) -> None: remote = testrepo.remotes['origin'] assert remote.refspec_count == 1 @@ -144,7 +143,7 @@ def test_refspec(testrepo): assert refspec.force is True assert ORIGIN_REFSPEC == refspec.string - assert list == type(remote.fetch_refspecs) + assert list is type(remote.fetch_refspecs) assert 1 == len(remote.fetch_refspecs) assert ORIGIN_REFSPEC == remote.fetch_refspecs[0] @@ -153,18 +152,18 @@ def test_refspec(testrepo): assert 'refs/remotes/origin/master' == refspec.transform('refs/heads/master') assert 'refs/heads/master' == refspec.rtransform('refs/remotes/origin/master') - assert list == type(remote.push_refspecs) + assert list is type(remote.push_refspecs) assert 0 == len(remote.push_refspecs) push_specs = remote.push_refspecs - assert list == type(push_specs) + assert list is type(push_specs) assert 0 == len(push_specs) testrepo.remotes.add_fetch('origin', '+refs/test/*:refs/test/remotes/*') remote = testrepo.remotes['origin'] fetch_specs = remote.fetch_refspecs - assert list == type(fetch_specs) + assert list is type(fetch_specs) assert 2 == len(fetch_specs) assert [ '+refs/heads/*:refs/remotes/origin/*', @@ -174,13 +173,13 @@ def test_refspec(testrepo): testrepo.remotes.add_push('origin', '+refs/test/*:refs/test/remotes/*') with pytest.raises(TypeError): - testrepo.remotes.add_fetch(['+refs/*:refs/*', 5]) + testrepo.remotes.add_fetch(['+refs/*:refs/*', 5]) # type: ignore remote = testrepo.remotes['origin'] assert ['+refs/test/*:refs/test/remotes/*'] == remote.push_refspecs -def test_remote_list(testrepo): +def test_remote_list(testrepo: Repository) -> None: assert 1 == len(testrepo.remotes) remote = testrepo.remotes[0] assert REMOTE_NAME == remote.name @@ -194,18 +193,59 @@ def test_remote_list(testrepo): @utils.requires_network -def test_ls_remotes(testrepo): +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. - 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_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 -def test_remote_collection(testrepo): +@utils.requires_network +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.list_heads(connect=False) + + remote.connect() + 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')) + + +def test_remote_collection(testrepo: Repository) -> None: remote = testrepo.remotes['origin'] assert REMOTE_NAME == remote.name assert REMOTE_URL == remote.url @@ -220,8 +260,8 @@ def test_remote_collection(testrepo): assert remote.name in [x.name for x in testrepo.remotes] -@utils.refcount -def test_remote_refcount(testrepo): +@utils.requires_refcount +def test_remote_refcount(testrepo: Repository) -> None: start = sys.getrefcount(testrepo) remote = testrepo.remotes[0] del remote @@ -229,7 +269,7 @@ def test_remote_refcount(testrepo): assert start == end -def test_fetch(emptyrepo): +def test_fetch(emptyrepo: Repository) -> None: remote = emptyrepo.remotes[0] stats = remote.fetch() assert stats.received_bytes > 2700 @@ -239,7 +279,7 @@ def test_fetch(emptyrepo): @utils.requires_network -def test_fetch_depth_zero(testrepo): +def test_fetch_depth_zero(testrepo: Repository) -> None: remote = testrepo.remotes[0] stats = remote.fetch(REMOTE_FETCHTEST_FETCHSPECS, depth=0) assert stats.indexed_objects == REMOTE_REPO_FETCH_ALL_OBJECTS @@ -247,17 +287,17 @@ def test_fetch_depth_zero(testrepo): @utils.requires_network -def test_fetch_depth_one(testrepo): +def test_fetch_depth_one(testrepo: Repository) -> None: remote = testrepo.remotes[0] stats = remote.fetch(REMOTE_FETCHTEST_FETCHSPECS, depth=1) assert stats.indexed_objects == REMOTE_REPO_FETCH_HEAD_COMMIT_OBJECTS assert stats.received_objects == REMOTE_REPO_FETCH_HEAD_COMMIT_OBJECTS -def test_transfer_progress(emptyrepo): +def test_transfer_progress(emptyrepo: Repository) -> None: class MyCallbacks(pygit2.RemoteCallbacks): - def transfer_progress(emptyrepo, stats): - emptyrepo.tp = stats + def transfer_progress(self, stats: TransferProgress) -> None: + self.tp = stats callbacks = MyCallbacks() remote = emptyrepo.remotes[0] @@ -267,27 +307,29 @@ def transfer_progress(emptyrepo, stats): assert stats.received_objects == callbacks.tp.received_objects -def test_update_tips(emptyrepo): +def test_update_tips(emptyrepo: Repository) -> None: remote = emptyrepo.remotes[0] tips = [ ( 'refs/remotes/origin/master', - Oid(hex='0' * 40), - Oid(hex='784855caf26449a1914d2cf62d12b9374d76ae78'), + pygit2.Oid(hex='0' * 40), + pygit2.Oid(hex='784855caf26449a1914d2cf62d12b9374d76ae78'), ), ( 'refs/tags/root', - Oid(hex='0' * 40), - Oid(hex='3d2962987c695a29f1f80b6c3aa4ec046ef44369'), + pygit2.Oid(hex='0' * 40), + pygit2.Oid(hex='3d2962987c695a29f1f80b6c3aa4ec046ef44369'), ), ] 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 @@ -296,14 +338,46 @@ def update_tips(self, name, old, new): assert callbacks.i > 0 +@utils.requires_network +def test_list_heads_certificate_check() -> None: + url = 'https://github.com/pygit2/empty.git' + + class MyCallbacks(pygit2.RemoteCallbacks): + def __init__(self) -> None: + self.i = 0 + + def certificate_check( + self, certificate: None, valid: bool, host: str | bytes + ) -> bool: + self.i += 1 + + assert certificate is None + assert valid is True + assert host == b'github.com' + return True + + # We create an in-memory repository + git = pygit2.Repository() + remote = git.remotes.create_anonymous(url) + + callbacks = MyCallbacks() + refs = remote.list_heads(callbacks=callbacks) + + # Sanity check that we indeed got some refs. + assert len(refs) > 0 + + # Make sure our certificate_check callback triggered. + assert callbacks.i > 0 + + @pytest.fixture -def origin(tmp_path): +def origin(tmp_path: Path) -> Generator[Repository, None, None]: with utils.TemporaryRepository('barerepo.zip', tmp_path) as path: yield pygit2.Repository(path) @pytest.fixture -def clone(tmp_path): +def clone(tmp_path: Path) -> Generator[Repository, None, None]: clone = tmp_path / 'clone' clone.mkdir() with utils.TemporaryRepository('barerepo.zip', clone) as path: @@ -311,11 +385,13 @@ def clone(tmp_path): @pytest.fixture -def remote(origin, clone): +def remote(origin: Repository, clone: Repository) -> Generator[Remote, None, None]: yield clone.remotes.create('origin', origin.path) -def test_push_fast_forward_commits_to_remote_succeeds(origin, clone, remote): +def test_push_fast_forward_commits_to_remote_succeeds( + origin: Repository, clone: Repository, remote: Remote +) -> None: tip = clone[clone.head.target] oid = clone.create_commit( 'refs/heads/master', @@ -329,14 +405,87 @@ def test_push_fast_forward_commits_to_remote_succeeds(origin, clone, remote): assert origin[origin.head.target].id == oid -def test_push_when_up_to_date_succeeds(origin, clone, remote): +def test_push_when_up_to_date_succeeds( + origin: Repository, clone: Repository, remote: Remote +) -> None: remote.push(['refs/heads/master']) origin_tip = origin[origin.head.target].id clone_tip = clone[clone.head.target].id assert origin_tip == clone_tip -def test_push_non_fast_forward_commits_to_remote_fails(origin, clone, remote): +def test_push_transfer_progress( + origin: Repository, clone: Repository, remote: Remote +) -> None: + tip = clone[clone.head.target] + new_tip_id = clone.create_commit( + 'refs/heads/master', + tip.author, + tip.author, + 'empty commit', + tip.tree.id, + [tip.id], + ) + + # NOTE: We're currently not testing bytes_pushed due to a bug in libgit2 + # 1.9.0: it passes a junk value for bytes_pushed when pushing to a remote + # on the local filesystem, as is the case in this unit test. (When pushing + # to a remote over the network, the value is correct.) + class MyCallbacks(pygit2.RemoteCallbacks): + def push_transfer_progress( + self, objects_pushed: int, total_objects: int, bytes_pushed: int + ) -> None: + self.objects_pushed = objects_pushed + self.total_objects = total_objects + + assert origin.branches['master'].target == tip.id + + callbacks = MyCallbacks() + remote.push(['refs/heads/master'], callbacks=callbacks) + assert callbacks.objects_pushed == 1 + assert callbacks.total_objects == 1 + 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, reject_from: str +) -> None: + reject_message = 'retreat! retreat!' + + tip = clone[clone.head.target] + clone.create_commit( + 'refs/heads/master', + tip.author, + tip.author, + 'empty commit', + tip.tree.id, + [tip.id], + ) + + 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: + if reject_from == 'push_transfer_progress': + raise InterruptedError(reject_message) + + assert origin.branches['master'].target == tip.id + + callbacks = MyCallbacks() + with pytest.raises(InterruptedError, match='retreat! retreat!'): + remote.push(['refs/heads/master'], callbacks=callbacks) + + assert origin.branches['master'].target == tip.id + + +def test_push_non_fast_forward_commits_to_remote_fails( + origin: Repository, clone: Repository, remote: Remote +) -> None: tip = origin[origin.head.target] origin.create_commit( 'refs/heads/master', @@ -360,22 +509,81 @@ def test_push_non_fast_forward_commits_to_remote_fails(origin, clone, remote): remote.push(['refs/heads/master']) -@patch.object(pygit2.callbacks, 'RemoteCallbacks') -def test_push_options(mock_callbacks, origin, clone, remote): - remote.push(['refs/heads/master']) - remote_push_options = mock_callbacks.return_value.push_options.remote_push_options +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 assert remote_push_options.count == 0 - remote.push(['refs/heads/master'], push_options=[]) - remote_push_options = mock_callbacks.return_value.push_options.remote_push_options + callbacks = RemoteCallbacks() + remote.push(['refs/heads/master'], callbacks, push_options=[]) + remote_push_options = callbacks.push_options.remote_push_options assert remote_push_options.count == 0 - remote.push(['refs/heads/master'], push_options=['foo']) - remote_push_options = mock_callbacks.return_value.push_options.remote_push_options + callbacks = RemoteCallbacks() + # Local remotes don't support push_options, so pushing will raise an error. + # However, push_options should still be set in RemoteCallbacks. + with pytest.raises(pygit2.GitError, match='push-options not supported by remote'): + remote.push(['refs/heads/master'], callbacks, push_options=['foo']) + remote_push_options = callbacks.push_options.remote_push_options assert remote_push_options.count == 1 # strings pointed to by remote_push_options.strings[] are already freed - remote.push(['refs/heads/master'], push_options=['Option A', 'Option B']) - remote_push_options = mock_callbacks.return_value.push_options.remote_push_options + callbacks = RemoteCallbacks() + with pytest.raises(pygit2.GitError, match='push-options not supported by remote'): + remote.push(['refs/heads/master'], callbacks, push_options=['Opt A', 'Opt B']) + remote_push_options = callbacks.push_options.remote_push_options assert remote_push_options.count == 2 # strings pointed to by remote_push_options.strings[] are already freed + + +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 + + callbacks = RemoteCallbacks() + remote.push(['refs/heads/master'], callbacks, threads=0) + assert callbacks.push_options.pb_parallelism == 0 + + 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 diff --git a/test/test_remote_prune.py b/test/test_remote_prune.py index 1688626d..2fdaab0d 100644 --- a/test/test_remote_prune.py +++ b/test/test_remote_prune.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -23,14 +23,20 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. +from collections.abc import Generator +from pathlib import Path + 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() diff --git a/test/test_remote_utf8.py b/test/test_remote_utf8.py index bd410218..03edd6b4 100644 --- a/test/test_remote_utf8.py +++ b/test/test_remote_utf8.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -23,17 +23,22 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. -import pygit2 +from collections.abc import Generator +from pathlib import Path + import pytest + +import pygit2 + from . import utils @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() diff --git a/test/test_repository.py b/test/test_repository.py index fb240323..68913ff5 100644 --- a/test/test_repository.py +++ b/test/test_repository.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -23,19 +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 +from typing import Optional import pytest # pygit2 import pygit2 -from pygit2 import init_repository, clone_repository, discover_repository -from pygit2 import Oid +from pygit2 import ( + Blob, + Commit, + 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, RepositoryOpenFlag, @@ -43,34 +58,36 @@ ResetMode, StashApplyProgress, ) +from pygit2.index import MergeFileResult + 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 @@ -79,77 +96,104 @@ 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 + ) -> None: + self.completed_steps = completed_steps + self.total_steps = total_steps + # checkout i18n with conflicts and default strategy should not be possible callbacks = MyCheckoutCallbacks() with pytest.raises(pygit2.GitError): testrepo.checkout(ref_i18n, callbacks=callbacks) # make sure the callbacks caught that assert {'bye.txt'} == callbacks.conflicting_paths + 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) # make sure the callbacks caught the files affected by the checkout assert set() == callbacks.conflicting_paths assert {'bye.txt', 'new'} == callbacks.updated_paths + assert callbacks.completed_steps > 0 + 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() @@ -165,7 +209,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 @@ -174,19 +218,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') @@ -197,7 +241,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') @@ -213,7 +257,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() @@ -222,7 +266,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) @@ -231,7 +275,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', @@ -247,7 +291,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', @@ -272,7 +316,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', @@ -288,7 +332,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() @@ -305,7 +349,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() @@ -326,7 +370,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() @@ -345,11 +389,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( @@ -386,7 +431,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 @@ -405,13 +450,15 @@ def test_stash_partial(testrepo): 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 ) 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 @@ -424,7 +471,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 ) @@ -439,7 +486,7 @@ def test_stash_progress_callback(testrepo): 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 @@ -457,7 +504,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 ) @@ -474,7 +521,7 @@ def test_stash_aborted_from_callbacks(testrepo): # 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!') @@ -496,7 +543,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 ) @@ -512,7 +559,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') @@ -539,9 +593,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 @@ -553,9 +610,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 @@ -573,7 +631,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' @@ -583,65 +641,66 @@ def test_default_signature(testrepo): 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') - assert type(oid) == Oid + assert type(oid) is Oid 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)) -@utils.fspath -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) @@ -649,7 +708,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 @@ -659,14 +718,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) @@ -674,15 +733,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( @@ -695,7 +756,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) @@ -705,9 +766,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' @@ -716,18 +782,32 @@ 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 ) assert repo.lookup_reference('HEAD').target == 'refs/heads/test' +@utils.requires_proxy +@utils.requires_network +def test_clone_with_proxy(tmp_path: Path) -> None: + url = 'https://github.com/libgit2/TestGitRepository' + repo = clone_repository( + url, + tmp_path / 'testrepo-orig.git', + proxy=True, + ) + assert not repo.is_empty + + # FIXME The tests below are commented because they are broken: # # - test_clone_push_url: Passes, but does nothing useful. @@ -769,14 +849,14 @@ def test_clone_with_checkout_branch(barerepo, 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 # 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 @@ -811,8 +891,7 @@ def _check_worktree(worktree): assert testrepo.list_worktrees() == [] -@utils.fspath -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 @@ -822,13 +901,14 @@ 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' # 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 @@ -856,7 +936,7 @@ def test_worktree_custom_ref(testrepo): 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 @@ -890,7 +970,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 @@ -900,7 +980,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 @@ -910,8 +990,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 @@ -923,7 +1003,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: @@ -950,8 +1030,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 @@ -966,3 +1046,152 @@ def test_repository_hashfile_filter(testrepo): testrepo.config['core.safecrlf'] = 'fail' with pytest.raises(pygit2.GitError): h = testrepo.hashfile('hello.txt') + + +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 == 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 theirs + res = testrepo.merge_file_from_index(hello_txt, hello_txt, 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 + ) + 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 theirs + res = testrepo.merge_file_from_index(hello_txt, hello_txt, 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) + 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 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() + + +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, get_hello_txt_from_repo() + ) + + # executable switch on ours + res = testrepo.merge_file_from_index( + hello_txt, hello_txt_executable, hello_txt, use_deprecated=False + ) + assert res == MergeFileResult( + True, + hello_txt.path, + hello_txt_executable.mode, + get_hello_txt_from_repo(), + ) + + # executable switch on theirs + res = testrepo.merge_file_from_index( + hello_txt, hello_txt, hello_txt_executable, use_deprecated=False + ) + assert res == MergeFileResult( + True, + hello_txt.path, + hello_txt_executable.mode, + 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=False + ) + assert res == MergeFileResult( + True, + hello_txt.path, + hello_txt_executable.mode, + get_hello_txt_from_repo(), + ) + + # path switch on ours + res = testrepo.merge_file_from_index( + hello_txt, hello_world, hello_txt, use_deprecated=False + ) + 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, use_deprecated=False + ) + 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, use_deprecated=False + ) + 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 + ) + assert res == MergeFileResult( + True, + hello_world.path, + hello_txt_executable.mode, + 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=False + ) + assert res == MergeFileResult( + True, + hello_world.path, + hello_txt_executable.mode, + get_hello_txt_from_repo(), + ) diff --git a/test/test_repository_bare.py b/test/test_repository_bare.py index d418acdf..4021fc8b 100644 --- a/test/test_repository_bare.py +++ b/test/test_repository_bare.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -23,18 +23,20 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. -# Standard Library import binascii import os -from pathlib import Path +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 +from . import utils HEAD_SHA = '784855caf26449a1914d2cf62d12b9374d76ae78' PARENT_SHA = 'f5e5aa4e36ab0fe62ee1ccc6eb8f79b866863b87' # HEAD^ @@ -43,24 +45,24 @@ 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) == pygit2.Reference + assert type(head) is pygit2.Reference assert not barerepo.head_is_unborn assert not barerepo.head_is_detached -def test_set_head(barerepo): - # Test setting a detatched HEAD. +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 # And test setting a normal HEAD. @@ -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,19 +89,19 @@ 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): barerepo.write(ObjectType.ANY, data) oid = barerepo.write(ObjectType.BLOB, data) - assert type(oid) == pygit2.Oid + 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\n' 'This commit has some additional text.\n' + '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.refcount -def test_lookup_commit_refcount(barerepo): +@utils.requires_refcount +def test_lookup_commit_refcount(barerepo: Repository) -> None: start = sys.getrefcount(barerepo) commit_sha = '5fe808e8953c12735680c257f56600cb0de44b10' commit = barerepo[commit_sha] @@ -170,42 +174,42 @@ 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 = Path(barerepo.path).resolve() + 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: fh.write(data) hashed_sha1 = pygit2.hashfile(tempfile_path) - Path(tempfile_path).unlink() + pathlib.Path(tempfile_path).unlink() written_sha1 = barerepo.create_blob(data) 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) diff --git a/test/test_repository_custom.py b/test/test_repository_custom.py index 5961ef65..40698df0 100644 --- a/test/test_repository_custom.py +++ b/test/test_repository_custom.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -23,15 +23,18 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. +from collections.abc import Generator from pathlib import Path + 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() @@ -48,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'), @@ -56,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 4b923f5e..be8a8d34 100644 --- a/test/test_repository_empty.py +++ b/test/test_repository_empty.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -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 0bc7b40f..d61df77d 100644 --- a/test/test_revparse.py +++ b/test/test_revparse.py @@ -1,4 +1,4 @@ -# Copyright 2020-2024 The pygit2 contributors +# 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, @@ -25,22 +25,23 @@ """Tests for revision parsing.""" -from pygit2 import InvalidSpecError -from pygit2.enums import RevSpecFlag from pytest import raises +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'] @@ -54,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') @@ -76,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') @@ -84,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 a4edd7c8..28cfc406 100644 --- a/test/test_revwalk.py +++ b/test/test_revwalk.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -25,9 +25,9 @@ """Tests for revision walk.""" +from pygit2 import Repository from pygit2.enums import SortMode - # In the order given by git log log = [ '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98', @@ -51,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() @@ -94,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_settings.py b/test/test_settings.py new file mode 100644 index 00000000..5c521101 --- /dev/null +++ b/test/test_settings.py @@ -0,0 +1,284 @@ +# 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: + 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: + 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: + 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: + 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: + 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: + 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: + 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: + 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: + 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: + 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: + 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: + 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: + 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: + 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: + 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: + 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: + 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: + 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_ciphers() -> None: + assert callable(pygit2.settings.set_ssl_ciphers) + + try: + pygit2.settings.set_ssl_ciphers('DEFAULT') + except pygit2.GitError as e: + 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: + 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 diff --git a/test/test_signature.py b/test/test_signature.py index fbbc99c6..e90f5539 100644 --- a/test/test_signature.py +++ b/test/test_signature.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -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) diff --git a/test/test_status.py b/test/test_status.py index 27fb0539..653ed93d 100644 --- a/test/test_status.py +++ b/test/test_status.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -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_submodule.py b/test/test_submodule.py index bf456e97..90bdc758 100644 --- a/test/test_submodule.py +++ b/test/test_submodule.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -25,14 +25,17 @@ """Tests for Submodule objects.""" +from collections.abc import Generator from pathlib import Path -import pygit2 import pytest -from . import utils -from pygit2.enums import SubmoduleIgnore as SI, SubmoduleStatus as SS +import pygit2 +from pygit2 import Repository, Submodule +from pygit2.enums import SubmoduleIgnore as SI +from pygit2.enums import SubmoduleStatus as SS +from . import utils SUBM_NAME = 'TestGitRepository' SUBM_PATH = 'TestGitRepository' @@ -42,48 +45,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 +96,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 +109,33 @@ 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_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: f.write('[submodule "TestGitRepository"]\n') @@ -131,7 +145,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 +162,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 +171,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 +182,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 +204,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 +220,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 +244,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 +264,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 +316,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 +331,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/test_tag.py b/test/test_tag.py index 73cfcf0b..a3be72c2 100644 --- a/test/test_tag.py +++ b/test/test_tag.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -28,19 +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 @@ -49,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' @@ -62,6 +63,7 @@ def test_new_tag(barerepo): 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 @@ -71,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' @@ -88,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_transaction.py b/test/test_transaction.py new file mode 100644 index 00000000..5a6e97ed --- /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 diff --git a/test/test_tree.py b/test/test_tree.py index 0f9ec2d1..556c3751 100644 --- a/test/test_tree.py +++ b/test/test_tree.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -24,31 +24,33 @@ # Boston, MA 02110-1301, USA. import operator + import pytest import pygit2 +from pygit2 import Object, Repository, Tree from pygit2.enums import FileMode, ObjectType from . import utils - TREE_SHA = '967fce8df97cc71722d3c2a5930ef3e6f1d27b12' 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)) -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 consitent with the sha strings we could + 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'] diff --git a/test/test_treebuilder.py b/test/test_treebuilder.py index 3d4bb3aa..99e4d6b3 100644 --- a/test/test_treebuilder.py +++ b/test/test_treebuilder.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -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 diff --git a/test/utils.py b/test/utils.py index 1df9135f..48dc7c32 100644 --- a/test/utils.py +++ b/test/utils.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# 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, @@ -25,12 +25,15 @@ # Standard library import hashlib -from pathlib import Path import shutil import socket import stat import sys import zipfile +from collections.abc import Callable, Iterator +from pathlib import Path +from types import TracebackType +from typing import Any, Optional, ParamSpec, TypeVar # Requirements import pytest @@ -38,8 +41,10 @@ # Pygit2 import pygit2 +T = TypeVar('T') +P = ParamSpec('P') -requires_future_libgit2 = pytest.mark.skipif( +requires_future_libgit2 = pytest.mark.xfail( pygit2.LIBGIT2_VER < (2, 0, 0), reason='This test may work with a future version of libgit2', ) @@ -64,15 +69,14 @@ is_pypy = '__pypy__' in sys.builtin_module_names -fspath = pytest.mark.skipif( - is_pypy, - reason="PyPy doesn't fully support fspath, see https://foss.heptapod.net/pypy/pypy/-/issues/3168", -) +requires_refcount = pytest.mark.skipif(is_pypy, reason='skip refcounts checks in pypy') -refcount = pytest.mark.skipif(is_pypy, reason='skip refcounts checks in pypy') +fails_in_macos = pytest.mark.xfail( + sys.platform == 'darwin', reason='fails in macOS for an unknown reason' +) -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()) @@ -80,13 +84,18 @@ 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) -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. """ @@ -94,12 +103,24 @@ def rmtree(path): 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, 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': @@ -112,11 +133,22 @@ 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 -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,)