diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..a3d627a7c --- /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 index 9b5e5a6bc..8a46486fc 100644 --- a/.github/workflows/codespell.yml +++ b/.github/workflows/codespell.yml @@ -4,7 +4,9 @@ name: Codespell on: pull_request: + branches: [master] push: + branches: [master] permissions: contents: read @@ -16,7 +18,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Annotate locations with typos uses: codespell-project/codespell-problem-matcher@v1 - name: Codespell diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index cf81c382c..e54ec0ae5 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@v7 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: 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.7 /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 000000000..a2b25cda3 --- /dev/null +++ b/.github/workflows/parse_release_notes.py @@ -0,0 +1,94 @@ +"""Parse the latest release notes from CHANGELOG.md. + +If running in GitHub Actions, set the `release_title` output +variable for use in subsequent step(s). + +If running in CI, write the release notes to ReleaseNotes.md +for upload as an artifact. + +Otherwise, print the release title and notes to stdout. +""" + +import re +import subprocess +from os import environ +from pathlib import Path + + +class ChangesEntry: + def __init__(self, version: str, notes: str) -> None: + self.version = version + title = notes.splitlines()[0] + self.title = f'{version} {title}' + self.notes = notes[len(title) :].strip() + + +H1 = re.compile(r'^# (\d+\.\d+\.\d+)', re.MULTILINE) + + +def parse_changelog() -> list[ChangesEntry]: + changelog = Path('CHANGELOG.md').read_text(encoding='utf-8') + parsed = H1.split(changelog) # may result in a blank line at index 0 + if not parsed[0]: # leading entry is a blank line due to re.split() implementation + parsed = parsed[1:] + assert len(parsed) % 2 == 0, ( + 'Malformed CHANGELOG.md; Entries expected to start with "# x.y.x"' + ) + + changes: list[ChangesEntry] = [] + for i in range(0, len(parsed), 2): + version = parsed[i] + notes = parsed[i + 1].strip() + changes.append(ChangesEntry(version, notes)) + return changes + + +def get_version_tag() -> str | None: + if 'GITHUB_REF' in environ: # for use in GitHub Actions + git_ref = environ['GITHUB_REF'] + else: # for local use + git_out = subprocess.run( + ['git', 'rev-parse', '--symbolic-full-name', 'HEAD'], + capture_output=True, + text=True, + check=True, + ) + git_ref = git_out.stdout.strip() + version: str | None = None + if git_ref and git_ref.startswith('refs/tags/'): + version = git_ref[len('refs/tags/') :].lstrip('v') + else: + print( + f"Using latest CHANGELOG.md entry because the git ref '{git_ref}' is not a tag." + ) + return version + + +def get_entry(changes: list[ChangesEntry], version: str | None) -> ChangesEntry: + latest = changes[0] + if version is not None: + for entry in changes: + if entry.version == version: + latest = entry + break + else: + raise ValueError(f'No changelog entry found for version {version}') + return latest + + +def main() -> None: + changes = parse_changelog() + version = get_version_tag() + latest = get_entry(changes=changes, version=version) + if 'GITHUB_OUTPUT' in environ: + with Path(environ['GITHUB_OUTPUT']).open('a') as gh_out: + print(f'release_title={latest.title}', file=gh_out) + if environ.get('CI', 'false') == 'true': + Path('ReleaseNotes.md').write_text(latest.notes, encoding='utf-8') + else: + print('Release notes:') + print(f'# {latest.title}\n{latest.notes}') + + +if __name__ == '__main__': + main() diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 232d4ceb8..1a069281d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,46 +1,20 @@ -name: Tests +name: Tests (s390x) on: pull_request: + branches: [master] push: + branches: [master] paths-ignore: - '**.rst' jobs: - linux: - runs-on: ${{ matrix.os }} - strategy: - matrix: - include: - - os: ubuntu-24.04 - python-version: '3.10' - - os: ubuntu-24.04 - python-version: '3.13' - - os: ubuntu-24.04 - python-version: 'pypy3.10' - - os: ubuntu-24.04-arm - python-version: '3.13' - - steps: - - name: Checkout pygit2 - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Linux - run: | - sudo apt install tinyproxy - LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.1 /bin/sh build.sh test - linux-s390x: runs-on: ubuntu-24.04 if: github.ref == 'refs/heads/master' steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Build & test uses: uraimo/run-on-arch-action@v3 @@ -51,21 +25,5 @@ jobs: apt-get update -q -y apt-get install -q -y cmake libssl-dev python3-dev python3-venv wget run: | - LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.1 /bin/sh build.sh test + LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.7 /bin/sh build.sh test continue-on-error: true # Tests are expected to fail, see issue #812 - - macos-arm64: - runs-on: macos-latest - steps: - - name: Checkout pygit2 - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.13' - - - name: macOS - run: | - export OPENSSL_PREFIX=`brew --prefix openssl@3` - LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.1 /bin/sh build.sh test diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 914312150..78bbfb25a 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -1,81 +1,227 @@ name: Wheels +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref_name != 'master' }} + on: push: - branches: - - master - - wheels-* + branches: [master] tags: - - 'v*' + - 'v*' + pull_request: + branches: [master] + paths-ignore: + - 'docs/**' jobs: + sdist: + runs-on: ubuntu-latest + outputs: + release_title: ${{ steps.parse_changelog.outputs.release_title }} + steps: + - uses: actions/checkout@v7 + with: + # avoid leaking credentials in uploaded artifacts + persist-credentials: false + + - uses: actions/setup-python@v7 + with: + python-version: '3.13' + + - name: Build sdist + run: pipx run build --sdist --outdir dist + + - uses: actions/upload-artifact@v7 + with: + name: wheels-sdist + path: dist/* + + - name: parse CHANGELOG for release notes + id: parse_changelog + run: python .github/workflows/parse_release_notes.py + + - name: Upload Release Notes + uses: actions/upload-artifact@v7 + with: + name: release-notes + path: ReleaseNotes.md + build_wheels: - name: Wheels for ${{ matrix.name }} + name: ${{ matrix.name }} runs-on: ${{ matrix.os }} + needs: sdist strategy: + # let other jobs in matrix complete if one fails + fail-fast: false matrix: include: - - name: linux-amd + - name: linux-amd-glibc + os: ubuntu-24.04 + cibw_skip: '*musllinux*' + - name: linux-amd-musl os: ubuntu-24.04 - - name: linux-arm + cibw_skip: '*manylinux*' + - name: linux-arm-glibc os: ubuntu-24.04-arm - - name: macos - os: macos-13 + cibw_skip: '*musllinux*' + - name: linux-arm-musl + os: ubuntu-24.04-arm + cibw_skip: '*manylinux*' + - name: macos-intel + os: macos-15-intel + - name: macos-arm + os: macos-15 + - name: windows-x64 + os: windows-latest + libgit2_prefix: C:/libgit2_install_x86_64 + - name: windows-x86 + os: windows-latest + libgit2_prefix: C:/libgit2_install_x86 + - name: windows-arm64 + # https://github.com/actions/partner-runner-images#available-images + os: windows-11-arm + libgit2_prefix: C:/libgit2_install_arm64 steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v7 with: python-version: '3.13' + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Cache compiled dependencies + if: ${{ !startsWith(matrix.name, 'windows-') }} + uses: actions/cache@v4 + with: + path: ci + key: deps-ci-${{ matrix.name }}-${{ hashFiles('pyproject.toml', 'build.sh') }} + restore-keys: deps-ci-${{ matrix.name }}- + + - name: Cache compiled dependencies (Windows) + if: ${{ startsWith(matrix.name, 'windows-') }} + uses: actions/cache@v4 + with: + path: | + build/libgit2_src + ${{ matrix.libgit2_prefix }} + key: deps-ci-${{ matrix.name }}-${{ hashFiles('pyproject.toml') }} + restore-keys: deps-ci-${{ matrix.name }}- + + - name: Download sdist + uses: actions/download-artifact@v8 + with: + name: wheels-sdist + path: dist + - name: Install cibuildwheel - run: python -m pip install cibuildwheel==3.0.0 + run: python -m pip install cibuildwheel~=3.3 - name: Build wheels - run: python -m cibuildwheel --output-dir wheelhouse + shell: bash + env: + CIBW_ARCHS_WINDOWS: ${{ matrix.name == 'windows-x86' && 'auto32' || 'native' }} + CIBW_SKIP: ${{ matrix.cibw_skip }} + CIBW_CONTAINER_ENGINE: 'docker; create_args: -v ${{ github.workspace }}/ci:/project/ci' + run: python -m cibuildwheel dist/*.tar.gz --output-dir wheelhouse - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: wheels-${{ matrix.name }} path: ./wheelhouse/*.whl - build_wheels_ppc: - name: Wheels for linux-ppc + build_wheels_emulated_linux: + name: ${{ matrix.name }} runs-on: ubuntu-24.04 + needs: sdist + strategy: + fail-fast: true + matrix: + include: + - name: linux-ppc64le-glibc + qemu_platform: linux/ppc64le + cibw_arch: ppc64le + cibw_skip: '*musllinux*' + - name: linux-riscv64-glibc + qemu_platform: linux/riscv64 + cibw_arch: riscv64 + cibw_skip: '*musllinux*' + - name: linux-riscv64-musl + qemu_platform: linux/riscv64 + cibw_arch: riscv64 + cibw_skip: '*manylinux*' steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v7 with: python-version: '3.13' - - uses: docker/setup-qemu-action@v3 + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - uses: docker/setup-qemu-action@v4 + with: + platforms: ${{ matrix.qemu_platform }} + + - name: Cache compiled dependencies + uses: actions/cache@v4 + with: + path: ci + key: deps-ci-${{ matrix.name }}-${{ hashFiles('pyproject.toml', 'build.sh') }} + restore-keys: deps-ci-${{ matrix.name }}- + + - name: Download sdist + uses: actions/download-artifact@v8 with: - platforms: linux/ppc64le + name: wheels-sdist + path: dist - name: Install cibuildwheel - run: python -m pip install cibuildwheel==3.0.0 + run: python -m pip install cibuildwheel~=3.3 - name: Build wheels - run: python -m cibuildwheel --output-dir wheelhouse + shell: bash + run: python -m cibuildwheel dist/*.tar.gz --output-dir wheelhouse env: - CIBW_ARCHS: ppc64le - CIBW_ENVIRONMENT: LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.1 LIBGIT2=/project/ci + CIBW_ARCHS: ${{ matrix.cibw_arch }} + CIBW_SKIP: ${{ matrix.cibw_skip }} + CIBW_CONTAINER_ENGINE: 'docker; create_args: -v ${{ github.workspace }}/ci:/project/ci' + CIBW_ENVIRONMENT: LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.7 LIBGIT2=/project/ci - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: - name: wheels-linux-ppc + name: wheels-${{ matrix.name }} path: ./wheelhouse/*.whl + 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_emulated_linux, 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, build_wheels_ppc] + needs: [build_wheels, build_wheels_emulated_linux, 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-* @@ -88,3 +234,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 60e8a5500..be504cd4e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,246 @@ -/.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 +/.docenv +/ci/ *.swp +/pygit2/_libgit2.c +/pygit2/_libgit2.o diff --git a/.mailmap b/.mailmap index 6a85c073b..e47d815f1 100644 --- a/.mailmap +++ b/.mailmap @@ -3,6 +3,7 @@ Alexander Linne Anatoly Techtonik Bob Carroll Brandon Milton +Brendan Doherty <2bndy5@gmail.com> CJ Steiner <47841949+clintonsteiner@users.noreply.github.com> Carlos Martín Nieto Christian Boos @@ -14,16 +15,20 @@ Jeremy Westwood Jose Plana Kaarel Kitsemets Karl Malmros <44969574+ktpa@users.noreply.github.com> +Konstantin Baikov Lukas Fleischer Martin Lenders Matthew Duggan Matthew Gamble Matthias Bartelmeß Mikhail Yushkovskiy +Mukunda Rao Katta Nabijacz Leweli +Nicolas Rybowski Óscar San José Petr Hosek Phil Schleihauf +Raphael Medaer Richo Healey Robert Hölzl Saugat Pachhai @@ -34,6 +39,7 @@ Tamir Bahar Victor Florea Victor Garcia Vlad Temian +William Bowers William Schueller Wim Jeantine-Glenn Xavier Delannoy diff --git a/.vimrc b/.vimrc new file mode 100644 index 000000000..66868d60a --- /dev/null +++ b/.vimrc @@ -0,0 +1,16 @@ +" pygit2 local vimrc - C extension configuration + +" Get Python include path dynamically +let s:python_include = system('python3 -c "import sysconfig; print(sysconfig.get_path(''include''))"')[:-2] + +" Configure ALE C linters with proper includes +let g:ale_c_cc_options = '-std=c11 -Wall -I' . s:python_include . ' -I/usr/local/include' +let g:ale_c_gcc_options = '-std=c11 -Wall -I' . s:python_include . ' -I/usr/local/include' +let g:ale_c_clang_options = '-std=c11 -Wall -I' . s:python_include . ' -I/usr/local/include' + +" If you have libgit2 in a non-standard location, add it: +" let g:ale_c_cc_options .= ' -I/usr/local/include/git2' + +" Optional: Explicitly set which linters to use for C +let g:ale_linters = get(g:, 'ale_linters', {}) +let g:ale_linters.c = ['cc', 'clangtidy'] " or ['gcc'] if you prefer diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..cfeab3f25 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,295 @@ +# pygit2 Agent Guide + +## Project Overview + +**pygit2** is a Python library that provides bindings to +[libgit2](https://libgit2.org/), the shared C library that implements Git +plumbing operations. It exposes both a low-level API (direct libgit2 wrappers) +and a high-level, Pythonic API for repository manipulation. + +- **Version**: 1.19.3 (canonical version is defined in `pygit2/_build.py`) +- **License**: GPLv2 with linking exception (see `COPYING`) +- **Maintainer**: J. David Ibáñez +- **Python Support**: 3.11 – 3.14 and PyPy3 7.3+ +- **libgit2 Version**: 1.9.7 +- **Homepage**: +- **Repository**: + +## Architecture + +The project uses a **hybrid C/Python** architecture with two compiled extension +modules: + +- **`src/`** — C11 source and header files that compile into the `_pygit2` C + extension module. Each file generally maps to a libgit2 concept: + - Core objects: `blob.c`, `commit.c`, `object.c`, `tag.c`, `tree.c` + - Repository, refs and branches: `repository.c`, `branch.c`, `reference.c`, + `refdb.c`, `refdb_backend.c`, `revspec.c`, `worktree.c` + - Diff and patch: `diff.c`, `patch.c` + - ODB and backends: `odb.c`, `odb_backend.c` + - Index, walking and helpers: `treebuilder.c`, `walker.c`, `oid.c`, + `note.c`, `signature.c`, `mailmap.c`, `stash.c` + - Filters: `filter.c` + - Module infrastructure: `pygit2.c`, `error.c`, `utils.c`, `wildmatch.c` + - Headers: `*.h` files mirroring the C sources (e.g. `repository.h`, + `diff.h`, `types.h`, `error.h`) + +- **`pygit2/`** — The main Python package. + - **`_pygit2*.so`** — Compiled C extension built from `src/`. + - **`_libgit2.abi3.so`** — CFFI-generated ABI module built from + `pygit2/_run.py`. + - **`decl/`** — C header stub files used by CFFI to define the libgit2 API + surface (e.g. `types.h`, `repository.h`, `callbacks.h`, `diff.h`, + `remote.h`). `pygit2/_run.py` concatenates these stubs in a specific order + before passing them to CFFI. + - **`_build.py`** — Build-time helpers and the canonical `__version__` + string. Also used at runtime to locate libgit2. It must remain importable + without the rest of the package being built because `setup.py` imports it. + - **`_run.py`** — CFFI build script that aggregates `decl/*.h` and compiles + `pygit2._libgit2`. + - **`ffi.py`** — Runtime import of the CFFI `ffi` and `lib` (`C`) objects. + - **`_pygit2.pyi`** — Type stubs for the C extension. Keep it in sync when + adding or changing low-level APIs. + - **`py.typed`** — PEP 561 marker indicating the package is typed. + - **High-level modules** — Pure-Python wrappers that sit on top of the C + extension: + `repository.py`, `callbacks.py`, `config.py`, `index.py`, `remotes.py`, + `settings.py`, `submodules.py`, `transaction.py`, `filter.py`, `blob.py`, + `blame.py`, `branches.py`, `credentials.py`, `errors.py`, `options.py`, + `packbuilder.py`, `rebase.py`, `references.py`, `refspec.py`, `utils.py`, + `enums.py`. + +- **`test/`** — pytest suite with fixture-based repository handling. +- **`docs/`** — Sphinx documentation (RTD theme). + +## Key Configuration Files + +- **`setup.py`** — setuptools entry point. Builds both the C extension + (`src/*.c`) and the CFFI extension (`pygit2/_run.py:ffi`). On Windows it + also copies `git2.dll` into the package (see `BuildWithDLLs`). +- **`pyproject.toml`** — Build-system requirements, `cibuildwheel` + configuration, `ruff` settings, and `codespell` settings. +- **`setup.cfg`** — Legacy pycodestyle configuration. +- **`pytest.ini`** — pytest configuration (`--capture=no -ra --verbose`, + `testpaths = test/`). +- **`mypy.ini`** — mypy configuration with strict settings. +- **`mypy-stubtest.ini`** — mypy configuration for `stubtest` against + `_pygit2.pyi` (at the repo root). +- **`requirements.txt`** — Runtime/build requirements (`cffi>=2.0`, + `setuptools` for Python >= 3.12). +- **`requirements-test.txt`** — Test requirements (`pytest`, `pytest-cov`). +- **`requirements-typing.txt`** — Typing requirements (`mypy`, `types-cffi`). +- **`Makefile`** — Convenience targets: `make` builds dependencies + extension + inplace; `make html` builds docs. +- **`.vimrc`** — Local editor configuration for C development with ALE + (`-std=c11 -Wall`, Python include path, `/usr/local/include`). + +## Build and Test Commands + +### Quick Development Build (inplace) + +Requires libgit2 development headers and library to be installed on the system +or pointed to via the `LIBGIT2` environment variable. + +```bash +python setup.py build_ext --inplace +pytest +``` + +### Full Build with Dependencies + +The `build.sh` script can download, compile, and bundle libgit2 (and optionally +libssh2, OpenSSL, and zlib) into a local prefix. On Windows, `build.ps1` +handles libgit2 compilation via CMake. + +```bash +# Build inplace with bundled libgit2/libssh2/OpenSSL +make + +# Or manually: +LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.7 sh build.sh + +# Build a wheel and bundle the shared libraries into it +sh build.sh wheel +sh build.sh bundle + +# Build inplace and run the tests with coverage (build.sh adds --cov=pygit2) +sh build.sh test + +# Run mypy type checking +sh build.sh mypy + +# Run stubtest against the .pyi file +sh build.sh stubtest +``` + +`build.sh` creates a virtual environment under `ci//` by default, +where `` is computed by `build_tag.py`. Use the `PYTHON` +environment variable to select a different interpreter (default: `python3`). + +### Environment Variables + +Variables consumed by `setup.py` / `pygit2/_build.py`: + +- `LIBGIT2` — Base path where libgit2 is installed (default: `/usr/local` or + `%ProgramFiles%\libgit2` on Windows). +- `LIBGIT2_LIB` — Override the library directory specifically. + +Variables consumed by `build.sh`: + +- `LIBGIT2_VERSION` — If set, download and build this libgit2 version. +- `LIBSSH2_VERSION` — If set, download and build libssh2 with SSH support. +- `OPENSSL_VERSION` — If set, download and build OpenSSL (used on Linux and + macOS CI builds). +- `ZLIB_VERSION` — If set, download and build zlib. +- `BUILD_TYPE` — CMake build type (default: `Debug`). +- `PYTHON` — Python interpreter to use (default: `python3`). +- `PREFIX` — Installation prefix (default: `$(pwd)/ci/$PYTHON_TAG`). +- `CIBUILDWHEEL` — Set to `1` when invoked by cibuildwheel; changes package + manager and directory layout. +- `AUDITWHEEL_PLAT` — Linux platform for auditwheel repair. +- `LIBSSH2_OPENSSL` — Where to find OpenSSL when building libssh2. + +### Documentation Build + +```bash +# Build the extension first, then docs +make # builds deps + extension +make -C docs html # requires sphinx-rtd-theme +``` + +## Code Style Guidelines + +### Python + +- **Formatter / Linter**: [ruff](https://docs.astral.sh/ruff/) + - Target Python: 3.11+ + - Quote style: single quotes + - Selected rules: `E4`, `E7`, `E9`, `F`, `I`, `UP035`, `UP007` + - Run `ruff format` and `ruff check` on changed files before committing. + CI runs `ruff format --diff` and `ruff check`; formatting failures will + fail the build. +- **Type checker**: mypy (strict settings enabled; see `mypy.ini`). Test + modules additionally require typed defs and calls (`disallow_untyped_defs`, + `disallow_untyped_calls` under `[mypy-test.*]`). +- All Python source files must include the standard GPLv2 copyright header. +- `pygit2/__init__.py` is large because it re-exports a large surface of + constants and classes; follow existing patterns when adding new public + symbols. + +### C + +- Standard: C11 +- All C source files must include the standard GPLv2 copyright header. +- The `.vimrc` at repo root configures ALE with `-std=c11 -Wall` and includes + the Python headers and `/usr/local/include`. + +### Docstrings + +Use the following style (from `docs/development.rst`): + +```python +def f(a, b): + """ + The general description goes here. + + Returns: bla bla. + + Parameters: + + a : + Bla bla. + + b : + Bla bla. + + Examples:: + + >>> f(...) + """ +``` + +## Testing Instructions + +- **Runner**: pytest +- **Configuration**: `pytest.ini` + ```ini + [pytest] + addopts = --capture=no -ra --verbose + testpaths = test/ + ``` +- **Fixtures**: Defined in `test/conftest.py`. They yield `pygit2.Repository` + instances extracted from zipped sample repos in `test/data/` (e.g. + `testrepo.zip`, `barerepo.zip`). Named fixtures include `testrepo`, + `testrepo_path`, `barerepo`, `barerepo_path`, `emptyrepo`, `dirtyrepo`, + `mergerepo`, `encodingrepo`, `testrepopacked`, `gpgsigned`, `blameflagsrepo`, + and `pygit2_empty_key`. +- **Test utilities**: `test/utils.py` provides helpers such as + `TemporaryRepository`, `gen_blob_sha1`, `rmtree`, `diff_safeiter`, and + markers like `requires_network`, `requires_proxy`, `requires_ssh`, + `requires_refcount`, `fails_in_macos`, and `requires_future_libgit2`. +- **Isolation**: The session-scoped `global_git_config` fixture clears + `GLOBAL`, `XDG`, and `SYSTEM` config search paths to ensure reproducibility. +- **Coverage**: `pytest-cov` is used; run via `sh build.sh test`. + +## CI / Deployment + +GitHub Actions workflows live in `.github/workflows/`: + +- **`tests.yml`** — Runs on s390x via QEMU (`uraimo/run-on-arch-action`). + Allowed to fail (`continue-on-error`); see issue #812. +- **`lint.yml`** — Runs `ruff format --diff`, `ruff check`, and + `sh build.sh mypy`. +- **`wheels.yml`** — Uses `cibuildwheel` (`~=3.3`) to build wheels for Linux + (amd64, arm64, ppc64le and riscv64 via QEMU, musl), macOS (intel, arm64, + PyPy), and Windows (x64, x86, arm64). Linux, macOS and Windows jobs cache + the compiled dependencies between runs. It also builds an sdist, runs a + `twine check` (skipped on version tags), publishes to PyPI, and creates a + GitHub Release on version tags (`v*`), with release notes parsed from + `CHANGELOG.md` by `.github/workflows/parse_release_notes.py`. +- **`codespell.yml`** — Spell checking with the codespell action. + +The `cibuildwheel` configuration in `pyproject.toml` pins: + +- `LIBGIT2_VERSION="1.9.7"` +- `LIBSSH2_VERSION="1.11.1"` +- `OPENSSL_VERSION="3.5.7"` + +and skips `*musllinux_ppc64le` plus testing on `*-*linux_ppc64le`, +`*-*linux_riscv64` and `pp*-macosx_arm64`. On Windows it uses the +`Visual Studio 18 2026` CMake generator for x64/x86 and +`Visual Studio 17 2022` for ARM64. + +## Security Considerations + +- The project links against OpenSSL and libssh2. CI pins specific versions of + these libraries when building wheels. +- Wheel repair commands (`auditwheel`, `delocate-wheel`) bundle shared + libraries so wheels are self-contained. +- Credentials callbacks (`RemoteCallbacks`, `get_credentials`) are the primary + interface for supplying secrets; never hardcode credentials in tests. +- Valgrind support: see `docs/development.rst` and + `misc/valgrind-python.supp` for memory-leak debugging instructions. + +## Git Commits + +- Do not run `git commit` or create commits unless the user explicitly asks for it. +- Commits created by an AI agent must end with the `Assisted-by:` trailer. + Human-authored commits do not need it. + +## Useful Notes for Agents + +- **Do not assume libgit2 is installed globally.** Check for `LIBGIT2` or use + `build.sh` / `make`. +- **`pygit2/_build.py`** is imported by `setup.py`; it must remain importable + without the rest of the package being built. +- **CFFI and setuptools extensions are both built from `setup.py`.** + `ext_modules` builds the C extension from `src/*.c`; `cffi_modules` triggers + the CFFI build via `pygit2/_run.py:ffi`. +- **`.pyi` stub file**: `pygit2/_pygit2.pyi` provides type stubs for the C + extension. Keep it in sync when adding or changing low-level APIs. +- **Header stub order matters**: `pygit2/_run.py` concatenates `decl/*.h` in a + fixed list; add new stubs in the correct position if dependencies require it. +- Run the full test suite, type checks, and linting/formatting before + considering a change complete: + `sh build.sh test`, `sh build.sh mypy`, `ruff check .`, and `ruff format .`. diff --git a/AUTHORS.md b/AUTHORS.md index 84cfc4390..04c7fbb85 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,8 @@ Authors: Nick Hynes Richard Möhn Xu Tao + Łukasz Langa + Konstantin Baikov Matthew Duggan Matthew Gamble Jeremy Westwood @@ -34,11 +38,13 @@ Authors: Xavier Delannoy Michael Jones Saugat Pachhai + Andrej730 Bernardo Heynemann John Szakmeister Nabijacz Leweli Simon Cozens Vlad Temian + WANG Xuerui Brodie Rao Chad Dombrova Lukas Fleischer @@ -46,7 +52,7 @@ Authors: Mathieu Parent Michał Kępień Nicolas Dandrimont - Raphael Medaer (Escaux) + Raphael Medaer Yaroslav Halchenko Anatoly Techtonik Andrew Olsen @@ -66,7 +72,9 @@ Authors: Assaf Nativ Bob Carroll Christian Häggström + Edmundo Carmona Antoranz Erik Johnson + Ethan Meng Filip Rindler Fraser Tweedale Grégoire ROCHER @@ -84,7 +92,6 @@ Authors: Sukhman Bhuller Thomas Kluyver Tyler Cipriani - WANG Xuerui Alex Chamberlain Alexander Bayandin Amit Bakshi @@ -93,6 +100,7 @@ Authors: Ben Davis CJ Steiner Colin Watson + Craig de Stigter Dan Yeaw Dustin Raimondi Eric Schrijver @@ -119,6 +127,7 @@ Authors: Masud Rahman Michael Sondergaard Natanael Arndt + Nick Williams Ondřej Nový Sarath Lakshman Steve Kieffer @@ -131,8 +140,10 @@ Authors: 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 @@ -150,7 +161,6 @@ Authors: Chris Rebert Christopher Hunt Claudio Jolowicz - Craig de Stigter Cristian Hotea Cyril Jouve Dan Cecile @@ -163,7 +173,6 @@ Authors: David Six Dennis Schwertel Devaev Maxim - Edmundo Carmona Antoranz Eric Davis Erik Meusel Erik van Zijst @@ -176,6 +185,7 @@ Authors: Hugh Cole-Baker Isabella Stephens Jacob Swanson + Jah-yee Jasper Lievisse Adriaanse Jimisola Laursen Jiri Benc @@ -198,7 +208,9 @@ Authors: Matěj Cepl Maxwell G Michał Górny + Mukunda Rao Katta Na'aman Hirschfeld + Nicolas Rybowski Nicolás Sanguinetti Nikita Kartashov Nikolai Zujev @@ -220,6 +232,7 @@ Authors: Rui Chen Sandro Jäckel Saul Pwanson + Sebastian Hamann Shane Turner Sheeo Simone Mosciatti @@ -229,6 +242,8 @@ Authors: Timo Röhling Victor Florea Vladimir Rutsky + Vruyr Gyolchanyan + William Bowers William Schueller Wim Jeantine-Glenn Yu Jianjian diff --git a/CHANGELOG.md b/CHANGELOG.md index f26b92bf3..aa90c76fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,260 @@ -# 1.18.1 (UNRELEASED) +# 1.20.1 (UNRELEASED) + +- Update wheels to libgit2 1.9.7 + +- CI (Linux, macOS, Windows): cache compiled dependencies between wheel + builds, keyed by platform and dependency versions. + +- CI (macOS): build architecture-specific OpenSSL/libssh2/libgit2 + dependencies instead of universal binaries, producing smaller wheels. + +- New exception hierarchy: `AlreadyExistsError`, `InvalidSpecError`, + `InvalidError`, `NotFoundError`, `AmbiguousError`, `AuthError`, and + `CertificateError` all inherit from `GitError` and the appropriate Python + built-in exception (`ValueError`/`KeyError`) for backward compatibility + [#830](https://github.com/libgit2/pygit2/issues/830). + +- Fix custom ODB and refdb backend callbacks overwriting a pending Python + exception (e.g. `RuntimeError`) with a stale libgit2 error message. + +- Fix `DiffDelta.is_binary` and `DiffDelta.flags` returning stale values for + deltas obtained from `Diff.deltas`; flags are now loaded lazily + [#962](https://github.com/libgit2/pygit2/issues/962) + [#1100](https://github.com/libgit2/pygit2/pull/1100). + +- Fix invalid oid arguments being silently ignored in `BlobIO`, the + `Reference` constructor, `RefdbBackend.write()`/`delete()`, and custom + `OdbBackend` callbacks; they now raise the appropriate Python exception + instead of producing wrong results, `SystemError`, or crashes + [#1478](https://github.com/libgit2/pygit2/issues/1478). + +- Fix potential crash and memory leak in `DiffHunk.lines` and `Patch.hunks` + on allocation or per-item failure + [#1479](https://github.com/libgit2/pygit2/issues/1479). + +- Fix memory leaks in `Repository.listall_branches()` and + `Tree.diff_to_index()` on early error paths + [#1480](https://github.com/libgit2/pygit2/issues/1480). + +- Fix reference leaks in the blob filter stream callbacks and in + `_cache_enums()` + [#1481](https://github.com/libgit2/pygit2/issues/1481). + + +# 1.20.0 (2026-08-08) + +- New `RemoteCallbacks.custom_headers()` + [#1465](https://github.com/libgit2/pygit2/pull/1465) + +- New rebase API: `Repository.rebase_init(...)`, `Repository.rebase_open(...)`, + `Rebase`, and `RebaseOperation` + [#1483](https://github.com/libgit2/pygit2/pull/1483) + +- Fix `Config.snapshot()` for non-repository configs, allow `PathLike` in + `Config.__init__()`, and improve config documentation + [#1468](https://github.com/libgit2/pygit2/pull/1468) + +- Fix `UnicodeDecodeError` with non-UTF-8 file paths in `Repository.status()`, + `DiffFile.path`, index paths, checkout callbacks, and related APIs + [#1451](https://github.com/libgit2/pygit2/issues/1451) + [#1469](https://github.com/libgit2/pygit2/pull/1469) + +- Fix `enums.CheckoutStrategy.CONFLICT_STYLE_ZDIFF3`, which was mistakenly + bound to the `DIFF3` constant + [#1483](https://github.com/libgit2/pygit2/pull/1483) + +- Fix crashes and reference-lifetime bugs in custom refdb backends + [#1471](https://github.com/libgit2/pygit2/issues/1471) + [#1474](https://github.com/libgit2/pygit2/issues/1474) + [#1475](https://github.com/libgit2/pygit2/issues/1475) + [#1476](https://github.com/libgit2/pygit2/issues/1476) + +- Update wheels to libgit2 1.9.6 and OpenSSL 3.5.7 + +- Add riscv64 wheels + [#1463](https://github.com/libgit2/pygit2/pull/1463) + +Breaking changes: + +- Remove deprecated `pygit2.legacyenums` module and `GIT_*` constants, + use `pygit2.enums` instead + +- Remove deprecated support for passing `str` to `Repository.merge(...)`, + pass a `Commit`, `Oid`, or `Reference` object instead + +- `Repository.merge_file_from_index(...)` now returns `MergeFileResult` by + default; pass `use_deprecated=True` for the previous string return, now + deprecated. + +- Remove deprecated `Remote.ls_remotes(...)`, use `Remote.list_heads(...)` + instead + +- Remove `pygit2.to_bytes(...)` and `pygit2.to_str(...)`; they were undocumented + accidental public APIs + + +# 1.19.3 (2026-06-13) + +- Memory fixes + [#1368](https://github.com/libgit2/pygit2/issues/1368) + [#1417](https://github.com/libgit2/pygit2/issues/1417) + [#1443](https://github.com/libgit2/pygit2/issues/1443) + +- Fix `Repository.ident` + [#1461](https://github.com/libgit2/pygit2/pull/1461) + +- Build/CI fixes and updates + [#1454](https://github.com/libgit2/pygit2/issues/1454) + [#1459](https://github.com/libgit2/pygit2/pull/1459) + +- Documentation and annotation fixes + [#410](https://github.com/libgit2/pygit2/issues/410) + [#1289](https://github.com/libgit2/pygit2/issues/1289) + [#1323](https://github.com/libgit2/pygit2/issues/1323) + [#1333](https://github.com/libgit2/pygit2/issues/1333) + [#1458](https://github.com/libgit2/pygit2/issues/1458) + [#1460](https://github.com/libgit2/pygit2/pull/1460) + +- Add `AGENTS.md` file generated by Kimi-k2.6 + + +# 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) @@ -15,6 +265,7 @@ [#1371](https://github.com/libgit2/pygit2/pull/1371) [#1373](https://github.com/libgit2/pygit2/pull/1373) [#1384](https://github.com/libgit2/pygit2/pull/1384) + [#1386](https://github.com/libgit2/pygit2/pull/1386) Deprecations: @@ -235,31 +486,39 @@ Deprecations: # 1.14.0 (2024-01-26) -- Drop support for Python 3.8 -- Add Linux wheels for musl on x86\_64 - [#1266](https://github.com/libgit2/pygit2/pull/1266) -- New `Repository.submodules` namespace - [#1250](https://github.com/libgit2/pygit2/pull/1250) -- New `Repository.listall_mergeheads()`, `Repository.message`, - `Repository.raw_message` and `Repository.remove_message()` - [#1261](https://github.com/libgit2/pygit2/pull/1261) -- New `pygit2.enums` supersedes the `GIT_` constants - [#1251](https://github.com/libgit2/pygit2/pull/1251) -- Now `Repository.status()`, `Repository.status_file()`, - `Repository.merge_analysis()`, `DiffFile.flags`, `DiffFile.mode`, - `DiffDelta.flags` and `DiffDelta.status` return enums - [#1263](https://github.com/libgit2/pygit2/pull/1263) -- Now repository\'s `merge()`, `merge_commits()` and `merge_trees()` - take enums/flags for their `favor`, `flags` and `file_flags` arguments. - [#1271](https://github.com/libgit2/pygit2/pull/1271) - [#1272](https://github.com/libgit2/pygit2/pull/1272) -- Fix crash in filter cleanup - [#1259](https://github.com/libgit2/pygit2/pull/1259) -- Documentation fixes - [#1255](https://github.com/libgit2/pygit2/pull/1255) - [#1258](https://github.com/libgit2/pygit2/pull/1258) - [#1268](https://github.com/libgit2/pygit2/pull/1268) - [#1270](https://github.com/libgit2/pygit2/pull/1270) +- Drop support for Python 3.8 + +- Add Linux wheels for musl on x86\_64 + [#1266](https://github.com/libgit2/pygit2/pull/1266) + +- New `Repository.submodules` namespace + [#1250](https://github.com/libgit2/pygit2/pull/1250) + +- New `Repository.listall_mergeheads()`, `Repository.message`, + `Repository.raw_message` and `Repository.remove_message()` + [#1261](https://github.com/libgit2/pygit2/pull/1261) + +- New `pygit2.enums` supersedes the `GIT_` constants + [#1251](https://github.com/libgit2/pygit2/pull/1251) + +- Now `Repository.status()`, `Repository.status_file()`, + `Repository.merge_analysis()`, `DiffFile.flags`, `DiffFile.mode`, + `DiffDelta.flags` and `DiffDelta.status` return enums + [#1263](https://github.com/libgit2/pygit2/pull/1263) + +- Now repository\'s `merge()`, `merge_commits()` and `merge_trees()` + take enums/flags for their `favor`, `flags` and `file_flags` arguments. + [#1271](https://github.com/libgit2/pygit2/pull/1271) + [#1272](https://github.com/libgit2/pygit2/pull/1272) + +- Fix crash in filter cleanup + [#1259](https://github.com/libgit2/pygit2/pull/1259) + +- Documentation fixes + [#1255](https://github.com/libgit2/pygit2/pull/1255) + [#1258](https://github.com/libgit2/pygit2/pull/1258) + [#1268](https://github.com/libgit2/pygit2/pull/1268) + [#1270](https://github.com/libgit2/pygit2/pull/1270) Breaking changes: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..2c65ed652 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,91 @@ +# Contributing to pygit2 + +Thank you for your interest in improving pygit2! This document covers how to submit pull requests and help others in the community. + +--- + +## Pull Requests + +We welcome pull requests that fix bugs, add features, improve documentation, or clean up code. To ensure a smooth review process, please follow the steps below. + +### Development Setup + +See the [install documentation](https://www.pygit2.org/install.html) for instructions on building pygit2 and its libgit2 dependency. + +### Making Changes + +1. **Fork the repository** and create a feature branch. +2. **Follow the existing code style** (see below). +3. **Add or update tests** for any new or changed behavior. Tests live in `test/` and are run with `pytest`. +4. **Update type stubs** (`pygit2/_pygit2.pyi`) if you modify the C extension's public API. +5. **Update `pygit2/__init__.py`** if you add new public symbols that should be re-exported. +6. **Ensure the test suite passes**: + ```bash + pytest + ``` +7. **Run the linters and type checker**: + ```bash + ruff format --diff + ruff check + sh build.sh mypy # or: mypy + sh build.sh stubtest # validate .pyi stubs + ``` +8. **Build the documentation** if you changed it (requires `sphinx-rtd-theme`): + ```bash + make -C docs html + ``` +9. **Write a clear commit message** explaining the *what* and *why*. + +### Code Style + +- **Python:** We target Python 3.11+. Use single quotes. Run `ruff format` and `ruff check` before submitting. +- **C:** We use C11. Follow `-std=c11 -Wall`. Match the style of the surrounding code in `src/`. +- **Copyright headers:** All source files must include the standard GPLv2 copyright header. Copy it from an existing file. +- **Docstrings:** Use the style shown in `docs/development.rst`: + ```python + def f(a, b): + """ + The general description goes here. + + Returns: bla bla. + + Parameters: + + a : + Bla bla. + + b : + Bla bla. + """ + ``` + +### Pull Request Review + +- All PRs require review from a maintainer. +- CI will run tests, linting, and type checks automatically. +- Be responsive to feedback and willing to iterate. +- Keep PRs focused. A pull request that does one thing well is easier to review than a large, mixed one. + +--- + +## Helping Others + +You do not need to write code to contribute. Helping others is valuable: + +- **Answer questions** in open issues and pull requests. If you know the answer, share it. +- **Review PRs.** Even if you are not a maintainer, constructive reviews from the community are welcome. +- **Improve documentation.** Doc fixes, clarifications, and typo corrections can be submitted as PRs just like code. +- **Reproduce reported issues.** Confirming a bug on your system helps maintainers prioritize fixes. + +--- + +## Commit Messages + +- Use the present tense and imperative mood (e.g., "Add support for…", not "Added support for…"). +- Keep the subject line under 72 characters. +- Reference related issues with `Fixes #123` or `Closes #456` when applicable. +- If you used AI assistance while preparing the change, mention it in the commit message with a tag such as `Assisted-by: Kimi-k2.6` (or the appropriate model name). + +--- + +Thank you for contributing! diff --git a/COPYING b/COPYING index 631492395..6445bf87a 100644 --- a/COPYING +++ b/COPYING @@ -316,9 +316,8 @@ the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software; you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation; either version 2 of the License, or - (at your option) any later version. + it under the terms of the GNU General Public License, version 2, + as published by the Free Software Foundation. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of diff --git a/Makefile b/Makefile index 9cfe1226f..6a0f14df5 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ .PHONY: build html build: - OPENSSL_VERSION=3.3.3 LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.1 sh build.sh + OPENSSL_VERSION=3.5.7 LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.7 sh build.sh html: build make -C docs html diff --git a/README.md b/README.md index 0182486fe..3d0891e92 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 e62c62151..000000000 --- a/appveyor.yml +++ /dev/null @@ -1,68 +0,0 @@ -version: 1.18.{build} -image: Visual Studio 2019 -configuration: Release -environment: - global: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: - secure: 7YD82RnQJ9rnJE/josiQ/V6VWh+tlhmJpWVM/u5jGdl8XqyhsLEKF5MNMYd4ZYxA/MGaYBCQ525d4m9RSDk9RB+uIFMZJLnl1eOjHQVyJ+ZZmJb65tqd/fR5hybhWtVhn+0wANiI4uqrojFFVy1HjfBYSrvyk+7LLDxfSVTqkhMEhbZbWBpGP/3VET1gPy+qdlWcL7quwhSBPSbKpyMi/cqvp5/yFLAM615RRABgQUDpRyXxtBTReRgWSxi9kUXXqR18ZvQlvMLnAsEnGFRenA== - matrix: - - GENERATOR: 'Visual Studio 14' - PYTHON: 'C:\Python310\python.exe' - - GENERATOR: 'Visual Studio 14 Win64' - PYTHON: 'C:\Python310-x64\python.exe' - - GENERATOR: 'Visual Studio 14' - PYTHON: 'C:\Python311\python.exe' - - GENERATOR: 'Visual Studio 14 Win64' - PYTHON: 'C:\Python311-x64\python.exe' - - GENERATOR: 'Visual Studio 14' - PYTHON: 'C:\Python312\python.exe' - - GENERATOR: 'Visual Studio 14 Win64' - PYTHON: 'C:\Python312-x64\python.exe' - - GENERATOR: 'Visual Studio 14' - PYTHON: 'C:\Python313\python.exe' - - GENERATOR: 'Visual Studio 14 Win64' - PYTHON: 'C:\Python313-x64\python.exe' - -matrix: - fast_finish: true - -init: -- cmd: | - "%PYTHON%" -m pip install -U pip wheel - -build_script: -# Clone, build and install libgit2 -- cmd: | - set LIBGIT2=%APPVEYOR_BUILD_FOLDER%\venv - git clone --depth=1 -b v1.9.1 https://github.com/libgit2/libgit2.git libgit2 - cd libgit2 - cmake . -DBUILD_TESTS=OFF -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="%LIBGIT2%" -G "%GENERATOR%" - cmake --build . --target install - cd .. - -# Build and install pygit2 -# Rename pygit2 folder, so when testing it picks the installed one -- cmd: | - "%PYTHON%" -m pip install -r requirements-test.txt - "%PYTHON%" -m pip wheel --wheel-dir=dist . - "%PYTHON%" -m pip install --no-index --find-links=dist pygit2 - mv pygit2 pygit2.bak - -test_script: -- ps: | - &$env:PYTHON -m pytest test --junitxml=testresults.xml - - if ($LastExitCode -ne 0) { $host.SetShouldExit($LastExitCode) } - - # upload results to AppVeyor - $wc = New-Object 'System.Net.WebClient' - $wc.UploadFile("https://ci.appveyor.com/api/testresults/junit/$($env:APPVEYOR_JOB_ID)", (Resolve-Path ".\testresults.xml")) - -artifacts: -- path: dist\pygit2-*.whl - -deploy_script: -- ps: if ($env:APPVEYOR_REPO_TAG -eq $TRUE) { pip install twine; twine upload dist/pygit2-*.whl } - -deploy: on diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 000000000..360eefaf4 --- /dev/null +++ b/build.ps1 @@ -0,0 +1,51 @@ +$ErrorActionPreference = 'Stop' + +$LIBGIT2_VERSION = $env:LIBGIT2_VERSION +$LIBGIT2_SRC = $env:LIBGIT2_SRC +if (-not $LIBGIT2_SRC) { + $LIBGIT2_SRC = "build/libgit2_src" +} + +# Prefer CMAKE_INSTALL_PREFIX, then LIBGIT2, then the default Program Files location. +$INSTALL_PREFIX = $env:CMAKE_INSTALL_PREFIX +if (-not $INSTALL_PREFIX) { + $INSTALL_PREFIX = $env:LIBGIT2 +} +if (-not $INSTALL_PREFIX) { + $INSTALL_PREFIX = "$env:ProgramFiles\libgit2" +} + +# Use cached dependencies if they match the requested version. +if ((Test-Path -Path "$INSTALL_PREFIX\versions.txt") -and $LIBGIT2_VERSION) { + $cached = Get-Content "$INSTALL_PREFIX\versions.txt" + $matches = $cached | Select-String "^LIBGIT2_VERSION=$LIBGIT2_VERSION$" + if ($matches) { + Write-Host "Using cached dependencies" + exit 0 + } +} + +if (!(Test-Path -Path "build")) { + # in case the pygit2 package build/ workspace has not been created by cibuildwheel yet + mkdir build +} +if (Test-Path -Path "$LIBGIT2_SRC") { + Set-Location "$LIBGIT2_SRC" + # for local runs, reuse build/libgit_src if it exists + if (Test-Path -Path build) { + # purge previous build env (likely for a different arch type) + Remove-Item -Recurse -Force build + } + # ensure we are checked out to the right version + git fetch --depth=1 --tags + git checkout "v$LIBGIT2_VERSION" +} else { + # from a fresh run (like in CI) + git clone --depth=1 -b "v$LIBGIT2_VERSION" https://github.com/libgit2/libgit2.git $LIBGIT2_SRC + Set-Location "$LIBGIT2_SRC" +} +cmake -B build -S . -DBUILD_TESTS=OFF -DCMAKE_INSTALL_PREFIX="$INSTALL_PREFIX" +cmake --build build/ --config=Release --target install + +# Record version so the cache can be reused. +"LIBGIT2_VERSION=$LIBGIT2_VERSION" | Set-Content "$INSTALL_PREFIX\versions.txt" -NoNewline diff --git a/build.sh b/build.sh index 1a415a23a..8f10af14c 100644 --- a/build.sh +++ b/build.sh @@ -14,7 +14,7 @@ # LIBSSH2_VERSION= - Build the given version of libssh2 # LIBGIT2_VERSION= - Build the given version of libgit2 # OPENSSL_VERSION= - Build the given version of OpenSSL -# (only needed for Mac universal on CI) +# (used on Linux and macOS CI builds) # # Examples. # @@ -22,14 +22,14 @@ # # sh build.sh # -# Build libgit2 1.9.0 (will use libssh2 if available), then build pygit2 +# Build libgit2 1.9.7 (will use libssh2 if available), then build pygit2 # inplace: # -# LIBGIT2_VERSION=1.9.0 sh build.sh +# LIBGIT2_VERSION=1.9.7 sh build.sh # -# Build libssh2 1.11.1 and libgit2 1.9.0, then build pygit2 inplace: +# Build libssh2 1.11.1 and libgit2 1.9.7, then build pygit2 inplace: # -# LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.0 sh build.sh +# LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.7 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 @@ -70,15 +72,30 @@ if [ "$CIBUILDWHEEL" = "1" ]; then else yum install perl-IPC-Cmd -y yum install perl-Pod-Html -y + yum install perl-Time-Piece -y fi elif [ -f /sbin/apk ]; then apk add wget if [ -z "$OPENSSL_VERSION" ]; then - apk add openssl-dev + apk add --no-cache openssl-dev + else + apk add --no-cache perl fi fi - rm -rf ci - mkdir ci || true + + # Use cached dependencies if they match the requested versions. + if [ -f ci/versions.txt ] && \ + grep -q "^LIBGIT2_VERSION=$LIBGIT2_VERSION$" ci/versions.txt && \ + grep -q "^LIBSSH2_VERSION=$LIBSSH2_VERSION$" ci/versions.txt && \ + grep -q "^OPENSSL_VERSION=$OPENSSL_VERSION$" ci/versions.txt; then + echo "Using cached dependencies" + exit 0 + fi + + # The ci directory may be a bind-mount (e.g. inside cibuildwheel), so + # remove its contents but keep the directory itself. + rm -rf ci/* ci/.[!.]* ci/..?* 2>/dev/null || true + mkdir -p ci cd ci else # Create a virtual environment @@ -105,36 +122,29 @@ if [ -n "$OPENSSL_VERSION" ]; then wget https://www.openssl.org/source/$FILENAME.tar.gz -N --no-check-certificate if [ "$KERNEL" = "Darwin" ]; then + # Build OpenSSL for the host architecture only. tar xf $FILENAME.tar.gz - mv $FILENAME openssl-x86 - - tar xf $FILENAME.tar.gz - mv $FILENAME openssl-arm - - cd openssl-x86 - ./Configure darwin64-x86_64-cc shared - make - cd ../openssl-arm - ./Configure enable-rc5 zlib darwin64-arm64-cc no-asm + cd $FILENAME + if [ "$ARCH" = "arm64" ]; then + ./Configure enable-rc5 zlib darwin64-arm64-cc no-asm shared --prefix=$PREFIX --libdir=$PREFIX/lib + else + ./Configure darwin64-x86_64-cc shared --prefix=$PREFIX --libdir=$PREFIX/lib + fi make - cd .. - - mkdir openssl-universal - - LIBSSL=$(basename openssl-x86/libssl.*.dylib) - lipo -create openssl-x86/libssl.*.dylib openssl-arm/libssl.*.dylib -output openssl-universal/$LIBSSL - LIBCRYPTO=$(basename openssl-x86/libcrypto.*.dylib) - lipo -create openssl-x86/libcrypto.*.dylib openssl-arm/libcrypto.*.dylib -output openssl-universal/$LIBCRYPTO - cd openssl-universal - install_name_tool -id "@rpath/$LIBSSL" $LIBSSL - install_name_tool -id "@rpath/$LIBCRYPTO" $LIBCRYPTO - OPENSSL_PREFIX=$(pwd) - cd .. + make install + OPENSSL_PREFIX=$PREFIX + # Set install names so delocate can bundle the libraries. + cd $PREFIX/lib + LIBSSL=$(find . -maxdepth 1 -name 'libssl.*.dylib' -type f | head -n1 | sed 's|^\./||') + LIBCRYPTO=$(find . -maxdepth 1 -name 'libcrypto.*.dylib' -type f | head -n1 | sed 's|^\./||') + install_name_tool -id "@rpath/$LIBSSL" "$LIBSSL" + install_name_tool -id "@rpath/$LIBCRYPTO" "$LIBCRYPTO" + cd ../.. else # Linux tar xf $FILENAME.tar.gz 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) @@ -149,14 +159,11 @@ if [ -n "$LIBSSH2_VERSION" ]; then tar xf $FILENAME.tar.gz cd $FILENAME if [ "$KERNEL" = "Darwin" ] && [ "$CIBUILDWHEEL" = "1" ]; then - cmake . \ + CMAKE_PREFIX_PATH=$PREFIX cmake . \ -DCMAKE_INSTALL_PREFIX=$PREFIX \ -DBUILD_SHARED_LIBS=ON \ -DBUILD_EXAMPLES=OFF \ - -DCMAKE_OSX_ARCHITECTURES="arm64;x86_64" \ - -DOPENSSL_CRYPTO_LIBRARY="../openssl-universal/$LIBCRYPTO" \ - -DOPENSSL_SSL_LIBRARY="../openssl-universal/$LIBSSL" \ - -DOPENSSL_INCLUDE_DIR="../openssl-x86/include" \ + -DCMAKE_OSX_ARCHITECTURES="$ARCH" \ -DBUILD_TESTING=OFF else cmake . \ @@ -178,17 +185,14 @@ 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 .. \ + CMAKE_PREFIX_PATH=$PREFIX cmake .. \ -DBUILD_SHARED_LIBS=ON \ -DBUILD_TESTS=OFF \ -DCMAKE_BUILD_TYPE=$BUILD_TYPE \ - -DCMAKE_OSX_ARCHITECTURES="arm64;x86_64" \ - -DOPENSSL_CRYPTO_LIBRARY="../openssl-universal/$LIBCRYPTO" \ - -DOPENSSL_SSL_LIBRARY="../openssl-universal/$LIBSSL" \ - -DOPENSSL_INCLUDE_DIR="../openssl-x86/include" \ + -DCMAKE_OSX_ARCHITECTURES="$ARCH" \ -DCMAKE_INSTALL_PREFIX=$PREFIX \ -DUSE_SSH=$USE_SSH else @@ -207,12 +211,16 @@ if [ -n "$LIBGIT2_VERSION" ]; then fi if [ "$CIBUILDWHEEL" = "1" ]; then + # Record versions so the cache can be reused. + cat > $PREFIX/versions.txt <>> 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 + >>> # And delete it >>> repo.branches.delete('new-branch') diff --git a/docs/callbacks.rst b/docs/callbacks.rst new file mode 100644 index 000000000..e4e417557 --- /dev/null +++ b/docs/callbacks.rst @@ -0,0 +1,40 @@ +********************************************************************** +Callbacks +********************************************************************** + +Many pygit2 operations accept callback objects. The callbacks module provides +base classes that you can subclass to customize behavior such as progress +reporting, credential lookup, or checkout notifications. + +.. contents:: Contents + :local: + + +Remote callbacks +================ + +.. autoclass:: pygit2.RemoteCallbacks + :members: + + +Checkout callbacks +================== + +.. autoclass:: pygit2.CheckoutCallbacks + :members: + + +Stash apply callbacks +===================== + +.. autoclass:: pygit2.StashApplyCallbacks + :members: + + +Passthrough +=========== + +Callbacks may raise :exc:`pygit2.Passthrough` to tell libgit2 to behave as if +the callback had not been set. This is useful when a callback only wants to +handle some cases and let libgit2 use its default behavior for the rest. See +:doc:`general` for the exception reference. diff --git a/docs/conf.py b/docs/conf.py index d44dcc827..6c4a9bb58 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -19,11 +19,11 @@ # -- Project information ----------------------------------------------------- project = 'pygit2' -copyright = '2010-2025 The pygit2 contributors' +copyright = '2010-2026 The pygit2 contributors' # author = '' # The full version, including alpha/beta/rc tags -release = '1.18.0' +release = '1.20.0' # -- General configuration --------------------------------------------------- diff --git a/docs/development.rst b/docs/development.rst index b9422bf3d..79d7e6bf3 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -5,8 +5,8 @@ The development version .. image:: https://github.com/libgit2/pygit2/actions/workflows/tests.yml/badge.svg :target: https://github.com/libgit2/pygit2/actions/workflows/tests.yml -.. image:: https://ci.appveyor.com/api/projects/status/edmwc0dctk5nacx0/branch/master?svg=true - :target: https://ci.appveyor.com/project/jdavid/pygit2/branch/master +.. image:: https://github.com/libgit2/pygit2/actions/workflows/wheels.yml/badge.svg + :target: https://github.com/libgit2/pygit2/actions/workflows/wheels.yml .. contents:: Contents :local: diff --git a/docs/enums.rst b/docs/enums.rst new file mode 100644 index 000000000..5774ddaa3 --- /dev/null +++ b/docs/enums.rst @@ -0,0 +1,193 @@ +********************************************************************** +Enums +********************************************************************** + +pygit2 exposes libgit2 constants as Python enums in the :mod:`pygit2.enums` +module. They are preferred over the top-level ``GIT_*`` integer constants. + +.. contents:: Contents + :local: + + +Repository +========== + +.. autoclass:: pygit2.enums.RepositoryInitFlag + :members: + +.. autoclass:: pygit2.enums.RepositoryInitMode + :members: + +.. autoclass:: pygit2.enums.RepositoryOpenFlag + :members: + +.. autoclass:: pygit2.enums.RepositoryState + :members: + + +References and branches +======================= + +.. autoclass:: pygit2.enums.BranchType + :members: + +.. autoclass:: pygit2.enums.ReferenceFilter + :members: + +.. autoclass:: pygit2.enums.ReferenceType + :members: + +.. autoclass:: pygit2.enums.ResetMode + :members: + +.. autoclass:: pygit2.enums.RevSpecFlag + :members: + + +Objects +======= + +.. autoclass:: pygit2.enums.ObjectType + :members: + +.. autoclass:: pygit2.enums.FileMode + :members: + + +Diff +==== + +.. autoclass:: pygit2.enums.DeltaStatus + :members: + +.. autoclass:: pygit2.enums.DiffFind + :members: + +.. autoclass:: pygit2.enums.DiffFlag + :members: + +.. autoclass:: pygit2.enums.DiffOption + :members: + +.. autoclass:: pygit2.enums.DiffStatsFormat + :members: + + +Status +====== + +.. autoclass:: pygit2.enums.FileStatus + :members: + + +Checkout +======== + +.. autoclass:: pygit2.enums.CheckoutNotify + :members: + +.. autoclass:: pygit2.enums.CheckoutStrategy + :members: + + +Merge +===== + +.. autoclass:: pygit2.enums.MergeAnalysis + :members: + +.. autoclass:: pygit2.enums.MergeFavor + :members: + +.. autoclass:: pygit2.enums.MergeFileFlag + :members: + +.. autoclass:: pygit2.enums.MergeFlag + :members: + +.. autoclass:: pygit2.enums.MergePreference + :members: + + +Blame +===== + +.. autoclass:: pygit2.enums.BlameFlag + :members: + + +Filters +======= + +.. autoclass:: pygit2.enums.FilterMode + :members: + +.. autoclass:: pygit2.enums.FilterFlag + :members: + +.. autoclass:: pygit2.enums.BlobFilter + :members: + + +Attributes +========== + +.. autoclass:: pygit2.enums.AttrCheck + :members: + + +Remotes +======= + +.. autoclass:: pygit2.enums.CredentialType + :members: + +.. autoclass:: pygit2.enums.FetchPrune + :members: + + +Submodules +========== + +.. autoclass:: pygit2.enums.SubmoduleIgnore + :members: + +.. autoclass:: pygit2.enums.SubmoduleStatus + :members: + + +Stash +===== + +.. autoclass:: pygit2.enums.StashApplyProgress + :members: + + +Revwalk +======= + +.. autoclass:: pygit2.enums.SortMode + :members: + +.. autoclass:: pygit2.enums.DescribeStrategy + :members: + + +Apply +===== + +.. autoclass:: pygit2.enums.ApplyLocation + :members: + + +Library +======= + +.. autoclass:: pygit2.enums.Feature + :members: + +.. autoclass:: pygit2.enums.Option + :members: + +.. autoclass:: pygit2.enums.ConfigLevel + :members: diff --git a/docs/filters.rst b/docs/filters.rst index 6c29a753b..33b24aa06 100644 --- a/docs/filters.rst +++ b/docs/filters.rst @@ -19,6 +19,17 @@ Registering filters .. autofunction:: pygit2.filter_register .. autofunction:: pygit2.filter_unregister +Loading filters +=============== + +.. automethod:: pygit2.Repository.load_filter_list + +The FilterList type +------------------- + +.. autoclass:: pygit2.filter.FilterList + :members: + Example ======= diff --git a/docs/general.rst b/docs/general.rst index 7f1edf333..9e0222c3e 100644 --- a/docs/general.rst +++ b/docs/general.rst @@ -18,41 +18,41 @@ library that has been built against. The version number has a .. py:data:: LIBGIT2_VER_MAJOR Integer value of the major version number. For example, for the version - ``0.26.0``:: + ``1.9.7``:: >>> print(pygit2.LIBGIT2_VER_MAJOR) - 0 + 1 .. py:data:: LIBGIT2_VER_MINOR Integer value of the minor version number. For example, for the version - ``0.26.0``:: + ``1.9.7``:: >>> print(pygit2.LIBGIT2_VER_MINOR) - 26 + 9 .. py:data:: LIBGIT2_VER_REVISION Integer value of the revision version number. For example, for the version - ``0.26.0``:: + ``1.9.7``:: >>> print(pygit2.LIBGIT2_VER_REVISION) - 0 + 6 .. py:data:: LIBGIT2_VER Tuple value of the revision version numbers. For example, for the version - ``0.26.0``:: + ``1.9.7``:: >>> print(pygit2.LIBGIT2_VER) - (0, 26, 0) + (1, 9, 6) .. py:data:: LIBGIT2_VERSION The libgit2 version number as a string:: >>> print(pygit2.LIBGIT2_VERSION) - '0.26.0' + '1.9.7' Options ========= @@ -80,3 +80,93 @@ Exception when trying to create an object (reference, etc) that already exists. :undoc-members: Exception when an input specification such as a reference name is invalid. + +.. autoexception:: pygit2.InvalidError + :members: + :show-inheritance: + :undoc-members: + +Exception when an operation or input is invalid. + +.. autoexception:: pygit2.NotFoundError + :members: + :show-inheritance: + :undoc-members: + +Exception when a requested object could not be found. + +.. autoexception:: pygit2.AmbiguousError + :members: + :show-inheritance: + :undoc-members: + +Exception when more than one object matches. + +.. autoexception:: pygit2.AuthError + :members: + :show-inheritance: + :undoc-members: + +Exception when an authentication error occurs. + +.. autoexception:: pygit2.CertificateError + :members: + :show-inheritance: + :undoc-members: + +Exception when a server certificate is invalid. + +.. autoexception:: pygit2.Passthrough + :members: + :show-inheritance: + :undoc-members: + +Exception that can be raised from a callback to tell libgit2 to behave as if +that callback had not been set. See :doc:`callbacks` for details. + +Error mapping +============= + +The following table shows how libgit2 error codes map to pygit2 exceptions. +The new exception classes inherit from :py:exc:`pygit2.GitError` and, where +noted, from a Python built-in exception for backward compatibility. + +.. list-table:: + :header-rows: 1 + :widths: 35 35 30 + + * - pygit2 exception + - libgit2 code / class + - Built-in base + + * - :py:exc:`AlreadyExistsError` + - ``GIT_EEXISTS`` + - ``ValueError`` + + * - :py:exc:`InvalidSpecError` + - ``GIT_EINVALIDSPEC`` + - ``ValueError`` + + * - :py:exc:`InvalidError` + - ``GIT_EINVALID``, ``GIT_ERROR_INVALID`` + - ``ValueError`` + + * - :py:exc:`NotFoundError` + - ``GIT_ENOTFOUND`` + - ``KeyError`` + + * - :py:exc:`AmbiguousError` + - ``GIT_EAMBIGUOUS`` + - ``ValueError`` + + * - :py:exc:`AuthError` + - ``GIT_EAUTH`` + - + + * - :py:exc:`CertificateError` + - ``GIT_ECERTIFICATE`` + - + + * - :py:exc:`GitError` + - generic / other errors + - diff --git a/docs/index.rst b/docs/index.rst index d728af53c..af3380e59 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 ===================================== @@ -62,9 +62,11 @@ Table of Contents backends blame branches + callbacks commit_log config diff + enums features filters index_file @@ -73,7 +75,9 @@ Table of Contents objects oid packing + rebase references + transactions remotes repository revparse diff --git a/docs/install.rst b/docs/install.rst index 9d8b21bb6..54bb07dc8 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.9.x**; binary wheels already include libgit2, so you only need to -worry about this if you install the source package. +worry about this if you `install from the source distribution`_. Optional libgit2 dependencies to support ssh and https: - https: WinHTTP (Windows), SecureTransport (OS X) or OpenSSL. -- ssh: libssh2 1.9.0 or later, pkg-config +- ssh: libssh2 1.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 @@ -86,6 +83,8 @@ of Python and the required libgit2 version. +-------------+----------------+------------+ | pygit2 | Python | libgit2 | +-------------+----------------+------------+ +| 1.19 - 1.20 | 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 | @@ -128,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 ----------------------- @@ -139,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.9.0.tar.gz -O libgit2-1.9.0.tar.gz - $ tar xzf libgit2-1.9.0.tar.gz - $ cd libgit2-1.9.0/ - $ cmake . - $ make - $ sudo make install + wget https://github.com/libgit2/libgit2/archive/refs/tags/v1.9.7.tar.gz -O libgit2-1.9.7.tar.gz + tar -xzf libgit2-1.9.7.tar.gz + cd libgit2-1.9.7/ + cmake . + make + sudo make install .. seealso:: For detailed instructions on building libgit2 check - https://libgit2.github.com/docs/guides/build-and-link/ + https://libgit2.org/docs/guides/build-and-link/ Now install pygit2, and then verify it is correctly installed: .. code-block:: sh - $ pip install pygit2 - ... - $ python -c 'import pygit2' + pip install pygit2 + # ... + python -c 'import pygit2' Troubleshooting @@ -174,9 +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 @@ -188,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 @@ -222,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.9.0.tar.gz -O libgit2-1.9.0.tar.gz - $ tar xzf libgit2-1.9.0.tar.gz - $ cd libgit2-1.9.0/ - $ cmake . -DCMAKE_INSTALL_PREFIX=$LIBGIT2 - $ cmake --build . --target install + wget https://github.com/libgit2/libgit2/archive/refs/tags/v1.9.7.tar.gz -O libgit2-1.9.7.tar.gz + tar xzf libgit2-1.9.7.tar.gz + cd libgit2-1.9.7/ + cmake . -DCMAKE_INSTALL_PREFIX=$LIBGIT2 + cmake --build . --target install Install pygit2: .. code-block:: sh + :caption: On Linux using bash - $ export LDFLAGS="-Wl,-rpath,'$LIBGIT2/lib',--enable-new-dtags $LDFLAGS" + export LDFLAGS="-Wl,-rpath,'$LIBGIT2/lib',--enable-new-dtags $LDFLAGS" # on OSX: export LDFLAGS="-Wl,-rpath,'$LIBGIT2/lib' $LDFLAGS" - $ pip install pygit2 - $ python -c 'import pygit2' + pip install pygit2 + python -c 'import pygit2' The run-path @@ -258,9 +273,10 @@ this time. So you need to either set ``LD_LIBRARY_PATH`` before using pygit2, like: .. code-block:: sh + :caption: On Linux using bash - $ export LD_LIBRARY_PATH=$LIBGIT2/lib - $ python -c 'import pygit2' + export LD_LIBRARY_PATH=$LIBGIT2/lib + python -c 'import pygit2' Or, like we have done in the instructions above, use the `rpath `_, it hard-codes extra search paths within @@ -268,33 +284,38 @@ the pygit2 extension modules, so you don't need to set ``LD_LIBRARY_PATH`` every time. Verify yourself if curious: .. code-block:: sh + :caption: On Linux using bash - $ readelf --dynamic lib/python2.7/site-packages/pygit2-0.27.0-py2.7-linux-x86_64.egg/pygit2/_pygit2.so | grep PATH + readelf --dynamic lib/python2.7/site-packages/pygit2-0.27.0-py2.7-linux-x86_64.egg/pygit2/_pygit2.so | grep PATH 0x000000000000001d (RUNPATH) Library runpath: [/tmp/venv/lib] Installing on Windows =================================== -`pygit2` for Windows is packaged into wheels and can be easily installed with -`pip`: +``pygit2`` for Windows is packaged into wheels and can be easily installed with +``pip``: .. code-block:: console pip install pygit2 -For development it is also possible to build `pygit2` with `libgit2` from -sources. `libgit2` location is specified by the ``LIBGIT2`` environment -variable. The following recipe shows you how to do it from a bash shell: +For development it is also possible to build ``pygit2`` with ``libgit2`` from +sources. ``libgit2`` location is specified by the ``LIBGIT2`` environment +variable. The following recipe shows you how to do it: -.. code-block:: sh +.. code-block:: pwsh + :caption: On Windows using PowerShell (and CMake v3.21 or newer) + + git clone --depth=1 -b v1.9.7 https://github.com/libgit2/libgit2.git + $env:CMAKE_INSTALL_PREFIX = "C:/Dev/libgit2" + $env:CMAKE_GENERATOR = "Visual Studio 17 2022" # or "Visual Studio 18 2026" + $env:CMAKE_GENERATOR_PLATFORM = "x64" # or "Win32" or "ARM64" + cmake -B libgit2/build -S libgit2 + cmake --build libgit2/build --config release --target install - $ export LIBGIT2=C:/Dev/libgit2 - $ git clone --depth=1 -b v1.9.0 https://github.com/libgit2/libgit2.git - $ cd libgit2 - $ cmake . -DCMAKE_INSTALL_PREFIX=$LIBGIT2 -G "Visual Studio 14 Win64" - $ cmake --build . --config release --target install - $ ctest -v + # let pip know where to find libgit2 when building pygit2 + $env:LIBGIT2 = "$env:CMAKE_INSTALL_PREFIX" At this point, you're ready to execute the generic `pygit2` installation steps described at the start of this page. @@ -321,8 +342,8 @@ XCode and Homebrew are already installed. .. code-block:: sh - $ brew update - $ brew install libgit2 - $ pip3 install pygit2 + brew update + brew install libgit2 + pip3 install pygit2 To build from a non-Homebrew libgit2 follow the guide in `libgit2 within a virtual environment`_. diff --git a/docs/objects.rst b/docs/objects.rst index 7d323a3a1..e6cfa39ed 100644 --- a/docs/objects.rst +++ b/docs/objects.rst @@ -33,7 +33,7 @@ implements a subset of the mapping interface. >>> repo = Repository('path/to/pygit2') >>> obj = repo.get("101715bf37440d32291bde4f58c3142bcf7d8adb") >>> obj - <_pygit2.Commit object at 0x7ff27a6b60f0> + .. method:: Repository.__getitem__(id) @@ -112,10 +112,10 @@ them to the Git object database: Example: - >>> id = repo.create_blob('foo bar') # Creates blob from a byte string + >>> id = repo.create_blob(b'foo bar') # Creates blob from a byte string >>> blob = repo[id] >>> blob.data - 'foo bar' + b'foo bar' There are also some functions to calculate the id for a byte string without creating the blob object: @@ -123,6 +123,12 @@ creating the blob object: .. autofunction:: pygit2.hash .. autofunction:: pygit2.hashfile +To calculate the hash of a file using the repository's filtering rules (e.g. +``core.safecrlf``), use the repository's instance method: + +.. automethod:: pygit2.Repository.hashfile + :noindex: + Streaming blob content ---------------------- diff --git a/docs/rebase.rst b/docs/rebase.rst new file mode 100644 index 000000000..1cc27dc8b --- /dev/null +++ b/docs/rebase.rst @@ -0,0 +1,83 @@ +********************************************************************** +Rebase +********************************************************************** + +.. contents:: + +.. automethod:: pygit2.Repository.rebase_init +.. automethod:: pygit2.Repository.rebase_open + +The Rebase type +==================== + +.. autoclass:: pygit2.Rebase + :members: + :special-members: __len__, __getitem__, __next__ + +.. autoclass:: pygit2.RebaseOperation + :members: + +Example +======= + +Rebase the current branch onto its upstream:: + + >>> committer = repo.default_signature + >>> rebase = repo.rebase_init(upstream=repo.branches['origin/master']) + >>> for operation in rebase: + ... # If repo.index.conflicts is not None at this point, the + ... # operation left conflicts in the index and conflict markers + ... # in the working directory. Resolve them, stage each + ... # resolution with repo.index.add(path), and only then commit. + ... rebase.commit(committer=committer) + >>> rebase.finish(committer) + +Use ``abort()`` instead of ``finish()`` to reset the repository and the +working directory to their state before the rebase began. + +``commit()`` returns ``None`` for a patch that turns out to be already +present upstream; like ``git rebase``, simply move on to the next +operation. + +With ``inmemory=True`` the rebase does not touch HEAD, the repository +state, or the working directory; each step's result is available as +``rebase.inmemory_index`` and updating the branch reference afterwards is +the caller's responsibility. + +Working with rebase operations +============================== + +Iterating over a ``Rebase`` yields a :py:class:`~pygit2.RebaseOperation` +describing each step. ``len()`` and indexing expose the same operations +up front, without advancing the rebase, so the plan can be inspected +before applying it:: + + >>> rebase = repo.rebase_init(upstream=repo.branches['origin/master']) + >>> for i in range(len(rebase)): + ... print(rebase[i]) + + + +A rebase started with ``rebase_init()`` replays the non-merge commits +in ``upstream..branch``; merge commits are skipped, linearizing the +history, just like plain ``git rebase`` (libgit2 has no equivalent of +``--rebase-merges``). Every operation's ``type`` is therefore +``RebaseOperationType.PICK``, ``id`` names the original commit being +replayed, and ``exec`` is ``None``. The remaining +``RebaseOperationType`` values mirror the verbs of git's interactive +rebase, which libgit2 does not implement (as of 1.9): they are declared +for completeness but never produced. Looking the original commit up is +useful for progress reporting or for reusing its metadata:: + + >>> from pygit2.enums import RebaseOperationType + >>> committer = repo.default_signature + >>> for operation in rebase: + ... assert operation.type == RebaseOperationType.PICK + ... original = repo[operation.id] + ... step, total = rebase.current_index + 1, len(rebase) + ... print(f'[{step}/{total}] picking {original.short_id}:', + ... original.message.strip()) + ... rebase.commit(committer=committer) + [1/2] picking 4a3fe06: Add feature + [2/2] picking 8ae4a25: Fix tests + >>> rebase.finish(committer) diff --git a/docs/references.rst b/docs/references.rst index 0d06c3c36..56387855c 100644 --- a/docs/references.rst +++ b/docs/references.rst @@ -88,6 +88,21 @@ Example:: .. autoclass:: pygit2.RefLogEntry :members: +Reference Transactions +======================= + +For atomic updates of multiple references, use transactions. See the +:doc:`transactions` documentation for details. + +Example:: + + # Update multiple refs atomically + with repo.transaction() as txn: + txn.lock_ref('refs/heads/master') + txn.lock_ref('refs/heads/develop') + txn.set_target('refs/heads/master', new_oid, message='Release') + txn.set_target('refs/heads/develop', dev_oid, message='Continue dev') + Notes ==================== diff --git a/docs/remotes.rst b/docs/remotes.rst index 3b9968911..4b98f9ffd 100644 --- a/docs/remotes.rst +++ b/docs/remotes.rst @@ -22,7 +22,11 @@ The Remote type The RemoteCallbacks type ======================== +See :doc:`callbacks` for the full reference. The following autoclass is only +included here for discoverability. + .. autoclass:: pygit2.RemoteCallbacks + :noindex: :members: The TransferProgress type @@ -33,6 +37,24 @@ This class contains the data which is available to us during a fetch. .. autoclass:: pygit2.remotes.TransferProgress :members: +The RemoteHead type +=================== + +Description of a reference advertised by a remote server, returned by +:meth:`pygit2.Remote.list_heads`. + +.. autoclass:: pygit2.remotes.RemoteHead + :members: + +The PushUpdate type +=================== + +Represents an update which will be performed on the remote during push. +Passed to :meth:`pygit2.RemoteCallbacks.push_negotiation`. + +.. autoclass:: pygit2.remotes.PushUpdate + :members: + The Refspec type =================== diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 000000000..cbf1e3658 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,2 @@ +sphinx +sphinx-rtd-theme diff --git a/docs/revparse.rst b/docs/revparse.rst index 5d1cc812e..2cb613f77 100644 --- a/docs/revparse.rst +++ b/docs/revparse.rst @@ -17,5 +17,8 @@ You can use any of the fancy `` forms supported by libgit2:: Constants: .. py:data:: pygit2.enums.RevSpecFlag.SINGLE + :noindex: .. py:data:: pygit2.enums.RevSpecFlag.RANGE + :noindex: .. py:data:: pygit2.enums.RevSpecFlag.MERGE_BASE + :noindex: diff --git a/docs/submodule.rst b/docs/submodule.rst index 578806f45..4bdeee283 100644 --- a/docs/submodule.rst +++ b/docs/submodule.rst @@ -7,6 +7,7 @@ dedicated subdirectory of the repositories tree. .. autoclass:: pygit2.Repository :members: listall_submodules + :noindex: .. py:attribute:: Repository.submodules diff --git a/docs/transactions.rst b/docs/transactions.rst new file mode 100644 index 000000000..4645320e0 --- /dev/null +++ b/docs/transactions.rst @@ -0,0 +1,120 @@ +********************************************************************** +Reference Transactions +********************************************************************** + +Reference transactions allow you to update multiple references atomically. +All reference updates within a transaction either succeed together or fail +together, ensuring repository consistency. + +Basic Usage +=========== + +Use the :meth:`Repository.transaction` method as a context manager. The +transaction commits automatically when the context exits successfully, or +rolls back if an exception is raised:: + + with repo.transaction() as txn: + txn.lock_ref('refs/heads/master') + txn.set_target('refs/heads/master', new_oid, message='Update master') + +Atomic Multi-Reference Updates +=============================== + +Transactions are useful when you need to update multiple references +atomically:: + + # Swap two branches atomically + with repo.transaction() as txn: + txn.lock_ref('refs/heads/branch-a') + txn.lock_ref('refs/heads/branch-b') + + # Get current targets + ref_a = repo.lookup_reference('refs/heads/branch-a') + ref_b = repo.lookup_reference('refs/heads/branch-b') + + # Swap them + txn.set_target('refs/heads/branch-a', ref_b.target, message='Swap') + txn.set_target('refs/heads/branch-b', ref_a.target, message='Swap') + +Automatic Rollback +================== + +If an exception occurs during the transaction, changes are automatically +rolled back:: + + try: + with repo.transaction() as txn: + txn.lock_ref('refs/heads/master') + txn.set_target('refs/heads/master', new_oid) + + # If this raises an exception, the ref update is rolled back + validate_commit(new_oid) + except ValidationError: + # Master still points to its original target + pass + +Manual Commit +============= + +While the context manager is recommended, you can manually manage +transactions:: + + from pygit2 import ReferenceTransaction + + txn = ReferenceTransaction(repo) + try: + txn.lock_ref('refs/heads/master') + txn.set_target('refs/heads/master', new_oid, message='Update') + txn.commit() + finally: + del txn # Ensure transaction is freed + +API Reference +============= + +Repository Methods +------------------ + +.. automethod:: pygit2.Repository.transaction + +The ReferenceTransaction Type +------------------------------ + +.. autoclass:: pygit2.ReferenceTransaction + :members: + :special-members: __enter__, __exit__ + +Usage Notes +=========== + +- Always lock a reference with :meth:`~ReferenceTransaction.lock_ref` before + modifying it +- Transactions operate on reference names, not Reference objects +- Symbolic references can be updated with + :meth:`~ReferenceTransaction.set_symbolic_target` +- References can be deleted with :meth:`~ReferenceTransaction.remove` +- The signature parameter defaults to the repository's configured identity + +Thread Safety +============= + +Transactions are thread-local and must be used from the thread that created +them. Attempting to use a transaction from a different thread raises +:exc:`RuntimeError`:: + + # This is safe - each thread has its own transaction + def thread1(): + with repo.transaction() as txn: + txn.lock_ref('refs/heads/branch1') + txn.set_target('refs/heads/branch1', oid1) + + def thread2(): + with repo.transaction() as txn: + txn.lock_ref('refs/heads/branch2') + txn.set_target('refs/heads/branch2', oid2) + + # Both threads can run concurrently without conflicts + +Different threads can hold transactions simultaneously as long as they don't +attempt to lock the same references. If two threads try to acquire locks in +different orders, libgit2 will detect potential deadlocks and raise an error. diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 000000000..ea5a4ae13 --- /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 cf83557ce..785ffe958 100644 --- a/pygit2/__init__.py +++ b/pygit2/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -30,36 +30,350 @@ import os import typing -# Low level API -from ._pygit2 import * -from ._pygit2 import _cache_enums - # High level API -from . import enums +from . import enums, utils 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, + Blob, + Branch, + Commit, + Diff, + DiffDelta, + DiffFile, + DiffHunk, + DiffLine, + DiffStats, + FilterSource, + 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 ( + CheckoutCallbacks, + Payload, + RemoteCallbacks, + StashApplyCallbacks, + get_credentials, git_clone_options, git_fetch_options, git_proxy_options, - get_credentials, ) from .config import Config from .credentials import * -from .errors import check_error, Passthrough -from .ffi import ffi, C +from .errors import ( + AlreadyExistsError, + AmbiguousError, + AuthError, + CertificateError, + GitError, + InvalidError, + InvalidSpecError, + NotFoundError, + Passthrough, + check_error, +) +from .ffi import C, ffi from .filter import Filter from .index import Index, IndexEntry -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 .rebase import Rebase, RebaseOperation from .remotes import Remote from .repository import Repository from .settings import Settings from .submodules import Submodule -from .utils import to_bytes, to_str - +from .transaction import ReferenceTransaction # Features features = enums.Feature(C.git_libgit2_features()) @@ -73,12 +387,10 @@ def init_repository( - path: typing.Union[str, bytes, os.PathLike, None], + path: str | bytes | os.PathLike[str] | os.PathLike[bytes] | None, bare: bool = False, flags: enums.RepositoryInitFlag = enums.RepositoryInitFlag.MKPATH, - mode: 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, @@ -126,37 +438,37 @@ def init_repository( options.mode = mode if workdir_path: - workdir_path_ref = ffi.new('char []', to_bytes(workdir_path)) + workdir_path_ref = ffi.new('char []', utils.encode_fs_path(workdir_path)) options.workdir_path = workdir_path_ref if description: - description_ref = ffi.new('char []', to_bytes(description)) + description_ref = ffi.new('char []', utils.encode_string(description)) options.description = description_ref if template_path: - template_path_ref = ffi.new('char []', to_bytes(template_path)) + template_path_ref = ffi.new('char []', utils.encode_fs_path(template_path)) options.template_path = template_path_ref if initial_head: - initial_head_ref = ffi.new('char []', to_bytes(initial_head)) + initial_head_ref = ffi.new('char []', utils.encode_string(initial_head)) options.initial_head = initial_head_ref if origin_url: - origin_url_ref = ffi.new('char []', to_bytes(origin_url)) + origin_url_ref = ffi.new('char []', utils.encode_string(origin_url)) options.origin_url = origin_url_ref # Call crepository = ffi.new('git_repository **') - err = C.git_repository_init_ext(crepository, to_bytes(path), options) + err = C.git_repository_init_ext(crepository, utils.encode_fs_path(path), options) check_error(err) # Ok - return Repository(to_str(path)) + return Repository(utils.path_to_str(path)) def clone_repository( - url: str | bytes | os.PathLike, - path: str | bytes | os.PathLike, + url: str | bytes | os.PathLike[str] | os.PathLike[bytes], + path: str | bytes | os.PathLike[str] | os.PathLike[bytes], bare: bool = False, repository: typing.Callable | None = None, remote: typing.Callable | None = None, @@ -164,7 +476,7 @@ def clone_repository( callbacks: RemoteCallbacks | None = None, depth: int = 0, proxy: None | bool | str = None, -): +) -> Repository: """ Clones a new Git repository from *url* in the given *path*. @@ -226,19 +538,44 @@ def clone_repository( opts.fetch_opts.depth = depth if checkout_branch: - checkout_branch_ref = ffi.new('char []', to_bytes(checkout_branch)) + checkout_branch_ref = ffi.new( + 'char []', utils.encode_string(checkout_branch) + ) opts.checkout_branch = checkout_branch_ref with git_fetch_options(payload, opts=opts.fetch_opts): with git_proxy_options(payload, opts.fetch_opts.proxy_opts, proxy): crepo = ffi.new('git_repository **') - err = C.git_clone(crepo, to_bytes(url), to_bytes(path), opts) + err = C.git_clone( + crepo, utils.encode_string(url), utils.encode_fs_path(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 = utils.encode_string(name) + err = C.git_filter_unregister(c_name) + check_error(err) + + tree_entry_key = functools.cmp_to_key(tree_entry_cmp) settings = Settings() @@ -263,8 +600,11 @@ def clone_repository( 'Object', 'Reference', 'AlreadyExistsError', + 'AmbiguousError', + 'AuthError', 'Blob', 'Branch', + 'CertificateError', 'Commit', 'Diff', 'DiffDelta', @@ -273,9 +613,11 @@ def clone_repository( 'DiffLine', 'DiffStats', 'GitError', + 'InvalidError', 'InvalidSpecError', 'Mailmap', 'Note', + 'NotFoundError', 'Odb', 'OdbBackend', 'OdbBackendLoose', @@ -304,7 +646,6 @@ def clone_repository( # Low Level API (not present in .pyi) 'FilterSource', 'filter_register', - 'filter_unregister', 'GIT_APPLY_LOCATION_BOTH', 'GIT_APPLY_LOCATION_INDEX', 'GIT_APPLY_LOCATION_WORKDIR', @@ -456,37 +797,51 @@ def clone_repository( 'GIT_OBJECT_REF_DELTA', 'GIT_OBJECT_TAG', 'GIT_OBJECT_TREE', + 'GIT_OPT_ADD_SSL_X509_CERT', 'GIT_OPT_DISABLE_PACK_KEEP_FILE_CHECKS', 'GIT_OPT_ENABLE_CACHING', 'GIT_OPT_ENABLE_FSYNC_GITDIR', + 'GIT_OPT_ENABLE_HTTP_EXPECT_CONTINUE', 'GIT_OPT_ENABLE_OFS_DELTA', 'GIT_OPT_ENABLE_STRICT_HASH_VERIFICATION', 'GIT_OPT_ENABLE_STRICT_OBJECT_CREATION', 'GIT_OPT_ENABLE_STRICT_SYMBOLIC_REF_CREATION', 'GIT_OPT_ENABLE_UNSAVED_INDEX_SAFETY', 'GIT_OPT_GET_CACHED_MEMORY', + 'GIT_OPT_GET_EXTENSIONS', + 'GIT_OPT_GET_HOMEDIR', 'GIT_OPT_GET_MWINDOW_FILE_LIMIT', 'GIT_OPT_GET_MWINDOW_MAPPED_LIMIT', 'GIT_OPT_GET_MWINDOW_SIZE', 'GIT_OPT_GET_OWNER_VALIDATION', 'GIT_OPT_GET_PACK_MAX_OBJECTS', 'GIT_OPT_GET_SEARCH_PATH', + 'GIT_OPT_GET_SERVER_CONNECT_TIMEOUT', + 'GIT_OPT_GET_SERVER_TIMEOUT', 'GIT_OPT_GET_TEMPLATE_PATH', 'GIT_OPT_GET_USER_AGENT', + 'GIT_OPT_GET_USER_AGENT_PRODUCT', 'GIT_OPT_GET_WINDOWS_SHAREMODE', 'GIT_OPT_SET_ALLOCATOR', 'GIT_OPT_SET_CACHE_MAX_SIZE', 'GIT_OPT_SET_CACHE_OBJECT_LIMIT', + 'GIT_OPT_SET_EXTENSIONS', + 'GIT_OPT_SET_HOMEDIR', 'GIT_OPT_SET_MWINDOW_FILE_LIMIT', 'GIT_OPT_SET_MWINDOW_MAPPED_LIMIT', 'GIT_OPT_SET_MWINDOW_SIZE', + 'GIT_OPT_SET_ODB_LOOSE_PRIORITY', + 'GIT_OPT_SET_ODB_PACKED_PRIORITY', 'GIT_OPT_SET_OWNER_VALIDATION', 'GIT_OPT_SET_PACK_MAX_OBJECTS', 'GIT_OPT_SET_SEARCH_PATH', + 'GIT_OPT_SET_SERVER_CONNECT_TIMEOUT', + 'GIT_OPT_SET_SERVER_TIMEOUT', 'GIT_OPT_SET_SSL_CERT_LOCATIONS', 'GIT_OPT_SET_SSL_CIPHERS', 'GIT_OPT_SET_TEMPLATE_PATH', 'GIT_OPT_SET_USER_AGENT', + 'GIT_OPT_SET_USER_AGENT_PRODUCT', 'GIT_OPT_SET_WINDOWS_SHAREMODE', 'GIT_REFERENCES_ALL', 'GIT_REFERENCES_BRANCHES', @@ -576,71 +931,11 @@ def clone_repository( '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', + 'rebase', + 'Rebase', + 'RebaseOperation', 'refspec', 'remotes', 'Remote', @@ -652,9 +947,9 @@ def clone_repository( 'Settings', 'submodules', 'Submodule', + 'transaction', + 'ReferenceTransaction', 'utils', - 'to_bytes', - 'to_str', # __init__ module defined symbols 'features', 'LIBGIT2_VER', diff --git a/pygit2/_build.py b/pygit2/_build.py index 23d38e5ef..0cb6f928f 100644 --- a/pygit2/_build.py +++ b/pygit2/_build.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -34,13 +34,13 @@ # # The version number of pygit2 # -__version__ = '1.18.0' +__version__ = '1.20.0' # # 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 000000000..599fe415e --- /dev/null +++ b/pygit2/_libgit2/ffi.pyi @@ -0,0 +1,448 @@ +# Copyright 2010-2026 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +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 GitRemoteCallbacksC: + # TODO: Several Anys need filling in + version: int + sideband_progress: Any + completion: Any + credentials: Any + certificate_check: Any + transfer_progress: Any + update_tips: Any + pack_progress: Any + push_transfer_progress: Any + push_update_reference: Any + push_negotiation: Any + transport: Any + remote_ready: Any + payload: Any + resolve_url: Any + update_refs: Any + +class GitFetchOptionsC: + # TODO: FetchOptions exist in _pygit2.pyi + # incomplete + depth: int + callbacks: GitRemoteCallbacksC + custom_headers: GitStrrayC + +class GitPushOptionsC: + # TODO incomplete + callbacks: GitRemoteCallbacksC + custom_headers: GitStrrayC + +class GitSubmoduleC: + pass + +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 + ancestor_label: ArrayC[char] + our_label: ArrayC[char] + their_label: ArrayC[char] + +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: + # TODO: Several Anys need filling in + repository_cb: Any + repository_cb_payload: Any + remote_cb: Any + remote_cb_payload: Any + +class GitPackbuilderC: + pass + +class GitProxyTC: + pass + +class GitProxyOptionsC: + version: int + type: GitProxyTC + url: char_pointer + # credentials + # certificate_check + # payload + +class GitRebaseC: + pass + +class GitRebaseOperationC: + type: int + id: GitOidC + exec: char_pointer + +class GitRebaseOptionsC: + version: int + quiet: int + inmemory: int + rewrite_notes_ref: ArrayC[char] + merge_options: GitMergeOptionsC + checkout_options: GitCheckoutOptionsC + +class GitRemoteC: + pass + +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_remote_callbacks *']) -> GitRemoteCallbacksC: ... +@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_fetch_options *']) -> GitFetchOptionsC: ... +@overload +def new(a: Literal['git_merge_options *']) -> GitMergeOptionsC: ... +@overload +def new(a: Literal['git_push_options *']) -> GitPushOptionsC: ... +@overload +def new(a: Literal['git_blame_options *']) -> GitBlameOptionsC: ... +@overload +def new(a: Literal['git_annotated_commit **']) -> _Pointer[GitAnnotatedCommitC]: ... +@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_rebase **']) -> _Pointer[GitRebaseC]: ... +@overload +def new(a: Literal['git_rebase_options *']) -> GitRebaseOptionsC: ... +@overload +def new(a: Literal['git_rebase_operation **']) -> _Pointer[GitRebaseOperationC]: ... +@overload +def new(a: Literal['git_tree **']) -> _Pointer[GitTreeC]: ... +@overload +def new(a: Literal['git_buf *'], b: tuple[NULL_TYPE, Literal[0]]) -> GitBufC: ... +@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]: ... +def new_handle(a: T) -> _Pointer[T]: ... +def gc(cdata: T, destructor: Any, size: int = ...) -> T: ... + +class buffer(bytes): + def __init__(self, a: object) -> None: ... + 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 2cf8e39e0..f8ca06bd1 100644 --- a/pygit2/_pygit2.pyi +++ b/pygit2/_pygit2.pyi @@ -1,8 +1,31 @@ -from typing import Iterator, Literal, Optional, overload, Type, TypedDict -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, + final, + overload, +) + +from typing_extensions import disjoint_base + from . import Index +from ._libgit2.ffi import ( + GitCommitC, + GitObjectC, + GitSignatureC, + _Pointer, +) from .enums import ( ApplyLocation, + BlobFilter, BranchType, DeltaStatus, DiffFind, @@ -13,58 +36,270 @@ from .enums import ( MergeAnalysis, MergePreference, ObjectType, - Option, ReferenceFilter, ReferenceType, ResetMode, SortMode, ) -from collections.abc import Generator +from .filter import Filter -from .repository import BaseRepository -from .remotes import Remote - -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 id: Oid name: str | None raw_name: bytes | None short_id: str - type: 'Literal[GIT_OBJ_COMMIT] | Literal[GIT_OBJ_TREE] | Literal[GIT_OBJ_TAG] | Literal[GIT_OBJ_BLOB]' + type: 'Literal[ObjectType.COMMIT] | Literal[ObjectType.TREE] | Literal[ObjectType.TAG] | Literal[ObjectType.BLOB]' type_str: "Literal['commit'] | Literal['tree'] | Literal['tag'] | Literal['blob']" + author: Signature + committer: Signature + tree: Tree @overload - def peel(self, target_type: 'Literal[GIT_OBJ_COMMIT]') -> 'Commit': ... + def peel( + self, target_type: 'Literal[ObjectType.COMMIT] | Type[Commit]', / + ) -> 'Commit': ... @overload - def peel(self, target_type: 'Literal[GIT_OBJ_TREE]') -> 'Tree': ... + def peel( + self, target_type: 'Literal[ObjectType.TREE] | Type[Tree]', / + ) -> 'Tree': ... @overload - def peel(self, target_type: 'Literal[GIT_OBJ_TAG]') -> 'Tag': ... + def peel(self, target_type: 'Literal[ObjectType.TAG] | Type[Tag]', /) -> 'Tag': ... @overload - def peel(self, target_type: 'Literal[GIT_OBJ_BLOB]') -> 'Blob': ... + def peel( + self, target_type: 'Literal[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: ... - def __gt__(self, other) -> bool: ... + def __eq__(self, other, /) -> bool: ... + def __ge__(self, other, /) -> bool: ... + def __gt__(self, other, /) -> bool: ... def __hash__(self) -> int: ... - def __le__(self, other) -> bool: ... - def __lt__(self, other) -> bool: ... - def __ne__(self, other) -> bool: ... + def __le__(self, other, /) -> bool: ... + def __lt__(self, other, /) -> bool: ... + def __ne__(self, other, /) -> bool: ... + +@disjoint_base +class Object(_ObjectBase[GitObjectC]): + pass +@final class Reference: name: str raw_name: bytes @@ -77,27 +312,31 @@ class Reference: def delete(self) -> None: ... def log(self) -> Iterator[RefLogEntry]: ... @overload - def peel(self, type: 'Literal[GIT_OBJ_COMMIT] | Type[Commit]') -> 'Commit': ... + def peel(self, type: 'Literal[ObjectType.COMMIT] | Type[Commit]') -> 'Commit': ... @overload - def peel(self, type: 'Literal[GIT_OBJ_TREE] | Type[Tree]') -> 'Tree': ... + def peel(self, type: 'Literal[ObjectType.TREE] | Type[Tree]') -> 'Tree': ... @overload - def peel(self, type: 'Literal[GIT_OBJ_TAG] | Type[Tag]') -> 'Tag': ... + def peel(self, type: 'Literal[ObjectType.TAG] | Type[Tag]') -> 'Tag': ... @overload - def peel(self, type: 'Literal[GIT_OBJ_BLOB] | Type[Blob]') -> 'Blob': ... + def peel(self, type: 'Literal[ObjectType.BLOB] | Type[Blob]') -> 'Blob': ... @overload def peel(self, type: 'None' = None) -> 'Commit|Tree|Tag|Blob': ... - def rename(self, new_name: str) -> None: ... + def rename(self, new_name: str, /) -> None: ... def resolve(self) -> Reference: ... def set_target(self, target: _OidArg, message: str = ...) -> None: ... - def __eq__(self, other) -> bool: ... - def __ge__(self, other) -> bool: ... - def __gt__(self, other) -> bool: ... - def __le__(self, other) -> bool: ... - def __lt__(self, other) -> bool: ... - def __ne__(self, other) -> bool: ... - -class AlreadyExistsError(ValueError): ... - + def __eq__(self, other, /) -> bool: ... + def __ge__(self, other, /) -> bool: ... + def __gt__(self, other, /) -> bool: ... + def __le__(self, other, /) -> bool: ... + def __lt__(self, other, /) -> bool: ... + def __ne__(self, other, /) -> bool: ... + +class AlreadyExistsError(GitError, ValueError): ... +class AmbiguousError(GitError, ValueError): ... +class AuthError(GitError): ... +class InvalidError(GitError, ValueError): ... + +@final class Blob(Object): data: bytes is_binary: bool @@ -111,13 +350,25 @@ 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: ... -class Branch(Reference): +@final +class Branch(Reference): # type: ignore[misc] branch_name: str raw_branch_name: bytes remote_name: str @@ -128,7 +379,11 @@ class Branch(Reference): def is_head(self) -> bool: ... def rename(self, name: str, force: bool = False) -> 'Branch': ... # type: ignore[override] -class Commit(Object): +class CertificateError(GitError): ... + +@final +class Commit(_ObjectBase[GitCommitC]): + _pointer: _Pointer[GitCommitC] author: Signature commit_time: int commit_time_offset: int @@ -143,11 +398,13 @@ class Commit(Object): tree: Tree tree_id: Oid +@disjoint_base class Diff: deltas: Iterator[DiffDelta] patch: str | None patchid: Oid stats: DiffStats + text: str def find_similar( self, flags: DiffFind = DiffFind.FIND_BY_CONFIG, @@ -161,14 +418,15 @@ class Diff: @staticmethod 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 parse_diff(git_diff: str | bytes, /) -> Diff: ... + def __getitem__(self, index: int, /) -> Patch | None: ... # Diff_getitem + def __iter__(self) -> Iterator[Patch | None]: ... # -> DiffIter def __len__(self) -> int: ... +@final class DiffDelta: flags: DiffFlag - is_binary: bool + is_binary: bool | None nfiles: int new_file: DiffFile old_file: DiffFile @@ -176,6 +434,7 @@ class DiffDelta: status: DeltaStatus def status_char(self) -> str: ... +@final class DiffFile: flags: DiffFlag id: Oid @@ -184,8 +443,9 @@ class DiffFile: raw_path: bytes size: int @staticmethod - def from_c(bytes) -> DiffFile: ... + def from_c(bytes, /) -> DiffFile: ... +@disjoint_base class DiffHunk: header: str lines: list[DiffLine] @@ -194,6 +454,7 @@ class DiffHunk: old_lines: int old_start: int +@final class DiffLine: content: str content_offset: int @@ -203,22 +464,33 @@ class DiffLine: origin: str raw_content: bytes +@disjoint_base class DiffStats: deletions: int files_changed: int insertions: int def format(self, format: DiffStatsFormat, width: int) -> str: ... +@final +class FilterSource: + repo: object + path: str + filemode: int + oid: Oid | None + mode: int + flags: int + class GitError(Exception): ... -class InvalidSpecError(ValueError): ... +class InvalidSpecError(GitError, ValueError): ... +@final 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 @@ -228,53 +500,64 @@ class Mailmap: def resolve(self, name: str, email: str) -> tuple[str, str]: ... def resolve_signature(self, sig: Signature) -> Signature: ... +@final class Note: annotated_id: Oid id: Oid message: str + data: bytes def remove( self, author: Signature, committer: Signature, ref: str = 'refs/notes/commits' ) -> None: ... +class NotFoundError(GitError, KeyError): ... + +@final class Odb: backends: Iterator[OdbBackend] def __init__(self, *args, **kwargs) -> None: ... def add_backend(self, backend: OdbBackend, priority: int) -> None: ... - def add_disk_alternate(self, path: str) -> 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 __contains__(self, other: _OidArg) -> bool: ... + def add_disk_alternate(self, path: str | Path, /) -> None: ... + def exists(self, oid: _OidArg, /) -> bool: ... + def read(self, oid: _OidArg, /) -> tuple[ObjectType, bytes]: ... + def read_header(self, oid: _OidArg, /) -> tuple[ObjectType, int]: ... + def write(self, type: int, data: bytes | str) -> Oid: ... + def __contains__(self, other: _OidArg, /) -> bool: ... def __iter__(self) -> Iterator[Oid]: ... # Odb_as_iter +@disjoint_base class OdbBackend: def __init__(self, *args, **kwargs) -> None: ... - def exists(self, oid: _OidArg) -> bool: ... - def exists_prefix(self, partial_id: _OidArg) -> Oid: ... - def read(self, oid: _OidArg) -> tuple[int, bytes]: ... - def read_header(self, oid: _OidArg) -> tuple[int, int]: ... - def read_prefix(self, oid: _OidArg) -> tuple[int, bytes, Oid]: ... + def exists(self, oid: _OidArg, /) -> bool: ... + def exists_prefix(self, partial_id: _OidArg, /) -> Oid: ... + def read(self, oid: _OidArg, /) -> tuple[int, bytes]: ... + def read_header(self, oid: _OidArg, /) -> tuple[int, int]: ... + def read_prefix(self, oid: _OidArg, /) -> tuple[int, bytes, Oid]: ... def refresh(self) -> None: ... def __iter__(self) -> Iterator[Oid]: ... # OdbBackend_as_iter +@final class OdbBackendLoose(OdbBackend): def __init__(self, *args, **kwargs) -> None: ... +@final class OdbBackendPack(OdbBackend): def __init__(self, *args, **kwargs) -> None: ... +@final class Oid: raw: bytes def __init__(self, raw: bytes = ..., hex: str = ...) -> None: ... - def __eq__(self, other) -> bool: ... - def __ge__(self, other) -> bool: ... - def __gt__(self, other) -> bool: ... + def __eq__(self, other, /) -> bool: ... + def __ge__(self, other, /) -> bool: ... + def __gt__(self, other, /) -> bool: ... def __hash__(self) -> int: ... - def __le__(self, other) -> bool: ... - def __lt__(self, other) -> bool: ... - def __ne__(self, other) -> bool: ... + def __le__(self, other, /) -> bool: ... + def __lt__(self, other, /) -> bool: ... + def __ne__(self, other, /) -> bool: ... def __bool__(self) -> bool: ... +@final class Patch: data: bytes delta: DiffDelta @@ -293,6 +576,7 @@ class Patch: interhunk_lines: int = 0, ) -> Patch: ... +@final class RefLogEntry: committer: Signature message: str @@ -300,25 +584,34 @@ class RefLogEntry: oid_old: Oid def __init__(self, *args, **kwargs) -> None: ... +@final class Refdb: - def __init__(self, *args, **kwargs) -> None: ... + def __init__(self) -> None: ... def compress(self) -> None: ... @staticmethod - def new(repo: Repository) -> Refdb: ... + def new(repo: Repository, /) -> Refdb: ... @staticmethod - def open(repo: Repository) -> Refdb: ... - def set_backend(self, backend: RefdbBackend) -> None: ... + def open(repo: Repository, /) -> Refdb: ... + def set_backend(self, backend: RefdbBackend, /) -> None: ... +@disjoint_base class RefdbBackend: def __init__(self, *args, **kwargs) -> None: ... def compress(self) -> None: ... - def delete(self, ref_name: str, old_id: _OidArg, old_target: str) -> None: ... - def ensure_log(self, ref_name: str) -> bool: ... - def exists(self, refname: str) -> bool: ... - def has_log(self, ref_name: str) -> bool: ... - def lookup(self, refname: str) -> Reference: ... + def delete( + self, ref_name: str, old_id: _OidArg, old_target: str | None + ) -> None: ... + def ensure_log(self, ref_name: str, /) -> bool: ... + def exists(self, refname: str, /) -> bool: ... + def has_log(self, ref_name: str, /) -> bool: ... + def lookup(self, refname: str, /) -> Reference: ... def rename( - self, old_name: str, new_name: str, force: bool, who: Signature, message: str + self, + old_name: str, + new_name: str, + force: bool, + who: Signature, + message: str | None, ) -> Reference: ... def write( self, @@ -326,48 +619,20 @@ class RefdbBackend: force: bool, who: Signature, message: str, - old: _OidArg, - old_target: str, + old: None | _OidArg, + old_target: None | str, ) -> None: ... +@final class RefdbFsBackend(RefdbBackend): def __init__(self, *args, **kwargs) -> None: ... -class References: - def __init__(self, repository: BaseRepository) -> None: ... - def __getitem__(self, name: str) -> Reference: ... - def get(self, key: str) -> Reference: ... - def __iter__(self) -> Iterator[str]: ... - def iterator( - self, references_return_type: ReferenceFilter = ... - ) -> Iterator[Reference]: ... - def create(self, name: str, target: _OidArg, force: bool = False) -> Reference: ... - def delete(self, name: str) -> None: ... - def __contains__(self, name: str) -> bool: ... - @property - def objects(self) -> list[Reference]: ... - def compress(self) -> None: ... - _Proxy = None | Literal[True] | str class _StrArray: # incomplete count: int -class ProxyOpts: - # incomplete - type: object - url: str - -class PushOptions: - version: int - pb_parallelism: int - callbacks: object # TODO - proxy_opts: ProxyOpts - follow_redirects: object # TODO - custom_headers: _StrArray - remote_push_options: _StrArray - class _LsRemotesDict(TypedDict): local: bool loid: Oid | None @@ -375,40 +640,11 @@ class _LsRemotesDict(TypedDict): symref_target: str | None oid: Oid -class RemoteCollection: - def __init__(self, repo: BaseRepository) -> None: ... - def __len__(self) -> int: ... - def __iter__(self): ... - def __getitem__(self, name: str | int) -> Remote: ... - def names(self) -> Generator[str, None, None]: ... - def create(self, name: str, url: str, fetch: str | None = None) -> Remote: ... - def create_anonymous(self, url: str) -> Remote: ... - def rename(self, name: str, new_name: str) -> list[str]: ... - def delete(self, name: str) -> None: ... - def set_url(self, name: str, url: str) -> None: ... - def set_push_url(self, name: str, url: str) -> None: ... - def add_fetch(self, name: str, refspec: str) -> None: ... - def add_push(self, name: str, refspec: str) -> None: ... - -class Branches: - local: 'Branches' - remote: 'Branches' - def __init__( - self, - repository: BaseRepository, - flag: BranchType = ..., - commit: Commit | _OidArg | None = None, - ) -> None: ... - def __getitem__(self, name: str) -> Branch: ... - def get(self, key: str) -> Branch: ... - def __iter__(self) -> Iterator[str]: ... - def create(self, name: str, commit: Commit, force: bool = False) -> Branch: ... - def delete(self, name: str) -> None: ... - def with_commit(self, commit: Commit | _OidArg | None) -> 'Branches': ... - def __contains__(self, name: _OidArg) -> bool: ... - +@disjoint_base class Repository: - _pointer: bytes + def TreeBuilder(self, src: Tree | _OidArg = ...) -> TreeBuilder: ... + def __init__(self, /, *args, **kwargs) -> None: ... + def _disown(self) -> None: ... default_signature: Signature head: Reference head_is_detached: bool @@ -417,18 +653,12 @@ class Repository: is_empty: bool is_shallow: bool odb: Odb - path: str + path: str | None refdb: Refdb - workdir: str - references: References - remotes: RemoteCollection - branches: Branches - 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 __getitem__(self, key: str | bytes | Oid | Reference) -> Commit: ... - def add_worktree(self, name: str, path: str, ref: Reference = ...) -> Worktree: ... + workdir: str | None + def add_worktree( + self, name: str, path: str | Path, ref: Reference = ... + ) -> Worktree: ... def applies( self, diff: Diff, @@ -438,12 +668,12 @@ class Repository: def apply( self, diff: Diff, location: ApplyLocation = ApplyLocation.WORKDIR ) -> None: ... - def cherrypick(self, id: _OidArg) -> None: ... + def cherrypick(self, id: _OidArg, /) -> None: ... def compress_references(self) -> None: ... - def create_blob(self, data: 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(self, data: str | bytes) -> Oid: ... + def create_blob_fromdisk(self, path: str, /) -> Oid: ... + def create_blob_fromiobase(self, iobase: IOBase, /) -> Oid: ... + def create_blob_fromworkdir(self, path: str | Path, /) -> Oid: ... def create_branch(self, name: str, commit: Commit, force=False) -> Branch: ... def create_commit( self, @@ -452,7 +682,7 @@ class Repository: committer: Signature, message: str | bytes, tree: _OidArg, - parents: list[_OidArg], + parents: Sequence[_OidArg], encoding: str = ..., ) -> Oid: ... def create_commit_string( @@ -476,9 +706,6 @@ class Repository: ref: str = 'refs/notes/commits', force: bool = False, ) -> Oid: ... - def create_reference( - self, name: str, target: _OidArg, force: bool = False - ) -> Reference: ... def create_reference_direct( self, name: str, target: _OidArg, force: bool, message: Optional[str] = None ) -> Reference: ... @@ -489,22 +716,22 @@ class Repository: self, name: str, oid: _OidArg, type: ObjectType, tagger: Signature, message: str ) -> Oid: ... def descendant_of(self, oid1: _OidArg, oid2: _OidArg) -> bool: ... - def expand_id(self, hex: str) -> Oid: ... + def expand_id(self, hex: str, /) -> Oid: ... def free(self) -> None: ... - def git_object_lookup_prefix(self, oid: _OidArg) -> Object: ... + def git_object_lookup_prefix(self, oid: _OidArg, /) -> Object: ... def list_worktrees(self) -> list[str]: ... def listall_branches(self, flag: BranchType = BranchType.LOCAL) -> list[str]: ... def listall_mergeheads(self) -> list[Oid]: ... 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' ) -> Note: ... - def lookup_reference(self, name: str) -> Reference: ... - def lookup_reference_dwim(self, name: str) -> Reference: ... + def lookup_reference(self, name: str, /) -> Reference: ... + def lookup_reference_dwim(self, name: str, /) -> Reference: ... def lookup_worktree(self, name: str) -> Worktree: ... def merge_analysis( self, their_head: _OidArg, our_ref: str = 'HEAD' @@ -521,32 +748,33 @@ 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: ... - def revparse(self, revspec: str) -> RevSpec: ... - def revparse_ext(self, revision: str) -> tuple[Object, Reference]: ... - def revparse_single(self, revision: str) -> Object: ... - def set_ident(self, name: str, email: str) -> None: ... - def set_odb(self, odb: Odb) -> None: ... - def set_refdb(self, refdb: Refdb) -> None: ... + def revparse(self, revspec: str, /) -> RevSpec: ... + def revparse_ext(self, revision: str, /) -> tuple[Object, Reference]: ... + def revparse_single(self, revision: str, /) -> Object: ... + def set_odb(self, odb: Odb, /) -> None: ... + def set_refdb(self, refdb: Refdb, /) -> None: ... def status( self, untracked_files: str = 'all', ignored: bool = False ) -> dict[str, int]: ... - def status_file(self, path: str) -> int: ... + def status_file(self, path: str, /) -> int: ... def walk( self, oid: _OidArg | None, sort_mode: SortMode = SortMode.NONE ) -> Walker: ... +@disjoint_base class RevSpec: flags: int from_object: Object to_object: Object +@final class Signature: _encoding: str | None - _pointer: bytes + _pointer: _Pointer[GitSignatureC] email: str name: str offset: int @@ -555,30 +783,32 @@ class Signature: time: int def __init__( self, - name: str, + name: str | bytes, email: str, time: int = -1, offset: int = 0, encoding: Optional[str] = None, ) -> None: ... - def __eq__(self, other) -> bool: ... - def __ge__(self, other) -> bool: ... - def __gt__(self, other) -> bool: ... - def __le__(self, other) -> bool: ... - def __lt__(self, other) -> bool: ... - def __ne__(self, other) -> bool: ... - + def __eq__(self, other, /) -> bool: ... + def __ge__(self, other, /) -> bool: ... + def __gt__(self, other, /) -> bool: ... + def __le__(self, other, /) -> bool: ... + def __lt__(self, other, /) -> bool: ... + def __ne__(self, other, /) -> bool: ... + +@final class Stash: commit_id: Oid message: str raw_message: bytes - def __eq__(self, other) -> bool: ... - def __ge__(self, other) -> bool: ... - def __gt__(self, other) -> bool: ... - def __le__(self, other) -> bool: ... - def __lt__(self, other) -> bool: ... - def __ne__(self, other) -> bool: ... - + def __eq__(self, other, /) -> bool: ... + def __ge__(self, other, /) -> bool: ... + def __gt__(self, other, /) -> bool: ... + def __le__(self, other, /) -> bool: ... + def __lt__(self, other, /) -> bool: ... + def __ne__(self, other, /) -> bool: ... + +@final class Tag(Object): message: str name: str @@ -610,30 +840,33 @@ class Tree(Object): context_lines: int = 3, interhunk_lines: int = 0, ) -> Diff: ... - def __contains__(self, other: str) -> bool: ... # Tree_contains - def __getitem__(self, index: str | int) -> Object: ... # Tree_subscript + def __contains__(self, other: str, /) -> bool: ... # Tree_contains + def __getitem__(self, index: str | int, /) -> Tree | Blob: ... # Tree_subscript def __iter__(self) -> Iterator[Object]: ... def __len__(self) -> int: ... # Tree_len - def __rtruediv__(self, other: str) -> 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 +@disjoint_base class TreeBuilder: def clear(self) -> None: ... - def get(self, name: str) -> Object: ... + def get(self, name: str, /) -> Object: ... def insert(self, name: str, oid: _OidArg, attr: int) -> None: ... - def remove(self, name: str) -> None: ... + def remove(self, name: str, /) -> None: ... def write(self) -> Oid: ... def __len__(self) -> int: ... +@final class Walker: - def hide(self, oid: _OidArg) -> None: ... - def push(self, oid: _OidArg) -> None: ... + def hide(self, oid: _OidArg, /) -> None: ... + def push(self, oid: _OidArg, /) -> None: ... def reset(self) -> None: ... def simplify_first_parent(self) -> None: ... - def sort(self, mode: SortMode) -> None: ... + def sort(self, mode: SortMode, /) -> None: ... def __iter__(self) -> Iterator[Commit]: ... # Walker: ... def __next__(self) -> Commit: ... +@final class Worktree: is_prunable: bool name: str @@ -641,13 +874,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 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 815910ecc..7cee2c8e5 100644 --- a/pygit2/_run.py +++ b/pygit2/_run.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -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 @@ -74,18 +74,22 @@ 'graph.h', 'index.h', 'merge.h', + 'rebase.h', '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 +98,7 @@ C_PREAMBLE = """\ #include #include +#include """ # ffi diff --git a/pygit2/blame.py b/pygit2/blame.py index a1b8e42e9..aefb5c85d 100644 --- a/pygit2/blame.py +++ b/pygit2/blame.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -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 .utils import GenericIterator -from ._pygit2 import Signature, Oid +from .ffi import C, ffi +from .utils import GenericIterator, decode_fs_path + +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,88 +50,94 @@ def wrap_signature(csig): class BlameHunk: + _blame: 'Blame' + _hunk: 'GitHunkC' + @classmethod - def _from_c(cls, blame, ptr): + def _from_c(cls, blame: 'Blame', ptr: 'GitHunkC') -> 'BlameHunk': hunk = cls.__new__(cls) hunk._blame = blame hunk._hunk = ptr return hunk @property - def lines_in_hunk(self): + def lines_in_hunk(self) -> int: """Number of lines""" return self._hunk.lines_in_hunk @property - def boundary(self): + def boundary(self) -> bool: """Tracked to a boundary commit""" # Casting directly to bool via cffi does not seem to work return int(ffi.cast('int', self._hunk.boundary)) != 0 @property - def final_start_line_number(self): + def final_start_line_number(self) -> int: """Final start line number""" return self._hunk.final_start_line_number @property - def final_committer(self): + def final_committer(self) -> None | Signature: """Final committer""" return wrap_signature(self._hunk.final_signature) @property - def final_commit_id(self): + def final_commit_id(self) -> Oid: return Oid( raw=bytes(ffi.buffer(ffi.addressof(self._hunk, 'final_commit_id'))[:]) ) @property - def orig_start_line_number(self): + def orig_start_line_number(self) -> int: """Origin start line number""" return self._hunk.orig_start_line_number @property - def orig_committer(self): + def orig_committer(self) -> None | Signature: """Original committer""" return wrap_signature(self._hunk.orig_signature) @property - def orig_commit_id(self): + def orig_commit_id(self) -> Oid: return Oid( raw=bytes(ffi.buffer(ffi.addressof(self._hunk, 'orig_commit_id'))[:]) ) @property - def orig_path(self): + def orig_path(self) -> None | str: """Original path""" path = self._hunk.orig_path if not path: return None - return ffi.string(path).decode('utf-8') + return decode_fs_path(path) 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 d9f4de897..1ee6a9eb7 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 c6323a1f8..77cabfc1b 100644 --- a/pygit2/branches.py +++ b/pygit2/branches.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -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 c0c3249fd..06a53ba24 100644 --- a/pygit2/callbacks.py +++ b/pygit2/callbacks.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -63,23 +63,38 @@ """ # Standard Library +from collections.abc import Callable, Generator from contextlib import contextmanager from functools import wraps -from typing import Optional, Union, TYPE_CHECKING, Callable, Generator +from typing import TYPE_CHECKING, Any, 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 .credentials import Username, UserPass, Keypair +from .errors import Passthrough, check_error +from .ffi import C, ffi +from .utils import ( + StrArray, + decode_fs_path, + decode_string, + encode_fs_path, + encode_string, + ptr_to_bytes, +) _Credentials = Username | UserPass | Keypair if TYPE_CHECKING: - from .remotes import TransferProgress - from ._pygit2 import ProxyOpts, PushOptions + from pygit2._libgit2.ffi import ( + GitCloneOptionsC, + GitFetchOptionsC, + GitProxyOptionsC, + GitPushOptionsC, + GitStrrayC, + ) + + from .remotes import PushUpdate, Remote, TransferProgress # # The payload is the way to pass information from the pygit2 API, through # libgit2, to the Python callbacks. And back. @@ -87,6 +102,13 @@ class Payload: + repository: Callable | None + remote: Callable | None + clone_options: Any + fetch_options: Any + push_options: Any + remote_callbacks: Any + def __init__(self, **kw: object) -> None: for key, value in kw.items(): setattr(self, key, value) @@ -114,12 +136,10 @@ class RemoteCallbacks(Payload): method, or if it's a constant value, pass the value to the constructor, e.g. RemoteCallbacks(credentials=credentials). - You can as well pass the certificate the same way, for example: - RemoteCallbacks(certificate=certificate). + You can as well pass the certificate check callback the same way, for example: + RemoteCallbacks(certificate_check=certificate_check). """ - push_options: 'PushOptions' - def __init__( self, credentials: _Credentials | None = None, @@ -145,7 +165,7 @@ def sideband_progress(self, string: str) -> None: def credentials( self, url: str, - username_from_url: Union[str, None], + username_from_url: str | None, allowed_types: CredentialType, ) -> _Credentials: """ @@ -192,6 +212,15 @@ def certificate_check(self, certificate: None, valid: bool, host: bytes) -> bool raise Passthrough + def push_negotiation(self, updates: list['PushUpdate']) -> None: + """ + During a push, called once between the negotiation step and the upload. + Provides information about what updates will be performed. + + Override with your own function to check the pending updates + and possibly reject them (by raising an exception). + """ + def transfer_progress(self, stats: 'TransferProgress') -> None: """ During the download of new data, this will be regularly called with @@ -247,6 +276,19 @@ def push_update_reference(self, refname: str, message: str) -> None: Rejection message from the remote. If None, the update was accepted. """ + def custom_headers(self) -> list[str] | None: + """ + Custom headers callback. Override with your own function to return a + list of custom headers that should be used when connecting to, pushing + to, or fetching from the remote. + + Example use case to authenticate with bearer tokens instead of username/password: + + return [f"Authorization: Bearer {token}"] + + Returns: list of header strings or None + """ + class CheckoutCallbacks(Payload): """Base class for pygit2 checkout callbacks. @@ -293,7 +335,7 @@ def checkout_notify( Raising an exception from this callback will cancel the checkout. The exception will be propagated back and raised by the - Repository.checkout_... call. + ``Repository.checkout_...`` call. Notification callbacks are made prior to modifying any files on disk, so canceling on any notification will still happen prior to any files @@ -337,7 +379,22 @@ def stash_apply_progress(self, progress: StashApplyProgress) -> None: @contextmanager -def git_clone_options(payload, opts=None): +def git_custom_headers( + payload: RemoteCallbacks, + opts_custom_headers: Optional['GitStrrayC'] = None, +) -> Generator[StrArray, Any, None]: + custom_headers = payload.custom_headers() or None + with StrArray(custom_headers) as headers_array: + if opts_custom_headers is not None: + headers_array.assign_to(opts_custom_headers) + yield headers_array + + +@contextmanager +def git_clone_options( + payload: RemoteCallbacks, + opts: Optional['GitCloneOptionsC'] = None, +) -> Generator[RemoteCallbacks, Any, None]: if opts is None: opts = ffi.new('git_clone_options *') C.git_clone_options_init(opts, C.GIT_CLONE_OPTIONS_VERSION) @@ -359,7 +416,10 @@ def git_clone_options(payload, opts=None): @contextmanager -def git_fetch_options(payload, opts=None): +def git_fetch_options( + payload: RemoteCallbacks | None, + opts: Optional['GitFetchOptionsC'] = None, +) -> Generator[RemoteCallbacks, Any, None]: if payload is None: payload = RemoteCallbacks() @@ -377,18 +437,19 @@ def git_fetch_options(payload, opts=None): handle = ffi.new_handle(payload) opts.callbacks.payload = handle - # Give back control - payload.fetch_options = opts - payload._stored_exception = None - yield payload + with git_custom_headers(payload, opts.custom_headers): + # Give back control + payload.fetch_options = opts + payload._stored_exception = None + yield payload @contextmanager def git_proxy_options( - payload: object, - opts: Optional['ProxyOpts'] = None, + payload: 'Remote | RemoteCallbacks', + opts: Optional['GitProxyOptionsC'] = None, proxy: None | bool | str = None, -) -> Generator['ProxyOpts', None, None]: +) -> Generator['GitProxyOptionsC', None, None]: if opts is None: opts = ffi.new('git_proxy_options *') C.git_proxy_options_init(opts, C.GIT_PROXY_OPTIONS_VERSION) @@ -399,20 +460,24 @@ def git_proxy_options( elif type(proxy) is str: opts.type = C.GIT_PROXY_SPECIFIED # Keep url in memory, otherwise memory is freed and bad things happen - payload.__proxy_url = ffi.new('char[]', to_bytes(proxy)) # type: ignore[attr-defined, no-untyped-call] - opts.url = payload.__proxy_url # type: ignore[attr-defined] + payload.__proxy_url = ffi.new('char[]', encode_string(proxy)) # type: ignore[union-attr] + opts.url = payload.__proxy_url # type: ignore[union-attr] else: raise TypeError('Proxy must be None, True, or a string') yield opts @contextmanager -def git_push_options(payload, opts=None): +def git_push_options( + payload: RemoteCallbacks | None, + opts: Optional['GitPushOptionsC'] = None, +) -> Generator[RemoteCallbacks, Any, None]: if payload is None: payload = RemoteCallbacks() - opts = ffi.new('git_push_options *') - C.git_push_options_init(opts, C.GIT_PUSH_OPTIONS_VERSION) + if opts is None: + opts = ffi.new('git_push_options *') + C.git_push_options_init(opts, C.GIT_PUSH_OPTIONS_VERSION) # Plug callbacks opts.callbacks.sideband_progress = C._sideband_progress_cb @@ -421,6 +486,7 @@ def git_push_options(payload, opts=None): opts.callbacks.credentials = C._credentials_cb opts.callbacks.certificate_check = C._certificate_check_cb opts.callbacks.push_update_reference = C._push_update_reference_cb + opts.callbacks.push_negotiation = C._push_negotiation_cb # Per libgit2 sources, push_transfer_progress may incur a performance hit. # So, set it only if the user has overridden the no-op stub. if ( @@ -432,14 +498,17 @@ def git_push_options(payload, opts=None): handle = ffi.new_handle(payload) opts.callbacks.payload = handle - # Give back control - payload.push_options = opts - payload._stored_exception = None - yield payload + with git_custom_headers(payload, opts.custom_headers): + # Give back control + payload.push_options = opts + payload._stored_exception = None + yield payload @contextmanager -def git_remote_callbacks(payload): +def git_remote_callbacks( + payload: RemoteCallbacks | None, +) -> Generator[RemoteCallbacks, Any, None]: if payload is None: payload = RemoteCallbacks() @@ -473,8 +542,11 @@ def git_remote_callbacks(payload): # exception. # +P = ParamSpec('P') +T = TypeVar('T') -def libgit2_callback(f): + +def libgit2_callback(f: Callable[P, T]) -> Callable[P, T]: @wraps(f) def wrapper(*args): data = ffi.from_handle(args[-1]) @@ -492,10 +564,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]) @@ -512,7 +584,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 @@ -553,14 +625,27 @@ 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) if not push_update_reference: return 0 - refname = maybe_string(ref) - message = maybe_string(msg) + refname = decode_string(ref) + message = decode_string(msg) push_update_reference(refname, message) return 0 @@ -628,7 +713,7 @@ def _update_tips_cb(refname, a, b, data): if not update_tips: return 0 - s = maybe_string(refname) + s = decode_string(refname) a = Oid(raw=bytes(ffi.buffer(a)[:])) b = Oid(raw=bytes(ffi.buffer(b)[:])) update_tips(s, a, b) @@ -642,8 +727,8 @@ def _update_tips_cb(refname, a, b, data): def get_credentials(fn, url, username, allowed): """Call fn and return the credentials object.""" - url_str = maybe_string(url) - username_str = maybe_string(username) + url_str = decode_string(url) + username_str = decode_string(username) creds = fn(url_str, username_str, allowed) @@ -661,22 +746,26 @@ def get_credentials(fn, url, username, allowed): if cred_type == CredentialType.USERPASS_PLAINTEXT: name, passwd = credential_tuple err = C.git_credential_userpass_plaintext_new( - ccred, to_bytes(name), to_bytes(passwd) + ccred, encode_string(name), encode_string(passwd) ) elif cred_type == CredentialType.SSH_KEY: name, pubkey, privkey, passphrase = credential_tuple - name = to_bytes(name) + name = encode_string(name) if pubkey is None and privkey is None: err = C.git_credential_ssh_key_from_agent(ccred, name) else: err = C.git_credential_ssh_key_new( - ccred, name, to_bytes(pubkey), to_bytes(privkey), to_bytes(passphrase) + ccred, + name, + encode_string(pubkey), + encode_string(privkey), + encode_string(passphrase), ) elif cred_type == CredentialType.USERNAME: (name,) = credential_tuple - err = C.git_credential_username_new(ccred, to_bytes(name)) + err = C.git_credential_username_new(ccred, encode_string(name)) elif cred_type == CredentialType.SSH_MEMORY: name, pubkey, privkey, passphrase = credential_tuple @@ -684,10 +773,10 @@ def get_credentials(fn, url, username, allowed): raise TypeError('SSH keys from memory are empty') err = C.git_credential_ssh_key_memory_new( ccred, - to_bytes(name), - to_bytes(pubkey), - to_bytes(privkey), - to_bytes(passphrase), + encode_string(name), + encode_string(pubkey), + encode_string(privkey), + encode_string(passphrase), ) else: raise TypeError('unsupported credential type') @@ -706,7 +795,7 @@ def get_credentials(fn, url, username, allowed): def _checkout_notify_cb( why, path_cstr, baseline, target, workdir, data: CheckoutCallbacks ): - pypath = maybe_string(path_cstr) + pypath = decode_fs_path(path_cstr) pybaseline = DiffFile.from_c(ptr_to_bytes(baseline)) pytarget = DiffFile.from_c(ptr_to_bytes(target)) pyworkdir = DiffFile.from_c(ptr_to_bytes(workdir)) @@ -727,7 +816,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(decode_fs_path(path), completed_steps, total_steps) def _git_checkout_options( @@ -761,7 +850,7 @@ def _git_checkout_options( opts.checkout_strategy = int(strategy) if directory: - target_dir = ffi.new('char[]', to_bytes(directory)) + target_dir = ffi.new('char[]', encode_fs_path(directory)) refs.append(target_dir) opts.target_directory = target_dir diff --git a/pygit2/config.py b/pygit2/config.py index 3b739840b..66f4df9ce 100644 --- a/pygit2/config.py +++ b/pygit2/config.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -23,39 +23,47 @@ # 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 typing import TYPE_CHECKING, Optional + 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 .utils import to_bytes +from .ffi import C, ffi +from .utils import encode_fs_path, encode_string + +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 | bytes, name: str) -> bytes: if not isinstance(value, str): raise TypeError(f'{name} must be a string') - return to_bytes(value) + return encode_string(value) class ConfigIterator: - def __init__(self, config, ptr): + def __init__(self, config, ptr) -> None: self._iter = ptr self._config = config - def __del__(self): + def __del__(self) -> None: C.git_config_iterator_free(self._iter) - def __iter__(self): + def __iter__(self) -> 'ConfigIterator': return self - def __next__(self): + def __next__(self) -> 'ConfigEntry': return self._next_entry() - def _next_entry(self): + def _next_entry(self) -> 'ConfigEntry': centry = ffi.new('git_config_entry **') err = C.git_config_next(centry, self._iter) check_error(err) @@ -64,41 +72,72 @@ def _next_entry(self): class ConfigMultivarIterator(ConfigIterator): - def __next__(self): + def __next__(self) -> str | None: # type: ignore[override] entry = self._next_entry() return entry.value class Config: - """Git configuration management.""" - - def __init__(self, path=None): + """Git configuration management. + + This class is for the reading and writing of Git configuration files. + Configuration files are read individually, either by passing a path into + the constructor or by using one of the static methods + :meth:`Config.get_system_config`, :meth:`Config.get_global_config`, or + :meth:`Config.get_xdg_config`. Additional files can be loaded into the + `Config` object using :meth:`Config.add_file`. + + Changes made to the configuration with :meth:`Config.set_multivar` are + immediately persisted to disk. Reads performed with accessor methods like + :meth:`Config.get_multivar` or :meth:`Config.__getitem__` may result in + reading from different versions of the configuration file if this or + another process has modified the file. To avoid this and have all read + operations occur against the same version of the configuration file, use + :meth:`Config.snapshot()` to create a snapshot of the current config. This + is especially important when iterating, as the contents of the config + might change mid-iteration if you don't use a snapshot. + + This class can technically be used to manually read and write a repository's + configuration file by pointing the constructor to the repository's + ``.git/config`` file, but this is not recommended. The resulting ``Config`` + object represents only the configuration directly within ``.git/config``. + It does not represent the total effective configuration for that repository + that includes the combined system, global (user), and global (user) XDG. + Instead, use :meth:`BaseRepository.config` for loading a repository's + configuration. + """ + + _repo: Optional['BaseRepository'] + _config: 'GitConfigC' + + def __init__(self, path: PathLike | 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 = encode_fs_path(path) + err = C.git_config_open_ondisk(cconfig, path_bytes) check_error(err, io=True) + self._repo = None self._config = cconfig[0] @classmethod - def from_c(cls, repo, ptr): + def from_c(cls, repo: Optional['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 **') @@ -106,7 +145,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: @@ -115,7 +154,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: @@ -125,7 +164,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 @@ -135,7 +174,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 @@ -144,17 +183,17 @@ def __setitem__(self, key, value): elif isinstance(value, int): err = C.git_config_set_int64(self._config, key, value) else: - err = C.git_config_set_string(self._config, key, to_bytes(value)) + err = C.git_config_set_string(self._config, key, encode_string(value)) check_error(err) - 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 @@ -167,24 +206,29 @@ 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 = encode_string(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. + expression to indicate which values to replace. Changes are persisted + to the configuration file(s) backing this ``Config``. """ name = str_to_bytes(name, 'name') regex = str_to_bytes(regex, 'regex') @@ -193,9 +237,10 @@ 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. + indicate which values to delete. Changes are persisted to the + configuration file(s) backing this ``Config``. """ name = str_to_bytes(name, 'name') regex = str_to_bytes(regex, 'regex') @@ -203,7 +248,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). @@ -218,7 +263,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. @@ -233,16 +278,16 @@ def get_int(self, key): return res[0] - def add_file(self, path, level=0, force=0): + def add_file(self, path: str | PathLike, level: int = 0, force: int = 0) -> None: """Add a config file instance to an existing config.""" err = C.git_config_add_file_ondisk( - self._config, to_bytes(path), level, ffi.NULL, force + self._config, encode_fs_path(path), level, ffi.NULL, force ) check_error(err) - def snapshot(self): - """Create a snapshot from this Config object. + def snapshot(self) -> 'Config': + """Create a snapshot from this ``Config`` object. This means that looking up multiple values will use the same version of the configuration files. @@ -258,17 +303,17 @@ 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)) + err = C.git_config_parse_bool(res, encode_string(text)) check_error(err) 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)) + err = C.git_config_parse_int64(res, encode_string(text)) check_error(err) return res[0] @@ -278,7 +323,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) @@ -288,26 +333,46 @@ def _from_found_config(fn): return Config(cpath) @staticmethod - def get_system_config(): - """Return a object representing the system configuration file.""" + def get_system_config() -> 'Config': + """Return a ``Config`` object representing the system configuration file. + + The system configuration file is the one found at ``/etc/gitconfig`` or + ``%PROGRAMFILES%\\Git\\etc\\gitconfig``, depending on the operating system. + """ return Config._from_found_config(C.git_config_find_system) @staticmethod - def get_global_config(): - """Return a object representing the global configuration file.""" + def get_global_config() -> 'Config': + """Return a ``Config`` object representing the global configuration file. + + The global configuration file is the one found at the standard user config + location for Git, which is ``$HOME/.gitconfig``. This will not find the file + at the XDG-compatible user config file location (for that, see + :meth:`Config.get_xdg_config`). + """ return Config._from_found_config(C.git_config_find_global) @staticmethod - def get_xdg_config(): - """Return a object representing the global configuration file.""" + def get_xdg_config() -> 'Config': + """Return a ``Config`` object representing the XDG-compatible global configuration file. + + The XDG-compatible user config file follows the XDG Base Directory Specification. + This file is located at ``$HOME/.config/git/config``. This will not find the file + at the standard user config location (for that, see :meth:`Config.get_global_config`). + """ return Config._from_found_config(C.git_config_find_xdg) class ConfigEntry: """An entry in a configuration object.""" + _entry: 'GitConfigEntryC' + iterator: ConfigIterator | None + @classmethod - def _from_c(cls, ptr, iterator=None): + def _from_c( + cls, ptr: 'GitConfigEntryC', iterator: ConfigIterator | None = None + ) -> 'ConfigEntry': """Builds the entry from a ``git_config_entry`` pointer. ``iterator`` must be a ``ConfigIterator`` instance if the entry was @@ -330,34 +395,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 2b307f948..855490e1a 100644 --- a/pygit2/credentials.py +++ b/pygit2/credentials.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -29,7 +29,6 @@ from .enums import CredentialType - if TYPE_CHECKING: from pathlib import Path diff --git a/pygit2/decl/callbacks.h b/pygit2/decl/callbacks.h index 9d5409dee..64582718e 100644 --- a/pygit2/decl/callbacks.h +++ b/pygit2/decl/callbacks.h @@ -16,6 +16,11 @@ extern "Python" int _push_update_reference_cb( const char *status, void *data); +extern "Python" int _push_negotiation_cb( + const git_push_update **updates, + size_t len, + void *data); + extern "Python" int _remote_create_cb( git_remote **out, git_repository *repo, diff --git a/pygit2/decl/filter.h b/pygit2/decl/filter.h new file mode 100644 index 000000000..51734687a --- /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/options.h b/pygit2/decl/options.h new file mode 100644 index 000000000..f6556d5e2 --- /dev/null +++ b/pygit2/decl/options.h @@ -0,0 +1,50 @@ +typedef enum { + GIT_OPT_GET_MWINDOW_SIZE, + GIT_OPT_SET_MWINDOW_SIZE, + GIT_OPT_GET_MWINDOW_MAPPED_LIMIT, + GIT_OPT_SET_MWINDOW_MAPPED_LIMIT, + GIT_OPT_GET_SEARCH_PATH, + GIT_OPT_SET_SEARCH_PATH, + GIT_OPT_SET_CACHE_OBJECT_LIMIT, + GIT_OPT_SET_CACHE_MAX_SIZE, + GIT_OPT_ENABLE_CACHING, + GIT_OPT_GET_CACHED_MEMORY, + GIT_OPT_GET_TEMPLATE_PATH, + GIT_OPT_SET_TEMPLATE_PATH, + GIT_OPT_SET_SSL_CERT_LOCATIONS, + GIT_OPT_SET_USER_AGENT, + GIT_OPT_ENABLE_STRICT_OBJECT_CREATION, + GIT_OPT_ENABLE_STRICT_SYMBOLIC_REF_CREATION, + GIT_OPT_SET_SSL_CIPHERS, + GIT_OPT_GET_USER_AGENT, + GIT_OPT_ENABLE_OFS_DELTA, + GIT_OPT_ENABLE_FSYNC_GITDIR, + GIT_OPT_GET_WINDOWS_SHAREMODE, + GIT_OPT_SET_WINDOWS_SHAREMODE, + GIT_OPT_ENABLE_STRICT_HASH_VERIFICATION, + GIT_OPT_SET_ALLOCATOR, + GIT_OPT_ENABLE_UNSAVED_INDEX_SAFETY, + GIT_OPT_GET_PACK_MAX_OBJECTS, + GIT_OPT_SET_PACK_MAX_OBJECTS, + GIT_OPT_DISABLE_PACK_KEEP_FILE_CHECKS, + GIT_OPT_ENABLE_HTTP_EXPECT_CONTINUE, + GIT_OPT_GET_MWINDOW_FILE_LIMIT, + GIT_OPT_SET_MWINDOW_FILE_LIMIT, + GIT_OPT_SET_ODB_PACKED_PRIORITY, + GIT_OPT_SET_ODB_LOOSE_PRIORITY, + GIT_OPT_GET_EXTENSIONS, + GIT_OPT_SET_EXTENSIONS, + GIT_OPT_GET_OWNER_VALIDATION, + GIT_OPT_SET_OWNER_VALIDATION, + GIT_OPT_GET_HOMEDIR, + GIT_OPT_SET_HOMEDIR, + GIT_OPT_SET_SERVER_CONNECT_TIMEOUT, + GIT_OPT_GET_SERVER_CONNECT_TIMEOUT, + GIT_OPT_SET_SERVER_TIMEOUT, + GIT_OPT_GET_SERVER_TIMEOUT, + GIT_OPT_SET_USER_AGENT_PRODUCT, + GIT_OPT_GET_USER_AGENT_PRODUCT, + GIT_OPT_ADD_SSL_X509_CERT +} git_libgit2_opt_t; + +int git_libgit2_opts(int option, ...); \ No newline at end of file diff --git a/pygit2/decl/rebase.h b/pygit2/decl/rebase.h new file mode 100644 index 000000000..542143bfb --- /dev/null +++ b/pygit2/decl/rebase.h @@ -0,0 +1,80 @@ +typedef struct git_rebase git_rebase; + +#define GIT_REBASE_OPTIONS_VERSION ... +#define GIT_REBASE_NO_OPERATION ... + +typedef enum { + GIT_REBASE_OPERATION_PICK = 0, + GIT_REBASE_OPERATION_REWORD, + GIT_REBASE_OPERATION_EDIT, + GIT_REBASE_OPERATION_SQUASH, + GIT_REBASE_OPERATION_FIXUP, + GIT_REBASE_OPERATION_EXEC +} git_rebase_operation_t; + +typedef struct { + unsigned int version; + int quiet; + int inmemory; + const char *rewrite_notes_ref; + git_merge_options merge_options; + git_checkout_options checkout_options; + ...; +} git_rebase_options; + +typedef struct { + git_rebase_operation_t type; + const git_oid id; + const char *exec; + ...; +} git_rebase_operation; + +int git_rebase_options_init(git_rebase_options *opts, unsigned int version); + +int git_rebase_init( + git_rebase **out, + git_repository *repo, + const git_annotated_commit *branch, + const git_annotated_commit *upstream, + const git_annotated_commit *onto, + const git_rebase_options *opts); + +int git_rebase_open( + git_rebase **out, + git_repository *repo, + const git_rebase_options *opts); + +const char *git_rebase_orig_head_name(git_rebase *rebase); +const git_oid *git_rebase_orig_head_id(git_rebase *rebase); +const char *git_rebase_onto_name(git_rebase *rebase); +const git_oid *git_rebase_onto_id(git_rebase *rebase); + +size_t git_rebase_operation_entrycount(git_rebase *rebase); +size_t git_rebase_operation_current(git_rebase *rebase); +git_rebase_operation *git_rebase_operation_byindex( + git_rebase *rebase, + size_t idx); + +int git_rebase_next( + git_rebase_operation **operation, + git_rebase *rebase); + +int git_rebase_inmemory_index( + git_index **index, + git_rebase *rebase); + +int git_rebase_commit( + git_oid *id, + git_rebase *rebase, + const git_signature *author, + const git_signature *committer, + const char *message_encoding, + const char *message); + +int git_rebase_abort(git_rebase *rebase); + +int git_rebase_finish( + git_rebase *rebase, + const git_signature *signature); + +void git_rebase_free(git_rebase *rebase); diff --git a/pygit2/decl/submodule.h b/pygit2/decl/submodule.h index fda915a56..b16f4b031 100644 --- a/pygit2/decl/submodule.h +++ b/pygit2/decl/submodule.h @@ -41,3 +41,5 @@ const char * git_submodule_branch(git_submodule *submodule); const git_oid * git_submodule_head_id(git_submodule *submodule); int git_submodule_status(unsigned int *status, git_repository *repo, const char *name, git_submodule_ignore_t ignore); + +int git_submodule_set_url(git_repository *repo, const char *name, const char *url); diff --git a/pygit2/decl/transaction.h b/pygit2/decl/transaction.h new file mode 100644 index 000000000..20ac98de0 --- /dev/null +++ b/pygit2/decl/transaction.h @@ -0,0 +1,8 @@ +int git_transaction_new(git_transaction **out, git_repository *repo); +int git_transaction_lock_ref(git_transaction *tx, const char *refname); +int git_transaction_set_target(git_transaction *tx, const char *refname, const git_oid *target, const git_signature *sig, const char *msg); +int git_transaction_set_symbolic_target(git_transaction *tx, const char *refname, const char *target, const git_signature *sig, const char *msg); +int git_transaction_set_reflog(git_transaction *tx, const char *refname, const git_reflog *reflog); +int git_transaction_remove(git_transaction *tx, const char *refname); +int git_transaction_commit(git_transaction *tx); +void git_transaction_free(git_transaction *tx); diff --git a/pygit2/decl/types.h b/pygit2/decl/types.h index 8bb8fd297..64d7ce0a4 100644 --- a/pygit2/decl/types.h +++ b/pygit2/decl/types.h @@ -1,6 +1,8 @@ +typedef struct git_blob git_blob; typedef struct git_commit git_commit; typedef struct git_annotated_commit git_annotated_commit; typedef struct git_config git_config; +typedef struct git_filter_list git_filter_list; typedef struct git_index git_index; typedef struct git_index_conflict_iterator git_index_conflict_iterator; typedef struct git_object git_object; @@ -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 fe6421686..9248d1085 100644 --- a/pygit2/enums.py +++ b/pygit2/enums.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -25,7 +25,7 @@ from enum import IntEnum, IntFlag -from . import _pygit2 +from . import _pygit2, options from .ffi import C @@ -225,7 +225,7 @@ class CheckoutStrategy(IntFlag): notifications; don't update the working directory or index. """ - CONFLICT_STYLE_ZDIFF3 = _pygit2.GIT_CHECKOUT_CONFLICT_STYLE_DIFF3 + CONFLICT_STYLE_ZDIFF3 = _pygit2.GIT_CHECKOUT_CONFLICT_STYLE_ZDIFF3 """ Include common ancestor data in zdiff3 format for conflicts """ @@ -363,7 +363,7 @@ class DiffFind(IntFlag): """Flags to control the behavior of diff rename/copy detection.""" FIND_BY_CONFIG = _pygit2.GIT_DIFF_FIND_BY_CONFIG - """ Obey `diff.renames`. Overridden by any other FIND_... flag. """ + """ Obey ``diff.renames``. Overridden by any other ``FIND_...`` flag. """ FIND_RENAMES = _pygit2.GIT_DIFF_FIND_RENAMES """ Look for renames? (`--find-renames`) """ @@ -948,51 +948,83 @@ 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 RebaseOperationType(IntEnum): + """Type of a rebase operation, as returned when iterating over or + indexing a Rebase.""" + + PICK = C.GIT_REBASE_OPERATION_PICK + """The given commit is to be cherry-picked. The client should commit + the changes and continue if there are no conflicts.""" + + REWORD = C.GIT_REBASE_OPERATION_REWORD + """The given commit is to be cherry-picked, but the client should prompt + the user to provide an updated commit message.""" + + EDIT = C.GIT_REBASE_OPERATION_EDIT + """The given commit is to be cherry-picked, but the client should stop + to allow the user to edit the changes before committing them.""" + + SQUASH = C.GIT_REBASE_OPERATION_SQUASH + """The given commit is to be squashed into the previous commit. The + commit message will be merged with the previous message.""" + + FIXUP = C.GIT_REBASE_OPERATION_FIXUP + """The given commit is to be squashed into the previous commit. The + commit message from this commit will be discarded.""" + + EXEC = C.GIT_REBASE_OPERATION_EXEC + """No commit will be cherry-picked. The client should run the given + command and (if successful) continue.""" class ReferenceFilter(IntEnum): diff --git a/pygit2/errors.py b/pygit2/errors.py index 3ecef9df4..0921ab535 100644 --- a/pygit2/errors.py +++ b/pygit2/errors.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -24,14 +24,42 @@ # Boston, MA 02110-1301, USA. # Import from pygit2 -from .ffi import ffi, C -from ._pygit2 import GitError +from ._pygit2 import ( + AlreadyExistsError, + AmbiguousError, + AuthError, + CertificateError, + GitError, + InvalidError, + InvalidSpecError, + NotFoundError, +) +from .ffi import C, ffi +__all__ = [ + 'AlreadyExistsError', + 'AmbiguousError', + 'AuthError', + 'CertificateError', + 'GitError', + 'InvalidError', + 'InvalidSpecError', + 'NotFoundError', + 'Passthrough', +] -value_errors = set([C.GIT_EEXISTS, C.GIT_EINVALIDSPEC, C.GIT_EAMBIGUOUS]) +# Docstrings for C-defined exception classes +GitError.__doc__ = 'Generic libgit2 error.' +AlreadyExistsError.__doc__ = 'Object already exists.' +InvalidSpecError.__doc__ = 'Invalid name/ref spec.' +NotFoundError.__doc__ = 'Requested object could not be found.' +AmbiguousError.__doc__ = 'More than one object matches.' +AuthError.__doc__ = 'Authentication error.' +CertificateError.__doc__ = 'Server certificate is invalid.' +InvalidError.__doc__ = 'Invalid operation or input.' -def check_error(err, io=False): +def check_error(err: int, io: bool = False) -> None: if err >= 0: return @@ -47,17 +75,32 @@ def check_error(err, io=False): message = f'err {err} (no message provided)' # Translate to Python errors - if err in value_errors: + if err == C.GIT_EEXISTS: + raise AlreadyExistsError(message) + + if err == C.GIT_EINVALIDSPEC: + raise InvalidSpecError(message) + + if err == C.GIT_EINVALID: + raise InvalidError(message) + + if err == C.GIT_EAMBIGUOUS: + raise AmbiguousError(message) + + if err == C.GIT_EBUFS: raise ValueError(message) + if err == C.GIT_EAUTH: + raise AuthError(message) + + if err == C.GIT_ECERTIFICATE: + raise CertificateError(message) + if err == C.GIT_ENOTFOUND: if io: raise IOError(message) - raise KeyError(message) - - if err == C.GIT_EINVALIDSPEC: - raise ValueError(message) + raise NotFoundError(message) if err == C.GIT_ITEROVER: raise StopIteration() @@ -68,5 +111,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 04ffefa00..51cc153d8 100644 --- a/pygit2/ffi.py +++ b/pygit2/ffi.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -24,4 +24,7 @@ # Boston, MA 02110-1301, USA. # Import from pygit2 -from ._libgit2 import ffi, lib as C # noqa: F401 +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 00c651849..4e79a1c32 100644 --- a/pygit2/filter.py +++ b/pygit2/filter.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -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 encode_fs_path, encode_string + +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 = encode_string(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 = encode_fs_path(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 2238fa9d4..611940d10 100644 --- a/pygit2/index.py +++ b/pygit2/index.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -26,14 +26,17 @@ import typing import warnings 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, decode_fs_path, encode_fs_path + +if typing.TYPE_CHECKING: + from .repository import Repository class Index: @@ -42,14 +45,14 @@ 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 to read from and write to. """ cindex = ffi.new('git_index **') - err = C.git_index_open(cindex, to_bytes(path)) + err = C.git_index_open(cindex, encode_fs_path(path)) check_error(err) self._repo = None @@ -69,24 +72,24 @@ 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): - err = C.git_index_find(ffi.NULL, self._index, to_bytes(path)) + def __contains__(self, path) -> bool: + err = C.git_index_find(ffi.NULL, self._index, encode_fs_path(path)) if err == C.GIT_ENOTFOUND: return False 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) + centry = C.git_index_get_bypath(self._index, encode_fs_path(key), 0) elif isinstance(key, int): if key >= 0: centry = C.git_index_get_byindex(self._index, key) @@ -103,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. @@ -117,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 ). @@ -135,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): @@ -143,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. @@ -173,23 +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) + err = C.git_index_remove(self._index, encode_fs_path(path), level) check_error(err, io=True) - def remove_directory(self, path, level=0): + def remove_directory(self, path: PathLike[str] | str, level: int = 0) -> None: """Remove a directory from the Index.""" - err = C.git_index_remove_directory(self._index, to_bytes(path), level) + err = C.git_index_remove_directory(self._index, encode_fs_path(path), level) check_error(err, io=True) - def remove_all(self, pathspecs): + def remove_all(self, pathspecs: typing.Sequence[str | PathLike[str]]) -> None: """Remove all index entries matching pathspecs.""" with StrArray(pathspecs) as arr: err = C.git_index_remove_all(self._index, arr.ptr, ffi.NULL, ffi.NULL) check_error(err, io=True) - def add_all(self, pathspecs=None): + def add_all(self, pathspecs: None | list[str | PathLike[str]] = None) -> None: """Add or update index entries matching files in the working directory. If pathspecs are specified, only files matching those pathspecs will @@ -200,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 @@ -216,13 +221,15 @@ def add(self, path_or_entry): err = C.git_index_add(self._index, centry) elif isinstance(path_or_entry, str) or hasattr(path_or_entry, '__fspath__'): path = path_or_entry - err = C.git_index_add_bypath(self._index, to_bytes(path)) + err = C.git_index_add_bypath(self._index, encode_fs_path(path)) else: - raise TypeError('argument must be string or IndexEntry') + raise TypeError('argument must be string, Path or IndexEntry') check_error(err, io=True) - def add_conflict(self, ancestor, ours, theirs): + def add_conflict( + self, ancestor: 'IndexEntry', ours: 'IndexEntry', theirs: 'IndexEntry | None' + ) -> None: """ Add or update index entries to represent a conflict. Any staged entries that exist at the given paths will be removed. @@ -244,13 +251,15 @@ def add_conflict(self, ancestor, ours, theirs): if theirs and not isinstance(theirs, IndexEntry): raise TypeError('theirs has to be an instance of IndexEntry or None') - centry_ancestor = centry_ours = centry_theirs = ffi.NULL + centry_ancestor: ffi.NULL_TYPE | ffi.GitIndexEntryC = ffi.NULL + centry_ours: ffi.NULL_TYPE | ffi.GitIndexEntryC = ffi.NULL + centry_theirs: ffi.NULL_TYPE | ffi.GitIndexEntryC = ffi.NULL if ancestor is not None: - centry_ancestor, _ = ancestor._to_c() + centry_ancestor, path_ancestor = ancestor._to_c() if ours is not None: - centry_ours, _ = ours._to_c() + centry_ours, path_ours = ours._to_c() if theirs is not None: - centry_theirs, _ = theirs._to_c() + centry_theirs, path_theirs = theirs._to_c() err = C.git_index_conflict_add( self._index, centry_ancestor, centry_ours, centry_theirs ) @@ -383,7 +392,7 @@ class MergeFileResult: automergeable: bool 'True if the output was automerged, false if the output contains conflict markers' - path: typing.Union[str, None] + path: str | None | PathLike[str] 'The path that the resultant merge file should use, or None if a filename conflict would occur' mode: FileMode @@ -411,7 +420,7 @@ def _from_c(cls, centry): return None automergeable = centry.automergeable != 0 - path = to_str(ffi.string(centry.path)) if centry.path else None + path = decode_fs_path(ffi.string(centry.path)) if centry.path else None mode = FileMode(centry.mode) contents = ffi.string(centry.ptr, centry.len).decode('utf-8') @@ -419,7 +428,7 @@ def _from_c(cls, centry): class IndexEntry: - path: str + path: str | PathLike[str] 'The path of this entry' id: Oid @@ -428,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'' @@ -460,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 @@ -470,7 +475,7 @@ def _to_c(self): # basically memcpy() ffi.buffer(ffi.addressof(centry, 'id'))[:] = self.id.raw[:] centry.mode = int(self.mode) - path = ffi.new('char[]', to_bytes(self.path)) + path = ffi.new('char[]', encode_fs_path(self.path)) centry.path = path return centry, path @@ -481,7 +486,7 @@ def _from_c(cls, centry): return None entry = cls.__new__(cls) - entry.path = to_str(ffi.string(centry.path)) + entry.path = decode_fs_path(ffi.string(centry.path)) entry.mode = FileMode(centry.mode) entry.id = Oid(raw=bytes(ffi.buffer(ffi.addressof(centry, 'id'))[:])) @@ -498,7 +503,7 @@ def __getitem__(self, path): ctheirs = ffi.new('git_index_entry **') err = C.git_index_conflict_get( - cancestor, cours, ctheirs, self._index._index, to_bytes(path) + cancestor, cours, ctheirs, self._index._index, encode_fs_path(path) ) check_error(err) @@ -509,7 +514,7 @@ def __getitem__(self, path): return ancestor, ours, theirs def __delitem__(self, path): - err = C.git_index_conflict_remove(self._index._index, to_bytes(path)) + err = C.git_index_conflict_remove(self._index._index, encode_fs_path(path)) check_error(err) def __iter__(self): @@ -521,7 +526,7 @@ def __contains__(self, path): ctheirs = ffi.new('git_index_entry **') err = C.git_index_conflict_get( - cancestor, cours, ctheirs, self._index._index, to_bytes(path) + cancestor, cours, ctheirs, self._index._index, encode_fs_path(path) ) if err == C.GIT_ENOTFOUND: return False diff --git a/pygit2/legacyenums.py b/pygit2/legacyenums.py deleted file mode 100644 index 176534a6b..000000000 --- a/pygit2/legacyenums.py +++ /dev/null @@ -1,113 +0,0 @@ -# Copyright 2010-2025 The pygit2 contributors -# -# This file is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License, version 2, -# as published by the Free Software Foundation. -# -# In addition to the permissions in the GNU General Public License, -# the authors give you unlimited permission to link the compiled -# version of this file into combinations with other programs, -# and to distribute those combinations without any restriction -# coming from the use of this file. (The General Public License -# restrictions do apply in other respects; for example, they cover -# modification of the file, and distribution when not linked into -# a combined executable.) -# -# This file is distributed in the hope that it will be useful, but -# WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; see the file COPYING. If not, write to -# the Free Software Foundation, 51 Franklin Street, Fifth Floor, -# Boston, MA 02110-1301, USA. - -""" -GIT_* enum values for compatibility with legacy code. - -These values are deprecated starting with pygit2 1.14. -User programs should migrate to the enum classes defined in `pygit2.enums`. - -Note that our C module _pygit2 already exports many libgit2 enums -(which are all imported by __init__.py). This file only exposes the enums -that are not available through _pygit2. -""" - -from . import enums - -GIT_FEATURE_THREADS = enums.Feature.THREADS -GIT_FEATURE_HTTPS = enums.Feature.HTTPS -GIT_FEATURE_SSH = enums.Feature.SSH -GIT_FEATURE_NSEC = enums.Feature.NSEC - -GIT_REPOSITORY_INIT_BARE = enums.RepositoryInitFlag.BARE -GIT_REPOSITORY_INIT_NO_REINIT = enums.RepositoryInitFlag.NO_REINIT -GIT_REPOSITORY_INIT_NO_DOTGIT_DIR = enums.RepositoryInitFlag.NO_DOTGIT_DIR -GIT_REPOSITORY_INIT_MKDIR = enums.RepositoryInitFlag.MKDIR -GIT_REPOSITORY_INIT_MKPATH = enums.RepositoryInitFlag.MKPATH -GIT_REPOSITORY_INIT_EXTERNAL_TEMPLATE = enums.RepositoryInitFlag.EXTERNAL_TEMPLATE -GIT_REPOSITORY_INIT_RELATIVE_GITLINK = enums.RepositoryInitFlag.RELATIVE_GITLINK - -GIT_REPOSITORY_INIT_SHARED_UMASK = enums.RepositoryInitMode.SHARED_UMASK -GIT_REPOSITORY_INIT_SHARED_GROUP = enums.RepositoryInitMode.SHARED_GROUP -GIT_REPOSITORY_INIT_SHARED_ALL = enums.RepositoryInitMode.SHARED_ALL - -GIT_REPOSITORY_OPEN_NO_SEARCH = enums.RepositoryOpenFlag.NO_SEARCH -GIT_REPOSITORY_OPEN_CROSS_FS = enums.RepositoryOpenFlag.CROSS_FS -GIT_REPOSITORY_OPEN_BARE = enums.RepositoryOpenFlag.BARE -GIT_REPOSITORY_OPEN_NO_DOTGIT = enums.RepositoryOpenFlag.NO_DOTGIT -GIT_REPOSITORY_OPEN_FROM_ENV = enums.RepositoryOpenFlag.FROM_ENV - -GIT_REPOSITORY_STATE_NONE = enums.RepositoryState.NONE -GIT_REPOSITORY_STATE_MERGE = enums.RepositoryState.MERGE -GIT_REPOSITORY_STATE_REVERT = enums.RepositoryState.REVERT -GIT_REPOSITORY_STATE_REVERT_SEQUENCE = enums.RepositoryState.REVERT_SEQUENCE -GIT_REPOSITORY_STATE_CHERRYPICK = enums.RepositoryState.CHERRYPICK -GIT_REPOSITORY_STATE_CHERRYPICK_SEQUENCE = enums.RepositoryState.CHERRYPICK_SEQUENCE -GIT_REPOSITORY_STATE_BISECT = enums.RepositoryState.BISECT -GIT_REPOSITORY_STATE_REBASE = enums.RepositoryState.REBASE -GIT_REPOSITORY_STATE_REBASE_INTERACTIVE = enums.RepositoryState.REBASE_INTERACTIVE -GIT_REPOSITORY_STATE_REBASE_MERGE = enums.RepositoryState.REBASE_MERGE -GIT_REPOSITORY_STATE_APPLY_MAILBOX = enums.RepositoryState.APPLY_MAILBOX -GIT_REPOSITORY_STATE_APPLY_MAILBOX_OR_REBASE = ( - enums.RepositoryState.APPLY_MAILBOX_OR_REBASE -) - -GIT_ATTR_CHECK_FILE_THEN_INDEX = enums.AttrCheck.FILE_THEN_INDEX -GIT_ATTR_CHECK_INDEX_THEN_FILE = enums.AttrCheck.INDEX_THEN_FILE -GIT_ATTR_CHECK_INDEX_ONLY = enums.AttrCheck.INDEX_ONLY -GIT_ATTR_CHECK_NO_SYSTEM = enums.AttrCheck.NO_SYSTEM -GIT_ATTR_CHECK_INCLUDE_HEAD = enums.AttrCheck.INCLUDE_HEAD -GIT_ATTR_CHECK_INCLUDE_COMMIT = enums.AttrCheck.INCLUDE_COMMIT - -GIT_FETCH_PRUNE_UNSPECIFIED = enums.FetchPrune.UNSPECIFIED -GIT_FETCH_PRUNE = enums.FetchPrune.PRUNE -GIT_FETCH_NO_PRUNE = enums.FetchPrune.NO_PRUNE - -GIT_CHECKOUT_NOTIFY_NONE = enums.CheckoutNotify.NONE -GIT_CHECKOUT_NOTIFY_CONFLICT = enums.CheckoutNotify.CONFLICT -GIT_CHECKOUT_NOTIFY_DIRTY = enums.CheckoutNotify.DIRTY -GIT_CHECKOUT_NOTIFY_UPDATED = enums.CheckoutNotify.UPDATED -GIT_CHECKOUT_NOTIFY_UNTRACKED = enums.CheckoutNotify.UNTRACKED -GIT_CHECKOUT_NOTIFY_IGNORED = enums.CheckoutNotify.IGNORED -GIT_CHECKOUT_NOTIFY_ALL = enums.CheckoutNotify.ALL - -GIT_STASH_APPLY_PROGRESS_NONE = enums.StashApplyProgress.NONE -GIT_STASH_APPLY_PROGRESS_LOADING_STASH = enums.StashApplyProgress.LOADING_STASH -GIT_STASH_APPLY_PROGRESS_ANALYZE_INDEX = enums.StashApplyProgress.ANALYZE_INDEX -GIT_STASH_APPLY_PROGRESS_ANALYZE_MODIFIED = enums.StashApplyProgress.ANALYZE_MODIFIED -GIT_STASH_APPLY_PROGRESS_ANALYZE_UNTRACKED = enums.StashApplyProgress.ANALYZE_UNTRACKED -GIT_STASH_APPLY_PROGRESS_CHECKOUT_UNTRACKED = ( - enums.StashApplyProgress.CHECKOUT_UNTRACKED -) -GIT_STASH_APPLY_PROGRESS_CHECKOUT_MODIFIED = enums.StashApplyProgress.CHECKOUT_MODIFIED -GIT_STASH_APPLY_PROGRESS_DONE = enums.StashApplyProgress.DONE - -GIT_CREDENTIAL_USERPASS_PLAINTEXT = enums.CredentialType.USERPASS_PLAINTEXT -GIT_CREDENTIAL_SSH_KEY = enums.CredentialType.SSH_KEY -GIT_CREDENTIAL_SSH_CUSTOM = enums.CredentialType.SSH_CUSTOM -GIT_CREDENTIAL_DEFAULT = enums.CredentialType.DEFAULT -GIT_CREDENTIAL_SSH_INTERACTIVE = enums.CredentialType.SSH_INTERACTIVE -GIT_CREDENTIAL_USERNAME = enums.CredentialType.USERNAME -GIT_CREDENTIAL_SSH_MEMORY = enums.CredentialType.SSH_MEMORY diff --git a/pygit2/options.py b/pygit2/options.py new file mode 100644 index 000000000..86261567e --- /dev/null +++ b/pygit2/options.py @@ -0,0 +1,800 @@ +# Copyright 2010-2026 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +""" +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 decode_fs_path, decode_string, encode_fs_path, encode_string + +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 = decode_fs_path(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 = encode_fs_path(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 = encode_fs_path(cert_file) + cert_file_cdata = ffi.new('char[]', cert_file_bytes) + + cert_dir_cdata: ArrayC[char] | NULL_TYPE + if cert_dir is None: + cert_dir_cdata = ffi.NULL + else: + cert_dir_bytes = encode_fs_path(cert_dir) + cert_dir_cdata = ffi.new('char[]', cert_dir_bytes) + + err = C.git_libgit2_opts(option_type, cert_file_cdata, cert_dir_cdata) + 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 = decode_fs_path(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 = encode_fs_path(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: + result = decode_string(buf.ptr) + 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 = encode_string(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 = encode_string(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): + s = decode_string(strings[i]) + if s is not None: + result.append(s) + finally: + # Must dispose of the strarray to free the memory + C.git_strarray_dispose(strarray) + + 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 = encode_string(ext) + ext_string: ArrayC[char] = ffi.new('char[]', ext_bytes) + ext_strings.append(ext_string) + ext_array[i] = ffi.cast('char *', ext_string) + + 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 = decode_fs_path(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 = encode_fs_path(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: + result = decode_string(buf.ptr) + 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 = encode_string(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 b9844d52e..fdaea1bb8 100644 --- a/pygit2/packbuilder.py +++ b/pygit2/packbuilder.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -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 .utils import to_bytes +from .ffi import C, ffi +from .utils import encode_fs_path + +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 encode_fs_path(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/rebase.py b/pygit2/rebase.py new file mode 100644 index 000000000..46726d3c9 --- /dev/null +++ b/pygit2/rebase.py @@ -0,0 +1,249 @@ +# Copyright 2010-2026 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +from typing import TYPE_CHECKING + +# Import from pygit2 +from ._pygit2 import Oid, Signature +from .enums import RebaseOperationType +from .errors import check_error +from .ffi import C, ffi +from .index import Index +from .utils import decode_string + +if TYPE_CHECKING: + from ._libgit2.ffi import GitRebaseC, GitRebaseOperationC + from .repository import BaseRepository + + +def _signature_ptr(signature: 'Signature | None'): + """Return a git_signature* cdata for the given signature, or ffi.NULL. + + The returned pointer borrows the memory owned by the Signature object, + which the caller must keep alive for the duration of the C call. + """ + if signature is None: + return ffi.NULL + ptr = ffi.new('git_signature **') + ffi.buffer(ptr)[:] = signature._pointer[:] + return ptr[0] + + +class RebaseOperation: + """A single instruction to be performed during a rebase.""" + + def __init__(self, type: RebaseOperationType, id: Oid, exec: 'str | None') -> None: + self.type = type + 'The type of rebase operation.' + + self.id = id + """The commit ID being cherry-picked. For operations of type + RebaseOperationType.EXEC this is the zero OID.""" + + self.exec = exec + """The executable the user has requested be run. This will only + be populated for operations of type RebaseOperationType.EXEC.""" + + @classmethod + def _from_c(cls, coperation: 'GitRebaseOperationC') -> 'RebaseOperation': + type = RebaseOperationType(coperation.type) + id = Oid(raw=bytes(ffi.buffer(ffi.addressof(coperation, 'id'))[:])) + exec = decode_string(coperation.exec) + return cls(type, id, exec) + + def __repr__(self) -> str: + return f'' + + +class Rebase: + """An in-progress rebase. + + Returned by Repository.rebase_init() and Repository.rebase_open(). + Iterating over this object performs the rebase operations one by one; + each must be committed with commit(), after resolving any conflicts + that were left in the repository's index. Finalize with finish(), or + roll everything back with abort(). + """ + + def __init__( + self, repo: 'BaseRepository', crebase: 'GitRebaseC', refs: list + ) -> None: + """The constructor is for internal use only.""" + self._repo = repo + self._rebase = ffi.gc(crebase, C.git_rebase_free) + # Keep alive the git_rebase_options and every cdata it points into: + # libgit2 reads the options during __next__() and abort(), long + # after rebase_init() returned. + self._refs = refs + + def __len__(self) -> int: + """The total number of rebase operations.""" + return C.git_rebase_operation_entrycount(self._rebase) + + def __getitem__(self, index: int) -> RebaseOperation: + """The rebase operation at the given index.""" + if index < 0: + index += len(self) + if index < 0: + raise IndexError('rebase operation index out of range') + coperation = C.git_rebase_operation_byindex(self._rebase, index) + if coperation == ffi.NULL: + raise IndexError('rebase operation index out of range') + return RebaseOperation._from_c(coperation) + + def __iter__(self) -> 'Rebase': + return self + + def __next__(self) -> RebaseOperation: + """ + Perform the next rebase operation and return it. + + If the operation is one that applies a patch (which is any + operation except RebaseOperationType.EXEC) then the patch will be + applied and the index and working directory will be updated with + the changes. If there are conflicts, you will need to address + those before calling commit(). + + Raises StopIteration when there are no more operations to perform. + """ + coperation = ffi.new('git_rebase_operation **') + err = C.git_rebase_next(coperation, self._rebase) + check_error(err) # raises StopIteration on GIT_ITEROVER + return RebaseOperation._from_c(coperation[0]) + + @property + def current_index(self) -> 'int | None': + """The index of the rebase operation that is currently being + applied, or None if the first operation has not yet been applied + (because __next__() has not been called yet).""" + index = C.git_rebase_operation_current(self._rebase) + if index == C.GIT_REBASE_NO_OPERATION: + return None + return index + + @property + def inmemory_index(self) -> Index: + """ + The index produced by the last operation, which is the result of + __next__() and which will be committed by the next invocation of + commit(). This is useful for resolving conflicts in an in-memory + rebase before committing them. + + This is only applicable for in-memory rebases; for rebases within + a working directory, the changes were applied to the repository's + index. + """ + cindex = ffi.new('git_index **') + err = C.git_rebase_inmemory_index(cindex, self._rebase) + check_error(err) + return Index.from_c(self._repo, cindex) + + def commit( + self, + committer: Signature, + author: 'Signature | None' = None, + message: 'str | None' = None, + ) -> 'Oid | None': + """ + Commit the current patch and return the id of the new commit, or + None if the current commit has already been applied to the upstream + and there is nothing to commit — mirroring how `git rebase` skips + already-applied patches. You must have resolved any conflicts that + were introduced during the patch application from the last + __next__() invocation. + + Raises GitError if there are unmerged changes in the index. + + Parameters: + + committer : Signature + The committer of the rebase. + + author : Signature + The author of the updated commit, or None to keep the author + from the original commit. + + message : str + The message for this commit, or None to use the message from + the original commit. + """ + cmessage = ( + ffi.new('char[]', message.encode('utf-8')) + if message is not None + else ffi.NULL + ) + coid = ffi.new('git_oid *') + err = C.git_rebase_commit( + coid, + self._rebase, + _signature_ptr(author), + _signature_ptr(committer), + ffi.NULL, + cmessage, + ) + if err == C.GIT_EAPPLIED: + return None + check_error(err) + return Oid(raw=bytes(ffi.buffer(coid)[:])) + + def finish(self, signature: 'Signature | None' = None) -> None: + """ + Finish the rebase once all patches have been applied. + + Parameters: + + signature : Signature + The identity that is finishing the rebase (optional). + """ + err = C.git_rebase_finish(self._rebase, _signature_ptr(signature)) + check_error(err) + + def abort(self) -> None: + """Abort the rebase, resetting the repository and working + directory to their state before the rebase began.""" + err = C.git_rebase_abort(self._rebase) + check_error(err) + + @property + def orig_head_name(self) -> 'str | None': + """The original HEAD ref name.""" + return decode_string(C.git_rebase_orig_head_name(self._rebase)) + + @property + def orig_head_id(self) -> Oid: + """The original HEAD id.""" + coid = C.git_rebase_orig_head_id(self._rebase) + return Oid(raw=bytes(ffi.buffer(coid)[:])) + + @property + def onto_name(self) -> 'str | None': + """The onto ref name.""" + return decode_string(C.git_rebase_onto_name(self._rebase)) + + @property + def onto_id(self) -> Oid: + """The onto id.""" + coid = C.git_rebase_onto_id(self._rebase) + return Oid(raw=bytes(ffi.buffer(coid)[:])) diff --git a/pygit2/references.py b/pygit2/references.py index ca1d23dcc..533e90ab5 100644 --- a/pygit2/references.py +++ b/pygit2/references.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -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 447cf7dc3..7d937bfab 100644 --- a/pygit2/refspec.py +++ b/pygit2/refspec.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -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 .utils import to_bytes +from .ffi import C, ffi +from .utils import encode_string 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): + 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,20 +63,20 @@ 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))) + return bool(C.git_refspec_src_matches(self._refspec, encode_string(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))) + return bool(C.git_refspec_dst_matches(self._refspec, encode_string(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)) + err = fn(buf, self._refspec, encode_string(ref)) check_error(err) try: @@ -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 7e91f6aef..df4931132 100644 --- a/pygit2/remotes.py +++ b/pygit2/remotes.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -24,28 +24,91 @@ # Boston, MA 02110-1301, USA. from __future__ import annotations -from typing import TYPE_CHECKING, Any + +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_custom_headers, git_fetch_options, - git_push_options, git_proxy_options, + git_push_options, git_remote_callbacks, ) from .enums import FetchPrune from .errors import check_error -from .ffi import ffi, C +from .ffi import C, ffi from .refspec import Refspec -from . import utils -from .utils import maybe_string, to_bytes, strarray_to_strings, StrArray +from .utils import StrArray, decode_string, encode_string, strarray_to_strings # 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 = decode_string(c_struct.name) + self.symref_target = decode_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 = decode_string(c_struct.src_refname) + dst_refname = decode_string(c_struct.dst_refname) + assert src_refname is not None, 'libgit2 returned null src_refname' + assert dst_refname is not None, 'libgit2 returned null dst_refname' + self.src_refname = src_refname + 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.""" @@ -81,34 +144,39 @@ def __init__(self, tp: Any) -> None: class Remote: - def __init__(self, repo: BaseRepository, ptr): + def __init__(self, repo: BaseRepository, ptr: 'GitRemoteC') -> None: """The constructor is for internal use only.""" self._repo = repo self._remote = ptr self._stored_exception = None - def __del__(self): + def __del__(self) -> None: C.git_remote_free(self._remote) @property - def name(self): + def name(self) -> str | None: """Name of the remote""" - return maybe_string(C.git_remote_name(self._remote)) + return decode_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)) + return decode_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)) + return decode_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: @@ -122,24 +190,25 @@ def connect(self, callbacks=None, direction=C.GIT_DIRECTION_FETCH, proxy=None): """ with git_proxy_options(self, proxy=proxy) as proxy_opts: with git_remote_callbacks(callbacks) as payload: - err = C.git_remote_connect( - self._remote, - direction, - payload.remote_callbacks, - proxy_opts, - ffi.NULL, - ) - payload.check_error(err) + with git_custom_headers(payload) as custom_headers: + err = C.git_remote_connect( + self._remote, + direction, + payload.remote_callbacks, + proxy_opts, + custom_headers.ptr, + ) + payload.check_error(err) def fetch( self, - 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. @@ -172,72 +241,66 @@ def fetch( with git_proxy_options(self, payload.fetch_options.proxy_opts, proxy): with StrArray(refspecs) as arr: err = C.git_remote_fetch( - self._remote, arr.ptr, opts, to_bytes(message) + self._remote, arr.ptr, opts, encode_string(message) ) payload.check_error(err) 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 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 *') @@ -246,7 +309,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 *') @@ -254,7 +317,14 @@ def push_refspecs(self): check_error(err) return strarray_to_strings(specs) - def push(self, specs, callbacks=None, proxy=None, push_options=None, threads=1): + def push( + self, + specs: list[str], + callbacks: RemoteCallbacks | None = None, + proxy: None | bool | str = None, + push_options: None | list[str] = None, + threads: int = 1, + ) -> None: """ Push the given refspec to the remote. Raises ``GitError`` on protocol error or unpack failure. @@ -270,6 +340,8 @@ def push(self, specs, callbacks=None, proxy=None, push_options=None, threads=1): specs : [str] Push refspecs to use. + callbacks : + proxy : None or True or str Proxy configuration. Can be one of: @@ -310,16 +382,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) @@ -327,29 +399,29 @@ 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] cremote = ffi.new('git_remote **') - err = C.git_remote_lookup(cremote, self._repo._repo, to_bytes(name)) + err = C.git_remote_lookup(cremote, self._repo._repo, encode_string(name)) check_error(err) return Remote(self._repo, cremote[0]) - 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) + yield decode_string(name) - def create(self, name, url, fetch=None) -> Remote: + def create(self, name: str, url: str, fetch: str | None = None) -> Remote: """Create a new remote with the given name and url. Returns a object. @@ -358,31 +430,31 @@ def create(self, name, url, fetch=None) -> Remote: """ cremote = ffi.new('git_remote **') - name = to_bytes(name) - url = to_bytes(url) + name_bytes = encode_string(name) + url_bytes = encode_string(url) if fetch: - fetch = to_bytes(fetch) + fetch_bytes = encode_string(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 = encode_string(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. @@ -398,39 +470,45 @@ def rename(self, name, new_name): problems = ffi.new('git_strarray *') err = C.git_remote_rename( - problems, self._repo._repo, to_bytes(name), to_bytes(new_name) + problems, self._repo._repo, encode_string(name), encode_string(new_name) ) check_error(err) return strarray_to_strings(problems) - 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. """ - err = C.git_remote_delete(self._repo._repo, to_bytes(name)) + err = C.git_remote_delete(self._repo._repo, encode_string(name)) check_error(err) - def set_url(self, name, 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)) + err = C.git_remote_set_url( + self._repo._repo, encode_string(name), encode_string(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)) + err = C.git_remote_set_pushurl( + self._repo._repo, encode_string(name), encode_string(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( - self._repo._repo, to_bytes(name), to_bytes(refspec) + self._repo._repo, encode_string(name), encode_string(refspec) ) check_error(err) - def add_push(self, name, 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)) + err = C.git_remote_add_push( + self._repo._repo, encode_string(name), encode_string(refspec) + ) check_error(err) diff --git a/pygit2/repository.py b/pygit2/repository.py index 37a6d2c52..2c5637992 100644 --- a/pygit2/repository.py +++ b/pygit2/repository.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -22,23 +22,40 @@ # along with this program; see the file COPYING. If not, write to # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. + +import tarfile import 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, Literal, 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, @@ -47,6 +64,7 @@ DescribeStrategy, DiffOption, FileMode, + FilterMode, MergeFavor, MergeFileFlag, MergeFlag, @@ -55,25 +73,66 @@ RepositoryState, ) from .errors import check_error -from .ffi import ffi, C +from .ffi import C, ffi +from .filter import FilterList from .index import Index, IndexEntry, MergeFileResult from .packbuilder import PackBuilder +from .rebase import Rebase from .references import References from .remotes import RemoteCollection from .submodules import SubmoduleCollection -from .utils import to_bytes, StrArray +from .transaction import ReferenceTransaction +from .utils import ( + StrArray, + decode_fs_path, + decode_string, + encode_fs_path, + encode_string, +) + +if TYPE_CHECKING: + from pygit2._libgit2.ffi import ( + ArrayC, + GitAnnotatedCommitC, + GitMergeOptionsC, + GitRebaseOptionsC, + 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 @@ -82,22 +141,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. @@ -133,8 +197,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, @@ -164,12 +228,13 @@ def hashfile( If this is `None` and the `path` parameter is a file within the repository's working directory, then the `path` will be used. """ - c_path = to_bytes(path) + c_path = encode_fs_path(path) + c_as_path: ffi.NULL_TYPE | bytes if as_path is None: c_as_path = ffi.NULL else: - c_as_path = to_bytes(as_path) + c_as_path = encode_string(as_path) c_oid = ffi.new('git_oid *') @@ -181,36 +246,61 @@ 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 = encode_string(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 + If the configuration hasn't been set yet, the default config for repository will be returned, including global and system configurations (if they are available). """ @@ -236,7 +326,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. @@ -258,25 +354,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. @@ -296,9 +393,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. + + 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:: - return (commit, reference) + 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 @@ -337,7 +450,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. @@ -405,7 +522,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. @@ -423,7 +540,7 @@ def set_head(self, target): return # if it's a string, then it's a reference name - err = C.git_repository_set_head(self._repo, to_bytes(target)) + err = C.git_repository_set_head(self._repo, encode_string(target)) check_error(err) # @@ -452,15 +569,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 @@ -486,7 +623,7 @@ def diff( If 'b' is None, by default the working directory is compared to 'a'. If 'cached' is set to True, the index/staging area is used for comparing. - flag + flags A combination of enums.DiffOption constants. context_lines @@ -529,7 +666,7 @@ def diff( # Case 1: Diff tree to tree if isinstance(a, Tree) and isinstance(b, Tree): - return a.diff_to_tree(b, **options) + return a.diff_to_tree(b, **options) # type: ignore[arg-type] # Case 2: Index to workdir elif a is None and b is None: @@ -538,13 +675,13 @@ def diff( # Case 3: Diff tree to index or workdir elif isinstance(a, Tree) and b is None: if cached: - return a.diff_to_index(self.index, **options) + return a.diff_to_index(self.index, **options) # type: ignore[arg-type] else: - return a.diff_to_workdir(**options) + return a.diff_to_workdir(**options) # type: ignore[arg-type] # Case 4: Diff blob to blob if isinstance(a, Blob) and isinstance(b, Blob): - return a.diff(b, **options) + return a.diff(b, **options) # type: ignore[arg-type] raise ValueError('Only blobs and treeish can be diffed') @@ -559,9 +696,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. @@ -573,14 +710,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. @@ -614,6 +751,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) @@ -633,7 +771,7 @@ def blame( options.max_line = max_line cblame = ffi.new('git_blame **') - err = C.git_blame_file(cblame, self._repo, to_bytes(path), options) + err = C.git_blame_file(cblame, self._repo, encode_string(path), options) check_error(err) return Blame._from_c(self, cblame[0]) @@ -654,7 +792,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,18 +817,36 @@ def _merge_options(favor: MergeFavor, flags: MergeFlag, file_flags: MergeFileFla return opts + @overload + def merge_file_from_index( + self, + ancestor: 'IndexEntry | None', + ours: 'IndexEntry | None', + theirs: 'IndexEntry | None', + use_deprecated: Literal[True], + ) -> str: ... + + @overload def merge_file_from_index( self, - ancestor: typing.Union[None, IndexEntry], - ours: typing.Union[None, IndexEntry], - theirs: typing.Union[None, IndexEntry], - use_deprecated: bool = True, - ) -> typing.Union[str, typing.Union[MergeFileResult, None]]: + ancestor: 'IndexEntry | None', + ours: 'IndexEntry | None', + theirs: 'IndexEntry | None', + use_deprecated: Literal[False] = False, + ) -> MergeFileResult: ... + + def merge_file_from_index( + self, + ancestor: 'IndexEntry | None', + ours: 'IndexEntry | None', + theirs: 'IndexEntry | None', + use_deprecated: bool = False, + ) -> 'MergeFileResult | str': """Merge files from index. - Returns: A string with the content of the file containing - possible conflicts if use_deprecated==True. - If use_deprecated==False then it returns an instance of MergeFileResult. + Returns: An instance of MergeFileResult by default. + If use_deprecated==True then it returns a string with the content of + the file containing possible conflicts. ancestor The index entry which will be used as a common @@ -698,9 +856,9 @@ def merge_file_from_index( theirs The index entry which will be merged into "ours" use_deprecated - This controls what will be returned. If use_deprecated==True (default), - a string with the contents of the file will be returned. - An instance of MergeFileResult will be returned otherwise. + This controls what will be returned. If use_deprecated==False (default), + an instance of MergeFileResult will be returned. + A string with the contents of the file will be returned otherwise. """ cmergeresult = ffi.new('git_merge_file_result *') @@ -720,6 +878,8 @@ def merge_file_from_index( mergeFileResult = MergeFileResult._from_c(cmergeresult) C.git_merge_file_result_free(cmergeresult) + assert mergeFileResult is not None + if use_deprecated: warnings.warn( 'Getting an str from Repository.merge_file_from_index is deprecated. ' @@ -733,12 +893,12 @@ def merge_file_from_index( def merge_commits( self, - ours: typing.Union[str, Oid, Commit], - theirs: typing.Union[str, Oid, Commit], - favor=MergeFavor.NORMAL, - flags=MergeFlag.FIND_RENAMES, - file_flags=MergeFileFlag.DEFAULT, - ) -> Index: + ours: str | Oid | Commit, + theirs: str | Oid | Commit, + favor: MergeFavor = MergeFavor.NORMAL, + flags: MergeFlag = MergeFlag.FIND_RENAMES, + file_flags: MergeFileFlag = MergeFileFlag.DEFAULT, + ) -> 'Index': """ Merge two arbitrary commits. @@ -770,9 +930,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) @@ -789,13 +955,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. @@ -827,16 +993,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) @@ -851,13 +1010,39 @@ def merge_trees( return Index.from_c(self, cindex) + def _annotated_commit( + self, source: 'Reference | Commit | Oid | None' + ) -> '_Pointer[GitAnnotatedCommitC] | None': + """Return a git_annotated_commit** cdata for the given source, or + None if source is None. The caller must free the result with + git_annotated_commit_free.""" + if source is None: + return None + commit_ptr = ffi.new('git_annotated_commit **') + if isinstance(source, Reference): + cptr = ffi.new('struct git_reference **') + ffi.buffer(cptr)[:] = source._pointer[:] # type: ignore[attr-defined] + err = C.git_annotated_commit_from_ref(commit_ptr, self._repo, cptr[0]) + else: + if isinstance(source, Commit): + oid = source.id + elif isinstance(source, Oid): + oid = source + else: + raise TypeError('expected Reference, Commit, or Oid') + c_id = ffi.new('git_oid *') + ffi.buffer(c_id)[:] = oid.raw[:] + err = C.git_annotated_commit_lookup(commit_ptr, self._repo, c_id) + check_error(err) + return commit_ptr + def merge( self, - source: typing.Union[Reference, Commit, Oid, str], - favor=MergeFavor.NORMAL, - flags=MergeFlag.FIND_RENAMES, - file_flags=MergeFileFlag.DEFAULT, - ): + source: Reference | Commit | Oid, + favor: MergeFavor = MergeFavor.NORMAL, + flags: MergeFlag = MergeFlag.FIND_RENAMES, + file_flags: MergeFileFlag = MergeFileFlag.DEFAULT, + ) -> None: """ Merges the given Reference or Commit into HEAD. @@ -873,8 +1058,6 @@ def merge( It is preferable to pass in a Reference, because this enriches the merge with additional information (for example, Repository.message will specify the name of the branch being merged). - Previous versions of pygit2 allowed passing in a partial commit - hash as a string; this is deprecated. favor An enums.MergeFavor constant specifying how to deal with file-level conflicts. @@ -887,34 +1070,9 @@ def merge( A combination of enums.MergeFileFlag constants. """ - if isinstance(source, Reference): - # Annotated commit from ref - cptr = ffi.new('struct git_reference **') - ffi.buffer(cptr)[:] = source._pointer[:] - 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) + commit_ptr = self._annotated_commit(source) + if commit_ptr is None: + raise TypeError('expected Reference, Commit, or Oid') merge_opts = self._merge_options(favor, flags, file_flags) @@ -928,6 +1086,203 @@ def merge( C.git_annotated_commit_free(commit_ptr[0]) check_error(err) + # + # Rebasing + # + def _rebase_options( + self, + inmemory: bool, + quiet: bool, + rewrite_notes_ref: 'str | None', + favor: MergeFavor, + flags: MergeFlag, + file_flags: MergeFileFlag, + checkout_strategy: 'CheckoutStrategy | None', + ancestor_label: 'str | None', + our_label: 'str | None', + their_label: 'str | None', + ) -> 'tuple[GitRebaseOptionsC, list]': + """Return a git_rebase_options pointer plus the list of cdata + objects that must be kept alive for as long as libgit2 may read + the options.""" + opts = ffi.new('git_rebase_options *') + err = C.git_rebase_options_init(opts, C.GIT_REBASE_OPTIONS_VERSION) + check_error(err) + refs: list = [opts] + + opts.inmemory = int(inmemory) + opts.quiet = int(quiet) + if rewrite_notes_ref is not None: + notes_ref = ffi.new('char[]', encode_string(rewrite_notes_ref)) + refs.append(notes_ref) + opts.rewrite_notes_ref = notes_ref + + merge_opts = self._merge_options(favor, flags, file_flags) + ffi.buffer(ffi.addressof(opts, 'merge_options'))[:] = ffi.buffer(merge_opts)[:] + + if checkout_strategy is not None: + opts.checkout_options.checkout_strategy = int(checkout_strategy) + labels = ( + ('ancestor_label', ancestor_label), + ('our_label', our_label), + ('their_label', their_label), + ) + for field, label in labels: + if label is not None: + clabel = ffi.new('char[]', encode_string(label)) + refs.append(clabel) + setattr(opts.checkout_options, field, clabel) + + return opts, refs + + def rebase_init( + self, + branch: 'Reference | Commit | Oid | None' = None, + upstream: 'Reference | Commit | Oid | None' = None, + onto: 'Reference | Commit | Oid | None' = None, + *, + inmemory: bool = False, + quiet: bool = False, + rewrite_notes_ref: 'str | None' = None, + favor: MergeFavor = MergeFavor.NORMAL, + flags: MergeFlag = MergeFlag.FIND_RENAMES, + file_flags: MergeFileFlag = MergeFileFlag.DEFAULT, + checkout_strategy: 'CheckoutStrategy | None' = None, + ancestor_label: 'str | None' = None, + our_label: 'str | None' = None, + their_label: 'str | None' = None, + ) -> Rebase: + """ + Initialize a rebase operation to rebase the changes in `branch` + relative to `upstream` onto another branch, and return a Rebase + object. To begin the rebase process, iterate over it; commit each + successful operation with Rebase.commit(), then call + Rebase.finish() or Rebase.abort(). + + Parameters: + + branch + The terminal commit to rebase: a Reference, Commit, or commit + Oid. None means rebase the current branch. + + upstream + The commit to begin rebasing from. None means rebase all + reachable commits. + + onto + The branch to rebase onto. None means rebase onto the given + upstream. + + inmemory + Begin an in-memory rebase, which will allow callers to step + through the rebase operations and commit the rebased changes, + but will not rewind HEAD or update the repository to be in a + rebasing state. This will not interfere with the working + directory. + + quiet + Instruct other clients working on this rebase that you want a + quiet rebase experience. This has no effect upon libgit2 + directly, but is provided for interoperability between Git + tools. + + rewrite_notes_ref + Name of the notes reference used to rewrite notes for rebased + commits when finishing the rebase. If None, the + `notes.rewriteRef` configuration option is examined. + + favor + An enums.MergeFavor constant specifying how to deal with + file-level conflicts. For all but NORMAL, the index will not + record a conflict. + + flags + A combination of enums.MergeFlag constants. + + file_flags + A combination of enums.MergeFileFlag constants. For example, + MergeFileFlag.STYLE_DIFF3 asks for conflict markers that + include the common ancestor content. + + checkout_strategy + A CheckoutStrategy value controlling how files are written + during Rebase.__next__() and Rebase.abort(), or None for + libgit2's default. + + ancestor_label, our_label, their_label + Override the labels used in conflict markers. By default + libgit2 labels the "ours" side with the name of the branch + being rebased onto, and the "theirs" side with the summary of + the commit being replayed. + """ + opts, refs = self._rebase_options( + inmemory, + quiet, + rewrite_notes_ref, + favor, + flags, + file_flags, + checkout_strategy, + ancestor_label, + our_label, + their_label, + ) + branch_c = self._annotated_commit(branch) + upstream_c = self._annotated_commit(upstream) + onto_c = self._annotated_commit(onto) + + crebase = ffi.new('git_rebase **') + err = C.git_rebase_init( + crebase, + self._repo, + branch_c[0] if branch_c is not None else ffi.NULL, + upstream_c[0] if upstream_c is not None else ffi.NULL, + onto_c[0] if onto_c is not None else ffi.NULL, + opts, + ) + for commit_c in (branch_c, upstream_c, onto_c): + if commit_c is not None: + C.git_annotated_commit_free(commit_c[0]) + check_error(err) + return Rebase(self, crebase[0], refs) + + def rebase_open( + self, + *, + inmemory: bool = False, + quiet: bool = False, + rewrite_notes_ref: 'str | None' = None, + favor: MergeFavor = MergeFavor.NORMAL, + flags: MergeFlag = MergeFlag.FIND_RENAMES, + file_flags: MergeFileFlag = MergeFileFlag.DEFAULT, + checkout_strategy: 'CheckoutStrategy | None' = None, + ancestor_label: 'str | None' = None, + our_label: 'str | None' = None, + their_label: 'str | None' = None, + ) -> Rebase: + """ + Open an existing rebase that was previously started by either an + invocation of rebase_init() or by another client. + + The keyword arguments have the same meaning as in rebase_init(). + """ + opts, refs = self._rebase_options( + inmemory, + quiet, + rewrite_notes_ref, + favor, + flags, + file_flags, + checkout_strategy, + ancestor_label, + our_label, + their_label, + ) + crebase = ffi.new('git_rebase **') + err = C.git_rebase_open(crebase, self._repo, opts) + check_error(err) + return Rebase(self, crebase[0], refs) + # # Prepared message (MERGE_MSG) # @@ -966,7 +1321,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. """ @@ -978,16 +1333,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. @@ -1052,7 +1407,7 @@ def describe( # The returned pointer object has ownership on the allocated # memory. Make sure it is kept alive until git_describe_commit() or # git_describe_workdir() are called below. - pattern_char = ffi.new('char[]', to_bytes(pattern)) + pattern_char = ffi.new('char[]', encode_string(pattern)) options.pattern = pattern_char if only_follow_first_parent is not None: options.only_follow_first_parent = only_follow_first_parent @@ -1061,10 +1416,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[:] @@ -1086,7 +1444,7 @@ def describe( format_options.always_use_long_format = always_use_long_format dirty_ptr = None if dirty_suffix: - dirty_ptr = ffi.new('char[]', to_bytes(dirty_suffix)) + dirty_ptr = ffi.new('char[]', encode_string(dirty_suffix)) format_options.dirty_suffix = dirty_ptr buf = ffi.new('git_buf *', (ffi.NULL, 0)) @@ -1107,13 +1465,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. @@ -1145,7 +1503,7 @@ def stash( Example:: >>> repo = pygit2.Repository('.') - >>> repo.stash(repo.default_signature(), 'WIP: stashing') + >>> repo.stash(repo.default_signature, 'WIP: stashing') """ opts = ffi.new('git_stash_save_options *') @@ -1163,12 +1521,12 @@ def stash( opts.stasher = stasher_cptr[0] if message: - message_ref = ffi.new('char[]', to_bytes(message)) + message_ref = ffi.new('char[]', encode_string(message)) opts.message = message_ref if paths: 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) @@ -1177,7 +1535,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. @@ -1208,14 +1572,18 @@ def stash_apply(self, index=0, **kwargs): Example:: >>> repo = pygit2.Repository('.') - >>> repo.stash(repo.default_signature(), 'WIP: stashing') + >>> repo.stash(repo.default_signature, 'WIP: stashing') >>> repo.stash_apply(strategy=CheckoutStrategy.ALLOW_CONFLICTS) """ - with git_stash_apply_options(**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. @@ -1227,19 +1595,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. @@ -1311,7 +1695,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. @@ -1351,11 +1735,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. @@ -1398,7 +1782,7 @@ def get_attr( cvalue = ffi.new('char **') err = C.git_attr_get_ext( - cvalue, self._repo, copts, to_bytes(path), to_bytes(name) + cvalue, self._repo, copts, encode_string(path), encode_string(name) ) check_error(err) @@ -1419,16 +1803,16 @@ def get_attr( # Identity for reference operations # @property - def ident(self): + def ident(self) -> tuple[Optional[str], Optional[str]]: cname = ffi.new('char **') cemail = ffi.new('char **') err = C.git_repository_ident(cname, cemail, self._repo) check_error(err) - return (ffi.string(cname).decode('utf-8'), ffi.string(cemail).decode('utf-8')) + return (decode_string(cname[0]), decode_string(cemail[0])) - def set_ident(self, name, email): + def set_ident(self, name: Optional[str], email: Optional[str]) -> None: """Set the identity to be used for reference operations. Updates to some references also append data to their @@ -1436,10 +1820,12 @@ def set_ident(self, name, email): used. If none is set, it will be read from the configuration. """ - err = C.git_repository_set_ident(self._repo, to_bytes(name), to_bytes(email)) + err = C.git_repository_set_ident( + self._repo, encode_string(name), encode_string(email) + ) check_error(err) - def revert(self, commit: Commit): + def revert(self, commit: Commit) -> None: """ Revert the given commit, producing changes in the index and working directory. @@ -1452,7 +1838,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. @@ -1493,14 +1881,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. @@ -1549,30 +1937,30 @@ 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): - refname_cstr = ffi.new('char[]', to_bytes(refname.name)) + refname_cstr = ffi.new('char[]', encode_string(refname.name)) elif type(refname) is str: - refname_cstr = ffi.new('char[]', to_bytes(refname)) + refname_cstr = ffi.new('char[]', encode_string(refname)) elif refname is not None: raise TypeError('refname must be a str or Reference') @@ -1590,15 +1978,17 @@ def amend_commit( # Get message and encoding as C strings. if message is not None: - message_cstr = ffi.new('char[]', to_bytes(message, encoding)) - encoding_cstr = ffi.new('char[]', to_bytes(encoding)) + message_cstr = ffi.new('char[]', encode_string(message, encoding)) + encoding_cstr = ffi.new('char[]', encode_string(encoding)) # Get tree as pointer to git_tree. if tree is not None: 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( @@ -1615,11 +2005,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, ): """ @@ -1646,17 +2041,17 @@ def __init__( if hasattr(path, '__fspath__'): path = path.__fspath__() if not isinstance(path, str): - path = path.decode('utf-8') + path = decode_fs_path(path) path_backend = init_file_backend(path, int(flags)) super().__init__(path_backend) else: 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 52eca6d94..6f3141400 100644 --- a/pygit2/settings.py +++ b/pygit2/settings.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -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 d8506d20f..facf1c274 100644 --- a/pygit2/submodules.py +++ b/pygit2/submodules.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -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 decode_fs_path, decode_string, encode_string # 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. @@ -126,29 +138,34 @@ def reload(self, force: bool = False): @property def name(self): """Name of the submodule.""" - name = C.git_submodule_name(self._subm) - return ffi.string(name).decode('utf-8') + return decode_string(C.git_submodule_name(self._subm)) @property def path(self): """Path of the submodule.""" - path = C.git_submodule_path(self._subm) - return ffi.string(path).decode('utf-8') + return decode_fs_path(C.git_submodule_path(self._subm)) @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) + return decode_string(url) + + @url.setter + def url(self, url: str) -> None: + crepo = self._repo._repo + cname = ffi.new('char[]', encode_string(self.name)) + curl = ffi.new('char[]', encode_string(url)) + err = C.git_submodule_set_url(crepo, cname, curl) + check_error(err) @property def branch(self): """Branch that is to be tracked by the submodule.""" - branch = C.git_submodule_branch(self._subm) - return ffi.string(branch).decode('utf-8') + return decode_string(C.git_submodule_branch(self._subm)) @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,13 +184,13 @@ 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. """ csub = ffi.new('git_submodule **') - cpath = ffi.new('char[]', to_bytes(name)) + cpath = ffi.new('char[]', encode_string(name)) err = C.git_submodule_lookup(csub, self._repository._repo, cpath) check_error(err) @@ -186,7 +203,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. @@ -229,8 +246,8 @@ def add( The default is 0 (full commit history). """ csub = ffi.new('git_submodule **') - curl = ffi.new('char[]', to_bytes(url)) - cpath = ffi.new('char[]', to_bytes(path)) + curl = ffi.new('char[]', encode_string(url)) + cpath = ffi.new('char[]', encode_string(path)) gitlink = 1 if link else 0 err = C.git_submodule_add_setup( @@ -261,7 +278,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 +308,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 @@ -341,12 +360,12 @@ def status( """ cstatus = ffi.new('unsigned int *') err = C.git_submodule_status( - cstatus, self._repository._repo, to_bytes(name), ignore + cstatus, self._repository._repo, encode_string(name), ignore ) check_error(err) return SubmoduleStatus(cstatus[0]) - def cache_all(self): + def cache_all(self) -> None: """ Load and cache all submodules in the repository. @@ -359,7 +378,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 000000000..5c33b2356 --- /dev/null +++ b/pygit2/transaction.py @@ -0,0 +1,199 @@ +# Copyright 2010-2026 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +from __future__ import annotations + +import threading +from typing import TYPE_CHECKING + +from .errors import check_error +from .ffi import C, ffi +from .utils import encode_string + +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[]', encode_string(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[]', encode_string(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[]', encode_string(message)) if message else ffi.NULL + + err = C.git_transaction_set_target(self._tx, c_refname, c_oid, c_sig, c_msg) + check_error(err) + + 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[]', encode_string(refname)) + c_target = ffi.new('char[]', encode_string(target)) + c_sig = signature._pointer if signature else ffi.NULL + c_msg = ffi.new('char[]', encode_string(message)) if message else ffi.NULL + + err = C.git_transaction_set_symbolic_target( + self._tx, c_refname, c_target, c_sig, c_msg + ) + 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[]', encode_string(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 1139b23cf..95e382c32 100644 --- a/pygit2/utils.py +++ b/pygit2/utils.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -25,19 +25,87 @@ 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): + +PathStrOrBytes = str | bytes | os.PathLike[str] | os.PathLike[bytes] + + +def decode_string(ptr: 'char_pointer | None') -> str | None: if not ptr: return None return ffi.string(ptr).decode('utf8', errors='surrogateescape') -def to_bytes(s, encoding='utf-8', errors='strict'): +@overload +def decode_fs_path(ptr_or_bytes: 'char_pointer | None') -> str | None: ... +@overload +def decode_fs_path(ptr_or_bytes: bytes) -> str: ... +def decode_fs_path(ptr_or_bytes: 'char_pointer | bytes | None') -> str | None: + if ptr_or_bytes is None: + return None + + if isinstance(ptr_or_bytes, bytes): + return os.fsdecode(ptr_or_bytes) + + if not ptr_or_bytes: + return None + + return os.fsdecode(ffi.string(ptr_or_bytes)) + + +@overload +def encode_fs_path(s: PathStrOrBytes) -> bytes: ... +@overload +def encode_fs_path(s: 'ffi.NULL_TYPE | None') -> 'ffi.NULL_TYPE': ... +def encode_fs_path( + s: 'PathStrOrBytes | ffi.NULL_TYPE | None', +) -> 'bytes | ffi.NULL_TYPE': + if s is None or s == ffi.NULL: + return ffi.NULL + + return os.fsencode(s) # type: ignore[arg-type] + + +# TODO decode_string uses errors='surrogateescape', but encode_string defaults +# to errors='strict', so a value read from libgit2 with bad bytes cannot be +# written back without raising. Decide whether encode_string should default to +# 'surrogateescape' too, and audit every caller to make sure that's safe +# (this is a behavior change, not just a rename). +@overload +def encode_string( + s: str | bytes | os.PathLike[str] | os.PathLike[bytes], + encoding: str = 'utf-8', + errors: str = 'strict', +) -> bytes: ... +@overload +def encode_string( + s: Union['ffi.NULL_TYPE', None], + encoding: str = 'utf-8', + errors: str = 'strict', +) -> Union['ffi.NULL_TYPE']: ... +def encode_string( + 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 +115,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 path_to_str(s: str | bytes | os.PathLike[str] | os.PathLike[bytes]) -> str: if hasattr(s, '__fspath__'): s = os.fspath(s) @@ -63,7 +131,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 +142,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,7 +181,11 @@ class StrArray: contents of 'struct' only remain valid within the StrArray context. """ - def __init__(self, lst): + __array: 'GitStrrayC | ffi.NULL_TYPE' + __strings: list['None | ArrayC[char]'] + __arr: 'ArrayC[char_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 lst is None: self.__array = ffi.NULL @@ -122,29 +194,34 @@ def __init__(self, lst): if not isinstance(lst, (list, tuple)): raise TypeError('Value must be a list') - strings = [None] * len(lst) + strings: list[None | 'ArrayC[char]'] = [None] * len(lst) for i in range(len(lst)): li = lst[i] if not isinstance(li, str) and not hasattr(li, '__fspath__'): raise TypeError('Value must be a string or PathLike object') - strings[i] = ffi.new('char []', to_bytes(li)) + strings[i] = ffi.new('char []', encode_string(li)) self.__arr = ffi.new('char *[]', strings) self.__strings = strings - 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 +230,31 @@ def assign_to(self, git_strarray): git_strarray.count = len(self.__strings) -class GenericIterator: +T = TypeVar('T') +U = TypeVar('U', covariant=True) + + +class SequenceProtocol(Protocol[U]): + def __len__(self) -> int: ... + def __getitem__(self, index: int) -> U: ... + + +class GenericIterator(Generic[T]): """Helper to easily implement an iterator. The constructor gets a container which must implement __len__ and __getitem__ """ - def __init__(self, container): + def __init__(self, container: SequenceProtocol[T]) -> None: self.container = container self.length = len(container) self.idx = 0 - def __iter__(self): + def __iter__(self) -> Iterator[T]: return self - def __next__(self): + def __next__(self) -> T: idx = self.idx if idx >= self.length: raise StopIteration diff --git a/pyproject.toml b/pyproject.toml index c625df473..3829aa5c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,16 +1,24 @@ [build-system] -requires = ["setuptools", "wheel"] +requires = ["setuptools", "cffi>=2.0"] +build-backend = "setuptools.build_meta" [tool.cibuildwheel] enable = ["pypy"] -skip = "*musllinux_aarch64 *musllinux_ppc64le" +skip = "*musllinux_ppc64le" archs = ["native"] build-frontend = "default" dependency-versions = "pinned" -environment = {LIBGIT2_VERSION="1.9.1", LIBSSH2_VERSION="1.11.1", OPENSSL_VERSION="3.3.3", LIBGIT2="/project/ci"} +environment = {LIBGIT2="$(pwd)/ci", LIBGIT2_VERSION="1.9.7", LIBSSH2_VERSION="1.11.1", OPENSSL_VERSION="3.5.7"} before-all = "sh build.sh" +test-command = "pytest" +test-sources = ["test", "pytest.ini"] +before-test = "pip install -r {package}/requirements-test.txt" +# Will avoid testing on emulated architectures (specifically ppc64le and riscv64) +# Also, skip testing pypy on macOS arm64 due issue with bootstrapping git config paths +# see https://github.com/libgit2/pygit2/issues/1442 +test-skip = "*-*linux_ppc64le *-*linux_riscv64 pp*-macosx_arm64" [tool.cibuildwheel.linux] repair-wheel-command = "LD_LIBRARY_PATH=/project/ci/lib64 auditwheel repair -w {dest_dir} {wheel}" @@ -20,13 +28,44 @@ select = "*-musllinux*" repair-wheel-command = "LD_LIBRARY_PATH=/project/ci/lib auditwheel repair -w {dest_dir} {wheel}" [tool.cibuildwheel.macos] -archs = ["universal2"] -environment = {LIBGIT2_VERSION="1.9.1", LIBSSH2_VERSION="1.11.1", OPENSSL_VERSION="3.3.3", LIBGIT2="/Users/runner/work/pygit2/pygit2/ci"} -repair-wheel-command = "DYLD_LIBRARY_PATH=/Users/runner/work/pygit2/pygit2/ci/lib delocate-wheel --require-archs {delocate_archs} -w {dest_dir} {wheel}" +repair-wheel-command = "DYLD_LIBRARY_PATH={package}/ci/lib delocate-wheel --require-archs {delocate_archs} -w {dest_dir} {wheel}" + +[tool.cibuildwheel.windows] +environment.LIBGIT2_SRC = "build/libgit2_src" +environment.LIBGIT2_VERSION = "1.9.7" +before-all = "powershell -File build.ps1" +before-build = "" + +[[tool.cibuildwheel.overrides]] +select="*-win_amd64" +inherit.environment="append" +environment.CMAKE_GENERATOR = "Visual Studio 18 2026" +environment.CMAKE_GENERATOR_PLATFORM = "x64" +environment.CMAKE_INSTALL_PREFIX = "C:/libgit2_install_x86_64" +environment.LIBGIT2 = "C:/libgit2_install_x86_64" + +[[tool.cibuildwheel.overrides]] +select="*-win32" +inherit.environment="append" +environment.CMAKE_GENERATOR = "Visual Studio 18 2026" +environment.CMAKE_GENERATOR_PLATFORM = "Win32" +environment.CMAKE_INSTALL_PREFIX = "C:/libgit2_install_x86" +environment.LIBGIT2 = "C:/libgit2_install_x86" + +[[tool.cibuildwheel.overrides]] +select="*-win_arm64" +inherit.environment="append" +environment.CMAKE_GENERATOR = "Visual Studio 17 2022" +environment.CMAKE_GENERATOR_PLATFORM = "ARM64" +environment.CMAKE_INSTALL_PREFIX = "C:/libgit2_install_arm64" +environment.LIBGIT2 = "C:/libgit2_install_arm64" [tool.ruff] extend-exclude = [ ".cache", ".coverage", "build", "site-packages", "venv*"] -target-version = "py310" # oldest supported Python version +target-version = "py311" # oldest supported Python version + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F", "I", "UP035", "UP007"] [tool.ruff.format] quote-style = "single" diff --git a/requirements-typing.txt b/requirements-typing.txt new file mode 100644 index 000000000..c16ede753 --- /dev/null +++ b/requirements-typing.txt @@ -0,0 +1,2 @@ +mypy +types-cffi diff --git a/requirements-wheel.txt b/requirements-wheel.txt new file mode 100644 index 000000000..e14d9f0a7 --- /dev/null +++ b/requirements-wheel.txt @@ -0,0 +1 @@ +cibuildwheel ~= 3.3 diff --git a/requirements.txt b/requirements.txt index e06cd8ab1..df6e4d8c4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -cffi>=1.16.0 +cffi>=2.0 setuptools ; python_version >= "3.12" diff --git a/setup.py b/setup.py index 88d1536d0..778a6c0e8 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -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,8 @@ 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', + 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 dac991bf8..68d3271bf 100644 --- a/src/blob.c +++ b/src/blob.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -190,6 +190,7 @@ static int blob_filter_stream_write( err = GIT_ERROR; goto done; } + Py_DECREF(result); pos += chunk_size; } @@ -202,7 +203,7 @@ static int blob_filter_stream_close(git_writestream *s) { struct blob_filter_stream *stream = (struct blob_filter_stream *)s; PyGILState_STATE gil = PyGILState_Ensure(); - PyObject *result; + PyObject *result = NULL; int err = 0; /* Signal closed and then ready in that order so consumers can block on @@ -214,6 +215,7 @@ static int blob_filter_stream_close(git_writestream *s) git_error_set(GIT_ERROR_OS, "failed to signal writer closed"); err = GIT_ERROR; } + Py_XDECREF(result); result = PyObject_CallMethod(stream->py_ready, "set", NULL); if (result == NULL) { @@ -221,6 +223,7 @@ static int blob_filter_stream_close(git_writestream *s) git_error_set(GIT_ERROR_OS, "failed to signal queue ready"); err = GIT_ERROR; } + Py_XDECREF(result); PyGILState_Release(gil); return err; @@ -235,7 +238,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" @@ -316,9 +319,11 @@ Blob__write_to_queue(Blob *self, PyObject *args, PyObject *kwds) { if (py_oid != NULL && py_oid != Py_None) { - err = py_oid_to_git_oid(py_oid, &opts.attr_commit_id); - if (err < 0) - return Error_set(err); + size_t len = py_oid_to_git_oid(py_oid, &opts.attr_commit_id); + if (len == 0) { + git_blob_free(blob); + return NULL; + } } if ((opts.flags & GIT_BLOB_FILTER_NO_SYSTEM_ATTRIBUTES) != 0) diff --git a/src/branch.c b/src/branch.c index 60e0e2dc6..334431382 100644 --- a/src/branch.c +++ b/src/branch.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -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 5ee6de7e2..c108e3a32 100644 --- a/src/branch.h +++ b/src/branch.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/commit.c b/src/commit.c index f758bdd87..2c2533d78 100644 --- a/src/commit.c +++ b/src/commit.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/diff.c b/src/diff.c index 0bc7c6136..0f1650e67 100644 --- a/src/diff.c +++ b/src/diff.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -93,7 +93,7 @@ wrap_diff_file(const git_diff_file *file) } PyObject * -wrap_diff_delta(const git_diff_delta *delta) +wrap_diff_delta(const git_diff_delta *delta, Diff *diff, size_t idx) { DiffDelta *py_delta; @@ -108,6 +108,10 @@ wrap_diff_delta(const git_diff_delta *delta) py_delta->nfiles = delta->nfiles; py_delta->old_file = wrap_diff_file(&delta->old_file); py_delta->new_file = wrap_diff_file(&delta->new_file); + py_delta->diff = diff; + if (diff) + Py_INCREF(diff); + py_delta->idx = idx; } return (PyObject *) py_delta; @@ -228,15 +232,25 @@ DiffFile_mode__get__(DiffFile *self) return pygit2_enum(FileModeEnum, self->mode); } +PyDoc_STRVAR(DiffFile_path__doc__, "Path to the entry."); + +PyObject * +DiffFile_path__get__(DiffFile *self) +{ + if (self->path == NULL) + Py_RETURN_NONE; + + return PyUnicode_DecodeFSDefault(self->path); +} + PyMemberDef DiffFile_members[] = { MEMBER(DiffFile, id, T_OBJECT, "Oid of the item."), - MEMBER(DiffFile, path, T_STRING, "Path to the entry."), MEMBER(DiffFile, raw_path, T_OBJECT, "Path to the entry (bytes)."), MEMBER(DiffFile, size, T_LONG, "Size of the entry."), {NULL} }; -PyMethodDef DiffFile_methods[] = { +static PyMethodDef DiffFile_methods[] = { METHOD(DiffFile, from_c, METH_STATIC | METH_O), {NULL}, }; @@ -244,6 +258,7 @@ PyMethodDef DiffFile_methods[] = { PyGetSetDef DiffFile_getsetters[] = { GETTER(DiffFile, flags), GETTER(DiffFile, mode), + GETTER(DiffFile, path), {NULL}, }; @@ -304,6 +319,37 @@ DiffDelta_status_char(DiffDelta *self) return Py_BuildValue("C", status); } +/* Load the file data so delta->flags is up to date. This is a no-op if the + flags have already been loaded or if the delta was not created from a Diff + object (e.g. it came from a Patch, whose flags are already loaded). */ +static int +DiffDelta_ensure_flags(DiffDelta *self) +{ + git_patch *patch = NULL; + const git_diff_delta *delta; + int err; + + if (self->diff == NULL) + return 0; + + if (self->flags & (GIT_DIFF_FLAG_BINARY | GIT_DIFF_FLAG_NOT_BINARY)) + return 0; + + err = git_patch_from_diff(&patch, self->diff->diff, self->idx); + if (err < 0) + return err; + + delta = git_diff_get_delta(self->diff->diff, self->idx); + if (delta == NULL) { + git_patch_free(patch); + return GIT_ENOTFOUND; + } + + self->flags = delta->flags; + git_patch_free(patch); + return 0; +} + PyDoc_STRVAR(DiffDelta_is_binary__doc__, "True if binary data, False if text, None if not (yet) known." ); @@ -311,6 +357,12 @@ PyDoc_STRVAR(DiffDelta_is_binary__doc__, PyObject * DiffDelta_is_binary__get__(DiffDelta *self) { + int err; + + err = DiffDelta_ensure_flags(self); + if (err < 0) + return Error_set(err); + if (self->flags & GIT_DIFF_FLAG_BINARY) Py_RETURN_TRUE; @@ -339,12 +391,19 @@ PyDoc_STRVAR(DiffDelta_flags__doc__, PyObject * DiffDelta_flags__get__(DiffDelta *self) { + int err; + + err = DiffDelta_ensure_flags(self); + if (err < 0) + return Error_set(err); + return pygit2_enum(DiffFlagEnum, self->flags); } static void DiffDelta_dealloc(DiffDelta *self) { + Py_CLEAR(self->diff); Py_CLEAR(self->old_file); Py_CLEAR(self->new_file); PyObject_Del(self); @@ -533,6 +592,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); } @@ -587,7 +651,7 @@ PyTypeObject DiffIterType = { }; PyObject * -diff_get_delta_byindex(git_diff *diff, size_t idx) +diff_get_delta_byindex(git_diff *diff, size_t idx, Diff *parent) { const git_diff_delta *delta = git_diff_get_delta(diff, idx); if (delta == NULL) { @@ -595,14 +659,14 @@ diff_get_delta_byindex(git_diff *diff, size_t idx) return NULL; } - return (PyObject*) wrap_diff_delta(delta); + return (PyObject*) wrap_diff_delta(delta, parent, idx); } PyObject * DeltasIter_iternext(DeltasIter *self) { if (self->i < self->n) - return diff_get_delta_byindex(self->diff->diff, self->i++); + return diff_get_delta_byindex(self->diff->diff, self->i++, self->diff); PyErr_SetNone(PyExc_StopIteration); return NULL; @@ -768,14 +832,21 @@ DiffHunk_lines__get__(DiffHunk *self) // TODO Replace by an iterator py_lines = PyList_New(self->n_lines); + if (py_lines == NULL) + return NULL; + for (i = 0; i < self->n_lines; ++i) { err = git_patch_get_line_in_hunk(&line, self->patch->patch, self->idx, i); - if (err < 0) + if (err < 0) { + Py_DECREF(py_lines); return Error_set(err); + } py_line = wrap_diff_line(line, self); - if (py_line == NULL) + if (py_line == NULL) { + Py_DECREF(py_lines); return NULL; + } PyList_SetItem(py_lines, i, py_line); } @@ -909,7 +980,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 17b64df8a..8a2c2196c 100644 --- a/src/diff.h +++ b/src/diff.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -34,7 +34,7 @@ #include "types.h" PyObject* wrap_diff(git_diff *diff, Repository *repo); -PyObject* wrap_diff_delta(const git_diff_delta *delta); +PyObject* wrap_diff_delta(const git_diff_delta *delta, Diff *diff, size_t idx); PyObject* wrap_diff_file(const git_diff_file *file); PyObject* wrap_diff_hunk(Patch *patch, size_t idx); PyObject* wrap_diff_line(const git_diff_line *line, DiffHunk *hunk); diff --git a/src/error.c b/src/error.c index d264e6196..8700bb715 100644 --- a/src/error.c +++ b/src/error.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -30,6 +30,11 @@ extern PyObject *GitError; extern PyObject *AlreadyExistsError; extern PyObject *InvalidSpecError; +extern PyObject *InvalidError; +extern PyObject *NotFoundError; +extern PyObject *AmbiguousError; +extern PyObject *AuthError; +extern PyObject *CertificateError; PyObject * Error_type(int type) @@ -39,7 +44,7 @@ Error_type(int type) switch (type) { /* Input does not exist in the scope searched. */ case GIT_ENOTFOUND: - return PyExc_KeyError; + return NotFoundError; /* A reference with this name already exists */ case GIT_EEXISTS: @@ -47,7 +52,7 @@ Error_type(int type) /* The given short oid is ambiguous */ case GIT_EAMBIGUOUS: - return PyExc_ValueError; + return AmbiguousError; /* The buffer is too short to satisfy the request */ case GIT_EBUFS: @@ -57,6 +62,18 @@ Error_type(int type) case GIT_EINVALIDSPEC: return InvalidSpecError; + /* Invalid operation or input */ + case GIT_EINVALID: + return InvalidError; + + /* Authentication error */ + case GIT_EAUTH: + return AuthError; + + /* Server certificate is invalid */ + case GIT_ECERTIFICATE: + return CertificateError; + /* Skip and passthrough the given ODB backend */ case GIT_PASSTHROUGH: return GitError; @@ -75,7 +92,7 @@ Error_type(int type) case GITERR_OS: return PyExc_OSError; case GITERR_INVALID: - return PyExc_ValueError; + return InvalidError; } } return GitError; @@ -87,6 +104,11 @@ Error_set(int err) { assert(err < 0); + /* GIT_EUSER means a Python callback raised an exception. Preserve that + * exception instead of overwriting it with a stale libgit2 error message. */ + if (err == GIT_EUSER && PyErr_Occurred()) + return NULL; + return Error_set_exc(Error_type(err)); } @@ -105,9 +127,13 @@ Error_set_exc(PyObject* exception) PyObject * Error_set_str(int err, const char *str) { + /* GIT_EUSER means a Python callback raised an exception. Preserve it. */ + if (err == GIT_EUSER && PyErr_Occurred()) + return NULL; + if (err == GIT_ENOTFOUND) { - /* KeyError expects the arg to be the missing key. */ - PyErr_SetString(PyExc_KeyError, str); + /* NotFoundError inherits from KeyError; the argument is the missing key. */ + PyErr_SetString(NotFoundError, str); return NULL; } @@ -121,6 +147,10 @@ Error_set_str(int err, const char *str) PyObject * Error_set_oid(int err, const git_oid *oid, size_t len) { + /* GIT_EUSER means a Python callback raised an exception. Preserve it. */ + if (err == GIT_EUSER && PyErr_Occurred()) + return NULL; + char hex[GIT_OID_HEXSZ + 1]; git_oid_fmt(hex, oid); diff --git a/src/error.h b/src/error.h index f08f3a998..e38bb8a4a 100644 --- a/src/error.h +++ b/src/error.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/filter.c b/src/filter.c index 730dbcf77..be19b7027 100644 --- a/src/filter.c +++ b/src/filter.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -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 04bbf7c0b..5328c1e22 100644 --- a/src/filter.h +++ b/src/filter.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/mailmap.c b/src/mailmap.c index 4bfc60faf..0671562cc 100644 --- a/src/mailmap.c +++ b/src/mailmap.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -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 0f61d96b6..d75c4b9f0 100644 --- a/src/mailmap.h +++ b/src/mailmap.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/note.c b/src/note.c index cb25e39c0..b0ab6bac7 100644 --- a/src/note.c +++ b/src/note.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -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 2e87b8942..98e091c2e 100644 --- a/src/note.h +++ b/src/note.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/object.c b/src/object.c index 15127c7f8..bd16f848a 100644 --- a/src/object.c +++ b/src/object.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -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 9fc41526e..0f2e01e95 100644 --- a/src/object.h +++ b/src/object.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/odb.c b/src/odb.c index 97181aa72..51ae3aa59 100644 --- a/src/odb.c +++ b/src/odb.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -37,6 +37,8 @@ extern PyTypeObject OdbBackendType; +extern PyObject *ObjectTypeEnum; + static git_otype int_to_loose_object_type(int type_id) { @@ -111,21 +113,24 @@ Odb_build_as_iter(const git_oid *oid, void *accum) PyObject * Odb_as_iter(Odb *self) { - int err; PyObject *accum = PyList_New(0); - PyObject *ret = NULL; + if (accum == NULL) + return NULL; - err = git_odb_foreach(self->odb, Odb_build_as_iter, (void*)accum); + int err = git_odb_foreach(self->odb, Odb_build_as_iter, (void*)accum); + if (err == GIT_EUSER && PyErr_Occurred()) { + Py_DECREF(accum); + return NULL; + } if (err == GIT_EUSER) - goto exit; + err = GIT_ERROR; + if (err < 0) { - ret = Error_set(err); - goto exit; + Py_DECREF(accum); + return Error_set(err); } - ret = PyObject_GetIter(accum); - -exit: + PyObject *ret = PyObject_GetIter(accum); Py_DECREF(accum); return ret; } @@ -170,7 +175,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 +185,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 +197,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 +211,44 @@ Odb_read(Odb *self, PyObject *py_hex) return tuple; } +PyDoc_STRVAR(Odb_read_header__doc__, + "read_header(oid: Oid) -> tuple[enums.ObjectType, int]\n" + "\n" + "Read the header of an object from the database, without reading its full\n" + "contents.\n" + "\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 +347,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 7a69a46c0..29e0610a4 100644 --- a/src/odb.h +++ b/src/odb.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/odb_backend.c b/src/odb_backend.c index a189aec53..47d0a54e9 100644 --- a/src/odb_backend.c +++ b/src/odb_backend.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -72,11 +72,13 @@ pgit_odb_backend_read(void **ptr, size_t *sz, git_object_t *type, const char *bytes; Py_ssize_t type_value; - if (!PyArg_ParseTuple(result, "ny#", &type_value, &bytes, sz) || !bytes) { + Py_ssize_t py_sz; + if (!PyArg_ParseTuple(result, "ny#", &type_value, &bytes, &py_sz) || !bytes) { Py_DECREF(result); return GIT_EUSER; } *type = (git_object_t)type_value; + *sz = (size_t)py_sz; *ptr = git_odb_backend_data_alloc(_be, *sz); if (!*ptr) { @@ -106,12 +108,14 @@ pgit_odb_backend_read_prefix(git_oid *oid_out, void **ptr, size_t *sz, git_objec // Parse output from callback PyObject *py_oid_out; Py_ssize_t type_value; + Py_ssize_t py_sz; const char *bytes; - if (!PyArg_ParseTuple(result, "ny#O", &type_value, &bytes, sz, &py_oid_out) || !bytes) { + if (!PyArg_ParseTuple(result, "ny#O", &type_value, &bytes, &py_sz, &py_oid_out) || !bytes) { Py_DECREF(result); return GIT_EUSER; } *type = (git_object_t)type_value; + *sz = (size_t)py_sz; *ptr = git_odb_backend_data_alloc(_be, *sz); if (!*ptr) { @@ -120,8 +124,10 @@ pgit_odb_backend_read_prefix(git_oid *oid_out, void **ptr, size_t *sz, git_objec } memcpy(*ptr, bytes, *sz); - py_oid_to_git_oid(py_oid_out, oid_out); + size_t oid_len = py_oid_to_git_oid(py_oid_out, oid_out); Py_DECREF(result); + if (oid_len == 0) + return GIT_EUSER; return 0; } @@ -200,8 +206,10 @@ pgit_odb_backend_exists_prefix(git_oid *out, git_odb_backend *_be, if (py_oid == NULL) return git_error_for_exc(); - py_oid_to_git_oid(py_oid, out); + size_t oid_len = py_oid_to_git_oid(py_oid, out); Py_DECREF(py_oid); + if (oid_len == 0) + return GIT_EUSER; return 0; } @@ -220,15 +228,33 @@ pgit_odb_backend_foreach(git_odb_backend *_be, PyObject *item; git_oid oid; pgit_odb_backend *be = (pgit_odb_backend *)_be; - PyObject *iterator = PyObject_GetIter((PyObject *)be->py_backend); - assert(iterator); + + /* Call the Python __iter__ method directly. PyObject_GetIter would invoke + * the C tp_iter slot (OdbBackend_as_iter), which calls this function back + * and causes infinite recursion for Python backends. */ + PyObject *iter_method = PyObject_GetAttrString((PyObject *)be->py_backend, "__iter__"); + if (iter_method == NULL) + return git_error_for_exc(); + + PyObject *iterator = PyObject_CallObject(iter_method, NULL); + Py_DECREF(iter_method); + if (iterator == NULL) + return git_error_for_exc(); while ((item = PyIter_Next(iterator))) { - py_oid_to_git_oid(item, &oid); - cb(&oid, payload); + size_t len = py_oid_to_git_oid(item, &oid); Py_DECREF(item); + if (len == 0) { + Py_DECREF(iterator); + return GIT_EUSER; + } + if (cb(&oid, payload) != 0) { + Py_DECREF(iterator); + return GIT_EUSER; + } } + Py_DECREF(iterator); return git_error_for_exc(); } @@ -255,6 +281,10 @@ OdbBackend_init(OdbBackend *self, PyObject *args, PyObject *kwds) // Create the C backend pgit_odb_backend *custom_backend = calloc(1, sizeof(pgit_odb_backend)); + if (custom_backend == NULL) { + PyErr_NoMemory(); + return -1; + } custom_backend->backend.version = GIT_ODB_BACKEND_VERSION; // Fill the member methods @@ -270,7 +300,7 @@ OdbBackend_init(OdbBackend *self, PyObject *args, PyObject *kwds) // custom_backend->backend.freshen = pgit_odb_backend_freshen; // custom_backend->backend.writestream = pgit_odb_backend_writestream; // custom_backend->backend.readstream = pgit_odb_backend_readstream; - if (PyIter_Check((PyObject *)self)) + if (PyObject_HasAttrString((PyObject *)self, "__iter__")) custom_backend->backend.foreach = pgit_odb_backend_foreach; // Cross reference (don't incref because it's something internal) @@ -313,20 +343,23 @@ PyObject * OdbBackend_as_iter(OdbBackend *self) { PyObject *accum = PyList_New(0); - PyObject *iter = NULL; + if (accum == NULL) + return NULL; int err = self->odb_backend->foreach(self->odb_backend, OdbBackend_build_as_iter, (void*)accum); + if (err == GIT_EUSER && PyErr_Occurred()) { + Py_DECREF(accum); + return NULL; + } if (err == GIT_EUSER) - goto exit; + err = GIT_ERROR; if (err < 0) { - Error_set(err); - goto exit; + Py_DECREF(accum); + return Error_set(err); } - iter = PyObject_GetIter(accum); - -exit: + PyObject *iter = PyObject_GetIter(accum); Py_DECREF(accum); return iter; } @@ -389,8 +422,9 @@ OdbBackend_read_prefix(OdbBackend *self, PyObject *py_hex) err = self->odb_backend->read_prefix(&oid_out, &data, &size, &type, self->odb_backend, &oid, len); if (err != 0) { - Error_set_oid(err, &oid, len); - return NULL; + if (err == GIT_EUSER && PyErr_Occurred()) + return NULL; + return Error_set_oid(err, &oid, len); } PyObject *py_oid_out = git_oid_to_python(&oid_out); @@ -484,8 +518,11 @@ OdbBackend_exists_prefix(OdbBackend *self, PyObject *py_hex) git_oid out; result = self->odb_backend->exists_prefix(&out, self->odb_backend, &oid, len); - if (result < 0) + if (result < 0) { + if (result == GIT_EUSER && PyErr_Occurred()) + return NULL; return Error_set(result); + } return git_oid_to_python(&out); } @@ -518,7 +555,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 15ec46c72..9a650ade5 100644 --- a/src/odb_backend.h +++ b/src/odb_backend.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/oid.c b/src/oid.c index ffce36a25..6183acd4e 100644 --- a/src/oid.c +++ b/src/oid.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/oid.h b/src/oid.h index 99ed3b868..9947e885a 100644 --- a/src/oid.h +++ b/src/oid.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/options.c b/src/options.c deleted file mode 100644 index 11711400b..000000000 --- a/src/options.c +++ /dev/null @@ -1,309 +0,0 @@ -/* - * Copyright 2010-2025 The pygit2 contributors - * - * This file is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License, version 2, - * as published by the Free Software Foundation. - * - * In addition to the permissions in the GNU General Public License, - * the authors give you unlimited permission to link the compiled - * version of this file into combinations with other programs, - * and to distribute those combinations without any restriction - * coming from the use of this file. (The General Public License - * restrictions do apply in other respects; for example, they cover - * modification of the file, and distribution when not linked into - * a combined executable.) - * - * This file is distributed in the hope that it will be useful, but - * WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; see the file COPYING. If not, write to - * the Free Software Foundation, 51 Franklin Street, Fifth Floor, - * Boston, MA 02110-1301, USA. - */ - -#define PY_SSIZE_T_CLEAN -#include -#include -#include "error.h" -#include "types.h" -#include "utils.h" - -extern PyObject *GitError; - -static PyObject * -get_search_path(long level) -{ - git_buf buf = {NULL}; - PyObject *py_path; - int err; - - err = git_libgit2_opts(GIT_OPT_GET_SEARCH_PATH, level, &buf); - if (err < 0) - return Error_set(err); - - py_path = to_unicode_n(buf.ptr, buf.size, NULL, NULL); - git_buf_dispose(&buf); - - if (!py_path) - return NULL; - - return py_path; -} - -PyObject * -option(PyObject *self, PyObject *args) -{ - long option; - int error; - PyObject *py_option; - - py_option = PyTuple_GetItem(args, 0); - if (!py_option) - return NULL; - - if (!PyLong_Check(py_option)) - return Error_type_error( - "option should be an integer, got %.200s", py_option); - - option = PyLong_AsLong(py_option); - - switch (option) { - - case GIT_OPT_GET_MWINDOW_FILE_LIMIT: - case GIT_OPT_GET_MWINDOW_MAPPED_LIMIT: - case GIT_OPT_GET_MWINDOW_SIZE: - { - size_t value; - - error = git_libgit2_opts(option, &value); - if (error < 0) - return Error_set(error); - - return PyLong_FromSize_t(value); - } - - case GIT_OPT_SET_MWINDOW_FILE_LIMIT: - case GIT_OPT_SET_MWINDOW_MAPPED_LIMIT: - case GIT_OPT_SET_MWINDOW_SIZE: - { - PyObject *py_value = PyTuple_GetItem(args, 1); - if (!py_value) - return NULL; - - if (!PyLong_Check(py_value)) - return Error_type_error("expected integer, got %.200s", py_value); - - size_t value = PyLong_AsSize_t(py_value); - error = git_libgit2_opts(option, value); - if (error < 0) - return Error_set(error); - - Py_RETURN_NONE; - } - - case GIT_OPT_GET_SEARCH_PATH: - { - PyObject *py_level = PyTuple_GetItem(args, 1); - if (!py_level) - return NULL; - - if (!PyLong_Check(py_level)) - return Error_type_error("level should be an integer, got %.200s", py_level); - - return get_search_path(PyLong_AsLong(py_level)); - } - - case GIT_OPT_SET_SEARCH_PATH: - { - PyObject *py_level = PyTuple_GetItem(args, 1); - if (!py_level) - return NULL; - - PyObject *py_path = PyTuple_GetItem(args, 2); - if (!py_path) - return NULL; - - if (!PyLong_Check(py_level)) - return Error_type_error("level should be an integer, got %.200s", py_level); - - const char *path = pgit_borrow(py_path); - if (!path) - return NULL; - - int err = git_libgit2_opts(option, PyLong_AsLong(py_level), path); - if (err < 0) - return Error_set(err); - - Py_RETURN_NONE; - } - - case GIT_OPT_SET_CACHE_OBJECT_LIMIT: - { - size_t limit; - int object_type; - PyObject *py_object_type, *py_limit; - - py_object_type = PyTuple_GetItem(args, 1); - if (!py_object_type) - return NULL; - - py_limit = PyTuple_GetItem(args, 2); - if (!py_limit) - return NULL; - - if (!PyLong_Check(py_limit)) - return Error_type_error( - "limit should be an integer, got %.200s", py_limit); - - object_type = PyLong_AsLong(py_object_type); - limit = PyLong_AsSize_t(py_limit); - error = git_libgit2_opts(option, object_type, limit); - - if (error < 0) - return Error_set(error); - - Py_RETURN_NONE; - } - - case GIT_OPT_SET_CACHE_MAX_SIZE: - { - size_t max_size; - PyObject *py_max_size; - - py_max_size = PyTuple_GetItem(args, 1); - if (!py_max_size) - return NULL; - - if (!PyLong_Check(py_max_size)) - return Error_type_error( - "max_size should be an integer, got %.200s", py_max_size); - - max_size = PyLong_AsSize_t(py_max_size); - error = git_libgit2_opts(option, max_size); - if (error < 0) - return Error_set(error); - - Py_RETURN_NONE; - } - - case GIT_OPT_GET_CACHED_MEMORY: - { - size_t current; - size_t allowed; - PyObject* tup = PyTuple_New(2); - - error = git_libgit2_opts(option, ¤t, &allowed); - if (error < 0) - return Error_set(error); - - PyTuple_SetItem(tup, 0, PyLong_FromLong(current)); - PyTuple_SetItem(tup, 1, PyLong_FromLong(allowed)); - - return tup; - } - - case GIT_OPT_GET_TEMPLATE_PATH: - case GIT_OPT_SET_TEMPLATE_PATH: - { - Py_INCREF(Py_NotImplemented); - return Py_NotImplemented; - } - - case GIT_OPT_SET_SSL_CERT_LOCATIONS: - { - PyObject *py_file, *py_dir; - char *file_path=NULL, *dir_path=NULL; - int err; - - py_file = PyTuple_GetItem(args, 1); - if (!py_file) - return NULL; - py_dir = PyTuple_GetItem(args, 2); - if (!py_dir) - return NULL; - - /* py_file and py_dir are only valid if they are strings */ - PyObject *tvalue_file = NULL; - if (PyUnicode_Check(py_file) || PyBytes_Check(py_file)) - file_path = pgit_borrow_fsdefault(py_file, &tvalue_file); - - PyObject *tvalue_dir = NULL; - if (PyUnicode_Check(py_dir) || PyBytes_Check(py_dir)) - dir_path = pgit_borrow_fsdefault(py_dir, &tvalue_dir); - - err = git_libgit2_opts(option, file_path, dir_path); - Py_XDECREF(tvalue_file); - Py_XDECREF(tvalue_dir); - - if (err) - return Error_set(err); - - Py_RETURN_NONE; - } - - case GIT_OPT_SET_USER_AGENT: - { - Py_INCREF(Py_NotImplemented); - return Py_NotImplemented; - } - - // int enabled - case GIT_OPT_ENABLE_CACHING: - case GIT_OPT_ENABLE_STRICT_OBJECT_CREATION: - case GIT_OPT_ENABLE_STRICT_SYMBOLIC_REF_CREATION: - case GIT_OPT_ENABLE_OFS_DELTA: - case GIT_OPT_ENABLE_FSYNC_GITDIR: - case GIT_OPT_ENABLE_STRICT_HASH_VERIFICATION: - case GIT_OPT_ENABLE_UNSAVED_INDEX_SAFETY: - case GIT_OPT_DISABLE_PACK_KEEP_FILE_CHECKS: - case GIT_OPT_SET_OWNER_VALIDATION: - { - PyObject *py_value = PyTuple_GetItem(args, 1); - if (!py_value) - return NULL; - - if (!PyLong_Check(py_value)) - return Error_type_error("expected integer, got %.200s", py_value); - - int value = PyLong_AsSize_t(py_value); - error = git_libgit2_opts(option, value); - if (error < 0) - return Error_set(error); - - Py_RETURN_NONE; - } - - // int enabled getter - case GIT_OPT_GET_OWNER_VALIDATION: - { - int enabled; - - error = git_libgit2_opts(option, &enabled); - if (error < 0) - return Error_set(error); - - return PyLong_FromLong(enabled); - } - - // Not implemented - case GIT_OPT_SET_SSL_CIPHERS: - case GIT_OPT_GET_USER_AGENT: - case GIT_OPT_GET_WINDOWS_SHAREMODE: - case GIT_OPT_SET_WINDOWS_SHAREMODE: - case GIT_OPT_SET_ALLOCATOR: - case GIT_OPT_GET_PACK_MAX_OBJECTS: - case GIT_OPT_SET_PACK_MAX_OBJECTS: - { - Py_INCREF(Py_NotImplemented); - return Py_NotImplemented; - } - - } - - PyErr_SetString(PyExc_ValueError, "unknown/unsupported option value"); - return NULL; -} diff --git a/src/options.h b/src/options.h deleted file mode 100644 index f8b9a08e1..000000000 --- a/src/options.h +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright 2010-2025 The pygit2 contributors - * - * This file is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License, version 2, - * as published by the Free Software Foundation. - * - * In addition to the permissions in the GNU General Public License, - * the authors give you unlimited permission to link the compiled - * version of this file into combinations with other programs, - * and to distribute those combinations without any restriction - * coming from the use of this file. (The General Public License - * restrictions do apply in other respects; for example, they cover - * modification of the file, and distribution when not linked into - * a combined executable.) - * - * This file is distributed in the hope that it will be useful, but - * WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; see the file COPYING. If not, write to - * the Free Software Foundation, 51 Franklin Street, Fifth Floor, - * Boston, MA 02110-1301, USA. - */ - -#ifndef INCLUDE_pygit2_blame_h -#define INCLUDE_pygit2_blame_h - -#define PY_SSIZE_T_CLEAN -#include -#include -#include "types.h" - -PyDoc_STRVAR(option__doc__, - "option(option, ...)\n" - "\n" - "Get or set a libgit2 option.\n" - "\n" - "Parameters:\n" - "\n" - "GIT_OPT_GET_SEARCH_PATH, level\n" - " Get the config search path for the given level.\n" - "\n" - "GIT_OPT_SET_SEARCH_PATH, level, path\n" - " Set the config search path for the given level.\n" - "\n" - "GIT_OPT_GET_MWINDOW_SIZE\n" - " Get the maximum mmap window size.\n" - "\n" - "GIT_OPT_SET_MWINDOW_SIZE, size\n" - " Set the maximum mmap window size.\n" - "\n" - "GIT_OPT_GET_MWINDOW_FILE_LIMIT\n" - " Get the maximum number of files that will be mapped at any time by the library.\n" - "\n" - "GIT_OPT_SET_MWINDOW_FILE_LIMIT, size\n" - " Set the maximum number of files that can be mapped at any time by the library. The default (0) is unlimited.\n" - "\n" - "GIT_OPT_GET_OWNER_VALIDATION\n" - " Gets the owner validation setting for repository directories.\n" - "\n" - "GIT_OPT_SET_OWNER_VALIDATION, enabled\n" - " Set that repository directories should be owned by the current user.\n" - " The default is to validate ownership.\n" - ); - - -PyObject *option(PyObject *self, PyObject *args); - -#endif diff --git a/src/patch.c b/src/patch.c index 256e7e0fe..0f10abfac 100644 --- a/src/patch.c +++ b/src/patch.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -76,7 +76,7 @@ PyObject * Patch_delta__get__(Patch *self) { assert(self->patch); - return wrap_diff_delta(git_patch_get_delta(self->patch)); + return wrap_diff_delta(git_patch_get_delta(self->patch), NULL, 0); } PyDoc_STRVAR(Patch_line_stats__doc__, @@ -223,10 +223,15 @@ Patch_hunks__get__(Patch *self) hunk_amounts = git_patch_num_hunks(self->patch); py_hunks = PyList_New(hunk_amounts); + if (py_hunks == NULL) + return NULL; + for (i = 0; i < hunk_amounts; i++) { py_hunk = wrap_diff_hunk(self, i); - if (py_hunk == NULL) + if (py_hunk == NULL) { + Py_DECREF(py_hunks); return NULL; + } PyList_SET_ITEM((PyObject*) py_hunks, i, py_hunk); } @@ -235,7 +240,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 3c0ad759f..691b13f21 100644 --- a/src/patch.h +++ b/src/patch.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/pygit2.c b/src/pygit2.c index ca4739680..7cc22f393 100644 --- a/src/pygit2.c +++ b/src/pygit2.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -35,12 +35,16 @@ #include "utils.h" #include "repository.h" #include "oid.h" -#include "options.h" #include "filter.h" PyObject *GitError; PyObject *AlreadyExistsError; PyObject *InvalidSpecError; +PyObject *InvalidError; +PyObject *NotFoundError; +PyObject *AmbiguousError; +PyObject *AuthError; +PyObject *CertificateError; PyObject *DeltaStatusEnum; PyObject *DiffFlagEnum; @@ -48,6 +52,7 @@ PyObject *FileModeEnum; PyObject *FileStatusEnum; PyObject *MergeAnalysisEnum; PyObject *MergePreferenceEnum; +PyObject *ObjectTypeEnum; PyObject *ReferenceTypeEnum; extern PyTypeObject RepositoryType; @@ -305,72 +310,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 +366,7 @@ forget_enums(void) Py_CLEAR(FileStatusEnum); Py_CLEAR(MergeAnalysisEnum); Py_CLEAR(MergePreferenceEnum); + Py_CLEAR(ObjectTypeEnum); Py_CLEAR(ReferenceTypeEnum); } @@ -415,10 +402,12 @@ _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 + Py_DECREF(enums); Py_RETURN_NONE; fail: @@ -434,16 +423,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,50 +454,26 @@ 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); - ADD_EXC(m, AlreadyExistsError, PyExc_ValueError); - ADD_EXC(m, InvalidSpecError, PyExc_ValueError); + ADD_EXC2(m, AlreadyExistsError, GitError, PyExc_ValueError); + ADD_EXC2(m, InvalidSpecError, GitError, PyExc_ValueError); + ADD_EXC2(m, InvalidError, GitError, PyExc_ValueError); + ADD_EXC2(m, NotFoundError, GitError, PyExc_KeyError); + ADD_EXC2(m, AmbiguousError, GitError, PyExc_ValueError); + ADD_EXC(m, AuthError, GitError); + ADD_EXC(m, CertificateError, GitError); /* Repository */ INIT_TYPE(RepositoryType, NULL, PyType_GenericNew) diff --git a/src/refdb.c b/src/refdb.c index 5558d47ac..8036a49d2 100644 --- a/src/refdb.c +++ b/src/refdb.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -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 984423542..57df647d0 100644 --- a/src/refdb.h +++ b/src/refdb.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/refdb_backend.c b/src/refdb_backend.c index 6f6b0da08..28cba7396 100644 --- a/src/refdb_backend.c +++ b/src/refdb_backend.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -36,6 +36,8 @@ #include "wildmatch.h" #include #include +#include +#include extern PyTypeObject ReferenceType; extern PyTypeObject RepositoryType; @@ -65,21 +67,82 @@ struct pygit2_refdb_backend struct pygit2_refdb_iterator { struct git_reference_iterator base; PyObject *iterator; + PyObject *current; /* keeps the last Reference yielded by next_name alive */ char *glob; }; +// Copy a reference that does not belong to a refdb yet. git_reference_dup() +// cannot be used here because it requires the db field to be set, which +// libgit2 only does after the backend callback returns. The copy does not +// preserve the cached peel of direct references, which is only a hint. +static git_reference * +copy_reference(const git_reference *ref) +{ + if (git_reference_type(ref) == GIT_REF_SYMBOLIC) { + return git_reference__alloc_symbolic(git_reference_name(ref), + git_reference_symbolic_target(ref)); + } + return git_reference__alloc(git_reference_name(ref), + git_reference_target(ref), NULL); +} + +// Transfer the reference returned by a backend callback to libgit2, which +// takes ownership and sets its db field itself once the callback returns. +// If the callback returned a fresh object (refcount 1), detach the pointer +// from the Python object before releasing it; if it returned a shared +// object (e.g. one the backend caches and returns on every call), leave it +// intact and give libgit2 a copy instead. +// Consumes the reference to result; returns 0 or a libgit2 error code. +static int +transfer_reference(git_reference **out, Reference *result) +{ + if (result->reference == NULL) { + PyErr_SetString(PyExc_ValueError, "Reference object is no longer valid"); + Py_DECREF(result); + return GIT_EUSER; + } + + if (Py_REFCNT((PyObject *)result) == 1) { + *out = result->reference; + result->reference = NULL; + Py_DECREF(result); + return 0; + } + + *out = copy_reference(result->reference); + Py_DECREF(result); + if (*out == NULL) { + git_error_set(GIT_ERROR_NOMEMORY, "out of memory"); + return GIT_ERROR; + } + return 0; +} + +// Returns the next valid Reference from the Python iterator, or NULL when +// the iterator is exhausted or an error occurred; check PyErr_Occurred() +// to tell the two cases apart. static Reference * iterator_get_next(struct pygit2_refdb_iterator *iter) { Reference *ref; while ((ref = (Reference *)PyIter_Next(iter->iterator)) != NULL) { - if (!iter->glob) { - return ref; + if (!PyObject_IsInstance((PyObject *)ref, (PyObject *)&ReferenceType)) { + PyErr_SetString(PyExc_TypeError, + "RefdbBackend iterator must yield References"); + Py_DECREF(ref); + return NULL; } - const char *name = git_reference_name(ref->reference); - if (wildmatch(iter->glob, name, 0) != WM_NOMATCH) { + if (ref->reference == NULL) { + PyErr_SetString(PyExc_ValueError, + "Reference object is no longer valid"); + Py_DECREF(ref); + return NULL; + } + if (!iter->glob || + wildmatch(iter->glob, git_reference_name(ref->reference), 0) != WM_NOMATCH) { return ref; } + Py_DECREF(ref); } return NULL; } @@ -90,16 +153,12 @@ pygit2_refdb_iterator_next(git_reference **out, git_reference_iterator *_iter) struct pygit2_refdb_iterator *iter = (struct pygit2_refdb_iterator *)_iter; Reference *ref = iterator_get_next(iter); if (ref == NULL) { + if (PyErr_Occurred()) + return GIT_EUSER; *out = NULL; return GIT_ITEROVER; } - if (!PyObject_IsInstance((PyObject *)ref, (PyObject *)&ReferenceType)) { - PyErr_SetString(PyExc_TypeError, - "RefdbBackend iterator must yield References"); - return GIT_EUSER; - } - *out = ref->reference; - return 0; + return transfer_reference(out, ref); } static int @@ -108,14 +167,15 @@ pygit2_refdb_iterator_next_name(const char **ref_name, git_reference_iterator *_ struct pygit2_refdb_iterator *iter = (struct pygit2_refdb_iterator *)_iter; Reference *ref = iterator_get_next(iter); if (ref == NULL) { + if (PyErr_Occurred()) + return GIT_EUSER; *ref_name = NULL; return GIT_ITEROVER; } - if (!PyObject_IsInstance((PyObject *)ref, (PyObject *)&ReferenceType)) { - PyErr_SetString(PyExc_TypeError, - "RefdbBackend iterator must yield References"); - return GIT_EUSER; - } + // The name is borrowed from the Reference; keep the object alive until + // the next call or until the iterator is freed. + Py_XDECREF(iter->current); + iter->current = (PyObject *)ref; *ref_name = git_reference_name(ref->reference); return 0; } @@ -124,6 +184,7 @@ static void pygit2_refdb_iterator_free(git_reference_iterator *_iter) { struct pygit2_refdb_iterator *iter = (struct pygit2_refdb_iterator *)_iter; + Py_CLEAR(iter->current); Py_DECREF(iter->iterator); free(iter->glob); } @@ -137,14 +198,23 @@ pygit2_refdb_backend_iterator(git_reference_iterator **iter, PyObject *iterator = PyObject_GetIter((PyObject *)be->RefdbBackend); assert(iterator); - struct pygit2_refdb_iterator *pyiter = - calloc(1, sizeof(struct pygit2_refdb_iterator)); + struct pygit2_refdb_iterator *pyiter = calloc(1, sizeof(struct pygit2_refdb_iterator)); + if (pyiter == NULL) { + Py_DECREF(iterator); + git_error_set(GIT_ERROR_NOMEMORY, "out of memory"); + return GIT_ERROR; + } + if (glob && (pyiter->glob = strdup(glob)) == NULL) { + Py_DECREF(iterator); + free(pyiter); + git_error_set(GIT_ERROR_NOMEMORY, "out of memory"); + return GIT_ERROR; + } *iter = (git_reference_iterator *)pyiter; pyiter->iterator = iterator; pyiter->base.next = pygit2_refdb_iterator_next; pyiter->base.next_name = pygit2_refdb_iterator_next_name; pyiter->base.free = pygit2_refdb_iterator_free; - pyiter->glob = strdup(glob); return 0; } @@ -167,8 +237,8 @@ pygit2_refdb_backend_exists(int *exists, *exists = PyObject_IsTrue(result); out: - Py_DECREF(result); - return 0; + Py_XDECREF(result); + return err; } static int @@ -185,18 +255,24 @@ pygit2_refdb_backend_lookup(git_reference **out, result = (Reference *)PyObject_CallObject(be->lookup, args); Py_DECREF(args); - if ((err = git_error_for_exc()) != 0) - goto out; + if ((err = git_error_for_exc()) != 0) { + Py_XDECREF(result); + return err; + } + + // A lookup that finds nothing returns None, per RefdbBackend.lookup() + if ((PyObject *)result == Py_None) { + Py_DECREF(result); + return GIT_ENOTFOUND; + } if (!PyObject_IsInstance((PyObject *)result, (PyObject *)&ReferenceType)) { PyErr_SetString(PyExc_TypeError, "Expected object of type pygit2.Reference"); - err = GIT_EUSER; - goto out; + Py_DECREF(result); + return GIT_EUSER; } - *out = result->reference; -out: - return err; + return transfer_reference(out, result); } static int @@ -205,33 +281,60 @@ pygit2_refdb_backend_write(git_refdb_backend *_be, const git_signature *_who, const char *message, const git_oid *_old, const char *old_target) { - int err; - PyObject *args = NULL, *ref = NULL, *who = NULL, *old = NULL; struct pygit2_refdb_backend *be = (struct pygit2_refdb_backend *)_be; - // XXX: Drops const - if ((ref = wrap_reference((git_reference *)_ref, NULL)) == NULL) - goto euser; - if ((who = build_signature(NULL, _who, "utf-8")) == NULL) - goto euser; - if ((old = git_oid_to_python(_old)) == NULL) - goto euser; - if ((args = Py_BuildValue("(NNNsNs)", ref, - force ? Py_True : Py_False, - who, message, old, old_target)) == NULL) - goto euser; + // The Python objects take ownership of the reference and the signature, + // so pass them copies; _ref and _who belong to the caller (libgit2). + git_reference *reference; + int err = git_reference_dup(&reference, (git_reference *)_ref); // XXX: Drops const + if (err != 0) { + return err; + } + + PyObject *ref = wrap_reference(reference, NULL); + if (ref == NULL) { + git_reference_free(reference); + return GIT_EUSER; + } + + git_signature *signature; + err = git_signature_dup(&signature, _who); + if (err != 0) { + Py_DECREF(ref); + return err; + } + + PyObject *who = build_signature(NULL, signature, "utf-8"); + if (who == NULL) { + Py_DECREF(ref); + return GIT_EUSER; + } + + PyObject *old; + if (_old == NULL) { + old = Py_None; + Py_INCREF(old); + } else { + old = git_oid_to_python(_old); + if (old == NULL) { + Py_DECREF(ref); + Py_DECREF(who); + return GIT_EUSER; + } + } + + // Py_BuildValue takes ownership of ref, who and old (N format), even on failure, so + // they must not be decref'd past this point. + PyObject *args = Py_BuildValue("(NNNsNs)", ref, PyBool_FromLong(force), who, message, + old, old_target); + if (args == NULL) { + return GIT_EUSER; + } PyObject_CallObject(be->write, args); err = git_error_for_exc(); -out: - Py_DECREF(ref); - Py_DECREF(who); - Py_DECREF(old); Py_DECREF(args); return err; -euser: - err = GIT_EUSER; - goto out; } static int @@ -239,32 +342,45 @@ pygit2_refdb_backend_rename(git_reference **out, git_refdb_backend *_be, const char *old_name, const char *new_name, int force, const git_signature *_who, const char *message) { - int err; - PyObject *args, *who; struct pygit2_refdb_backend *be = (struct pygit2_refdb_backend *)_be; - if ((who = build_signature(NULL, _who, "utf-8")) != NULL) + // The Python object takes ownership of the signature, so pass it a copy; + // _who belongs to the caller (libgit2). + git_signature *signature; + int err = git_signature_dup(&signature, _who); + if (err != 0) { + return err; + } + + PyObject *who = build_signature(NULL, signature, "utf-8"); + if (who == NULL) { return GIT_EUSER; - if ((args = Py_BuildValue("(ssNNs)", old_name, new_name, - force ? Py_True : Py_False, who, message)) == NULL) { - Py_DECREF(who); + } + + // Py_BuildValue takes ownership of who (N format), even on failure, so + // it must not be decref'd past this point. + PyObject *args = Py_BuildValue("(ssNNs)", old_name, new_name, + PyBool_FromLong(force), who, message); + if (args == NULL) { return GIT_EUSER; } + Reference *ref = (Reference *)PyObject_CallObject(be->rename, args); - Py_DECREF(who); Py_DECREF(args); - if ((err = git_error_for_exc()) != 0) + err = git_error_for_exc(); + if (err != 0) { + Py_XDECREF(ref); return err; + } if (!PyObject_IsInstance((PyObject *)ref, (PyObject *)&ReferenceType)) { PyErr_SetString(PyExc_TypeError, "Expected object of type pygit2.Reference"); + Py_DECREF(ref); return GIT_EUSER; } - git_reference_dup(out, ref->reference); - Py_DECREF(ref); - return 0; + return transfer_reference(out, ref); } static int @@ -307,6 +423,7 @@ pygit2_refdb_backend_has_log(git_refdb_backend *_be, const char *refname) Py_DECREF(args); if ((err = git_error_for_exc()) != 0) { + Py_XDECREF(result); return err; } @@ -333,6 +450,7 @@ pygit2_refdb_backend_ensure_log(git_refdb_backend *_be, const char *refname) Py_DECREF(args); if ((err = git_error_for_exc()) != 0) { + Py_XDECREF(result); return err; } @@ -389,18 +507,20 @@ int RefdbBackend_init(RefdbBackend *self, PyObject *args, PyObject *kwds) { if (args && PyTuple_Size(args) > 0) { - PyErr_SetString(PyExc_TypeError, - "RefdbBackend takes no arguments"); + PyErr_SetString(PyExc_TypeError, "RefdbBackend takes no arguments"); return -1; } if (kwds && PyDict_Size(kwds) > 0) { - PyErr_SetString(PyExc_TypeError, - "RefdbBackend takes no keyword arguments"); + PyErr_SetString(PyExc_TypeError, "RefdbBackend takes no keyword arguments"); return -1; } struct pygit2_refdb_backend *be = calloc(1, sizeof(struct pygit2_refdb_backend)); + if (be == NULL) { + PyErr_NoMemory(); + return -1; + } git_refdb_init_backend(&be->backend, GIT_REFDB_BACKEND_VERSION); be->RefdbBackend = (PyObject *)self; @@ -591,7 +711,8 @@ RefdbBackend_write(RefdbBackend *self, PyObject *args) return NULL; if ((PyObject *)py_old != Py_None) { - py_oid_to_git_oid(py_old, &_old); + if (py_oid_to_git_oid(py_old, &_old) == 0) + return NULL; old = &_old; } @@ -613,7 +734,7 @@ RefdbBackend_write(RefdbBackend *self, PyObject *args) } PyDoc_STRVAR(RefdbBackend_rename__doc__, - "rename(old_name: str, new_name: str, force: bool, who: Signature, message: str) -> Reference\n" + "rename(old_name: str, new_name: str, force: bool, who: Signature, message: str | None) -> Reference\n" "\n" "Renames a reference."); @@ -631,7 +752,7 @@ RefdbBackend_rename(RefdbBackend *self, PyObject *args) return Py_NotImplemented; } - if (!PyArg_ParseTuple(args, "sspO!s", &old_name, &new_name, + if (!PyArg_ParseTuple(args, "sspO!z", &old_name, &new_name, &force, &SignatureType, &who, &message)) return NULL; @@ -665,7 +786,8 @@ RefdbBackend_delete(RefdbBackend *self, PyObject *args) return NULL; if (py_old_id != Py_None) { - py_oid_to_git_oid(py_old_id, &old_id); + if (py_oid_to_git_oid(py_old_id, &old_id) == 0) + return NULL; err = self->refdb_backend->del(self->refdb_backend, ref_name, &old_id, old_target); } else { @@ -772,7 +894,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 c6a5ff366..b976de3e7 100644 --- a/src/refdb_backend.h +++ b/src/refdb_backend.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/reference.c b/src/reference.c index b535cbe48..be1cae2bc 100644 --- a/src/reference.c +++ b/src/reference.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -165,12 +165,15 @@ Reference_init(Reference *self, PyObject *args, PyObject *kwds) return -1; } - py_oid_to_git_oid(py_oid, &oid); + if (py_oid_to_git_oid(py_oid, &oid) == 0) + return -1; if (py_peel != Py_None) { - py_oid_to_git_oid(py_peel, &peel); + if (py_oid_to_git_oid(py_peel, &peel) == 0) + return -1; } - self->reference = git_reference__alloc(name, &oid, &peel); + self->reference = git_reference__alloc(name, &oid, + py_peel == Py_None ? NULL : &peel); return 0; } @@ -658,7 +661,7 @@ PyTypeObject RefLogEntryType = { 0, /* tp_new */ }; -PyMethodDef Reference_methods[] = { +static PyMethodDef Reference_methods[] = { METHOD(Reference, delete, METH_NOARGS), METHOD(Reference, rename, METH_O), METHOD(Reference, resolve, METH_NOARGS), diff --git a/src/reference.h b/src/reference.h index 909cbf30a..dc754742d 100644 --- a/src/reference.h +++ b/src/reference.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/repository.c b/src/repository.c index 4be614bf7..d3e50fce7 100644 --- a/src/repository.c +++ b/src/repository.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -25,9 +25,9 @@ * Boston, MA 02110-1301, USA. */ -#include #define PY_SSIZE_T_CLEAN #include +#include #include "error.h" #include "types.h" #include "reference.h" @@ -46,6 +46,17 @@ #include #include +// TODO: remove this function when Python 3.13 becomes the minimum supported version +#if PY_MAJOR_VERSION == 3 && PY_MINOR_VERSION < 13 +static inline PyObject * +PyList_GetItemRef(PyObject *op, Py_ssize_t index) +{ + PyObject *item = PyList_GetItem(op, index); + Py_XINCREF(item); + return item; +} +#endif + extern PyObject *GitError; extern PyTypeObject IndexType; @@ -599,8 +610,11 @@ merge_base_xxx(Repository *self, PyObject *args, git_merge_base_xxx_t git_merge_ } for (; i < commit_oid_count; i++) { - py_commit_oid = PyList_GET_ITEM(py_commit_oids, i); + py_commit_oid = PyList_GetItemRef(py_commit_oids, i); + if (py_commit_oid == NULL) + goto out; err = py_oid_to_git_oid_expand(self->repo, py_commit_oid, &commit_oids[i]); + Py_DECREF(py_commit_oid); if (err < 0) goto out; } @@ -1052,8 +1066,11 @@ Repository_create_commit(Repository *self, PyObject *args) goto out; } for (; i < parent_count; i++) { - py_parent = PyList_GET_ITEM(py_parents, i); + py_parent = PyList_GetItemRef(py_parents, i); + if (py_parent == NULL) + goto out; len = py_oid_to_git_oid(py_parent, &oid); + Py_DECREF(py_parent); if (len == 0) goto out; err = git_commit_lookup_prefix(&parents[i], self->repo, &oid, len); @@ -1135,8 +1152,11 @@ Repository_create_commit_string(Repository *self, PyObject *args) goto out; } for (; i < parent_count; i++) { - py_parent = PyList_GET_ITEM(py_parents, i); + py_parent = PyList_GetItemRef(py_parents, i); + if (py_parent == NULL) + goto out; len = py_oid_to_git_oid(py_parent, &oid); + Py_DECREF(py_parent); if (len == 0) goto out; err = git_commit_lookup_prefix(&parents[i], self->repo, &oid, len); @@ -1196,7 +1216,7 @@ Repository_create_commit_with_signature(Repository *self, PyObject *args) } PyDoc_STRVAR(Repository_create_tag__doc__, - "create_tag(name: str, oid: Oid, type: enums.ObjectType, tagger: Signature[, message: str]) -> Oid\n" + "create_tag(name: str, oid: Oid, type: enums.ObjectType, tagger: Signature, message: str) -> Oid\n" "\n" "Create a new tag object, return its oid."); @@ -1418,8 +1438,10 @@ Repository_listall_branches_impl(Repository *self, PyObject *args, PyObject *(*i if (list == NULL) return NULL; - if ((err = git_branch_iterator_new(&iter, self->repo, list_flags)) < 0) + if ((err = git_branch_iterator_new(&iter, self->repo, list_flags)) < 0) { + Py_DECREF(list); return Error_set(err); + } while ((err = git_branch_next(&ref, &type, iter)) == 0) { PyObject *py_branch_name = item_trans(git_reference_shorthand(ref)); @@ -1777,7 +1799,14 @@ Repository_status(Repository *self, PyObject *args, PyObject *kw) if (status == NULL) goto error; - err = PyDict_SetItemString(dict, path, status); + PyObject *py_path = PyUnicode_DecodeFSDefault(path); + if (py_path == NULL) { + Py_CLEAR(status); + goto error; + } + + err = PyDict_SetItem(dict, py_path, status); + Py_DECREF(py_path); Py_CLEAR(status); if (err < 0) @@ -2403,7 +2432,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 059d774a5..4af7dd819 100755 --- a/src/repository.h +++ b/src/repository.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/revspec.c b/src/revspec.c index 64e462bda..d7365f2f8 100644 --- a/src/revspec.c +++ b/src/revspec.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/revspec.h b/src/revspec.h index 2f80af913..b769b6c0d 100644 --- a/src/revspec.h +++ b/src/revspec.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/signature.c b/src/signature.c index f384bd7d0..4e1e75b5c 100644 --- a/src/signature.c +++ b/src/signature.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/signature.h b/src/signature.h index 9c646d86c..5366c560d 100644 --- a/src/signature.h +++ b/src/signature.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/stash.c b/src/stash.c index e60dcb8b5..75932f5b7 100644 --- a/src/stash.c +++ b/src/stash.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/tag.c b/src/tag.c index 0d42f0f56..6601a35c9 100644 --- a/src/tag.c +++ b/src/tag.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -142,7 +142,7 @@ Tag_raw_message__get__(Tag *self) return PyBytes_FromString(message); } -PyMethodDef Tag_methods[] = { +static PyMethodDef Tag_methods[] = { METHOD(Tag, get_object, METH_NOARGS), {NULL} }; diff --git a/src/tree.c b/src/tree.c index ef40e275f..4b574fc69 100644 --- a/src/tree.c +++ b/src/tree.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -308,7 +308,7 @@ Tree_diff_to_index(Tree *self, PyObject *args, PyObject *kwds) index = *((git_index **) buffer); /* the "buffer" contains the pointer */ /* Call git_diff_tree_to_index */ - if (Object__load((Object*)self) == NULL) { return NULL; } // Lazy load + if (Object__load((Object*)self) == NULL) { goto error; } // Lazy load err = git_diff_tree_to_index(&diff, self->repo->repo, self->tree, index, &opts); Py_DECREF(py_idx_ptr); @@ -405,7 +405,7 @@ PyMappingMethods Tree_as_mapping = { 0, /* mp_ass_subscript */ }; -PyMethodDef Tree_methods[] = { +static PyMethodDef Tree_methods[] = { METHOD(Tree, diff_to_tree, METH_VARARGS | METH_KEYWORDS), METHOD(Tree, diff_to_workdir, METH_VARARGS | METH_KEYWORDS), METHOD(Tree, diff_to_index, METH_VARARGS | METH_KEYWORDS), diff --git a/src/tree.h b/src/tree.h index f7866695a..d4b241403 100644 --- a/src/tree.h +++ b/src/tree.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/treebuilder.c b/src/treebuilder.c index 8c47477b8..ddcb67572 100644 --- a/src/treebuilder.c +++ b/src/treebuilder.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -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 5a6c82427..cd775a61f 100644 --- a/src/treebuilder.h +++ b/src/treebuilder.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/types.h b/src/types.h index 24a66aa10..afdf8d096 100644 --- a/src/types.h +++ b/src/types.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -182,6 +182,8 @@ typedef struct { uint16_t nfiles; PyObject *old_file; PyObject *new_file; + Diff *diff; + size_t idx; } DiffDelta; typedef struct { diff --git a/src/utils.c b/src/utils.c index 614b9770b..f013183a3 100644 --- a/src/utils.c +++ b/src/utils.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -151,6 +151,35 @@ pgit_borrow(PyObject *value) } +char* +pgit_strdup(PyObject *value) +{ + const char *str; + char *copy; + size_t len; + + if (PyUnicode_Check(value)) { + str = PyUnicode_AsUTF8(value); + if (str == NULL) + return NULL; + len = strlen(str); + } + else { + Error_type_error("unexpected %.200s", value); + return NULL; + } + + copy = malloc(len + 1); + if (copy == NULL) { + PyErr_NoMemory(); + return NULL; + } + + memcpy(copy, str, len + 1); + return copy; +} + + static git_otype py_type_to_git_type(PyTypeObject *py_type) { diff --git a/src/utils.h b/src/utils.h index c1b8989dd..560a84677 100644 --- a/src/utils.h +++ b/src/utils.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -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); @@ -127,23 +128,6 @@ PyObject *pygit2_enum(PyObject *enum_type, int value); {#attr, attr_type, offsetof(type, attr), READONLY, PyDoc_STR(docstr)} -/* Helpers for memory allocation */ -#define CALLOC(ptr, num, size, label) \ - ptr = calloc((num), size);\ - if (ptr == NULL) {\ - err = GIT_ERROR;\ - giterr_set_oom();\ - goto label;\ - } - -#define MALLOC(ptr, size, label) \ - ptr = malloc(size);\ - if (ptr == NULL) {\ - err = GIT_ERROR;\ - giterr_set_oom();\ - goto label;\ - } - /* Helpers to make type init shorter. */ #define INIT_TYPE(type, base, new) \ type.tp_base = base; \ @@ -164,6 +148,19 @@ PyObject *pygit2_enum(PyObject *enum_type, int value); goto fail;\ } +#define ADD_EXC2(m, name, base1, base2) {\ + PyObject *bases = PyTuple_Pack(2, base1, base2);\ + if (bases == NULL) goto fail;\ + name = PyErr_NewException("_pygit2." #name, bases, NULL);\ + Py_DECREF(bases);\ + if (name == NULL) goto fail;\ + Py_INCREF(name);\ + if (PyModule_AddObject(m, #name, name)) {\ + Py_DECREF(name);\ + goto fail;\ + }\ +} + #define ADD_CONSTANT_INT(m, name) \ if (PyModule_AddIntConstant(m, #name, name) == -1) return NULL; diff --git a/src/walker.c b/src/walker.c index b4967a33f..e09482709 100644 --- a/src/walker.c +++ b/src/walker.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -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 75b3afc92..3d0d296aa 100644 --- a/src/walker.h +++ b/src/walker.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/worktree.c b/src/worktree.c index 2ed3772fc..2168d2844 100644 --- a/src/worktree.c +++ b/src/worktree.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -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 198f7b076..1f407b1b6 100644 --- a/src/worktree.h +++ b/src/worktree.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2025 The pygit2 contributors + * Copyright 2010-2026 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/test/__init__.py b/test/__init__.py index 7fb15c4fc..137e4a942 100644 --- a/test/__init__.py +++ b/test/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -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 1c6d7b8f5..69d3e6389 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 87e766ddb..b1761fbe1 100644 --- a/test/test_apply_diff.py +++ b/test/test_apply_diff.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -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 7e2454f10..be0fb9cba 100644 --- a/test/test_archive.py +++ b/test/test_archive.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -23,18 +23,23 @@ # 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): - archive = tarfile.open('foo.tar', mode='w') +def check_writing( + repo: Repository, + treeish: str | Tree | Oid | Object, + tmp_path: Path, + timestamp: int | None = None, +) -> None: + archive_path = tmp_path / 'foo.tar' + archive = tarfile.open(archive_path, mode='w') repo.write_archive(treeish, archive) index = Index() @@ -50,19 +55,17 @@ def check_writing(repo, treeish, timestamp=None): assert timestamp == fileinfo.mtime archive.close() - path = Path('foo.tar') - assert path.is_file() - path.unlink() + assert archive_path.is_file() -def test_write_tree(testrepo): - check_writing(testrepo, TREE_HASH) - check_writing(testrepo, Oid(hex=TREE_HASH)) - check_writing(testrepo, testrepo[TREE_HASH]) +def test_write_tree(testrepo: Repository, tmp_path: Path) -> None: + check_writing(testrepo, TREE_HASH, tmp_path) + check_writing(testrepo, Oid(hex=TREE_HASH), tmp_path) + check_writing(testrepo, testrepo[TREE_HASH], tmp_path) -def test_write_commit(testrepo): +def test_write_commit(testrepo: Repository, tmp_path: Path) -> None: commit_timestamp = testrepo[COMMIT_HASH].committer.time - check_writing(testrepo, COMMIT_HASH, commit_timestamp) - check_writing(testrepo, Oid(hex=COMMIT_HASH), commit_timestamp) - check_writing(testrepo, testrepo[COMMIT_HASH], commit_timestamp) + check_writing(testrepo, COMMIT_HASH, tmp_path, commit_timestamp) + check_writing(testrepo, Oid(hex=COMMIT_HASH), tmp_path, commit_timestamp) + check_writing(testrepo, testrepo[COMMIT_HASH], tmp_path, commit_timestamp) diff --git a/test/test_attributes.py b/test/test_attributes.py index 00ac91add..cb2564a5a 100644 --- a/test/test_attributes.py +++ b/test/test_attributes.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -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 251f7e6db..929da2523 100644 --- a/test/test_blame.py +++ b/test/test_blame.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -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 c9025f498..fdc78a2ac 100644 --- a/test/test_blob.py +++ b/test/test_blob.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -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.enums import ObjectType -from . import utils +from pygit2 import Repository +from pygit2.enums import BlobFilter, ObjectType +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,59 @@ 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] + + +def test_blob_write_to_queue_invalid_commit_id_type(testrepo: Repository) -> None: + # Regression test (issue #1478): an invalid commit_id type must raise + # TypeError instead of being ignored and leaving an exception set. + queue: Queue[bytes] = Queue() + ready = Event() + done = Event() + blob_oid = testrepo.create_blob_fromworkdir('bye.txt') + blob = testrepo[blob_oid] + assert isinstance(blob, pygit2.Blob) + with pytest.raises(TypeError): + blob._write_to_queue( + queue, + ready, + done, + as_path='bye.txt', + flags=BlobFilter.ATTRIBUTES_FROM_COMMIT, + commit_id=1234, # type: ignore + ) + + +def test_blob_write_to_queue_invalid_commit_id_str(testrepo: Repository) -> None: + # Regression test (issue #1478): a malformed commit_id string must raise + # InvalidError instead of being ignored and leaving an exception set. + queue: Queue[bytes] = Queue() + ready = Event() + done = Event() + blob_oid = testrepo.create_blob_fromworkdir('bye.txt') + blob = testrepo[blob_oid] + assert isinstance(blob, pygit2.Blob) + with pytest.raises(pygit2.InvalidError): + blob._write_to_queue( + queue, + ready, + done, + as_path='bye.txt', + flags=BlobFilter.ATTRIBUTES_FROM_COMMIT, + commit_id='not-a-valid-oid', # type: ignore[arg-type] + ) diff --git a/test/test_branch.py b/test/test_branch.py index 1128a1b1d..14cb965f7 100644 --- a/test/test_branch.py +++ b/test/test_branch.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -25,12 +25,13 @@ """Tests for branch methods.""" -import pygit2 -import pytest import os -from pygit2.enums import BranchType -from pygit2 import Repository +import pytest + +import pygit2 +from pygit2 import Commit, Repository +from pygit2.enums import BranchType LAST_COMMIT = '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98' I18N_LAST_COMMIT = '5470a671a80ac3789f1a6a8cefbcf43ce7af0563' @@ -57,6 +58,7 @@ def test_branches(testrepo: Repository) -> None: def test_branches_create(testrepo: Repository) -> None: commit = testrepo[LAST_COMMIT] + assert isinstance(commit, Commit) reference = testrepo.branches.create('version1', commit) assert 'version1' in testrepo.branches reference = testrepo.branches['version1'] @@ -141,7 +143,9 @@ def test_branches_with_commit(testrepo: Repository) -> None: branches = testrepo.branches.with_commit(LAST_COMMIT) assert sorted(branches) == ['master'] - branches = testrepo.branches.with_commit(testrepo[LAST_COMMIT]) + commit = testrepo[LAST_COMMIT] + assert isinstance(commit, Commit) + branches = testrepo.branches.with_commit(commit) assert sorted(branches) == ['master'] branches = testrepo.branches.remote.with_commit(LAST_COMMIT) @@ -153,7 +157,7 @@ def test_branches_with_commit(testrepo: Repository) -> None: # -def test_lookup_branch_local(testrepo): +def test_lookup_branch_local(testrepo: Repository) -> None: assert testrepo.lookup_branch('master').target == LAST_COMMIT assert testrepo.lookup_branch(b'master').target == LAST_COMMIT @@ -166,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 @@ -190,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 c1d079705..b0ef793cf 100644 --- a/test/test_branch_empty.py +++ b/test/test_branch_empty.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -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 136e7d26d..d7417ab75 100644 --- a/test/test_cherrypick.py +++ b/test/test_cherrypick.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -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 8967e5cda..c4d38d7a9 100644 --- a/test/test_commit.py +++ b/test/test_commit.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -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 = ( @@ -41,7 +41,7 @@ @utils.requires_refcount -def test_commit_refcount(barerepo): +def test_commit_refcount(barerepo: Repository) -> None: commit = barerepo[COMMIT_SHA] start = sys.getrefcount(commit) tree = commit.tree @@ -50,8 +50,9 @@ def test_commit_refcount(barerepo): assert start == end -def test_read_commit(barerepo): +def test_read_commit(barerepo: Repository) -> None: commit = barerepo[COMMIT_SHA] + assert isinstance(commit, Commit) assert COMMIT_SHA == commit.id parents = commit.parents assert 1 == len(parents) @@ -71,7 +72,7 @@ def test_read_commit(barerepo): assert '967fce8df97cc71722d3c2a5930ef3e6f1d27b12' == commit.tree.id -def test_new_commit(barerepo): +def test_new_commit(barerepo: Repository) -> None: repo = barerepo message = 'New commit.\n\nMessage with non-ascii chars: ééé.\n' committer = Signature('John Doe', 'jdoe@example.com', 12346, 0) @@ -88,8 +89,9 @@ def test_new_commit(barerepo): sha = repo.create_commit(None, author, committer, message, tree_prefix, parents) commit = repo[sha] + assert isinstance(commit, Commit) - assert ObjectType.COMMIT == commit.type + assert ObjectType.COMMIT.value == commit.type assert '98286caaab3f1fde5bf52c8369b2b0423bad743b' == commit.id assert commit.message_encoding is None assert message == commit.message @@ -103,7 +105,7 @@ def test_new_commit(barerepo): assert Oid(hex=COMMIT_SHA) == commit.parent_ids[0] -def test_new_commit_encoding(barerepo): +def test_new_commit_encoding(barerepo: Repository) -> None: repo = barerepo encoding = 'iso-8859-1' message = 'New commit.\n\nMessage with non-ascii chars: ééé.\n' @@ -117,8 +119,9 @@ def test_new_commit_encoding(barerepo): None, author, committer, message, tree_prefix, parents, encoding ) commit = repo[sha] + assert isinstance(commit, Commit) - assert ObjectType.COMMIT == commit.type + assert ObjectType.COMMIT.value == commit.type assert 'iso-8859-1' == commit.message_encoding assert message.encode(encoding) == commit.raw_message assert 12346 == commit.commit_time @@ -131,7 +134,7 @@ def test_new_commit_encoding(barerepo): assert Oid(hex=COMMIT_SHA) == commit.parent_ids[0] -def test_modify_commit(barerepo): +def test_modify_commit(barerepo: Repository) -> None: message = 'New commit.\n\nMessage.\n' committer = ('John Doe', 'jdoe@example.com', 12346) author = ('Jane Doe', 'jdoe2@example.com', 12345) @@ -150,9 +153,10 @@ def test_modify_commit(barerepo): setattr(commit, 'parents', None) -def test_amend_commit_metadata(barerepo): +def test_amend_commit_metadata(barerepo: Repository) -> None: repo = barerepo commit = repo[COMMIT_SHA_TO_AMEND] + assert isinstance(commit, Commit) assert commit.id == repo.head.target encoding = 'iso-8859-1' @@ -173,9 +177,10 @@ def test_amend_commit_metadata(barerepo): encoding=encoding, ) amended_commit = repo[amended_oid] + assert isinstance(amended_commit, Commit) assert repo.head.target == amended_oid - assert ObjectType.COMMIT == amended_commit.type + assert ObjectType.COMMIT.value == amended_commit.type assert amended_committer == amended_commit.committer assert amended_author == amended_commit.author assert amended_message.encode(encoding) == amended_commit.raw_message @@ -184,9 +189,10 @@ def test_amend_commit_metadata(barerepo): assert commit.tree == amended_commit.tree # we didn't touch the tree -def test_amend_commit_tree(barerepo): +def test_amend_commit_tree(barerepo: Repository) -> None: repo = barerepo commit = repo[COMMIT_SHA_TO_AMEND] + assert isinstance(commit, Commit) assert commit.id == repo.head.target tree = '967fce8df97cc71722d3c2a5930ef3e6f1d27b12' @@ -194,9 +200,11 @@ def test_amend_commit_tree(barerepo): amended_oid = repo.amend_commit(commit, 'HEAD', tree=tree_prefix) amended_commit = repo[amended_oid] + assert isinstance(amended_commit, Commit) + assert isinstance(commit, Commit) assert repo.head.target == amended_oid - assert ObjectType.COMMIT == amended_commit.type + assert ObjectType.COMMIT.value == amended_commit.type assert commit.message == amended_commit.message assert commit.author == amended_commit.author assert commit.committer == amended_commit.committer @@ -204,11 +212,12 @@ def test_amend_commit_tree(barerepo): assert Oid(hex=tree) == amended_commit.tree_id -def test_amend_commit_not_tip_of_branch(barerepo): +def test_amend_commit_not_tip_of_branch(barerepo: Repository) -> None: repo = barerepo # This commit isn't at the tip of the branch. commit = repo['5fe808e8953c12735680c257f56600cb0de44b10'] + assert isinstance(commit, Commit) assert commit.id != repo.head.target # Can't update HEAD to the rewritten commit because it's not the tip of the branch. @@ -219,16 +228,17 @@ def test_amend_commit_not_tip_of_branch(barerepo): repo.amend_commit(commit, None, message='this will work') -def test_amend_commit_no_op(barerepo): +def test_amend_commit_no_op(barerepo: Repository) -> None: repo = barerepo commit = repo[COMMIT_SHA_TO_AMEND] + assert isinstance(commit, Commit) assert commit.id == repo.head.target amended_oid = repo.amend_commit(commit, None) assert amended_oid == commit.id -def test_amend_commit_argument_types(barerepo): +def test_amend_commit_argument_types(barerepo: Repository) -> None: repo = barerepo some_tree = repo['967fce8df97cc71722d3c2a5930ef3e6f1d27b12'] @@ -236,33 +246,34 @@ def test_amend_commit_argument_types(barerepo): alt_commit1 = Oid(hex=COMMIT_SHA_TO_AMEND) alt_commit2 = COMMIT_SHA_TO_AMEND alt_tree = some_tree + assert isinstance(alt_tree, Tree) alt_refname = ( repo.head ) # try this one last, because it'll change the commit at the tip # Pass bad values/types for the commit with pytest.raises(ValueError): - repo.amend_commit(None, None) + repo.amend_commit(None, None) # type: ignore with pytest.raises(TypeError): - repo.amend_commit(some_tree, None) + repo.amend_commit(some_tree, None) # type: ignore # Pass bad types for signatures with pytest.raises(TypeError): - repo.amend_commit(commit, None, author='Toto') + repo.amend_commit(commit, None, author='Toto') # type: ignore with pytest.raises(TypeError): - repo.amend_commit(commit, None, committer='Toto') + repo.amend_commit(commit, None, committer='Toto') # type: ignore # Pass bad refnames with pytest.raises(ValueError): - repo.amend_commit(commit, 'this-ref-doesnt-exist') + repo.amend_commit(commit, 'this-ref-doesnt-exist') # type: ignore with pytest.raises(TypeError): - repo.amend_commit(commit, repo) + repo.amend_commit(commit, repo) # type: ignore # Pass bad trees with pytest.raises(ValueError): - repo.amend_commit(commit, None, tree="can't parse this") + repo.amend_commit(commit, None, tree="can't parse this") # type: ignore with pytest.raises(KeyError): - repo.amend_commit(commit, None, tree='baaaaad') + repo.amend_commit(commit, None, tree='baaaaad') # type: ignore # Pass an Oid for the commit amended_oid = repo.amend_commit(alt_commit1, None, message='Hello') @@ -273,7 +284,8 @@ def test_amend_commit_argument_types(barerepo): # Pass a str for the commit amended_oid = repo.amend_commit(alt_commit2, None, message='Hello', tree=alt_tree) amended_commit = repo[amended_oid] - assert ObjectType.COMMIT == amended_commit.type + assert isinstance(amended_commit, Commit) + assert ObjectType.COMMIT.value == amended_commit.type assert amended_oid != COMMIT_SHA_TO_AMEND assert repo[COMMIT_SHA_TO_AMEND].tree != amended_commit.tree assert alt_tree.id == amended_commit.tree_id diff --git a/test/test_commit_gpg.py b/test/test_commit_gpg.py index 88450b6cb..16cb2f129 100644 --- a/test/test_commit_gpg.py +++ b/test/test_commit_gpg.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -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 d7236cd8f..5bbd39b88 100644 --- a/test/test_commit_trailer.py +++ b/test/test_commit_trailer.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -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 0284d76f8..34aade599 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -23,90 +23,89 @@ # 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 pygit2 import Config, Repository, Settings + from . import utils -CONFIG_FILENAME = 'test_config' +@pytest.fixture +def config_path(tmp_path: Path) -> Path: + return tmp_path / 'test_config' @pytest.fixture -def config(testrepo): +def config(testrepo: Repository) -> Generator[Config, None, None]: yield testrepo.config - try: - Path(CONFIG_FILENAME).unlink() - except OSError: - 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: - # There is no user config - pass + except IOError as e: + settings = Settings() + pytest.skip(f'Unavailable for testing with home dir = {settings.homedir}: {e}') -def test_system_config(): +def test_system_config() -> None: try: assert Config.get_system_config() is not None - except IOError: - # There is no system config - pass + except IOError as e: + pytest.skip(f'Unavailable for testing: {e}') -def test_new(): +def test_new(config_path: Path) -> None: # Touch file - open(CONFIG_FILENAME, 'w').close() + config_path.touch() - config_write = Config(CONFIG_FILENAME) + config_write = Config(str(config_path)) assert config_write is not None config_write['core.bare'] = False config_write['core.editor'] = 'ed' - config_read = Config(CONFIG_FILENAME) + config_read = Config(str(config_path)) assert 'core.bare' in config_read assert not config_read.get_bool('core.bare') assert 'core.editor' in config_read assert config_read['core.editor'] == 'ed' -def test_add(): - with open(CONFIG_FILENAME, 'w') as new_file: +def test_add(config_path: Path) -> None: + with open(config_path, 'w') as new_file: new_file.write('[this]\n\tthat = true\n') new_file.write('[something "other"]\n\there = false') config = Config() - config.add_file(CONFIG_FILENAME, 0) + config.add_file(config_path, 0) assert 'this.that' in config assert config.get_bool('this.that') assert 'something.other.here' in config assert not config.get_bool('something.other.here') -def test_add_aspath(): - with open(CONFIG_FILENAME, 'w') as new_file: +def test_add_aspath(config_path: Path) -> None: + with open(config_path, 'w') as new_file: new_file.write('[this]\n\tthat = true\n') config = Config() - config.add_file(Path(CONFIG_FILENAME), 0) + config.add_file(config_path, 0) assert 'this.that' in config -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 +119,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,12 +146,12 @@ def test_write(config): assert 'core.dummy3' not in config -def test_multivar(): - with open(CONFIG_FILENAME, 'w') as new_file: +def test_multivar(config_path: Path) -> None: + with open(config_path, 'w') as new_file: new_file.write('[this]\n\tthat = foobar\n\tthat = foobeer\n') config = Config() - config.add_file(CONFIG_FILENAME, 6) + config.add_file(config_path, 6) assert 'this.that' in config assert ['foobar', 'foobeer'] == list(config.get_multivar('this.that')) @@ -174,7 +173,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,9 +183,97 @@ def test_iterator(config): assert lst['core.bare'] -def test_parsing(): +def test_valueless_key_iteration(config_path: Path) -> None: + # A valueless key (no `= value`) has a NULL value pointer in libgit2. + # Iterating over such entries must not raise a RuntimeError. + with open(config_path, 'w') as new_file: + new_file.write('[section]\n\tvaluelesskey\n\tnormalkey = somevalue\n') + + config = Config() + config.add_file(config_path, 6) + + entries = {entry.name: entry for entry in config} + assert 'section.valuelesskey' in entries + assert 'section.normalkey' in entries + + +def test_valueless_key_value(config_path: Path) -> None: + # A valueless key must expose value=None and raw_value=None. + with open(config_path, 'w') as new_file: + new_file.write('[section]\n\tvaluelesskey\n\tnormalkey = somevalue\n') + + config = Config() + config.add_file(config_path, 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') assert 5 == Config.parse_int('5') assert 1024 == Config.parse_int('1k') + + +def test_repository_config_snapshot(config: Config) -> None: + assert 'core.bare' in config + assert not config.get_bool('core.bare') + assert 'core.editor' in config + assert config['core.editor'] == 'ed' + assert 'core.repositoryformatversion' in config + assert config.get_int('core.repositoryformatversion') == 0 + + snapshot = config.snapshot() + assert 'core.bare' in snapshot + assert not snapshot.get_bool('core.bare') + assert 'core.editor' in snapshot + assert snapshot['core.editor'] == 'ed' + assert 'core.repositoryformatversion' in snapshot + assert snapshot.get_int('core.repositoryformatversion') == 0 + + assert 'core.snapshot1' not in config + assert 'core.snapshot1' not in snapshot + config['core.snapshot1'] = 42 + assert 'core.snapshot1' in config + assert 'core.snapshot1' not in snapshot + assert config.get_int('core.snapshot1') == 42 + utils.assertRaisesWithArg( + KeyError, + 'core.snapshot1', + lambda: snapshot.get_int('core.snapshot1'), + ) + + +def test_non_repository_config_snapshot(config_path: Path) -> None: + with config_path.open('w') as new_file: + new_file.write('[this]\n\tthat = true\n') + new_file.write('[something "other"]\n\there = false') + + config = Config(config_path) + assert 'this.that' in config + assert config.get_bool('this.that') + assert 'something.other.here' in config + assert not config.get_bool('something.other.here') + + snapshot = config.snapshot() + assert 'this.that' in snapshot + assert snapshot.get_bool('this.that') + assert 'something.other.here' in snapshot + assert not snapshot.get_bool('something.other.here') + + assert 'this.snapshot1' not in config + assert 'this.snapshot1' not in snapshot + config['this.snapshot1'] = 42 + assert 'this.snapshot1' in config + assert 'this.snapshot1' not in snapshot + assert config.get_int('this.snapshot1') == 42 + utils.assertRaisesWithArg( + KeyError, + 'this.snapshot1', + lambda: snapshot.get_int('this.snapshot1'), + ) diff --git a/test/test_credentials.py b/test/test_credentials.py index e9578b36a..5f1c1e1f7 100644 --- a/test/test_credentials.py +++ b/test/test_credentials.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -23,16 +23,23 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. -from pathlib import Path import platform +from pathlib import Path import pytest import pygit2 -from pygit2 import Username, UserPass, Keypair, KeypairFromAgent, KeypairFromMemory +from pygit2 import ( + Keypair, + KeypairFromAgent, + KeypairFromMemory, + 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' @@ -44,13 +51,13 @@ ORIGIN_REFSPEC = '+refs/heads/*:refs/remotes/origin/*' -def test_username(): +def test_username() -> None: username = 'git' cred = Username(username) assert (username,) == cred.credential_tuple -def test_userpass(): +def test_userpass() -> None: username = 'git' password = 'sekkrit' @@ -58,7 +65,7 @@ def test_userpass(): assert (username, password) == cred.credential_tuple -def test_ssh_key(): +def test_ssh_key() -> None: username = 'git' pubkey = 'id_rsa.pub' privkey = 'id_rsa' @@ -68,7 +75,7 @@ def test_ssh_key(): assert (username, pubkey, privkey, passphrase) == cred.credential_tuple -def test_ssh_key_aspath(): +def test_ssh_key_aspath() -> None: username = 'git' pubkey = Path('id_rsa.pub') privkey = Path('id_rsa') @@ -78,14 +85,14 @@ def test_ssh_key_aspath(): assert (username, pubkey, privkey, passphrase) == cred.credential_tuple -def test_ssh_agent(): +def test_ssh_agent() -> None: username = 'git' cred = KeypairFromAgent(username) assert (username, None, None, None) == cred.credential_tuple -def test_ssh_from_memory(): +def test_ssh_from_memory() -> None: username = 'git' pubkey = 'public key data' privkey = 'private key data' @@ -97,7 +104,7 @@ def test_ssh_from_memory(): @utils.requires_network @utils.requires_ssh -def test_keypair(tmp_path, pygit2_empty_key): +def test_keypair(tmp_path: Path, pygit2_empty_key: tuple[Path, str, str]) -> None: url = 'ssh://git@github.com/pygit2/empty' with pytest.raises(pygit2.GitError): pygit2.clone_repository(url, tmp_path) @@ -111,7 +118,9 @@ def test_keypair(tmp_path, pygit2_empty_key): @utils.requires_network @utils.requires_ssh -def test_keypair_from_memory(tmp_path, pygit2_empty_key): +def test_keypair_from_memory( + tmp_path: Path, pygit2_empty_key: tuple[Path, str, str] +) -> None: url = 'ssh://git@github.com/pygit2/empty' with pytest.raises(pygit2.GitError): pygit2.clone_repository(url, tmp_path) @@ -128,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' @@ -141,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' @@ -154,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' @@ -179,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) @@ -191,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 22650a5df..6ea5bb028 100644 --- a/test/test_describe.py +++ b/test/test_describe.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -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 f73a4c64e..29d4649c6 100644 --- a/test/test_diff.py +++ b/test/test_diff.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -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' @@ -170,73 +174,74 @@ """ -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 @@ -261,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) 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] @@ -309,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 @@ -326,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] @@ -335,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) @@ -352,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) @@ -363,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] @@ -392,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 @@ -410,12 +426,10 @@ def test_deltas(barerepo): assert delta.new_file.id == patch_delta.new_file.id assert delta.old_file.mode == patch_delta.old_file.mode assert delta.new_file.mode == patch_delta.new_file.mode - - # As explained in the libgit2 documentation, flags are not set - # assert delta.flags == patch_delta.flags + assert delta.flags == patch_delta.flags -def test_diff_parse(barerepo): +def test_diff_parse(barerepo: Repository) -> None: diff = pygit2.Diff.parse_diff(PATCH) stats = diff.stats @@ -427,12 +441,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 @@ -446,7 +460,7 @@ def test_parse_diff_bad(): pygit2.Diff.parse_diff(diff) -def test_diff_blobs(emptyrepo): +def test_diff_blobs(emptyrepo: Repository) -> None: repo = emptyrepo blob1 = repo.create_blob(TEXT_BLOB1.encode()) blob2 = repo.create_blob(TEXT_BLOB2.encode()) @@ -458,3 +472,33 @@ def test_diff_blobs(emptyrepo): 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 e23583ada..bb2f36e2b 100644 --- a/test/test_diff_binary.py +++ b/test/test_diff_binary.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -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) @@ -63,3 +67,9 @@ def test_binary_diff(repo): assert PATCH_BINARY == diff.patch diff = repo.diff(b'HEAD', b'HEAD^', flags=DiffOption.SHOW_BINARY) assert PATCH_BINARY_SHOW == diff.patch + + +def test_binary_delta_is_binary(repo: Repository) -> None: + diff = repo.diff('HEAD', 'HEAD^') + for delta in diff.deltas: + assert delta.is_binary diff --git a/test/test_errors.py b/test/test_errors.py new file mode 100644 index 000000000..6bc111b1e --- /dev/null +++ b/test/test_errors.py @@ -0,0 +1,126 @@ +# Copyright 2010-2026 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +"""Tests for the exception hierarchy.""" + +import pytest + +from pygit2 import ( + AlreadyExistsError, + AmbiguousError, + AuthError, + CertificateError, + GitError, + InvalidError, + InvalidSpecError, + NotFoundError, + Repository, +) + + +def test_already_exists_error_inheritance() -> None: + assert issubclass(AlreadyExistsError, GitError) + assert issubclass(AlreadyExistsError, ValueError) + + +def test_invalid_spec_error_inheritance() -> None: + assert issubclass(InvalidSpecError, GitError) + assert issubclass(InvalidSpecError, ValueError) + + +def test_invalid_error_inheritance() -> None: + assert issubclass(InvalidError, GitError) + assert issubclass(InvalidError, ValueError) + + +def test_not_found_error_inheritance() -> None: + assert issubclass(NotFoundError, GitError) + assert issubclass(NotFoundError, KeyError) + + +def test_ambiguous_error_inheritance() -> None: + assert issubclass(AmbiguousError, GitError) + assert issubclass(AmbiguousError, ValueError) + + +def test_auth_error_inheritance() -> None: + assert issubclass(AuthError, GitError) + + +def test_certificate_error_inheritance() -> None: + assert issubclass(CertificateError, GitError) + + +def test_create_reference_already_exists(testrepo: Repository) -> None: + target = testrepo.head.target + testrepo.create_reference_direct('refs/heads/foo', target, False) + + with pytest.raises(AlreadyExistsError) as excinfo: + testrepo.create_reference_direct('refs/heads/foo', target, False) + + assert isinstance(excinfo.value, GitError) + assert isinstance(excinfo.value, ValueError) + + +def test_create_reference_invalid_spec(testrepo: Repository) -> None: + target = testrepo.head.target + + with pytest.raises(InvalidSpecError) as excinfo: + testrepo.create_reference_direct('invalid ref name', target, False) + + assert isinstance(excinfo.value, GitError) + assert isinstance(excinfo.value, ValueError) + + +def test_remote_lookup_not_found(emptyrepo: Repository) -> None: + with pytest.raises(NotFoundError) as excinfo: + emptyrepo.remotes['nonexistent'] + + assert isinstance(excinfo.value, GitError) + assert isinstance(excinfo.value, KeyError) + + +def test_revparse_single_not_found(testrepo: Repository) -> None: + with pytest.raises(NotFoundError) as excinfo: + testrepo.revparse_single('nonexistent-ref-12345') + + assert isinstance(excinfo.value, GitError) + assert isinstance(excinfo.value, KeyError) + + +def test_oid_parse_invalid_error(testrepo: Repository) -> None: + with pytest.raises(InvalidError) as excinfo: + testrepo['notahexoid'] + + assert isinstance(excinfo.value, GitError) + assert isinstance(excinfo.value, ValueError) + + +def test_lookup_short_oid_ambiguous(testrepo: Repository) -> None: + with pytest.raises(AmbiguousError) as excinfo: + testrepo['5fe'] + + assert isinstance(excinfo.value, GitError) + assert isinstance(excinfo.value, ValueError) diff --git a/test/test_filter.py b/test/test_filter.py index f37f9e1c4..c4338489c 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 0fb0586f8..92d979026 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -30,20 +30,21 @@ import pytest import pygit2 -from pygit2 import Repository, Index, Oid, IndexEntry +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,21 +186,21 @@ def test_bare_index(testrepo): index.add('bye.txt') -def test_remove(testrepo): +def test_remove(testrepo: Repository) -> None: index = testrepo.index assert 'hello.txt' in index index.remove('hello.txt') assert 'hello.txt' not in index -def test_remove_directory(dirtyrepo): +def test_remove_directory(dirtyrepo: Repository) -> None: index = dirtyrepo.index assert 'subdir/current_file' in index index.remove_directory('subdir') assert 'subdir/current_file' not in index -def test_remove_all(testrepo): +def test_remove_all(testrepo: Repository) -> None: index = testrepo.index assert 'hello.txt' in index index.remove_all(['*.txt']) @@ -208,28 +209,28 @@ def test_remove_all(testrepo): index.remove_all(['not-existing']) # this doesn't error -def test_remove_aspath(testrepo): +def test_remove_aspath(testrepo: Repository) -> None: index = testrepo.index assert 'hello.txt' in index index.remove(Path('hello.txt')) assert 'hello.txt' not in index -def test_remove_directory_aspath(dirtyrepo): +def test_remove_directory_aspath(dirtyrepo: Repository) -> None: index = dirtyrepo.index assert 'subdir/current_file' in index index.remove_directory(Path('subdir')) assert 'subdir/current_file' not in index -def test_remove_all_aspath(testrepo): +def test_remove_all_aspath(testrepo: Repository) -> None: index = testrepo.index assert 'hello.txt' in index index.remove_all([Path('hello.txt')]) assert 'hello.txt' not in index -def test_change_attributes(testrepo): +def test_change_attributes(testrepo: Repository) -> None: index = testrepo.index entry = index['hello.txt'] ign_entry = index['.gitignore'] @@ -243,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) @@ -251,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) @@ -259,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) @@ -267,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) @@ -284,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 ( @@ -297,24 +298,26 @@ 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): +def test_create_empty_read_tree(testrepo: Repository) -> None: index = Index() - index.read_tree(testrepo['fd937514cb799514d4b81bb24c5fcfeb6472b245']) + tree = testrepo['fd937514cb799514d4b81bb24c5fcfeb6472b245'] + assert isinstance(tree, Tree) + index.read_tree(tree) @utils.fails_in_macos -def test_add_conflict(testrepo): +def test_add_conflict(testrepo: Repository) -> None: ancestor_blob_id = testrepo.create_blob('ancestor') ancestor = IndexEntry('conflict.txt', ancestor_blob_id, FileMode.BLOB_EXECUTABLE) diff --git a/test/test_mailmap.py b/test/test_mailmap.py index 3cdef0564..141f92830 100644 --- a/test/test_mailmap.py +++ b/test/test_mailmap.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -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 959854b3d..a10d28512 100644 --- a/test/test_merge.py +++ b/test/test_merge.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -30,28 +30,19 @@ 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): +@pytest.mark.parametrize('id', [None, 42, '5ebeeebb320790caf276b9fc8b24546d63316533']) +def test_merge_invalid_type(mergerepo: Repository, id: None | int | str) -> None: with pytest.raises(TypeError): - mergerepo.merge(id) + mergerepo.merge(id) # type:ignore -# TODO: Once Repository.merge drops support for str arguments, -# add an extra parameter to test_merge_invalid_type above -# to make sure we cover legacy code. -def test_merge_string_argument_deprecated(mergerepo): +def test_merge_analysis_uptodate(mergerepo: Repository) -> None: branch_head_hex = '5ebeeebb320790caf276b9fc8b24546d63316533' - - with pytest.warns(DeprecationWarning, match=r'Pass Commit.+instead'): - mergerepo.merge(branch_head_hex) - - -def test_merge_analysis_uptodate(mergerepo): - branch_head_hex = '5ebeeebb320790caf276b9fc8b24546d63316533' - branch_id = mergerepo.get(branch_head_hex).id + branch_id = mergerepo[branch_head_hex].id analysis, preference = mergerepo.merge_analysis(branch_id) assert analysis & MergeAnalysis.UP_TO_DATE @@ -64,9 +55,9 @@ def test_merge_analysis_uptodate(mergerepo): assert {} == mergerepo.status() -def test_merge_analysis_fastforward(mergerepo): +def test_merge_analysis_fastforward(mergerepo: Repository) -> None: branch_head_hex = 'e97b4cfd5db0fb4ebabf4f203979ca4e5d1c7c87' - branch_id = mergerepo.get(branch_head_hex).id + branch_id = mergerepo[branch_head_hex].id analysis, preference = mergerepo.merge_analysis(branch_id) assert not analysis & MergeAnalysis.UP_TO_DATE @@ -79,9 +70,9 @@ def test_merge_analysis_fastforward(mergerepo): assert {} == mergerepo.status() -def test_merge_no_fastforward_no_conflicts(mergerepo): +def test_merge_no_fastforward_no_conflicts(mergerepo: Repository) -> None: branch_head_hex = '03490f16b15a09913edb3a067a3dc67fbb8d41f1' - branch_id = mergerepo.get(branch_head_hex).id + branch_id = mergerepo[branch_head_hex].id analysis, preference = mergerepo.merge_analysis(branch_id) assert not analysis & MergeAnalysis.UP_TO_DATE assert not analysis & MergeAnalysis.FASTFORWARD @@ -90,18 +81,9 @@ def test_merge_no_fastforward_no_conflicts(mergerepo): assert {} == mergerepo.status() -def test_merge_invalid_hex(mergerepo): - branch_head_hex = '12345678' - with ( - pytest.raises(KeyError), - pytest.warns(DeprecationWarning, match=r'Pass Commit.+instead'), - ): - mergerepo.merge(branch_head_hex) - - -def test_merge_already_something_in_index(mergerepo): +def test_merge_already_something_in_index(mergerepo: Repository) -> None: branch_head_hex = '03490f16b15a09913edb3a067a3dc67fbb8d41f1' - branch_oid = mergerepo.get(branch_head_hex).id + branch_oid = mergerepo[branch_head_hex].id with (Path(mergerepo.workdir) / 'inindex.txt').open('w') as f: f.write('new content') mergerepo.index.add('inindex.txt') @@ -109,9 +91,9 @@ def test_merge_already_something_in_index(mergerepo): mergerepo.merge(branch_oid) -def test_merge_no_fastforward_conflicts(mergerepo): +def test_merge_no_fastforward_conflicts(mergerepo: Repository) -> None: branch_head_hex = '1b2bae55ac95a4be3f8983b86cd579226d0eb247' - branch_id = mergerepo.get(branch_head_hex).id + branch_id = mergerepo[branch_head_hex].id analysis, preference = mergerepo.merge_analysis(branch_id) assert not analysis & MergeAnalysis.UP_TO_DATE @@ -144,7 +126,7 @@ def test_merge_no_fastforward_conflicts(mergerepo): assert {'.gitignore': FileStatus.INDEX_MODIFIED} == mergerepo.status() -def test_merge_remove_conflicts(mergerepo): +def test_merge_remove_conflicts(mergerepo: Repository) -> None: other_branch_tip = pygit2.Oid(hex='1b2bae55ac95a4be3f8983b86cd579226d0eb247') mergerepo.merge(other_branch_tip) idx = mergerepo.index @@ -154,7 +136,7 @@ def test_merge_remove_conflicts(mergerepo): try: conflicts['.gitignore'] except KeyError: - mergerepo.fail("conflicts['.gitignore'] raised KeyError unexpectedly") + mergerepo.fail("conflicts['.gitignore'] raised KeyError unexpectedly") # type: ignore del idx.conflicts['.gitignore'] with pytest.raises(KeyError): conflicts.__getitem__('.gitignore') @@ -170,14 +152,14 @@ def test_merge_remove_conflicts(mergerepo): MergeFavor.UNION, ], ) -def test_merge_favor(mergerepo, favor): +def test_merge_favor(mergerepo: Repository, favor: MergeFavor) -> None: branch_head = pygit2.Oid(hex='1b2bae55ac95a4be3f8983b86cd579226d0eb247') mergerepo.merge(branch_head, favor=favor) assert mergerepo.index.conflicts is None -def test_merge_fail_on_conflict(mergerepo): +def test_merge_fail_on_conflict(mergerepo: Repository) -> None: branch_head = pygit2.Oid(hex='1b2bae55ac95a4be3f8983b86cd579226d0eb247') with pytest.raises(pygit2.GitError, match=r'merge conflicts exist'): @@ -186,7 +168,7 @@ def test_merge_fail_on_conflict(mergerepo): ) -def test_merge_commits(mergerepo): +def test_merge_commits(mergerepo: Repository) -> None: branch_head = pygit2.Oid(hex='03490f16b15a09913edb3a067a3dc67fbb8d41f1') merge_index = mergerepo.merge_commits(mergerepo.head.target, branch_head) @@ -201,7 +183,7 @@ def test_merge_commits(mergerepo): assert merge_tree == merge_commits_tree -def test_merge_commits_favor(mergerepo): +def test_merge_commits_favor(mergerepo: Repository) -> None: branch_head = pygit2.Oid(hex='1b2bae55ac95a4be3f8983b86cd579226d0eb247') merge_index = mergerepo.merge_commits( @@ -211,10 +193,10 @@ def test_merge_commits_favor(mergerepo): # Incorrect favor value with pytest.raises(TypeError, match=r'favor argument must be MergeFavor'): - mergerepo.merge_commits(mergerepo.head.target, branch_head, favor='foo') + mergerepo.merge_commits(mergerepo.head.target, branch_head, favor='foo') # type: ignore -def test_merge_trees(mergerepo): +def test_merge_trees(mergerepo: Repository) -> None: branch_id = pygit2.Oid(hex='03490f16b15a09913edb3a067a3dc67fbb8d41f1') ancestor_id = mergerepo.merge_base(mergerepo.head.target, branch_id) @@ -230,7 +212,7 @@ def test_merge_trees(mergerepo): assert merge_tree == merge_commits_tree -def test_merge_trees_favor(mergerepo): +def test_merge_trees_favor(mergerepo: Repository) -> None: branch_head_hex = '1b2bae55ac95a4be3f8983b86cd579226d0eb247' ancestor_id = mergerepo.merge_base(mergerepo.head.target, branch_head_hex) merge_index = mergerepo.merge_trees( @@ -240,14 +222,19 @@ def test_merge_trees_favor(mergerepo): with pytest.raises(TypeError): mergerepo.merge_trees( - ancestor_id, mergerepo.head.target, branch_head_hex, favor='foo' + ancestor_id, + mergerepo.head.target, + branch_head_hex, + favor='foo', # type: ignore ) -def test_merge_options(): +def test_merge_options() -> None: favor = MergeFavor.OURS - flags = MergeFlag.FIND_RENAMES | MergeFlag.FAIL_ON_CONFLICT - file_flags = MergeFileFlag.IGNORE_WHITESPACE | MergeFileFlag.DIFF_PATIENCE + flags: int | MergeFlag = MergeFlag.FIND_RENAMES | MergeFlag.FAIL_ON_CONFLICT + file_flags: int | MergeFileFlag = ( + MergeFileFlag.IGNORE_WHITESPACE | MergeFileFlag.DIFF_PATIENCE + ) o1 = pygit2.Repository._merge_options( favor=favor, flags=flags, file_flags=file_flags ) @@ -280,9 +267,9 @@ def test_merge_options(): assert file_flags == o1.file_flags -def test_merge_many(mergerepo): +def test_merge_many(mergerepo: Repository) -> None: branch_head_hex = '03490f16b15a09913edb3a067a3dc67fbb8d41f1' - branch_id = mergerepo.get(branch_head_hex).id + branch_id = mergerepo[branch_head_hex].id ancestor_id = mergerepo.merge_base_many([mergerepo.head.target, branch_id]) merge_index = mergerepo.merge_trees( @@ -299,9 +286,9 @@ def test_merge_many(mergerepo): assert merge_tree == merge_commits_tree -def test_merge_octopus(mergerepo): +def test_merge_octopus(mergerepo: Repository) -> None: branch_head_hex = '03490f16b15a09913edb3a067a3dc67fbb8d41f1' - branch_id = mergerepo.get(branch_head_hex).id + branch_id = mergerepo[branch_head_hex].id ancestor_id = mergerepo.merge_base_octopus([mergerepo.head.target, branch_id]) merge_index = mergerepo.merge_trees( @@ -318,7 +305,7 @@ def test_merge_octopus(mergerepo): assert merge_tree == merge_commits_tree -def test_merge_mergeheads(mergerepo): +def test_merge_mergeheads(mergerepo: Repository) -> None: assert mergerepo.listall_mergeheads() == [] branch_head = pygit2.Oid(hex='1b2bae55ac95a4be3f8983b86cd579226d0eb247') @@ -332,7 +319,7 @@ def test_merge_mergeheads(mergerepo): ) -def test_merge_message(mergerepo): +def test_merge_message(mergerepo: Repository) -> None: assert not mergerepo.message assert not mergerepo.raw_message @@ -346,7 +333,7 @@ def test_merge_message(mergerepo): assert not mergerepo.message -def test_merge_remove_message(mergerepo): +def test_merge_remove_message(mergerepo: Repository) -> None: branch_head = pygit2.Oid(hex='1b2bae55ac95a4be3f8983b86cd579226d0eb247') mergerepo.merge(branch_head) @@ -355,7 +342,7 @@ def test_merge_remove_message(mergerepo): assert not mergerepo.message -def test_merge_commit(mergerepo): +def test_merge_commit(mergerepo: Repository) -> None: commit = mergerepo['1b2bae55ac95a4be3f8983b86cd579226d0eb247'] assert isinstance(commit, pygit2.Commit) mergerepo.merge(commit) @@ -364,7 +351,7 @@ def test_merge_commit(mergerepo): assert mergerepo.listall_mergeheads() == [commit.id] -def test_merge_reference(mergerepo): +def test_merge_reference(mergerepo: Repository) -> None: branch = mergerepo.branches.local['branch-conflicts'] branch_head_hex = '1b2bae55ac95a4be3f8983b86cd579226d0eb247' mergerepo.merge(branch) diff --git a/test/test_nonunicode.py b/test/test_nonunicode.py index 26b446801..5b0d89520 100644 --- a/test/test_nonunicode.py +++ b/test/test_nonunicode.py @@ -1,4 +1,4 @@ -# Copyright 2010-2024 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -28,12 +28,16 @@ import os import shutil import sys +from pathlib import Path import pytest import pygit2 -from . import utils +from pygit2 import Repository +from pygit2.enums import FileStatus +from pygit2.utils import encode_fs_path +from . import utils # FIXME Detect the filesystem rather than the operating system works_in_linux = pytest.mark.xfail( @@ -44,15 +48,28 @@ @utils.requires_network @works_in_linux -def test_nonunicode_branchname(testrepo): - folderpath = 'temp_repo_nonutf' - if os.path.exists(folderpath): +def test_nonunicode_branchname(testrepo: Repository, tmp_path: Path) -> None: + folderpath = tmp_path / 'temp_repo_nonutf' + if folderpath.exists(): shutil.rmtree(folderpath) newrepo = pygit2.clone_repository( - path=folderpath, url='https://github.com/pygit2/test_branch_notutf.git' + path=str(folderpath), url='https://github.com/pygit2/test_branch_notutf.git' ) bstring = b'\xc3master' assert bstring in [ (ref.split('/')[-1]).encode('utf8', 'surrogateescape') for ref in newrepo.listall_references() ] # Remote branch among references: 'refs/remotes/origin/\udcc3master' + + +@works_in_linux +def test_nonunicode_status_path(tmp_path: Path) -> None: + repo = pygit2.init_repository(str(tmp_path / 'repo'), bare=False) + path_bytes = 'éléphant'.encode('latin1') + filepath = Path(repo.workdir) / path_bytes.decode('utf-8', 'surrogateescape') + filepath.write_bytes(b'dummy') + git_status = repo.status() + path_key = os.fsdecode(path_bytes) + assert path_bytes in [encode_fs_path(path) for path in git_status] + assert git_status[path_key] & FileStatus.WT_NEW + assert encode_fs_path(path_key) == path_bytes diff --git a/test/test_note.py b/test/test_note.py index 2a1719240..a3b488fa5 100644 --- a/test/test_note.py +++ b/test/test_note.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -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 668d2d666..f97c7e4f3 100644 --- a/test/test_object.py +++ b/test/test_object.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -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,7 +80,7 @@ def test_hashing(testrepo): assert commit_b == commit_a -def test_peel_commit(testrepo): +def test_peel_commit(testrepo: Repository) -> None: # start by looking up the commit commit_id = testrepo.lookup_reference('refs/heads/master').target commit = testrepo[commit_id] @@ -92,7 +91,7 @@ def test_peel_commit(testrepo): assert tree.id == 'fd937514cb799514d4b81bb24c5fcfeb6472b245' -def test_peel_commit_type(testrepo): +def test_peel_commit_type(testrepo: Repository) -> None: commit_id = testrepo.lookup_reference('refs/heads/master').target commit = testrepo[commit_id] tree = commit.peel(Tree) @@ -101,7 +100,7 @@ def test_peel_commit_type(testrepo): assert tree.id == 'fd937514cb799514d4b81bb24c5fcfeb6472b245' -def test_invalid(testrepo): +def test_invalid(testrepo: Repository) -> None: commit_id = testrepo.lookup_reference('refs/heads/master').target commit = testrepo[commit_id] @@ -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 c7e60f22b..df03ece47 100644 --- a/test/test_odb.py +++ b/test/test_odb.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -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,38 +55,53 @@ 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): diff --git a/test/test_odb_backend.py b/test/test_odb_backend.py index 026834c30..43a01d0cb 100644 --- a/test/test_odb_backend.py +++ b/test/test_odb_backend.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -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,33 +68,56 @@ 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) +class RaisingOdbBackend(pygit2.OdbBackend): + """A backend whose callbacks always raise a configurable exception.""" + + def __init__(self, exc: Exception) -> None: + super().__init__() + self.exc = exc + + def read_cb(self, oid: Oid | str) -> tuple[int, bytes]: + raise self.exc + + def read_prefix_cb(self, oid: Oid | str) -> tuple[int, bytes, Oid]: + raise self.exc + + def read_header_cb(self, oid: Oid | str) -> tuple[int, int]: + raise self.exc + + def exists_cb(self, oid: Oid | str) -> bool: + raise self.exc + + def exists_prefix_cb(self, oid: Oid | str) -> Oid: + raise self.exc + + # # Test a custom object backend alone (without adding it to an ODB) # This doesn't make much sense, but it's possible. @@ -100,18 +125,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,37 +145,75 @@ 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) +@pytest.fixture +def raising_backend() -> Generator[RaisingOdbBackend, None, None]: + yield RaisingOdbBackend(RuntimeError('boom')) + + +def test_read_cb_raises_runtime_error(raising_backend: RaisingOdbBackend) -> None: + # Regression test: a RuntimeError in read_cb must propagate as RuntimeError, + # not be overwritten by a stale libgit2 error message. + with pytest.raises(RuntimeError, match='boom'): + pygit2.OdbBackend.read(raising_backend, BLOB_OID) + + +def test_read_prefix_cb_raises_runtime_error( + raising_backend: RaisingOdbBackend, +) -> None: + with pytest.raises(RuntimeError, match='boom'): + pygit2.OdbBackend.read_prefix(raising_backend, BLOB_HEX[:4]) + + +def test_read_header_cb_raises_runtime_error( + raising_backend: RaisingOdbBackend, +) -> None: + with pytest.raises(RuntimeError, match='boom'): + pygit2.OdbBackend.read_header(raising_backend, BLOB_OID) + + +def test_exists_cb_raises_runtime_error(raising_backend: RaisingOdbBackend) -> None: + with pytest.raises(RuntimeError, match='boom'): + pygit2.OdbBackend.exists(raising_backend, BLOB_OID) + + +def test_exists_prefix_cb_raises_runtime_error( + raising_backend: RaisingOdbBackend, +) -> None: + with pytest.raises(RuntimeError, match='boom'): + pygit2.OdbBackend.exists_prefix(raising_backend, BLOB_HEX[:4]) + + # # Test a custom object backend, through a Repository. # @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,12 +221,59 @@ 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) ab = repo[BLOB_OID] a = repo[BLOB_HEX] assert ab == a + + +class BadOidReadPrefixBackend(ProxyBackend): + def read_prefix_cb(self, oid: Oid | str) -> tuple[int, bytes, Oid | str]: # type: ignore[override] + return (ObjectType.BLOB, b'bad', 'not-a-valid-oid') + + +class BadOidExistsPrefixBackend(ProxyBackend): + def exists_prefix_cb(self, oid: Oid | str) -> Oid | str: # type: ignore[override] + return 'not-a-valid-oid' + + +class BadOidIterBackend(ProxyBackend): + def __iter__(self) -> Iterator[Oid | str]: # type: ignore[override] + yield 'not-a-valid-oid' + + +def test_read_prefix_cb_bad_oid(barerepo: Repository) -> None: + # Regression test (issue #1478): an ODB backend returning an invalid oid + # from read_prefix_cb must raise InvalidError instead of silently returning + # garbage data. + path = Path(barerepo.path) / 'objects' + backend = BadOidReadPrefixBackend(pygit2.OdbBackendPack(path)) + with pytest.raises(pygit2.InvalidError): + backend.read_prefix(BLOB_HEX[:4]) + + +def test_exists_prefix_cb_bad_oid(barerepo: Repository) -> None: + # Regression test (issue #1478): an ODB backend returning an invalid oid + # from exists_prefix_cb must raise InvalidError instead of silently returning + # garbage data. + path = Path(barerepo.path) / 'objects' + backend = BadOidExistsPrefixBackend(pygit2.OdbBackendPack(path)) + with pytest.raises(pygit2.InvalidError): + backend.exists_prefix(BLOB_HEX[:4]) + + +def test_foreach_cb_bad_oid(barerepo: Repository) -> None: + # Regression test (issue #1478): an ODB backend yielding an invalid oid + # during iteration must raise InvalidError instead of crashing or returning + # garbage data. + path = Path(barerepo.path) / 'objects' + backend = BadOidIterBackend(pygit2.OdbBackendPack(path)) + odb = pygit2.Odb() + odb.add_backend(backend, 1) + with pytest.raises(pygit2.InvalidError): + next(iter(odb)) diff --git a/test/test_oid.py b/test/test_oid.py index c6cbf3e8b..d806fd727 100644 --- a/test/test_oid.py +++ b/test/test_oid.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -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)) @@ -101,7 +101,7 @@ def test_hash(): assert len(s) == 3 -def test_bool(): +def test_bool() -> None: assert Oid(raw=RAW) assert Oid(hex=HEX) assert not Oid(raw=b'') diff --git a/test/test_options.py b/test/test_options.py index 5510d7141..e6baaa911 100644 --- a/test/test_options.py +++ b/test/test_options.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -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 6d4ed0d95..06410699e 100644 --- a/test/test_packbuilder.py +++ b/test/test_packbuilder.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -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 5620f9b58..1426cf6b8 100644 --- a/test/test_patch.py +++ b/test/test_patch.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -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 23f4aca1b..b70a9b8b8 100644 --- a/test/test_patch_encoding.py +++ b/test/test_patch_encoding.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -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_rebase.py b/test/test_rebase.py new file mode 100644 index 000000000..9b422e374 --- /dev/null +++ b/test/test_rebase.py @@ -0,0 +1,795 @@ +# Copyright 2010-2026 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +"""Tests for rebasing. + +Two flavors are covered here: + +1. "Manual rebase": rebasing implemented from first principles out of + merge_base(), walk(), merge_trees(), create_commit() and checkout, the + way applications had to before pygit2 wrapped libgit2's rebase API. + These tests are kept as a behavioral baseline to compare edge cases + against. + +2. The native rebase API: Repository.rebase_init() / rebase_open() and the + Rebase object, wrapping libgit2's git_rebase_* functions. +""" + +from itertools import count +from pathlib import Path + +import pytest + +import pygit2 +from pygit2 import ( + Blob, + Commit, + Index, + IndexEntry, + Oid, + Reference, + Repository, + Signature, +) +from pygit2.enums import ( + CheckoutStrategy, + FileMode, + RebaseOperationType, + RepositoryState, + SortMode, +) + +# Fixed base timestamp (like libgit2's examples/rebase.c) so that commit ids +# do not depend on the clock. +_timestamps = count(1_700_000_000) + +FileSpec = tuple[str, str, str] # path, content, commit message + + +def _signature() -> Signature: + return Signature('Test User', 'test@example.com', time=next(_timestamps), offset=0) + + +def _tip(ref: Reference) -> Oid: + target = ref.target + assert isinstance(target, Oid), 'symbolic reference where a commit was expected' + return target + + +def _commit_index(repo: Repository, message: str) -> Oid: + index = repo.index + index.write() + tree = index.write_tree() + signature = _signature() + parents = [] if repo.head_is_unborn else [repo.head.target] + return repo.create_commit('HEAD', signature, signature, message, tree, parents) + + +def _commit_file(repo: Repository, name: str, content: str, message: str) -> Oid: + (Path(repo.workdir) / name).write_text(content) + repo.index.add(name) + return _commit_index(repo, message) + + +def _commit_removal(repo: Repository, name: str, message: str) -> Oid: + (Path(repo.workdir) / name).unlink() + repo.index.remove(name) + return _commit_index(repo, message) + + +def _diverge( + repo: Repository, upstream: list[FileSpec], local: list[FileSpec] +) -> tuple[Oid, list[Oid]]: + """Grow an 'upstream' branch and the current branch from the current HEAD. + + Returns the tip of the upstream branch and the local commit ids, and + leaves the repository back on the original branch. + """ + main = repo.head.shorthand + repo.branches.local.create('upstream', repo.head.peel(Commit)) + repo.checkout(repo.branches['upstream']) + for name, content, message in upstream: + _commit_file(repo, name, content, message) + upstream_target = _tip(repo.branches['upstream']) + repo.checkout(repo.branches[main]) + local_oids = [ + _commit_file(repo, name, content, message) for name, content, message in local + ] + return upstream_target, local_oids + + +def _entry_text(repo: Repository, entry: IndexEntry | None) -> str: + """One side of a conflict: '' for a deleted side, newline-terminated text + otherwise.""" + if entry is None: + return '' + blob = repo[entry.id] + assert isinstance(blob, Blob) + text = blob.data.decode('utf-8', errors='replace') + if text and not text.endswith('\n'): + text += '\n' + return text + + +def _resolve_with_markers(repo: Repository, merge_index: Index, commit: Commit) -> None: + """Resolve every conflict by embedding both sides of the file in git-style + conflict markers, then stage the marked-up file, the way `git rebase` + leaves conflicting files in the working tree for the user to edit.""" + conflicts = merge_index.conflicts + assert conflicts is not None + resolutions = [] + for ancestor_entry, our_entry, their_entry in conflicts: + some_entry = their_entry or our_entry or ancestor_entry + assert some_entry is not None + content = ( + '<<<<<<< HEAD (rebased)\n' + + _entry_text(repo, our_entry) + + '=======\n' + + _entry_text(repo, their_entry) + + f'>>>>>>> {commit.short_id} ({commit.message.strip()})\n' + ) + blob_oid = repo.create_blob(content.encode('utf-8')) + resolutions.append(IndexEntry(some_entry.path, blob_oid, FileMode.BLOB)) + for entry in resolutions: + del merge_index.conflicts[entry.path] + merge_index.add(entry) + + +def _replay_commit(repo: Repository, commit: Commit, onto: Oid) -> Oid: + """Replay one commit on top of `onto` with a three-way merge of trees.""" + merge_index = repo.merge_trees( + commit.parents[0].tree, # ancestor: state the commit was made against + repo[onto].peel(Commit).tree, # ours: state rebuilt so far + commit.tree, # theirs: the commit being replayed + ) + message = commit.message + if merge_index.conflicts is not None: + _resolve_with_markers(repo, merge_index, commit) + message = ( + f'{message.rstrip()}\n\n[Rebased with conflicts - manual resolution needed]' + ) + tree_oid = merge_index.write_tree(repo) + signature = _signature() + return repo.create_commit(None, signature, signature, message, tree_oid, [onto]) + + +def _fast_forward(repo: Repository, upstream_target: Oid) -> None: + repo.checkout_tree(repo[upstream_target]) # type: ignore[no-untyped-call] + repo.references[repo.head.name].set_target(upstream_target) + + +def _rebase_onto(repo: Repository, upstream_target: Oid) -> None: + """Rebase the current branch onto `upstream_target` from first principles. + + This is the second half of a hand-rolled `git pull --rebase` (the first + half being a fetch): fast-forward when possible, otherwise replay the + diverged local commits one by one on top of the upstream tip. + """ + merge_base = repo.merge_base(repo.head.target, upstream_target) + if merge_base == upstream_target: + # Upstream did not move, there is nothing to rebase onto. + return + if merge_base == repo.head.target: + _fast_forward(repo, upstream_target) + return + + walker = repo.walk(repo.head.target, SortMode.TOPOLOGICAL) + walker.hide(merge_base) + commits_to_replay = list(walker) + commits_to_replay.reverse() # replay oldest first + + repo.checkout_tree(repo[upstream_target]) # type: ignore[no-untyped-call] + current_parent = upstream_target + for commit in commits_to_replay: + current_parent = _replay_commit(repo, commit, current_parent) + + repo.references[repo.head.name].set_target(current_parent) + repo.checkout('HEAD', strategy=CheckoutStrategy.FORCE) + + +def _linear_history(repo: Repository) -> list[str]: + """Commit messages from HEAD down to the root, asserting that the history + contains no merge commits.""" + messages = [] + for commit in repo.walk(repo.head.target, SortMode.TOPOLOGICAL): + assert len(commit.parents) <= 1 + messages.append(commit.message) + return messages + + +@pytest.fixture +def rebaserepo(tmp_path: Path) -> Repository: + repo = pygit2.init_repository(tmp_path / 'rebaserepo') + _commit_file(repo, 'README.md', '# Test Repository\n', 'Initial commit') + _commit_file( + repo, 'file1.txt', 'Content of file 1\nLine 2\nLine 3\n', 'Add file1.txt' + ) + _commit_file( + repo, 'file2.txt', 'Content of file 2\nOriginal content\n', 'Add file2.txt' + ) + return repo + + +def test_rebase_noop_when_up_to_date(rebaserepo: Repository) -> None: + head_before = _tip(rebaserepo.head) + _rebase_onto(rebaserepo, head_before) + assert rebaserepo.head.target == head_before + assert rebaserepo.status() == {} + + +def test_rebase_noop_when_upstream_is_behind(rebaserepo: Repository) -> None: + upstream_target, local_oids = _diverge( + rebaserepo, + upstream=[], + local=[('file4.txt', 'New file 4\n', 'Add file4.txt')], + ) + _rebase_onto(rebaserepo, upstream_target) + assert rebaserepo.head.target == local_oids[0] + assert rebaserepo.status() == {} + + +def test_rebase_fast_forwards_when_local_did_not_diverge( + rebaserepo: Repository, +) -> None: + upstream_target, _ = _diverge( + rebaserepo, + upstream=[ + ('file3.txt', 'New file 3\n', 'Add file3.txt'), + ('file1.txt', 'Content of file 1\nLine 2 changed\n', 'Change file1.txt'), + ], + local=[], + ) + _rebase_onto(rebaserepo, upstream_target) + assert rebaserepo.head.target == upstream_target + workdir = Path(rebaserepo.workdir) + assert (workdir / 'file3.txt').read_text() == 'New file 3\n' + assert (workdir / 'file1.txt').read_text() == 'Content of file 1\nLine 2 changed\n' + assert rebaserepo.status() == {} + + +def test_rebase_replays_diverged_commit_cleanly(rebaserepo: Repository) -> None: + upstream_target, local_oids = _diverge( + rebaserepo, + upstream=[('file3.txt', 'New file 3 from upstream\n', 'Add file3.txt')], + local=[('file4.txt', 'New file 4 from local\n', 'Add file4.txt')], + ) + _rebase_onto(rebaserepo, upstream_target) + + head = rebaserepo.head.peel(Commit) + assert head.message == 'Add file4.txt' + # The replayed commit is a new object with the upstream tip as its parent. + assert head.id != local_oids[0] + assert [parent.id for parent in head.parents] == [upstream_target] + workdir = Path(rebaserepo.workdir) + assert (workdir / 'file3.txt').read_text() == 'New file 3 from upstream\n' + assert (workdir / 'file4.txt').read_text() == 'New file 4 from local\n' + assert rebaserepo.status() == {} + # The pre-rebase commit is still in the object database. + assert rebaserepo.get(local_oids[0]) is not None + + +def test_rebase_replays_multiple_commits_oldest_first(rebaserepo: Repository) -> None: + upstream_target, _ = _diverge( + rebaserepo, + upstream=[('file3.txt', 'upstream\n', 'Upstream commit')], + local=[ + ('a.txt', 'a\n', 'Add a.txt'), + ('b.txt', 'b\n', 'Add b.txt'), + ('c.txt', 'c\n', 'Add c.txt'), + ], + ) + _rebase_onto(rebaserepo, upstream_target) + assert _linear_history(rebaserepo) == [ + 'Add c.txt', + 'Add b.txt', + 'Add a.txt', + 'Upstream commit', + 'Add file2.txt', + 'Add file1.txt', + 'Initial commit', + ] + workdir = Path(rebaserepo.workdir) + for name in ('a.txt', 'b.txt', 'c.txt', 'file3.txt'): + assert (workdir / name).exists() + assert rebaserepo.status() == {} + + +def test_rebase_conflicting_commit_gets_markers(rebaserepo: Repository) -> None: + upstream_target, local_oids = _diverge( + rebaserepo, + upstream=[ + ( + 'file1.txt', + 'Content of file 1\nLine 2 changed upstream\nLine 3\n', + 'Change line 2 upstream', + ) + ], + local=[ + ( + 'file1.txt', + 'Content of file 1\nLine 2 changed locally\nLine 3\n', + 'Change line 2 locally', + ) + ], + ) + original = rebaserepo[local_oids[0]].peel(Commit) + _rebase_onto(rebaserepo, upstream_target) + + head = rebaserepo.head.peel(Commit) + assert head.message == ( + 'Change line 2 locally\n\n[Rebased with conflicts - manual resolution needed]' + ) + assert [parent.id for parent in head.parents] == [upstream_target] + expected = ( + '<<<<<<< HEAD (rebased)\n' + 'Content of file 1\n' + 'Line 2 changed upstream\n' + 'Line 3\n' + '=======\n' + 'Content of file 1\n' + 'Line 2 changed locally\n' + 'Line 3\n' + f'>>>>>>> {original.short_id} (Change line 2 locally)\n' + ) + assert (Path(rebaserepo.workdir) / 'file1.txt').read_text() == expected + assert rebaserepo.index.conflicts is None + assert rebaserepo.status() == {} + + +def test_rebase_conflict_when_local_deleted_a_modified_file( + rebaserepo: Repository, +) -> None: + main = rebaserepo.head.shorthand + rebaserepo.branches.local.create('upstream', rebaserepo.head.peel(Commit)) + rebaserepo.checkout(rebaserepo.branches['upstream']) + _commit_file( + rebaserepo, + 'file2.txt', + 'Content of file 2\nModified upstream\n', + 'Modify file2.txt upstream', + ) + upstream_target = _tip(rebaserepo.branches['upstream']) + rebaserepo.checkout(rebaserepo.branches[main]) + removal_oid = _commit_removal(rebaserepo, 'file2.txt', 'Delete file2.txt') + original = rebaserepo[removal_oid].peel(Commit) + + _rebase_onto(rebaserepo, upstream_target) + + head = rebaserepo.head.peel(Commit) + assert head.message == ( + 'Delete file2.txt\n\n[Rebased with conflicts - manual resolution needed]' + ) + # The deleted side is left empty between the conflict markers. + expected = ( + '<<<<<<< HEAD (rebased)\n' + 'Content of file 2\n' + 'Modified upstream\n' + '=======\n' + f'>>>>>>> {original.short_id} (Delete file2.txt)\n' + ) + assert (Path(rebaserepo.workdir) / 'file2.txt').read_text() == expected + assert rebaserepo.status() == {} + + +def test_rebase_mixed_clean_and_conflicting_commits(rebaserepo: Repository) -> None: + upstream_target, local_oids = _diverge( + rebaserepo, + upstream=[ + ( + 'file1.txt', + 'Content of file 1\nLine 2 changed upstream\nLine 3\n', + 'Change line 2 upstream', + ) + ], + local=[ + ('file4.txt', 'New file 4\n', 'Add file4.txt'), + ( + 'file1.txt', + 'Content of file 1\nLine 2 changed locally\nLine 3\n', + 'Change line 2 locally', + ), + ], + ) + original = rebaserepo[local_oids[1]].peel(Commit) + _rebase_onto(rebaserepo, upstream_target) + + # The clean commit is replayed verbatim, only the conflicting one is + # annotated. + assert _linear_history(rebaserepo) == [ + 'Change line 2 locally\n\n[Rebased with conflicts - manual resolution needed]', + 'Add file4.txt', + 'Change line 2 upstream', + 'Add file2.txt', + 'Add file1.txt', + 'Initial commit', + ] + workdir = Path(rebaserepo.workdir) + assert (workdir / 'file4.txt').read_text() == 'New file 4\n' + expected = ( + '<<<<<<< HEAD (rebased)\n' + 'Content of file 1\n' + 'Line 2 changed upstream\n' + 'Line 3\n' + '=======\n' + 'Content of file 1\n' + 'Line 2 changed locally\n' + 'Line 3\n' + f'>>>>>>> {original.short_id} (Change line 2 locally)\n' + ) + assert (workdir / 'file1.txt').read_text() == expected + assert rebaserepo.status() == {} + + +def test_pull_rebase_after_fetch_from_remote(tmp_path: Path) -> None: + """The full `git pull --rebase` flow against a local "remote".""" + origin_path = tmp_path / 'origin' + origin = pygit2.init_repository(origin_path) + _commit_file(origin, 'README.md', '# Test Repository\n', 'Initial commit') + _commit_file(origin, 'file1.txt', 'Content of file 1\n', 'Add file1.txt') + + local = pygit2.clone_repository(str(origin_path), str(tmp_path / 'local')) + + _commit_file(origin, 'file3.txt', 'From origin\n', 'Add file3.txt in origin') + local_oid = _commit_file(local, 'file4.txt', 'From local\n', 'Add file4.txt local') + + for remote in local.remotes: + remote.fetch() + + branch = local.branches[local.head.shorthand] + upstream = branch.upstream + assert upstream is not None + _rebase_onto(local, _tip(upstream)) + + assert _linear_history(local) == [ + 'Add file4.txt local', + 'Add file3.txt in origin', + 'Add file1.txt', + 'Initial commit', + ] + assert local.head.target != local_oid + workdir = Path(local.workdir) + assert (workdir / 'file3.txt').read_text() == 'From origin\n' + assert (workdir / 'file4.txt').read_text() == 'From local\n' + assert local.status() == {} + + +# --------------------------------------------------------------------------- +# The native rebase API: Repository.rebase_init() / rebase_open() and Rebase +# --------------------------------------------------------------------------- + +CONFLICT_SCENARIO = dict( + upstream=[ + ( + 'file1.txt', + 'Content of file 1\nLine 2 changed upstream\nLine 3\n', + 'Change line 2 upstream', + ) + ], + local=[ + ( + 'file1.txt', + 'Content of file 1\nLine 2 changed locally\nLine 3\n', + 'Change line 2 locally', + ) + ], +) + + +def test_rebase_api_clean(rebaserepo: Repository) -> None: + main = rebaserepo.head.shorthand + upstream_target, local_oids = _diverge( + rebaserepo, + upstream=[('file3.txt', 'New file 3 from upstream\n', 'Add file3.txt')], + local=[ + ('a.txt', 'a\n', 'Add a.txt'), + ('b.txt', 'b\n', 'Add b.txt'), + ], + ) + rebase = rebaserepo.rebase_init(upstream=rebaserepo.branches['upstream']) + + assert len(rebase) == 2 + assert rebase.current_index is None + assert rebase.onto_id == upstream_target + assert rebase.onto_name == 'upstream' + assert rebase.orig_head_name == f'refs/heads/{main}' + assert rebase.orig_head_id == local_oids[-1] + # Operations replay the diverged commits oldest first. + assert [rebase[i].id for i in range(len(rebase))] == local_oids + assert rebase[-1].id == local_oids[-1] + with pytest.raises(IndexError): + rebase[2] + + replayed: list[Oid] = [] + for i, operation in enumerate(rebase): + assert operation.type == RebaseOperationType.PICK + assert operation.id == local_oids[i] + assert operation.exec is None + assert rebase.current_index == i + assert rebaserepo.state() == RepositoryState.REBASE_MERGE + new_id = rebase.commit(committer=_signature()) + assert new_id is not None + replayed.append(new_id) + rebase.finish(_signature()) + + assert rebaserepo.state() == RepositoryState.NONE + assert replayed[0] != local_oids[0] + head = rebaserepo.head.peel(Commit) + assert head.id == replayed[-1] + assert rebaserepo.head.name == f'refs/heads/{main}' + assert _linear_history(rebaserepo)[:4] == [ + 'Add b.txt', + 'Add a.txt', + 'Add file3.txt', + 'Add file2.txt', + ] + workdir = Path(rebaserepo.workdir) + for name in ('a.txt', 'b.txt', 'file3.txt'): + assert (workdir / name).exists() + assert rebaserepo.status() == {} + + +def test_rebase_api_conflict_has_hunk_level_markers(rebaserepo: Repository) -> None: + _, local_oids = _diverge(rebaserepo, **CONFLICT_SCENARIO) + rebase = rebaserepo.rebase_init(upstream=rebaserepo.branches['upstream']) + + next(rebase) + conflicts = rebaserepo.index.conflicts + assert conflicts is not None + ancestor, ours, theirs = conflicts['file1.txt'] + assert ancestor is not None and ours is not None and theirs is not None + + # Unlike the manual whole-file markers, libgit2 wrote hunk-level + # markers into the working directory: common lines stay outside, and + # the sides are labeled with the onto name and the commit summary. + expected = ( + 'Content of file 1\n' + '<<<<<<< upstream\n' + 'Line 2 changed upstream\n' + '=======\n' + 'Line 2 changed locally\n' + '>>>>>>> Change line 2 locally\n' + 'Line 3\n' + ) + assert (Path(rebaserepo.workdir) / 'file1.txt').read_text() == expected + + # Keep the markers as the resolution, the autocommit way: staging the + # file marks the conflict as resolved. + rebaserepo.index.add('file1.txt') + rebaserepo.index.write() + assert rebaserepo.index.conflicts is None + rebase.commit( + committer=_signature(), + message='Change line 2 locally\n\n[Rebased with conflicts]', + ) + with pytest.raises(StopIteration): + next(rebase) + rebase.finish(_signature()) + + head = rebaserepo.head.peel(Commit) + assert head.message == 'Change line 2 locally\n\n[Rebased with conflicts]' + assert (Path(rebaserepo.workdir) / 'file1.txt').read_text() == expected + assert rebaserepo.status() == {} + + +def test_rebase_api_unresolved_conflict_blocks_commit(rebaserepo: Repository) -> None: + _diverge(rebaserepo, **CONFLICT_SCENARIO) + rebase = rebaserepo.rebase_init(upstream=rebaserepo.branches['upstream']) + next(rebase) + with pytest.raises(pygit2.GitError): + rebase.commit(committer=_signature()) + rebase.abort() + + +def test_rebase_api_custom_labels(rebaserepo: Repository) -> None: + _diverge(rebaserepo, **CONFLICT_SCENARIO) + rebase = rebaserepo.rebase_init( + upstream=rebaserepo.branches['upstream'], + our_label='HEAD (rebased)', + their_label='incoming', + ) + next(rebase) + content = (Path(rebaserepo.workdir) / 'file1.txt').read_text() + assert '<<<<<<< HEAD (rebased)\n' in content + assert '>>>>>>> incoming\n' in content + rebase.abort() + + +def test_rebase_api_diff3_conflict_style(rebaserepo: Repository) -> None: + _diverge(rebaserepo, **CONFLICT_SCENARIO) + rebase = rebaserepo.rebase_init( + upstream=rebaserepo.branches['upstream'], + checkout_strategy=CheckoutStrategy.SAFE + | CheckoutStrategy.RECREATE_MISSING + | CheckoutStrategy.CONFLICT_STYLE_DIFF3, + ) + next(rebase) + content = (Path(rebaserepo.workdir) / 'file1.txt').read_text() + # diff3 style includes the common ancestor version of the hunk. + assert '||||||| ancestor\nLine 2\n=======\n' in content + rebase.abort() + + +def test_rebase_api_abort(rebaserepo: Repository) -> None: + main = rebaserepo.head.shorthand + upstream_target, local_oids = _diverge( + rebaserepo, + upstream=[('file3.txt', 'upstream\n', 'Upstream commit')], + local=[ + ('a.txt', 'a\n', 'Add a.txt'), + ('b.txt', 'b\n', 'Add b.txt'), + ], + ) + head_before = _tip(rebaserepo.head) + + rebase = rebaserepo.rebase_init(upstream=rebaserepo.branches['upstream']) + operation = next(rebase) + rebase.commit(committer=_signature()) + operation = next(rebase) + assert operation.id == local_oids[1] + assert rebaserepo.state() == RepositoryState.REBASE_MERGE + + rebase.abort() + + assert rebaserepo.state() == RepositoryState.NONE + assert rebaserepo.head.name == f'refs/heads/{main}' + assert rebaserepo.head.target == head_before + workdir = Path(rebaserepo.workdir) + assert (workdir / 'a.txt').exists() + assert (workdir / 'b.txt').exists() + assert not (workdir / 'file3.txt').exists() + assert rebaserepo.status() == {} + + +def test_rebase_api_inmemory(rebaserepo: Repository) -> None: + main = rebaserepo.head.shorthand + upstream_target, local_oids = _diverge( + rebaserepo, + upstream=[('file3.txt', 'upstream\n', 'Upstream commit')], + local=[('file4.txt', 'local\n', 'Add file4.txt')], + ) + head_before = _tip(rebaserepo.head) + + rebase = rebaserepo.rebase_init( + upstream=rebaserepo.branches['upstream'], inmemory=True + ) + operation = next(rebase) + assert operation.id == local_oids[0] + # The repository is not put into a rebasing state, and the working + # directory is not touched. + assert rebaserepo.state() == RepositoryState.NONE + assert not (Path(rebaserepo.workdir) / 'file3.txt').exists() + + merged = rebase.inmemory_index + assert merged.conflicts is None + assert 'file4.txt' in merged + + new_oid = rebase.commit(committer=_signature()) + assert new_oid is not None + rebase.finish(_signature()) + + # HEAD and the branch were left alone; putting the result in place is + # the caller's job, like the manual _fast_forward() epilogue. + assert rebaserepo.head.target == head_before + new_commit = rebaserepo[new_oid].peel(Commit) + assert [parent.id for parent in new_commit.parents] == [upstream_target] + assert new_commit.message == 'Add file4.txt' + + rebaserepo.references[f'refs/heads/{main}'].set_target(new_oid) + rebaserepo.checkout('HEAD', strategy=CheckoutStrategy.FORCE) + assert _linear_history(rebaserepo)[:2] == ['Add file4.txt', 'Upstream commit'] + assert rebaserepo.status() == {} + + +def test_rebase_api_finish_moves_branch_when_local_did_not_diverge( + rebaserepo: Repository, +) -> None: + upstream_target, _ = _diverge( + rebaserepo, + upstream=[('file3.txt', 'New file 3\n', 'Add file3.txt')], + local=[], + ) + rebase = rebaserepo.rebase_init(upstream=rebaserepo.branches['upstream']) + # Local did not diverge: there is nothing to replay, and finishing + # fast-forwards the branch to the upstream tip. + assert len(rebase) == 0 + with pytest.raises(StopIteration): + next(rebase) + rebase.finish(_signature()) + assert rebaserepo.head.target == upstream_target + assert (Path(rebaserepo.workdir) / 'file3.txt').exists() + assert rebaserepo.status() == {} + + +def test_rebase_api_replays_even_when_upstream_is_behind( + rebaserepo: Repository, +) -> None: + """Unlike `git pull --rebase` porcelain (and the manual no-op check), + the plumbing does not detect that the upstream is simply behind: it + replays the local commits, rewriting their ids.""" + upstream_target, local_oids = _diverge( + rebaserepo, + upstream=[], + local=[('file4.txt', 'New file 4\n', 'Add file4.txt')], + ) + rebase = rebaserepo.rebase_init(upstream=rebaserepo.branches['upstream']) + assert len(rebase) == 1 + for _operation in rebase: + rebase.commit(committer=_signature()) + rebase.finish(_signature()) + assert _linear_history(rebaserepo)[:2] == ['Add file4.txt', 'Add file2.txt'] + assert rebaserepo.head.target != local_oids[0] + assert rebaserepo.status() == {} + + +def test_rebase_api_already_applied_commit(rebaserepo: Repository) -> None: + """A local commit whose changes are already present upstream has + nothing left to commit; commit() reports that by returning None and + the caller simply moves on to the next operation.""" + _diverge( + rebaserepo, + upstream=[('file3.txt', 'identical\n', 'Add file3.txt upstream')], + local=[('file3.txt', 'identical\n', 'Add file3.txt locally')], + ) + rebase = rebaserepo.rebase_init(upstream=rebaserepo.branches['upstream']) + next(rebase) + assert rebase.commit(committer=_signature()) is None + with pytest.raises(StopIteration): + next(rebase) + rebase.finish(_signature()) + assert rebaserepo.head.target == _tip(rebaserepo.branches['upstream']) + assert rebaserepo.status() == {} + + +def test_rebase_open_resumes_rebase(rebaserepo: Repository) -> None: + upstream_target, local_oids = _diverge( + rebaserepo, + upstream=[('file3.txt', 'upstream\n', 'Upstream commit')], + local=[ + ('a.txt', 'a\n', 'Add a.txt'), + ('b.txt', 'b\n', 'Add b.txt'), + ], + ) + rebase = rebaserepo.rebase_init(upstream=rebaserepo.branches['upstream']) + next(rebase) + rebase.commit(committer=_signature()) + del rebase + + # Another client (or a later process) picks the rebase up from disk. + resumed = rebaserepo.rebase_open() + assert len(resumed) == 2 + assert resumed.current_index == 0 + assert resumed.onto_id == upstream_target + operation = next(resumed) + assert operation.id == local_oids[1] + resumed.commit(committer=_signature()) + resumed.finish(_signature()) + + assert rebaserepo.state() == RepositoryState.NONE + assert _linear_history(rebaserepo)[:3] == [ + 'Add b.txt', + 'Add a.txt', + 'Upstream commit', + ] + assert rebaserepo.status() == {} diff --git a/test/test_refdb_backend.py b/test/test_refdb_backend.py index a7f10cf55..063c38049 100644 --- a/test/test_refdb_backend.py +++ b/test/test_refdb_backend.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -25,11 +25,17 @@ """Tests for Refdb objects.""" +import sys +from collections.abc import Generator, Iterator from pathlib import Path -import pygit2 import pytest +import pygit2 +from pygit2 import Commit, Oid, Reference, Repository, Signature + +from . import utils + # Note: the refdb abstraction from libgit2 is meant to provide information # which libgit2 transforms into something more useful, and in general YMMV by @@ -37,77 +43,288 @@ # 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: + super().__init__() + self.source = source + + def __iter__(self) -> 'ProxyRefdbBackend': + return self - def exists(testrepo, ref): - return testrepo.source.exists(ref) + def __next__(self) -> Reference: + raise StopIteration - def lookup(testrepo, ref): - return testrepo.source.lookup(ref) + def exists(self, ref: str) -> bool: + return self.source.exists(ref) - def write(testrepo, ref, force, who, message, old, old_target): - return testrepo.source.write(ref, force, who, message, old, old_target) + def lookup(self, ref: str) -> Reference: + return self.source.lookup(ref) - def rename(testrepo, old_name, new_name, force, who, message): - return testrepo.source.rename(old_name, new_name, force, who, message) + 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 delete(testrepo, ref_name, old_id, old_target): - return testrepo.source.delete(ref_name, old_id, old_target) + def rename( + self, + old_name: str, + new_name: str, + force: bool, + who: Signature, + message: str | None, + ) -> Reference: + return self.source.rename(old_name, new_name, force, who, message) - def compress(testrepo): - return testrepo.source.compress() + 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 has_log(testrepo, ref_name): - return testrepo.source.has_log(ref_name) + def compress(self) -> None: + return self.source.compress() - def ensure_log(testrepo, ref_name): - return testrepo.source.ensure_log(ref_name) + def has_log(self, ref_name: str) -> bool: + return self.source.has_log(ref_name) - def __iter__(testrepo): - return iter(testrepo.source) + def ensure_log(self, ref_name: str) -> bool: + return self.source.ensure_log(ref_name) @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): +class CachedRefdbBackend(ProxyRefdbBackend): + """A backend that caches and reuses the Reference objects it returns.""" + + def __init__(self, source: pygit2.RefdbBackend) -> None: + super().__init__(source) + self.cache: dict[str, Reference] = {} + + def lookup(self, ref: str) -> Reference: + if ref not in self.cache: + self.cache[ref] = self.source.lookup(ref) + return self.cache[ref] + + +class IterRefdbBackend(ProxyRefdbBackend): + """A backend whose iterator yields cached Reference objects.""" + + def __init__(self, source: pygit2.RefdbBackend) -> None: + super().__init__(source) + self.cache: list[Reference] | None = None + self.refs: Iterator[Reference] = iter([]) + + def __iter__(self) -> 'IterRefdbBackend': + if self.cache is None: + self.cache = [ + self.source.lookup('refs/heads/master'), + self.source.lookup('refs/heads/i18n'), + Reference('refs/heads/symbolic', 'refs/heads/master'), + ] + self.refs = iter(self.cache) + return self + + def __next__(self) -> Reference: + return next(self.refs) + + +def test_exists(repo: Repository) -> None: assert not repo.backend.exists('refs/heads/does-not-exist') assert repo.backend.exists('refs/heads/master') -def test_lookup(repo): +class RaisingRefdbBackend(ProxyRefdbBackend): + """A backend whose callbacks always raise RuntimeError.""" + + def __init__(self, source: pygit2.RefdbBackend, exc: Exception) -> None: + super().__init__(source) + self.exc = exc + + def exists(self, ref: str) -> bool: + raise self.exc + + def lookup(self, ref: str) -> Reference: + raise self.exc + + +def test_exists_callback_raises_runtime_error(testrepo: Repository) -> None: + # Regression test: when the exists callback raises RuntimeError, the C + # wrapper must propagate the original Python exception, not overwrite it + # with a stale libgit2 error message. + backend = RaisingRefdbBackend(pygit2.RefdbFsBackend(testrepo), RuntimeError('boom')) + with pytest.raises(RuntimeError, match='boom'): + pygit2.RefdbBackend.exists(backend, 'refs/heads/master') + + +def test_lookup_callback_raises_runtime_error(testrepo: Repository) -> None: + # Regression test: when the lookup callback raises RuntimeError, the C + # wrapper must propagate the original Python exception. + backend = RaisingRefdbBackend(pygit2.RefdbFsBackend(testrepo), RuntimeError('boom')) + with pytest.raises(RuntimeError, match='boom'): + pygit2.RefdbBackend.lookup(backend, 'refs/heads/master') + + +def test_lookup(repo: Repository) -> None: assert repo.backend.lookup('refs/heads/does-not-exist') is None assert repo.backend.lookup('refs/heads/master').name == 'refs/heads/master' -def test_write(repo): +def test_lookup_cached_callback(testrepo: Repository) -> None: + # Regression test: a backend may cache and return the same Reference + # object on every lookup; the callback must not invalidate it, and + # repeated lookups through libgit2 must keep working. + backend = CachedRefdbBackend(pygit2.RefdbFsBackend(testrepo)) + refdb = pygit2.Refdb.new(testrepo) + refdb.set_backend(backend) + testrepo.set_refdb(refdb) + + target = testrepo.references['refs/heads/master'].target + assert testrepo.references['refs/heads/master'].target == target + assert backend.cache['refs/heads/master'].name == 'refs/heads/master' + + +def test_iterator_callback(testrepo: Repository) -> None: + # Exercise the custom backend's iterator callback through libgit2's + # git_reference_iterator; the Python attribute alone doesn't install it. + backend = IterRefdbBackend(pygit2.RefdbFsBackend(testrepo)) + refdb = pygit2.Refdb.new(testrepo) + refdb.set_backend(backend) + testrepo.set_refdb(refdb) + + names = sorted(ref.name for ref in testrepo.references.iterator()) + assert names == ['refs/heads/i18n', 'refs/heads/master', 'refs/heads/symbolic'] + + # The backend's cached objects must still be usable after iteration. + assert backend.cache is not None + assert [ref.name for ref in backend.cache] == [ + 'refs/heads/master', + 'refs/heads/i18n', + 'refs/heads/symbolic', + ] + + +@utils.requires_refcount +def test_iterator_callback_no_leak(testrepo: Repository) -> None: + # Iterating must not leak the Reference objects the backend yields. + backend = IterRefdbBackend(pygit2.RefdbFsBackend(testrepo)) + refdb = pygit2.Refdb.new(testrepo) + refdb.set_backend(backend) + testrepo.set_refdb(refdb) + + list(testrepo.references.iterator()) + assert backend.cache is not None + refcount = sys.getrefcount(backend.cache[0]) + list(testrepo.references.iterator()) + # Keep the getrefcount call out of the assert: pytest's assertion + # rewriting holds the subscript result in a frame temporary, which + # inflates the refcount on some Python versions (e.g. 3.11). + new_refcount = sys.getrefcount(backend.cache[0]) + assert new_refcount == refcount + + +def test_write(repo: Repository) -> None: 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_write_invalid_old_type(repo: Repository) -> None: + # Regression test (issue #1478): RefdbBackend.write must raise TypeError + # when old is not a valid oid, not silently ignore the bad argument. + master = repo.backend.lookup('refs/heads/master') + commit = repo[master.target] + ref = pygit2.Reference('refs/heads/test-write', master.target, None) + with pytest.raises(TypeError): + repo.backend.write(ref, False, commit.author, 'Create test-write', 1234, None) # type: ignore + + +def test_write_invalid_old_str(repo: Repository) -> None: + # Regression test (issue #1478): RefdbBackend.write must raise InvalidError + # when old is a malformed oid string, not silently ignore the bad argument. + master = repo.backend.lookup('refs/heads/master') + commit = repo[master.target] + ref = pygit2.Reference('refs/heads/test-write', master.target, None) + with pytest.raises(pygit2.InvalidError): + repo.backend.write( + ref, False, commit.author, 'Create test-write', 'not-a-valid-oid', None + ) + + +def test_delete_invalid_old_type(repo: Repository) -> None: + # Regression test (issue #1478): RefdbBackend.delete must raise TypeError + # when old_id is not a valid oid, not silently ignore the bad argument. + with pytest.raises(TypeError): + repo.backend.delete('refs/heads/master', 1234, None) # type: ignore + + +def test_delete_invalid_old_str(repo: Repository) -> None: + # Regression test (issue #1478): RefdbBackend.delete must raise InvalidError + # when old_id is a malformed oid string, not silently ignore the bad argument. + with pytest.raises(pygit2.InvalidError): + repo.backend.delete('refs/heads/master', 'not-a-valid-oid', None) + + +def test_rename(repo: Repository) -> None: old_ref = repo.backend.lookup('refs/heads/i18n') target = repo.get(old_ref.target) + 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_rename_callback(repo: Repository) -> None: + # Exercise the custom backend's rename callback through libgit2's + # git_reference_rename; calling repo.backend.rename() directly bypasses it. + refdb = pygit2.Refdb.new(repo) + refdb.set_backend(repo.backend) + repo.set_refdb(refdb) + ref = repo.references['refs/heads/i18n'] + target = ref.target + ref.rename('refs/heads/intl') + assert repo.references['refs/heads/intl'].target == target + + +def test_write_callback(repo: Repository) -> None: + # Exercise the custom backend's write callback through libgit2's + # git_reference_set_target; calling repo.backend.write() directly + # bypasses it. + refdb = pygit2.Refdb.new(repo) + refdb.set_backend(repo.backend) + repo.set_refdb(refdb) + master = repo.references['refs/heads/master'] + i18n = repo.references['refs/heads/i18n'] + i18n.set_target(master.target) + assert repo.references['refs/heads/i18n'].target == master.target + + +def test_write_callback_create(repo: Repository) -> None: + # Exercise the custom backend's write callback through libgit2's + # git_reference_create, which passes old=NULL for new references. + refdb = pygit2.Refdb.new(repo) + refdb.set_backend(repo.backend) + repo.set_refdb(refdb) + master = repo.references['refs/heads/master'] + repo.references.create('refs/heads/test-write', master.target) + assert repo.references['refs/heads/test-write'].target == master.target + + +def test_delete(repo: Repository) -> None: old = repo.backend.lookup('refs/heads/i18n') repo.backend.delete('refs/heads/i18n', old.target, None) 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 +332,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 50dfa96e4..80c59fc9b 100644 --- a/test/test_refs.py +++ b/test/test_refs.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -29,15 +29,25 @@ import pytest -from pygit2 import Commit, Signature, Tree, reference_is_valid_name, Repository -from pygit2 import AlreadyExistsError, GitError, InvalidSpecError -from pygit2.enums import ReferenceType - +from pygit2 import ( + AlreadyExistsError, + Commit, + GitError, + InvalidError, + 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'), @@ -61,6 +71,7 @@ def test_refs_list(testrepo: Repository) -> None: def test_head(testrepo: Repository) -> None: head = testrepo.head assert LAST_COMMIT == testrepo[head.target].id + assert not isinstance(head.raw_target, bytes) assert LAST_COMMIT == testrepo[head.raw_target].id @@ -75,17 +86,20 @@ def test_refs_getitem(testrepo: Repository) -> None: # Test a lookup reference = testrepo.references.get('refs/heads/master') + assert reference is not None assert reference.name == 'refs/heads/master' def test_refs_get_sha(testrepo: Repository) -> None: reference = testrepo.references['refs/heads/master'] + assert reference is not None assert reference.target == LAST_COMMIT def test_refs_set_sha(testrepo: Repository) -> None: NEW_COMMIT = '5ebeeebb320790caf276b9fc8b24546d63316533' reference = testrepo.references.get('refs/heads/master') + assert reference is not None reference.set_target(NEW_COMMIT) assert reference.target == NEW_COMMIT @@ -93,23 +107,27 @@ def test_refs_set_sha(testrepo: Repository) -> None: def test_refs_set_sha_prefix(testrepo: Repository) -> None: NEW_COMMIT = '5ebeeebb320790caf276b9fc8b24546d63316533' reference = testrepo.references.get('refs/heads/master') + assert reference is not None reference.set_target(NEW_COMMIT[0:6]) assert reference.target == NEW_COMMIT def test_refs_get_type(testrepo: Repository) -> None: reference = testrepo.references.get('refs/heads/master') + assert reference is not None assert reference.type == ReferenceType.DIRECT def test_refs_get_target(testrepo: Repository) -> None: reference = testrepo.references.get('HEAD') + assert reference is not None assert reference.target == 'refs/heads/master' assert reference.raw_target == b'refs/heads/master' def test_refs_set_target(testrepo: Repository) -> None: reference = testrepo.references.get('HEAD') + assert reference is not None assert reference.target == 'refs/heads/master' assert reference.raw_target == b'refs/heads/master' reference.set_target('refs/heads/i18n') @@ -119,6 +137,7 @@ def test_refs_set_target(testrepo: Repository) -> None: def test_refs_get_shorthand(testrepo: Repository) -> None: reference = testrepo.references.get('refs/heads/master') + assert reference is not None assert reference.shorthand == 'master' reference = testrepo.references.create('refs/remotes/origin/master', LAST_COMMIT) assert reference.shorthand == 'origin/master' @@ -126,6 +145,7 @@ def test_refs_get_shorthand(testrepo: Repository) -> None: def test_refs_set_target_with_message(testrepo: Repository) -> None: reference = testrepo.references.get('HEAD') + assert reference is not None assert reference.target == 'refs/heads/master' assert reference.raw_target == b'refs/heads/master' sig = Signature('foo', 'bar') @@ -189,6 +209,7 @@ def test_refs_rename(testrepo: Repository) -> None: def test_refs_resolve(testrepo: Repository) -> None: reference = testrepo.references.get('HEAD') + assert reference is not None assert reference.type == ReferenceType.SYMBOLIC reference = reference.resolve() assert reference.type == ReferenceType.DIRECT @@ -197,16 +218,20 @@ def test_refs_resolve(testrepo: Repository) -> None: def test_refs_resolve_identity(testrepo: Repository) -> None: head = testrepo.references.get('HEAD') + assert head is not None ref = head.resolve() assert ref.resolve() is ref def test_refs_create(testrepo: Repository) -> None: # We add a tag as a new reference that points to "origin/master" - reference = testrepo.references.create('refs/tags/version1', LAST_COMMIT) + reference: Reference | None = testrepo.references.create( + 'refs/tags/version1', LAST_COMMIT + ) refs = testrepo.references assert 'refs/tags/version1' in refs reference = testrepo.references.get('refs/tags/version1') + assert reference is not None assert reference.target == LAST_COMMIT # try to create existing reference @@ -247,7 +272,9 @@ def test_refs_create_symbolic(testrepo: Repository) -> None: def test_refs_peel(testrepo: Repository) -> None: ref = testrepo.references.get('refs/heads/master') + assert ref is not None assert testrepo[ref.target].id == ref.peel().id + assert not isinstance(ref.raw_target, bytes) assert testrepo[ref.raw_target].id == ref.peel().id commit = ref.peel(Commit) @@ -283,7 +310,7 @@ def test_refs_compress(testrepo: Repository) -> None: # -def test_list_all_reference_objects(testrepo): +def test_list_all_reference_objects(testrepo: Repository) -> None: repo = testrepo refs = [(ref.name, ref.target) for ref in repo.listall_reference_objects()] @@ -293,7 +320,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 +344,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 +377,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 +391,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 +401,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 +418,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 +466,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 +496,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 +538,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 +577,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 +596,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 +630,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 +638,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 +650,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 +658,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 +685,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 +697,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 +718,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 +730,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 +739,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 +762,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 +782,33 @@ 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() # type: ignore with pytest.raises(TypeError): - reference_is_valid_name() + reference_is_valid_name(None) # type: ignore with pytest.raises(TypeError): - reference_is_valid_name(None) + reference_is_valid_name(1) # type: ignore with pytest.raises(TypeError): - reference_is_valid_name(1) + reference_is_valid_name('too', 'many') # type: ignore + + +def test_reference_init_invalid_target_type() -> None: + # Regression test (issue #1478): Reference constructor must raise TypeError + # for a non-oid target, not silently create a reference with garbage data. + with pytest.raises(TypeError): + Reference('refs/heads/test', 1234, None) + + +def test_reference_init_invalid_target_str() -> None: + # Regression test (issue #1478): Reference constructor must raise InvalidError + # for a malformed oid string, not silently create a reference with garbage data. + with pytest.raises(InvalidError): + Reference('refs/heads/test', 'not-a-valid-oid', None) + + +def test_reference_init_invalid_peel() -> None: + # Regression test (issue #1478): Reference constructor must raise TypeError + # for a malformed peel oid, not silently ignore the error. with pytest.raises(TypeError): - reference_is_valid_name('too', 'many') + Reference('refs/heads/test', LAST_COMMIT, 1234) diff --git a/test/test_remote.py b/test/test_remote.py index e3cc21469..98b83bf8e 100644 --- a/test/test_remote.py +++ b/test/test_remote.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -24,16 +24,17 @@ # Boston, MA 02110-1301, USA. import sys -from pathlib import Path from collections.abc import Generator +from pathlib import Path import pytest import pygit2 -from pygit2 import Repository, Remote -from pygit2.remotes import TransferProgress -from . import utils +from pygit2 import Remote, RemoteCallbacks, Repository +from pygit2.ffi import ffi +from pygit2.remotes import PushUpdate, TransferProgress +from . import utils REMOTE_NAME = 'origin' REMOTE_URL = 'https://github.com/libgit2/pygit2.git' @@ -193,15 +194,31 @@ def test_remote_list(testrepo: Repository) -> None: @utils.requires_network -def test_ls_remotes(testrepo: Repository) -> None: +def test_list_heads(testrepo: Repository) -> None: + assert 1 == len(testrepo.remotes) + remote = testrepo.remotes[0] + + refs = remote.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')) + + +@utils.requires_network +def test_list_heads_without_implicit_connect(testrepo: Repository) -> None: assert 1 == len(testrepo.remotes) remote = testrepo.remotes[0] - refs = remote.ls_remotes() + 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')) + assert next(iter(r for r in refs if r.name == 'refs/tags/v0.28.2')) def test_remote_collection(testrepo: Repository) -> None: @@ -282,11 +299,13 @@ def test_update_tips(emptyrepo: Repository) -> None: ] class MyCallbacks(pygit2.RemoteCallbacks): - def __init__(self, tips): + tips: list[tuple[str, pygit2.Oid, pygit2.Oid]] + + def __init__(self, tips: list[tuple[str, pygit2.Oid, pygit2.Oid]]) -> None: self.tips = tips self.i = 0 - def update_tips(self, name, old, new): + def update_tips(self, name: str, old: pygit2.Oid, new: pygit2.Oid) -> None: assert self.tips[self.i] == (name, old, new) self.i += 1 @@ -296,7 +315,7 @@ def update_tips(self, name, old, new): @utils.requires_network -def test_ls_remotes_certificate_check() -> None: +def test_list_heads_certificate_check() -> None: url = 'https://github.com/pygit2/empty.git' class MyCallbacks(pygit2.RemoteCallbacks): @@ -318,7 +337,7 @@ def certificate_check( remote = git.remotes.create_anonymous(url) callbacks = MyCallbacks() - refs = remote.ls_remotes(callbacks=callbacks) + refs = remote.list_heads(callbacks=callbacks) # Sanity check that we indeed got some refs. assert len(refs) > 0 @@ -342,7 +361,7 @@ def clone(tmp_path: Path) -> Generator[Repository, None, None]: @pytest.fixture -def remote(origin, clone): +def remote(origin: Repository, clone: Repository) -> Generator[Remote, None, None]: yield clone.remotes.create('origin', origin.path) @@ -404,9 +423,12 @@ def push_transfer_progress( assert origin.branches['master'].target == new_tip_id +@pytest.mark.parametrize('reject_from', ['push_transfer_progress', 'push_negotiation']) def test_push_interrupted_from_callbacks( - origin: Repository, clone: Repository, remote: Remote + origin: Repository, clone: Repository, remote: Remote, reject_from: str ) -> None: + reject_message = 'retreat! retreat!' + tip = clone[clone.head.target] clone.create_commit( 'refs/heads/master', @@ -418,10 +440,15 @@ def test_push_interrupted_from_callbacks( ) class MyCallbacks(pygit2.RemoteCallbacks): + def push_negotiation(self, updates: list[PushUpdate]) -> None: + if reject_from == 'push_negotiation': + raise InterruptedError(reject_message) + def push_transfer_progress( self, objects_pushed: int, total_objects: int, bytes_pushed: int ) -> None: - raise InterruptedError('retreat! retreat!') + if reject_from == 'push_transfer_progress': + raise InterruptedError(reject_message) assert origin.branches['master'].target == tip.id @@ -459,8 +486,6 @@ def test_push_non_fast_forward_commits_to_remote_fails( def test_push_options(origin: Repository, clone: Repository, remote: Remote) -> None: - from pygit2 import RemoteCallbacks - callbacks = RemoteCallbacks() remote.push(['refs/heads/master'], callbacks) remote_push_options = callbacks.push_options.remote_push_options @@ -489,8 +514,6 @@ def test_push_options(origin: Repository, clone: Repository, remote: Remote) -> def test_push_threads(origin: Repository, clone: Repository, remote: Remote) -> None: - from pygit2 import RemoteCallbacks - callbacks = RemoteCallbacks() remote.push(['refs/heads/master'], callbacks) assert callbacks.push_options.pb_parallelism == 1 @@ -502,3 +525,176 @@ def test_push_threads(origin: Repository, clone: Repository, remote: Remote) -> callbacks = RemoteCallbacks() remote.push(['refs/heads/master'], callbacks, threads=1) assert callbacks.push_options.pb_parallelism == 1 + + +def test_push_negotiation( + origin: Repository, clone: Repository, remote: Remote +) -> None: + old_tip = clone[clone.head.target] + new_tip_id = clone.create_commit( + 'refs/heads/master', + old_tip.author, + old_tip.author, + 'empty commit', + old_tip.tree.id, + [old_tip.id], + ) + + the_updates: list[PushUpdate] = [] + + class MyCallbacks(pygit2.RemoteCallbacks): + def push_negotiation(self, updates: list[PushUpdate]) -> None: + the_updates.extend(updates) + + assert origin.branches['master'].target == old_tip.id + assert 'new_branch' not in origin.branches + + callbacks = MyCallbacks() + remote.push(['refs/heads/master'], callbacks=callbacks) + + assert len(the_updates) == 1 + assert the_updates[0].src_refname == 'refs/heads/master' + assert the_updates[0].dst_refname == 'refs/heads/master' + assert the_updates[0].src == old_tip.id + assert the_updates[0].dst == new_tip_id + + assert origin.branches['master'].target == new_tip_id + + +class HeaderCallbacks(RemoteCallbacks): + def custom_headers(self) -> list[str] | None: + return ['X-Other-One: foo', 'X-Other-Two: bar'] + + +def test_git_custom_headers_context_manager( + origin: Repository, + clone: Repository, + remote: Remote, +) -> None: + from pygit2.callbacks import git_custom_headers, git_fetch_options, git_push_options + + class EmptyHeaderCallbacks(RemoteCallbacks): + def custom_headers(self) -> list[str] | None: + return [] + + callbacks = RemoteCallbacks() + with git_custom_headers(callbacks) as headers: + assert headers.ptr == ffi.NULL + + callbacks = EmptyHeaderCallbacks() + with git_custom_headers(callbacks) as headers: + assert headers.ptr == ffi.NULL + + callbacks = HeaderCallbacks() + with git_custom_headers(callbacks) as headers: + ptr = headers.ptr + assert ptr != ffi.NULL + assert ptr.count == 2 # type: ignore[union-attr] + assert ffi.string(ptr.strings[0]) == b'X-Other-One: foo' # type: ignore[union-attr,index] + assert ffi.string(ptr.strings[1]) == b'X-Other-Two: bar' # type: ignore[union-attr,index] + + callbacks = RemoteCallbacks() + with git_fetch_options(callbacks) as payload: + assert payload.fetch_options.custom_headers.count == 0 + assert payload.fetch_options.custom_headers.strings == ffi.NULL + + callbacks = EmptyHeaderCallbacks() + with git_fetch_options(callbacks) as payload: + assert payload.fetch_options.custom_headers.count == 0 + assert payload.fetch_options.custom_headers.strings == ffi.NULL + + callbacks = HeaderCallbacks() + with git_fetch_options(callbacks) as payload: + assert payload.fetch_options.custom_headers.count == 2 + assert ( + ffi.string(payload.fetch_options.custom_headers.strings[0]) + == b'X-Other-One: foo' + ) + assert ( + ffi.string(payload.fetch_options.custom_headers.strings[1]) + == b'X-Other-Two: bar' + ) + + callbacks = RemoteCallbacks() + with git_push_options(callbacks) as payload: + assert payload.push_options.custom_headers.count == 0 + assert payload.push_options.custom_headers.strings == ffi.NULL + + callbacks = EmptyHeaderCallbacks() + with git_push_options(callbacks) as payload: + assert payload.push_options.custom_headers.count == 0 + assert payload.push_options.custom_headers.strings == ffi.NULL + + callbacks = HeaderCallbacks() + with git_push_options(callbacks) as payload: + assert payload.push_options.custom_headers.count == 2 + assert ( + ffi.string(payload.push_options.custom_headers.strings[0]) + == b'X-Other-One: foo' + ) + assert ( + ffi.string(payload.push_options.custom_headers.strings[1]) + == b'X-Other-Two: bar' + ) + + +def test_push_headers(origin: Repository, clone: Repository, remote: Remote) -> None: + callbacks = RemoteCallbacks() + remote.push(['refs/heads/master'], callbacks=callbacks) + assert callbacks.push_options.custom_headers.count == 0 + assert callbacks.push_options.custom_headers.strings == ffi.NULL + + callbacks = HeaderCallbacks() + remote.push(['refs/heads/master'], callbacks=callbacks) + assert callbacks.push_options.custom_headers.count == 2 + assert callbacks.push_options.custom_headers.strings != ffi.NULL + # strings pointed to by callbacks.push_options.custom_headers.strings[] are already freed + + # make sure the custom headers don't "stick around" + callbacks = RemoteCallbacks() + remote.push(['refs/heads/master'], callbacks=callbacks) + assert callbacks.push_options.custom_headers.count == 0 + assert callbacks.push_options.custom_headers.strings == ffi.NULL + + +def test_fetch_headers(origin: Repository, clone: Repository, remote: Remote) -> None: + callbacks = RemoteCallbacks() + remote.fetch(['refs/heads/master'], callbacks=callbacks) + assert callbacks.fetch_options.custom_headers.count == 0 + assert callbacks.fetch_options.custom_headers.strings == ffi.NULL + + callbacks = HeaderCallbacks() + remote.fetch(['refs/heads/master'], callbacks=callbacks) + assert callbacks.fetch_options.custom_headers.count == 2 + assert callbacks.fetch_options.custom_headers.strings != ffi.NULL + # strings pointed to by callbacks.fetch_options.custom_headers.strings[] are already freed + + # make sure the custom headers don't "stick around" + callbacks = RemoteCallbacks() + remote.fetch(['refs/heads/master'], callbacks=callbacks) + assert callbacks.fetch_options.custom_headers.count == 0 + assert callbacks.fetch_options.custom_headers.strings == ffi.NULL + + +@utils.requires_network +def test_connect_headers(testrepo: Repository) -> None: + # This is just a check that having custom headers doesn't cause errors. As far as I can tell, + # there's no way to assert that C.git_remote_connect was called with the headers except for + # having a remote server that expects the headers and fails without them. + + assert 1 == len(testrepo.remotes) + remote = testrepo.remotes[0] + + callbacks = RemoteCallbacks() + remote.connect(callbacks=callbacks) + refs = remote.list_heads(connect=False) + assert refs + # Check that a known ref is returned. + assert next(iter(r for r in refs if r.name == 'refs/tags/v0.28.2')) + + callbacks = HeaderCallbacks() + remote.connect(callbacks=callbacks) + refs = remote.list_heads(connect=False) + assert refs + # Check that a known ref is returned. + assert next(iter(r for r in refs if r.name == 'refs/tags/v0.28.2')) diff --git a/test/test_remote_prune.py b/test/test_remote_prune.py index 927d812c9..2c57e0aa8 100644 --- a/test/test_remote_prune.py +++ b/test/test_remote_prune.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -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 cf58a8d53..29dfb6fcf 100644 --- a/test/test_remote_utf8.py +++ b/test/test_remote_utf8.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -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 d48aa7acc..03ba80811 100644 --- a/test/test_repository.py +++ b/test/test_repository.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -23,56 +23,71 @@ # 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, IndexEntry -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, RepositoryState, ResetMode, StashApplyProgress, - FileMode, ) 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 @@ -81,39 +96,48 @@ def test_checkout_ref(testrepo): testrepo.checkout(ref_i18n) # checkout i18n with GIT_CHECKOUT_FORCE - head = testrepo.head - head = testrepo[head.target] + head_object = testrepo.head + head = testrepo[head_object.target] assert 'new' not in head.tree testrepo.checkout(ref_i18n, strategy=CheckoutStrategy.FORCE) - head = testrepo.head - head = testrepo[head.target] + head_object = testrepo.head + head = testrepo[head_object.target] assert head.id == ref_i18n.target assert 'new' in head.tree assert 'bye.txt' not in testrepo.status() -def test_checkout_callbacks(testrepo): +def test_checkout_callbacks(testrepo: Repository) -> None: ref_i18n = testrepo.lookup_reference('refs/heads/i18n') class MyCheckoutCallbacks(pygit2.CheckoutCallbacks): - def __init__(self): + def __init__(self) -> None: super().__init__() - self.conflicting_paths = set() - self.updated_paths = set() + self.conflicting_paths: set[str] = set() + self.updated_paths: set[str] = set() self.completed_steps = -1 self.total_steps = -1 def checkout_notify_flags(self) -> CheckoutNotify: return CheckoutNotify.CONFLICT | CheckoutNotify.UPDATED - def checkout_notify(self, why, path, baseline, target, workdir): + def checkout_notify( + self, + why: CheckoutNotify, + path: str, + baseline: Optional[DiffFile], + target: Optional[DiffFile], + workdir: Optional[DiffFile], + ) -> None: if why == CheckoutNotify.CONFLICT: self.conflicting_paths.add(path) elif why == CheckoutNotify.UPDATED: self.updated_paths.add(path) - def checkout_progress(self, path: str, completed_steps: int, total_steps: int): + def checkout_progress( + self, path: str, completed_steps: int, total_steps: int + ) -> None: self.completed_steps = completed_steps self.total_steps = total_steps @@ -126,8 +150,8 @@ def checkout_progress(self, path: str, completed_steps: int, total_steps: int): assert -1 == callbacks.completed_steps # shouldn't have done anything # checkout i18n with GIT_CHECKOUT_FORCE - head = testrepo.head - head = testrepo[head.target] + head_object = testrepo.head + head = testrepo[head_object.target] assert 'new' not in head.tree callbacks = MyCheckoutCallbacks() testrepo.checkout(ref_i18n, strategy=CheckoutStrategy.FORCE, callbacks=callbacks) @@ -138,29 +162,38 @@ def checkout_progress(self, path: str, completed_steps: int, total_steps: int): assert callbacks.completed_steps == callbacks.total_steps -def test_checkout_aborted_from_callbacks(testrepo): +def test_checkout_aborted_from_callbacks(testrepo: Repository) -> None: ref_i18n = testrepo.lookup_reference('refs/heads/i18n') - def read_bye_txt(): - return testrepo[testrepo.create_blob_fromworkdir('bye.txt')].data + def read_bye_txt() -> bytes: + blob = testrepo[testrepo.create_blob_fromworkdir('bye.txt')] + assert isinstance(blob, Blob) + return blob.data s = testrepo.status() assert s == {'bye.txt': FileStatus.WT_NEW} class MyCheckoutCallbacks(pygit2.CheckoutCallbacks): - def __init__(self): + def __init__(self) -> None: super().__init__() self.invoked_times = 0 - def checkout_notify(self, why, path, baseline, target, workdir): + def checkout_notify( + self, + why: CheckoutNotify, + path: str, + baseline: Optional[DiffFile], + target: Optional[DiffFile], + workdir: Optional[DiffFile], + ) -> None: self.invoked_times += 1 # skip one file so we're certain that NO files are affected, # even if aborting the checkout from the second file if self.invoked_times == 2: raise InterruptedError('Stop the checkout!') - head = testrepo.head - head = testrepo[head.target] + head_object = testrepo.head + head = testrepo[head_object.target] assert 'new' not in head.tree assert b'bye world\n' == read_bye_txt() callbacks = MyCheckoutCallbacks() @@ -176,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 @@ -185,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') @@ -208,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') @@ -224,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() @@ -233,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) @@ -242,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', @@ -258,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', @@ -283,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', @@ -299,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() @@ -316,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() @@ -337,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() @@ -356,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( @@ -397,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 @@ -416,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 @@ -435,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 ) @@ -450,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 @@ -468,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 ) @@ -485,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!') @@ -507,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 ) @@ -523,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') @@ -550,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 @@ -564,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 @@ -584,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' @@ -594,7 +641,20 @@ def test_default_signature(testrepo): assert 'rjh@example.com' == sig.email -def test_new_repo(tmp_path): +def test_ident_get_set(testrepo: Repository) -> None: + # By default, reflog identity should be unset. + assert testrepo.ident == (None, None) + + cname = 'C O Mitter' + cemail = 'committer@example.com' + testrepo.set_ident(cname, cemail) + assert testrepo.ident == (cname, cemail) + + testrepo.set_ident(None, None) + assert testrepo.ident == (None, None) + + +def test_new_repo(tmp_path: Path) -> None: repo = init_repository(tmp_path, False) oid = repo.write(ObjectType.BLOB, 'Test') @@ -603,55 +663,57 @@ def test_new_repo(tmp_path): assert (tmp_path / '.git').exists() -def test_no_arg(tmp_path): +def test_no_arg(tmp_path: Path) -> None: repo = init_repository(tmp_path) assert not repo.is_bare -def test_no_arg_aspath(tmp_path): +def test_no_arg_aspath(tmp_path: Path) -> None: repo = init_repository(Path(tmp_path)) assert not repo.is_bare -def test_pos_arg_false(tmp_path): +def test_pos_arg_false(tmp_path: Path) -> None: repo = init_repository(tmp_path, False) assert not repo.is_bare -def test_pos_arg_true(tmp_path): +def test_pos_arg_true(tmp_path: Path) -> None: repo = init_repository(tmp_path, True) assert repo.is_bare -def test_keyword_arg_false(tmp_path): +def test_keyword_arg_false(tmp_path: Path) -> None: repo = init_repository(tmp_path, bare=False) assert not repo.is_bare -def test_keyword_arg_true(tmp_path): +def test_keyword_arg_true(tmp_path: Path) -> None: repo = init_repository(tmp_path, bare=True) assert repo.is_bare -def test_discover_repo(tmp_path): +def test_discover_repo(tmp_path: Path) -> None: repo = init_repository(tmp_path, False) subdir = tmp_path / 'test1' / 'test2' subdir.mkdir(parents=True) assert repo.path == discover_repository(str(subdir)) -def test_discover_repo_aspath(tmp_path): +def test_discover_repo_aspath(tmp_path: Path) -> None: repo = init_repository(Path(tmp_path), False) subdir = Path(tmp_path) / 'test1' / 'test2' subdir.mkdir(parents=True) assert repo.path == discover_repository(subdir) -def test_discover_repo_not_found(): - assert discover_repository(tempfile.tempdir) is None +def test_discover_repo_not_found() -> None: + tempdir = tempfile.tempdir + assert tempdir is not None + assert discover_repository(tempdir) is None -def test_repository_init(barerepo_path): +def test_repository_init(barerepo_path: tuple[Repository, Path]) -> None: barerepo, path = barerepo_path assert isinstance(path, Path) pygit2.Repository(path) @@ -659,7 +721,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 @@ -669,14 +731,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) @@ -684,15 +746,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( @@ -705,7 +769,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) @@ -715,9 +779,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' @@ -726,12 +795,14 @@ def credentials(self, url, username, allowed): assert str(exc.value) == 'Unexpected error' -def test_clone_with_checkout_branch(barerepo, tmp_path): +def test_clone_with_checkout_branch(barerepo: Repository, tmp_path: Path) -> None: # create a test case which isolates the remote test_repo = clone_repository( barerepo.path, tmp_path / 'testrepo-orig.git', bare=True ) - test_repo.create_branch('test', test_repo[test_repo.head.target]) + commit = test_repo[test_repo.head.target] + assert isinstance(commit, Commit) + test_repo.create_branch('test', commit) repo = clone_repository( test_repo.path, tmp_path / 'testrepo.git', checkout_branch='test', bare=True ) @@ -740,7 +811,7 @@ def test_clone_with_checkout_branch(barerepo, tmp_path): @utils.requires_proxy @utils.requires_network -def test_clone_with_proxy(tmp_path): +def test_clone_with_proxy(tmp_path: Path) -> None: url = 'https://github.com/libgit2/TestGitRepository' repo = clone_repository( url, @@ -791,14 +862,14 @@ def test_clone_with_proxy(tmp_path): # # assert repo.remotes[0].fetchspec == "refs/heads/test" -def test_worktree(testrepo): +def test_worktree(testrepo: Repository) -> None: worktree_name = 'foo' worktree_dir = Path(tempfile.mkdtemp()) # Delete temp path so that it's not present when we attempt to add the # 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 @@ -833,7 +904,7 @@ def _check_worktree(worktree): assert testrepo.list_worktrees() == [] -def test_worktree_aspath(testrepo): +def test_worktree_aspath(testrepo: Repository) -> None: worktree_name = 'foo' worktree_dir = Path(tempfile.mkdtemp()) # Delete temp path so that it's not present when we attempt to add the @@ -843,13 +914,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 @@ -877,7 +949,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 @@ -911,7 +983,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 @@ -921,7 +993,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 @@ -931,8 +1003,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 @@ -944,7 +1016,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: @@ -971,8 +1043,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 @@ -989,142 +1061,151 @@ def test_repository_hashfile_filter(testrepo): h = testrepo.hashfile('hello.txt') -def test_merge_file_from_index_deprecated(testrepo): +def test_merge_file_from_index_deprecated(testrepo: Repository) -> None: hello_txt = testrepo.index['hello.txt'] hello_txt_executable = IndexEntry( hello_txt.path, hello_txt.id, FileMode.BLOB_EXECUTABLE ) hello_world = IndexEntry('hello_world.txt', hello_txt.id, hello_txt.mode) - # no change - res = testrepo.merge_file_from_index(hello_txt, hello_txt, hello_txt) - assert res == testrepo.get(hello_txt.id).data.decode() + def get_hello_txt_from_repo() -> str: + blob = testrepo.get(hello_txt.id) + assert isinstance(blob, Blob) + return blob.data.decode() - # executable switch on ours - res = testrepo.merge_file_from_index(hello_txt, hello_txt_executable, hello_txt) - assert res == testrepo.get(hello_txt.id).data.decode() + with pytest.warns(DeprecationWarning, match='Getting an str'): + # no change + res = testrepo.merge_file_from_index( + hello_txt, hello_txt, hello_txt, use_deprecated=True + ) + assert res == get_hello_txt_from_repo() - # executable switch on theirs - res = testrepo.merge_file_from_index(hello_txt, hello_txt, hello_txt_executable) - assert res == testrepo.get(hello_txt.id).data.decode() + # executable switch on ours + res = testrepo.merge_file_from_index( + hello_txt, hello_txt_executable, hello_txt, use_deprecated=True + ) + assert res == get_hello_txt_from_repo() - # executable switch on both - res = testrepo.merge_file_from_index( - hello_txt, hello_txt_executable, hello_txt_executable - ) - assert res == testrepo.get(hello_txt.id).data.decode() + # executable switch on theirs + res = testrepo.merge_file_from_index( + hello_txt, hello_txt, hello_txt_executable, use_deprecated=True + ) + assert res == get_hello_txt_from_repo() - # path switch on ours - res = testrepo.merge_file_from_index(hello_txt, hello_world, hello_txt) - assert res == testrepo.get(hello_txt.id).data.decode() + # executable switch on both + res = testrepo.merge_file_from_index( + hello_txt, hello_txt_executable, hello_txt_executable, use_deprecated=True + ) + assert res == get_hello_txt_from_repo() - # path switch on theirs - res = testrepo.merge_file_from_index(hello_txt, hello_txt, hello_world) - assert res == testrepo.get(hello_txt.id).data.decode() + # path switch on ours + res = testrepo.merge_file_from_index( + hello_txt, hello_world, hello_txt, use_deprecated=True + ) + assert res == get_hello_txt_from_repo() - # path switch on both - res = testrepo.merge_file_from_index(hello_txt, hello_world, hello_world) - assert res == testrepo.get(hello_txt.id).data.decode() + # path switch on theirs + res = testrepo.merge_file_from_index( + hello_txt, hello_txt, hello_world, use_deprecated=True + ) + assert res == get_hello_txt_from_repo() - # path switch on ours, executable flag switch on theirs - res = testrepo.merge_file_from_index(hello_txt, hello_world, hello_txt_executable) - assert res == testrepo.get(hello_txt.id).data.decode() + # path switch on both + res = testrepo.merge_file_from_index( + hello_txt, hello_world, hello_world, use_deprecated=True + ) + assert res == get_hello_txt_from_repo() - # path switch on theirs, executable flag switch on ours - res = testrepo.merge_file_from_index(hello_txt, hello_txt_executable, hello_world) - assert res == testrepo.get(hello_txt.id).data.decode() + # path switch on ours, executable flag switch on theirs + res = testrepo.merge_file_from_index( + hello_txt, hello_world, hello_txt_executable, use_deprecated=True + ) + assert res == get_hello_txt_from_repo() + + # path switch on theirs, executable flag switch on ours + res = testrepo.merge_file_from_index( + hello_txt, hello_txt_executable, hello_world, use_deprecated=True + ) + assert res == get_hello_txt_from_repo() -def test_merge_file_from_index_non_deprecated(testrepo): +def test_merge_file_from_index(testrepo: Repository) -> None: hello_txt = testrepo.index['hello.txt'] hello_txt_executable = IndexEntry( hello_txt.path, hello_txt.id, FileMode.BLOB_EXECUTABLE ) 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 - ) + res = testrepo.merge_file_from_index(hello_txt, hello_txt, hello_txt) assert res == MergeFileResult( - True, hello_txt.path, hello_txt.mode, testrepo.get(hello_txt.id).data.decode() + True, hello_txt.path, hello_txt.mode, get_hello_txt_from_repo() ) # executable switch on ours - res = testrepo.merge_file_from_index( - hello_txt, hello_txt_executable, hello_txt, use_deprecated=False - ) + res = testrepo.merge_file_from_index(hello_txt, hello_txt_executable, hello_txt) assert res == MergeFileResult( True, hello_txt.path, hello_txt_executable.mode, - testrepo.get(hello_txt.id).data.decode(), + 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 - ) + res = testrepo.merge_file_from_index(hello_txt, hello_txt, hello_txt_executable) assert res == MergeFileResult( True, hello_txt.path, hello_txt_executable.mode, - testrepo.get(hello_txt.id).data.decode(), + 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 + hello_txt, hello_txt_executable, hello_txt_executable ) assert res == MergeFileResult( True, hello_txt.path, hello_txt_executable.mode, - testrepo.get(hello_txt.id).data.decode(), + get_hello_txt_from_repo(), ) # path switch on ours - res = testrepo.merge_file_from_index( - hello_txt, hello_world, hello_txt, use_deprecated=False - ) + res = testrepo.merge_file_from_index(hello_txt, hello_world, hello_txt) assert res == MergeFileResult( - True, hello_world.path, hello_txt.mode, testrepo.get(hello_txt.id).data.decode() + True, hello_world.path, hello_txt.mode, get_hello_txt_from_repo() ) # path switch on theirs - res = testrepo.merge_file_from_index( - hello_txt, hello_txt, hello_world, use_deprecated=False - ) + res = testrepo.merge_file_from_index(hello_txt, hello_txt, hello_world) assert res == MergeFileResult( - True, hello_world.path, hello_txt.mode, testrepo.get(hello_txt.id).data.decode() + True, hello_world.path, hello_txt.mode, get_hello_txt_from_repo() ) # path switch on both - res = testrepo.merge_file_from_index( - hello_txt, hello_world, hello_world, use_deprecated=False - ) - assert res == MergeFileResult( - True, None, hello_txt.mode, testrepo.get(hello_txt.id).data.decode() - ) + res = testrepo.merge_file_from_index(hello_txt, hello_world, hello_world) + assert res == MergeFileResult(True, None, hello_txt.mode, get_hello_txt_from_repo()) # path switch on ours, executable flag switch on theirs - res = testrepo.merge_file_from_index( - hello_txt, hello_world, hello_txt_executable, use_deprecated=False - ) + res = testrepo.merge_file_from_index(hello_txt, hello_world, hello_txt_executable) assert res == MergeFileResult( True, hello_world.path, hello_txt_executable.mode, - testrepo.get(hello_txt.id).data.decode(), + get_hello_txt_from_repo(), ) # path switch on theirs, executable flag switch on ours - res = testrepo.merge_file_from_index( - hello_txt, hello_txt_executable, hello_world, use_deprecated=False - ) + res = testrepo.merge_file_from_index(hello_txt, hello_txt_executable, hello_world) assert res == MergeFileResult( True, hello_world.path, hello_txt_executable.mode, - testrepo.get(hello_txt.id).data.decode(), + get_hello_txt_from_repo(), ) diff --git a/test/test_repository_bare.py b/test/test_repository_bare.py index 0274a4018..9a5028728 100644 --- a/test/test_repository_bare.py +++ b/test/test_repository_bare.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -28,13 +28,15 @@ 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,15 +45,15 @@ BLOB_OID = pygit2.Oid(raw=BLOB_RAW) -def test_is_empty(barerepo): +def test_is_empty(barerepo: Repository) -> None: assert not barerepo.is_empty -def test_is_bare(barerepo): +def test_is_bare(barerepo: Repository) -> None: assert barerepo.is_bare -def test_head(barerepo): +def test_head(barerepo: Repository) -> None: head = barerepo.head assert HEAD_SHA == head.target assert type(head) is pygit2.Reference @@ -59,7 +61,7 @@ def test_head(barerepo): assert not barerepo.head_is_detached -def test_set_head(barerepo): +def test_set_head(barerepo: Repository) -> None: # Test setting a detached HEAD. barerepo.set_head(pygit2.Oid(hex=PARENT_SHA)) assert barerepo.head.target == PARENT_SHA @@ -69,9 +71,9 @@ def test_set_head(barerepo): assert barerepo.head.target == HEAD_SHA -def test_read(barerepo): +def test_read(barerepo: Repository) -> None: with pytest.raises(TypeError): - barerepo.read(123) + barerepo.read(123) # type: ignore utils.assertRaisesWithArg(KeyError, '1' * 40, barerepo.read, '1' * 40) ab = barerepo.read(BLOB_OID) @@ -87,7 +89,7 @@ def test_read(barerepo): assert (ObjectType.BLOB, b'a contents\n') == a3 -def test_write(barerepo): +def test_write(barerepo: Repository) -> None: data = b'hello world' # invalid object type with pytest.raises(ValueError): @@ -97,9 +99,9 @@ def test_write(barerepo): assert type(oid) is pygit2.Oid -def test_contains(barerepo): +def test_contains(barerepo: Repository) -> None: with pytest.raises(TypeError): - 123 in barerepo + 123 in barerepo # type: ignore assert BLOB_OID in barerepo assert BLOB_HEX in barerepo assert BLOB_HEX[:10] in barerepo @@ -107,45 +109,47 @@ def test_contains(barerepo): assert ('a' * 20) not in barerepo -def test_iterable(barerepo): +def test_iterable(barerepo: Repository) -> None: oid = pygit2.Oid(hex=BLOB_HEX) assert oid in [obj for obj in barerepo] -def test_lookup_blob(barerepo): +def test_lookup_blob(barerepo: Repository) -> None: with pytest.raises(TypeError): - barerepo[123] + barerepo[123] # type: ignore assert barerepo[BLOB_OID].id == BLOB_HEX a = barerepo[BLOB_HEX] assert b'a contents\n' == a.read_raw() assert BLOB_HEX == a.id - assert ObjectType.BLOB == a.type + assert int(ObjectType.BLOB) == a.type -def test_lookup_blob_prefix(barerepo): +def test_lookup_blob_prefix(barerepo: Repository) -> None: a = barerepo[BLOB_HEX[:5]] assert b'a contents\n' == a.read_raw() assert BLOB_HEX == a.id - assert ObjectType.BLOB == a.type + assert int(ObjectType.BLOB) == a.type -def test_lookup_commit(barerepo): +def test_lookup_commit(barerepo: Repository) -> None: commit_sha = '5fe808e8953c12735680c257f56600cb0de44b10' commit = barerepo[commit_sha] assert commit_sha == commit.id - assert ObjectType.COMMIT == commit.type + assert int(ObjectType.COMMIT) == commit.type + assert isinstance(commit, Commit) assert commit.message == ( 'Second test data commit.\n\nThis commit has some additional text.\n' ) -def test_lookup_commit_prefix(barerepo): +def test_lookup_commit_prefix(barerepo: Repository) -> None: commit_sha = '5fe808e8953c12735680c257f56600cb0de44b10' commit_sha_prefix = commit_sha[:7] too_short_prefix = commit_sha[:3] commit = barerepo[commit_sha_prefix] assert commit_sha == commit.id - assert ObjectType.COMMIT == commit.type + assert int(ObjectType.COMMIT) == commit.type + assert isinstance(commit, Commit) assert ( 'Second test data commit.\n\n' 'This commit has some additional text.\n' == commit.message @@ -154,14 +158,14 @@ def test_lookup_commit_prefix(barerepo): barerepo.__getitem__(too_short_prefix) -def test_expand_id(barerepo): +def test_expand_id(barerepo: Repository) -> None: commit_sha = '5fe808e8953c12735680c257f56600cb0de44b10' expanded = barerepo.expand_id(commit_sha[:7]) assert commit_sha == expanded @utils.requires_refcount -def test_lookup_commit_refcount(barerepo): +def test_lookup_commit_refcount(barerepo: Repository) -> None: start = sys.getrefcount(barerepo) commit_sha = '5fe808e8953c12735680c257f56600cb0de44b10' commit = barerepo[commit_sha] @@ -170,30 +174,30 @@ def test_lookup_commit_refcount(barerepo): assert start == end -def test_get_path(barerepo_path): +def test_get_path(barerepo_path: tuple[Repository, Path]) -> None: barerepo, path = barerepo_path directory = pathlib.Path(barerepo.path).resolve() assert directory == path.resolve() -def test_get_workdir(barerepo): +def test_get_workdir(barerepo: Repository) -> None: assert barerepo.workdir is None -def test_revparse_single(barerepo): +def test_revparse_single(barerepo: Repository) -> None: parent = barerepo.revparse_single('HEAD^') assert parent.id == PARENT_SHA -def test_hash(barerepo): +def test_hash(barerepo: Repository) -> None: data = 'foobarbaz' hashed_sha1 = pygit2.hash(data) written_sha1 = barerepo.create_blob(data) assert hashed_sha1 == written_sha1 -def test_hashfile(barerepo): +def test_hashfile(barerepo: Repository) -> None: data = 'bazbarfoo' handle, tempfile_path = tempfile.mkstemp() with os.fdopen(handle, 'w') as fh: @@ -204,8 +208,8 @@ def test_hashfile(barerepo): assert hashed_sha1 == written_sha1 -def test_conflicts_in_bare_repository(barerepo): - def create_conflict_file(repo, branch, content): +def test_conflicts_in_bare_repository(barerepo: Repository) -> None: + def create_conflict_file(repo: Repository, branch: Branch, content: str) -> Oid: oid = repo.create_blob(content.encode('utf-8')) tb = repo.TreeBuilder() tb.insert('conflict', oid, FileMode.BLOB) @@ -218,9 +222,13 @@ def create_conflict_file(repo, branch, content): assert commit is not None return commit - b1 = barerepo.create_branch('b1', barerepo.head.peel()) + head_peeled = barerepo.head.peel() + assert isinstance(head_peeled, Commit) + b1 = barerepo.create_branch('b1', head_peeled) c1 = create_conflict_file(barerepo, b1, 'ASCII - abc') - b2 = barerepo.create_branch('b2', barerepo.head.peel()) + head_peeled = barerepo.head.peel() + assert isinstance(head_peeled, Commit) + b2 = barerepo.create_branch('b2', head_peeled) c2 = create_conflict_file(barerepo, b2, 'Unicode - äüö') index = barerepo.merge_commits(c1, c2) @@ -233,7 +241,7 @@ def create_conflict_file(repo, branch, content): (a, t, o) = index.conflicts['conflict'] diff = barerepo.merge_file_from_index(a, t, o) assert ( - diff + diff.contents == """<<<<<<< conflict ASCII - abc ======= diff --git a/test/test_repository_custom.py b/test/test_repository_custom.py index 5c365e09e..779ddc620 100644 --- a/test/test_repository_custom.py +++ b/test/test_repository_custom.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -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 ac44ad836..5e3595bf8 100644 --- a/test/test_repository_empty.py +++ b/test/test_repository_empty.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -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 10effc49e..9e93fb043 100644 --- a/test/test_revparse.py +++ b/test/test_revparse.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -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 483984c02..fbdb30932 100644 --- a/test/test_revwalk.py +++ b/test/test_revwalk.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -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 000000000..da482078c --- /dev/null +++ b/test/test_settings.py @@ -0,0 +1,284 @@ +# Copyright 2010-2026 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +"""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 a28a1e07f..20dae2064 100644 --- a/test/test_signature.py +++ b/test/test_signature.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -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 a875fd9df..5ebb4f89c 100644 --- a/test/test_status.py +++ b/test/test_status.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -23,12 +23,16 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. +from pathlib import Path + import pytest +import pygit2 +from pygit2 import Repository from pygit2.enums import FileStatus -def test_status(dirtyrepo): +def test_status(dirtyrepo: Repository) -> None: """ For every file in the status, check that the flags are correct. """ @@ -38,7 +42,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 +71,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,8 +81,46 @@ 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 } == expected + + +def test_status_file_non_ascii(tmp_path: Path) -> None: + """status_file must round-trip non-ASCII path names.""" + repo = pygit2.init_repository(str(tmp_path / 'repo')) + path = 'täst_é.txt' + (Path(repo.workdir) / path).write_text('hello') + repo.index.add(path) + repo.index.write() + assert repo.status_file(path) == FileStatus.INDEX_NEW + + +def test_status_file_non_breaking_space(tmp_path: Path) -> None: + """status_file must handle U+00A0 in the path.""" + repo = pygit2.init_repository(str(tmp_path / 'repo')) + path = 'file\u00a0name.txt' + (Path(repo.workdir) / path).write_text('hello') + repo.index.add(path) + repo.index.write() + assert repo.status_file(path) == FileStatus.INDEX_NEW + + +@pytest.mark.parametrize( + 'path', + [ + 'café.txt', # NFC + 'cafe\u0301.txt', # NFD + ], +) +def test_status_file_unicode_normalization(tmp_path: Path, path: str) -> None: + """status_file must work for both NFC and NFD forms of a path.""" + repo = pygit2.init_repository(str(tmp_path / 'repo')) + (Path(repo.workdir) / path).write_text('hello') + repo.index.add(path) + repo.index.write() + assert repo.status_file(path) == FileStatus.INDEX_NEW diff --git a/test/test_submodule.py b/test/test_submodule.py index 235fed669..5c26578ac 100644 --- a/test/test_submodule.py +++ b/test/test_submodule.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -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 e0e733227..ddaca735b 100644 --- a/test/test_tag.py +++ b/test/test_tag.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -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 000000000..226f1f9ed --- /dev/null +++ b/test/test_transaction.py @@ -0,0 +1,327 @@ +# Copyright 2010-2026 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +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 c50000830..f7ab2c8d8 100644 --- a/test/test_tree.py +++ b/test/test_tree.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -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 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 fc7bc436f..beac1ce07 100644 --- a/test/test_treebuilder.py +++ b/test/test_treebuilder.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -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 7c840f13d..3b56eff55 100644 --- a/test/utils.py +++ b/test/utils.py @@ -1,4 +1,4 @@ -# Copyright 2010-2025 The pygit2 contributors +# Copyright 2010-2026 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -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,6 +41,8 @@ # Pygit2 import pygit2 +T = TypeVar('T') +P = ParamSpec('P') requires_future_libgit2 = pytest.mark.xfail( pygit2.LIBGIT2_VER < (2, 0, 0), @@ -71,7 +76,7 @@ ) -def gen_blob_sha1(data): +def gen_blob_sha1(data: bytes) -> str: # http://stackoverflow.com/questions/552659/assigning-git-sha1s-without-git m = hashlib.sha1() m.update(f'blob {len(data)}\0'.encode()) @@ -79,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. """ @@ -93,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': @@ -111,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,)