diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..6e4f5d261 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.h text eol=lf diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..719f999d2 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: jdavid diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..faf726ef8 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,25 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: "monthly" + commit-message: + prefix: "chore(CI):" + groups: + actions: + patterns: + - "*" + # - package-ecosystem: pip + # directory: .github/ + # schedule: + # interval: "monthly" + # groups: + # pip: + # patterns: + # - "*" diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml new file mode 100644 index 000000000..863f4edb0 --- /dev/null +++ b/.github/workflows/codespell.yml @@ -0,0 +1,23 @@ +# Codespell configuration is within pyproject.toml +--- +name: Codespell + +on: + pull_request: + push: + +permissions: + contents: read + +jobs: + codespell: + name: Check for spelling errors + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v5 + - name: Annotate locations with typos + uses: codespell-project/codespell-problem-matcher@v1 + - name: Codespell + uses: codespell-project/actions-codespell@v2 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 000000000..81ae37f20 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,32 @@ +name: Lints + +on: + pull_request: + push: + paths-ignore: + - '**.rst' + +jobs: + ruff: + runs-on: ubuntu-24.04 + + steps: + - name: Checkout pygit2 + uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.13' + + - name: Install ruff + run: pip install ruff + + - name: Format code with ruff + run: ruff format --diff + + - name: Check code style with ruff + run: ruff check + + - name: Check typing with mypy + run: LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.1 /bin/sh build.sh mypy diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 000000000..91851e3be --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,71 @@ +name: Tests + +on: + pull_request: + push: + paths-ignore: + - '**.rst' + +jobs: + linux: + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - os: ubuntu-24.04 + python-version: '3.10' + - os: ubuntu-24.04 + python-version: '3.13' + - os: ubuntu-24.04 + python-version: 'pypy3.10' + - os: ubuntu-24.04-arm + python-version: '3.13' + + steps: + - name: Checkout pygit2 + uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + + - name: Linux + run: | + sudo apt install tinyproxy + LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.1 /bin/sh build.sh test + + linux-s390x: + runs-on: ubuntu-24.04 + if: github.ref == 'refs/heads/master' + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Build & test + uses: uraimo/run-on-arch-action@v3 + with: + arch: s390x + distro: ubuntu22.04 + install: | + apt-get update -q -y + apt-get install -q -y cmake libssl-dev python3-dev python3-venv wget + run: | + LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.1 /bin/sh build.sh test + continue-on-error: true # Tests are expected to fail, see issue #812 + + macos-arm64: + runs-on: macos-latest + steps: + - name: Checkout pygit2 + uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.13' + + - name: macOS + run: | + export OPENSSL_PREFIX=`brew --prefix openssl@3` + LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.1 /bin/sh build.sh test diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml new file mode 100644 index 000000000..133038896 --- /dev/null +++ b/.github/workflows/wheels.yml @@ -0,0 +1,142 @@ +name: Wheels + +on: + push: + branches: + - master + - wheels-* + tags: + - 'v*' + +jobs: + build_wheels: + name: Wheels for ${{ matrix.name }} + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - name: linux-amd + os: ubuntu-24.04 + - name: linux-arm + os: ubuntu-24.04-arm + - name: macos + os: macos-13 + - name: windows-x64 + os: windows-latest + - name: windows-x86 + os: windows-latest + - name: windows-arm64 + # https://github.com/actions/partner-runner-images#available-images + os: windows-11-arm + + steps: + - uses: actions/checkout@v5 + with: + # avoid leaking credentials in uploaded artifacts + persist-credentials: false + + - uses: actions/setup-python@v6 + with: + python-version: '3.13' + + - name: Install cibuildwheel + run: python -m pip install cibuildwheel~=3.1.1 + + - name: Build wheels + env: + CIBW_ARCHS_WINDOWS: ${{ matrix.name == 'windows-x86' && 'auto32' || 'native' }} + run: python -m cibuildwheel --output-dir wheelhouse + + - uses: actions/upload-artifact@v4 + with: + name: wheels-${{ matrix.name }} + path: ./wheelhouse/*.whl + + build_wheels_ppc: + name: Wheels for linux-ppc + runs-on: ubuntu-24.04 + + steps: + - uses: actions/checkout@v5 + with: + # avoid leaking credentials in uploaded artifacts + persist-credentials: false + + - uses: actions/setup-python@v6 + with: + python-version: '3.13' + + - uses: docker/setup-qemu-action@v3 + with: + platforms: linux/ppc64le + + - name: Install cibuildwheel + run: python -m pip install cibuildwheel~=3.1.1 + + - name: Build wheels + run: python -m cibuildwheel --output-dir wheelhouse + env: + CIBW_ARCHS: ppc64le + CIBW_ENVIRONMENT: LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.1 LIBGIT2=/project/ci + + - uses: actions/upload-artifact@v4 + with: + name: wheels-linux-ppc + path: ./wheelhouse/*.whl + + sdist: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + # avoid leaking credentials in uploaded artifacts + persist-credentials: false + + - uses: actions/setup-python@v6 + with: + python-version: '3.13' + + - name: Build sdist + run: pipx run build --sdist --outdir dist + + - uses: actions/upload-artifact@v4 + with: + name: wheels-sdist + path: dist/* + + twine-check: + name: Twine check + # It is good to do this check on non-tagged commits. + # Note, pypa/gh-action-pypi-publish (see job below) does this automatically. + if: ${{ !startsWith(github.ref, 'refs/tags/v') }} + needs: [build_wheels, build_wheels_ppc, sdist] + runs-on: ubuntu-latest + + steps: + - uses: actions/download-artifact@v5 + with: + path: dist + pattern: wheels-* + merge-multiple: true + - name: check distribution files + run: pipx run twine check dist/* + + pypi: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + needs: [build_wheels, build_wheels_ppc] + runs-on: ubuntu-24.04 + + steps: + - uses: actions/download-artifact@v5 + with: + path: dist + pattern: wheels-* + merge-multiple: true + + - name: Display structure of downloaded files + run: ls -lh dist + + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.gitignore b/.gitignore index d8022420b..a9106ffba 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,245 @@ -MANIFEST -build -dist -pygit2.so -_pygit2.so -test/*.pyc -test/__pycache__ -pygit2/*.pyc -pygit2/__pycache__ -*.egg-info +# 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/ +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* +/ci/ *.swp -docs/_build +/pygit2/_libgit2.c +/pygit2/_libgit2.o diff --git a/.mailmap b/.mailmap index da003e239..dc40e8c49 100644 --- a/.mailmap +++ b/.mailmap @@ -1,6 +1,43 @@ +Alexander Bayandin +Alexander Linne +Anatoly Techtonik +Bob Carroll +Brandon Milton +CJ Steiner <47841949+clintonsteiner@users.noreply.github.com> +Carlos Martín Nieto +Christian Boos +Grégory Herrero +Guillermo Pérez +Gustavo Di Pietro J. David Ibáñez +Jeremy Westwood +Jose Plana +Kaarel Kitsemets +Karl Malmros <44969574+ktpa@users.noreply.github.com> +Konstantin Baikov +Lukas Fleischer +Martin Lenders +Matthew Duggan +Matthew Gamble +Matthias Bartelmeß +Mikhail Yushkovskiy +Nabijacz Leweli +Nicolas Rybowski +Óscar San José +Petr Hosek +Phil Schleihauf Richo Healey +Robert Hölzl +Saugat Pachhai +Sriram Raghu +Sukhman Bhuller +Tamir Bahar +Tamir Bahar +Victor Florea +Victor Garcia +Vlad Temian +William Schueller +Wim Jeantine-Glenn Xavier Delannoy -Christian Boos -Martin Lenders -Xu Tao +Xu Tao +Xu Tao diff --git a/.pep8 b/.pep8 deleted file mode 100644 index a5a00a5d7..000000000 --- a/.pep8 +++ /dev/null @@ -1,3 +0,0 @@ -[pep8] -exclude = .git,build,docs -ignore = E303 diff --git a/.travis.sh b/.travis.sh deleted file mode 100755 index 26985cf4c..000000000 --- a/.travis.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/sh - -cd ~ - -git clone --depth=1 -b master https://github.com/libgit2/libgit2.git -cd libgit2/ - -mkdir build && cd build -cmake .. -DCMAKE_INSTALL_PREFIX=../_install -DBUILD_CLAR=OFF -cmake --build . --target install - -ls -la .. diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 2279b1581..000000000 --- a/.travis.yml +++ /dev/null @@ -1,16 +0,0 @@ -language: python - -python: - - "2.6" - - "2.7" - - "3.2" - - "3.3" - -env: LIBGIT2=~/libgit2/_install/ LD_LIBRARY_PATH=~/libgit2/_install/lib - -before_install: - - sudo apt-get install cmake - - "./.travis.sh" - -script: - - python setup.py test diff --git a/AUTHORS.md b/AUTHORS.md new file mode 100644 index 000000000..0b9e86e70 --- /dev/null +++ b/AUTHORS.md @@ -0,0 +1,245 @@ +Authors: + + J. David Ibáñez + Carlos Martín Nieto + Nico von Geyso + Iliyas Jorio + Benedikt Seidl + Sviatoslav Sydorenko + Matthias Bartelmeß + Robert Coup + W. Trevor King + Drew DeVault + Dave Borowitz + Brandon Milton + Daniel Rodríguez Troitiño + Peter Rowlands + Richo Healey + Christian Boos + Julien Miotte + Nick Hynes + Richard Möhn + Xu Tao + Konstantin Baikov + Matthew Duggan + Matthew Gamble + Jeremy Westwood + Jose Plana + Martin Lenders + Sriram Raghu + Victor Garcia + Yonggang Luo + Łukasz Langa + Patrick Steinhardt + Petr Hosek + Tamir Bahar + Valentin Haenel + Xavier Delannoy + Michael Jones + Saugat Pachhai + Andrej730 + Bernardo Heynemann + John Szakmeister + Nabijacz Leweli + Simon Cozens + Vlad Temian + Brodie Rao + Chad Dombrova + Lukas Fleischer + Mathias Leppich + Mathieu Parent + Michał Kępień + Nicolas Dandrimont + Raphael Medaer (Escaux) + Yaroslav Halchenko + Anatoly Techtonik + Andrew Olsen + Dan Sully + David Versmisse + Grégory Herrero + Mikhail Yushkovskiy + Robin Stocker + Rohit Sanjay + Rémi Duraffort + Santiago Perez De Rosso + Sebastian Thiel + Thom Wiggers + William Manley + Alexander Linne + Alok Singhal + Assaf Nativ + Bob Carroll + Christian Häggström + Edmundo Carmona Antoranz + Erik Johnson + Filip Rindler + Fraser Tweedale + Grégoire ROCHER + Han-Wen Nienhuys + Helio Machado + Jason Ziglar + Leonardo Rhodes + Mark Adams + Nika Layzell + Peter-Yi Zhang + Petr Viktorin + Robert Hölzl + Ron Cohen + Sebastian Böhm + Sukhman Bhuller + Thomas Kluyver + Tyler Cipriani + WANG Xuerui + Alex Chamberlain + Alexander Bayandin + Amit Bakshi + Andrey Devyatkin + Arno van Lumig + Ben Davis + CJ Steiner + Colin Watson + Dan Yeaw + Dustin Raimondi + Eric Schrijver + Greg Fitzgerald + Guillermo Pérez + Hervé Cauwelier + Hong Minhee + Huang Huang + Ian P. McCullough + Igor Gnatenko + Insomnia + Jack O'Connor + Jared Flatow + Jeremy Heiner + Jesse P. Johnson + Jiunn Haur Lim + Jorge C. Leitao + Jun Omae + Kaarel Kitsemets + Ken Dreyer + Kevin KIN-FOO + Kyle Gottfried + Marcel Waldvogel + Masud Rahman + Michael Sondergaard + Natanael Arndt + Ondřej Nový + Sarath Lakshman + Steve Kieffer + Szucs Krisztian + Vicent Marti + Zbigniew Jędrzejewski-Szmek + Zoran Zaric + nikitalita + Adam Gausmann + Adam Spiers + Adrien Nader + Albin Söderström + Alexandru Fikl + Andrew Chin + Andrew McNulty + Andrey Trubachev + András Veres-Szentkirályi + Ash Berlin + Benjamin Kircher + Benjamin Pollack + Benjamin Wohlwend + Bogdan Stoicescu + Bogdan Vasilescu + Bryan O'Sullivan + CJ Harries + Cam Cope + Chad Birch + Chason Chaffin + Chris Jerdonek + Chris Rebert + Christopher Hunt + Claudio Jolowicz + Craig de Stigter + Cristian Hotea + Cyril Jouve + Dan Cecile + Daniel Bruce + Daniele Esposti + Daniele Trifirò + David Black + David Fischer + David Sanders + David Six + Dennis Schwertel + Devaev Maxim + Eric Davis + Erik Meusel + Erik van Zijst + Fabrice Salvaire + Ferengee + Florian Weimer + Frazer McLean + Gustavo Di Pietro + Holger Frey + Hugh Cole-Baker + Isabella Stephens + Jacob Swanson + Jasper Lievisse Adriaanse + Jimisola Laursen + Jiri Benc + Johann Miller + Jonathan Robson + Josh Bleecher Snyder + Julia Evans + Justin Clift + Karl Malmros + Kevin Valk + Konstantinos Smanis + Kyriakos Oikonomakos + Lance Eftink + Legorooj + Lukas Berk + Martin von Zweigbergk + Mathieu Bridon + Mathieu Pillard + Matthaus Woolard + Matěj Cepl + Maxwell G + Michał Górny + Na'aman Hirschfeld + Nicolas Rybowski + Nicolás Sanguinetti + Nikita Kartashov + Nikolai Zujev + Nils Philippsen + Noah Fontes + Or Hayat + Óscar San José + Patrick Lühne + Paul Wagland + Peter Dave Hello + Phil Schleihauf + Philippe Ombredanne + Ram Rachum + Remy Suen + Ridge Kennedy + Rodrigo Bistolfi + Ross Nicoll + Rui Abreu Ferreira + Rui Chen + Sandro Jäckel + Saul Pwanson + Sebastian Hamann + Shane Turner + Sheeo + Simone Mosciatti + Soasme + Steven Winfield + Tad Hardesty + Timo Röhling + Victor Florea + Vladimir Rutsky + William Schueller + Wim Jeantine-Glenn + Yu Jianjian + buhl + chengyuhang + earl + odidev diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..9d3f33239 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,1789 @@ +# 1.18.2 (2025-08-16) + +- Add support for almost all global options + [#1409](https://github.com/libgit2/pygit2/pull/1409) + +- Now it's possible to set `Submodule.url = url` + [#1395](https://github.com/libgit2/pygit2/pull/1395) + +- New `RemoteCallbacks.push_negotiation(...)` + [#1396](https://github.com/libgit2/pygit2/pull/1396) + +- New optional boolean argument `connect` in `Remote.ls_remotes(...)` + [#1396](https://github.com/libgit2/pygit2/pull/1396) + +- New `Remote.list_heads(...)` returns a list of `RemoteHead` objects + [#1397](https://github.com/libgit2/pygit2/pull/1397) + [#1410](https://github.com/libgit2/pygit2/pull/1410) + +- Documentation fixes + [#1388](https://github.com/libgit2/pygit2/pull/1388) + +- Typing improvements + [#1387](https://github.com/libgit2/pygit2/pull/1387) + [#1389](https://github.com/libgit2/pygit2/pull/1389) + [#1390](https://github.com/libgit2/pygit2/pull/1390) + [#1391](https://github.com/libgit2/pygit2/pull/1391) + [#1392](https://github.com/libgit2/pygit2/pull/1392) + [#1393](https://github.com/libgit2/pygit2/pull/1393) + [#1394](https://github.com/libgit2/pygit2/pull/1394) + [#1398](https://github.com/libgit2/pygit2/pull/1398) + [#1399](https://github.com/libgit2/pygit2/pull/1399) + [#1400](https://github.com/libgit2/pygit2/pull/1400) + [#1402](https://github.com/libgit2/pygit2/pull/1402) + [#1403](https://github.com/libgit2/pygit2/pull/1403) + [#1406](https://github.com/libgit2/pygit2/pull/1406) + [#1407](https://github.com/libgit2/pygit2/pull/1407) + [#1408](https://github.com/libgit2/pygit2/pull/1408) + +Deprecations: + +- `Remote.ls_remotes(...)` is deprecated, use `Remote.list_heads(...)`: + + # Before + for head in remote.ls_remotes(): + head['name'] + head['oid'] + head['loid'] # None when local is False + head['local'] + head['symref_target'] + + # Now + for head in remote.list_heads(): + head.name + head.oid + head.loid # The zero oid when local is False + head.local + head.symref_target + + +# 1.18.1 (2025-07-26) + +- Update wheels to libgit2 1.9.1 and OpenSSL 3.3 + +- New `Index.remove_directory(...)` + [#1377](https://github.com/libgit2/pygit2/pull/1377) + +- New `Index.add_conflict(...)` + [#1382](https://github.com/libgit2/pygit2/pull/1382) + +- Now `Repository.merge_file_from_index(...)` returns a `MergeFileResult` object when + called with `use_deprecated=False` + [#1376](https://github.com/libgit2/pygit2/pull/1376) + +- Typing improvements + [#1369](https://github.com/libgit2/pygit2/pull/1369) + [#1370](https://github.com/libgit2/pygit2/pull/1370) + [#1371](https://github.com/libgit2/pygit2/pull/1371) + [#1373](https://github.com/libgit2/pygit2/pull/1373) + [#1384](https://github.com/libgit2/pygit2/pull/1384) + [#1386](https://github.com/libgit2/pygit2/pull/1386) + +Deprecations: + +- Update your code: + + # Before + contents = Repository.merge_file_from_index(...) + + # Now + result = Repository.merge_file_from_index(..., use_deprecated=False) + contents = result.contents + + At some point in the future `use_deprecated=False` will be the default. + + +# 1.18.0 (2025-04-24) + +- Upgrade Linux Glibc wheels to `manylinux_2_28` + +- Add `RemoteCallbacks.push_transfer_progress(...)` callback + [#1345](https://github.com/libgit2/pygit2/pull/1345) + +- New `bool(oid)` + [#1347](https://github.com/libgit2/pygit2/pull/1347) + +- Now `Repository.merge(...)` accepts a commit or reference object + [#1348](https://github.com/libgit2/pygit2/pull/1348) + +- New `threads` optional argument in `Remote.push(...)` + [#1352](https://github.com/libgit2/pygit2/pull/1352) + +- New `proxy` optional argument in `clone_repository(...)` + [#1354](https://github.com/libgit2/pygit2/pull/1354) + +- New optional arguments `context_lines` and `interhunk_lines` in `Blob.diff(...)` ; and + now `Repository.diff(...)` honors these two arguments when the objects diffed are blobs. + [#1360](https://github.com/libgit2/pygit2/pull/1360) + +- Now `Tree.diff_to_workdir(...)` accepts keyword arguments, not just positional. + +- Fix when a reference name has non UTF-8 chars + [#1329](https://github.com/libgit2/pygit2/pull/1329) + +- Fix condition check in `Repository.remotes.rename(...)` + [#1342](https://github.com/libgit2/pygit2/pull/1342) + +- Add codespell workflow, fix a number of typos + [#1344](https://github.com/libgit2/pygit2/pull/1344) + +- Documentation and typing + [#1343](https://github.com/libgit2/pygit2/pull/1343) + [#1347](https://github.com/libgit2/pygit2/pull/1347) + [#1356](https://github.com/libgit2/pygit2/pull/1356) + +- CI: Use ARM runner for tests and wheels + [#1346](https://github.com/libgit2/pygit2/pull/1346) + +- Build and CI updates + [#1363](https://github.com/libgit2/pygit2/pull/1363) + [#1365](https://github.com/libgit2/pygit2/pull/1365) + +Deprecations: + +- Passing str to `Repository.merge(...)` is deprecated, + instead pass an oid object (or a commit, or a reference) + [#1349](https://github.com/libgit2/pygit2/pull/1349) + +Breaking changes: + +- Keyword argument `flag` has been renamed to `flags` in `Blob.diff(...)` and + `Blob.diff_to_buffer(...)` + + +# 1.17.0 (2025-01-08) + +- Upgrade to libgit2 1.9 + +- Add `certificate_check` callback to `Remote.ls_remotes(...)` + [#1326](https://github.com/libgit2/pygit2/pull/1326) + +- Fix build with GCC 14 + [#1324](https://github.com/libgit2/pygit2/pull/1324) + +- Release wheels for PyPy + [#1336](https://github.com/libgit2/pygit2/pull/1336) + [#1339](https://github.com/libgit2/pygit2/pull/1339) + +- CI: update tests for macOS to use OpenSSL 3 + [#1335](https://github.com/libgit2/pygit2/pull/1335) + +- Documentation: fix typo in `Repository.status(...)` docstring + [#1327](https://github.com/libgit2/pygit2/pull/1327) + + +# 1.16.0 (2024-10-11) + +- Add support for Python 3.13 + +- Drop support for Python 3.9 + +- New `Repository.hashfile(...)` + [#1298](https://github.com/libgit2/pygit2/pull/1298) + +- New `Option.GET_MWINDOW_FILE_LIMIT` and `Option.SET_MWINDOW_FILE_LIMIT` + [#1312](https://github.com/libgit2/pygit2/pull/1312) + +- Fix overriding `certificate_check(...)` callback via argument to `RemoteCallbacks(...)` + [#1321](https://github.com/libgit2/pygit2/pull/1321) + +- Add py.typed + [#1310](https://github.com/libgit2/pygit2/pull/1310) + +- Fix `discover_repository(...)` annotation + [#1313](https://github.com/libgit2/pygit2/pull/1313) + + +# 1.15.1 (2024-07-07) + +- New `Repository.revert(...)` + [#1297](https://github.com/libgit2/pygit2/pull/1297) + +- New optional `depth` argument in submodules `add()` and `update()` methods + [#1296](https://github.com/libgit2/pygit2/pull/1296) + +- Now `Submodule.url` returns `None` when the submodule does not have a url + [#1294](https://github.com/libgit2/pygit2/pull/1294) + +- Fix use after free bug in error reporting + [#1299](https://github.com/libgit2/pygit2/pull/1299) + +- Fix `Submodule.head_id` when the submodule is not in the current HEAD tree + [#1300](https://github.com/libgit2/pygit2/pull/1300) + +- Fix `Submodule.open()` when subclassing `Repository` + [#1295](https://github.com/libgit2/pygit2/pull/1295) + +- Fix error in the test suite when running with address sanitizer + [#1304](https://github.com/libgit2/pygit2/pull/1304) + [#1301](https://github.com/libgit2/pygit2/issues/1301) + +- Annotations and documentation fixes + [#1293](https://github.com/libgit2/pygit2/pull/1293) + + +# 1.15.0 (2024-05-18) + +- Many deprecated features have been removed, see below + +- Upgrade to libgit2 v1.8.1 + +- New `push_options` optional argument in `Repository.push(...)` + [#1282](https://github.com/libgit2/pygit2/pull/1282) + +- New support comparison of `Oid` with text string + +- Fix `CheckoutNotify.IGNORED` + [#1288](https://github.com/libgit2/pygit2/issues/1288) + +- Use default error handler when decoding/encoding paths + [#537](https://github.com/libgit2/pygit2/issues/537) + +- Remove setuptools runtime dependency + [#1281](https://github.com/libgit2/pygit2/pull/1281) + +- Coding style with ruff + [#1280](https://github.com/libgit2/pygit2/pull/1280) + +- Add wheels for ppc64le + [#1279](https://github.com/libgit2/pygit2/pull/1279) + +- Fix tests on EPEL8 builds for s390x + [#1283](https://github.com/libgit2/pygit2/pull/1283) + +Deprecations: + +- Deprecate `IndexEntry.hex`, use `str(IndexEntry.id)` + +Breaking changes: + +- Remove deprecated `oid.hex`, use `str(oid)` +- Remove deprecated `object.hex`, use `str(object.id)` +- Remove deprecated `object.oid`, use `object.id` + +- Remove deprecated `Repository.add_submodule(...)`, use `Repository.submodules.add(...)` +- Remove deprecated `Repository.lookup_submodule(...)`, use `Repository.submodules[...]` +- Remove deprecated `Repository.init_submodules(...)`, use `Repository.submodules.init(...)` +- Remove deprecated `Repository.update_submodule(...)`, use `Repository.submodules.update(...)` + +- Remove deprecated constants `GIT_OBJ_XXX`, use `ObjectType` +- Remove deprecated constants `GIT_REVPARSE_XXX`, use `RevSpecFlag` +- Remove deprecated constants `GIT_REF_XXX`, use `ReferenceType` +- Remove deprecated `ReferenceType.OID`, use instead `ReferenceType.DIRECT` +- Remove deprecated `ReferenceType.LISTALL`, use instead `ReferenceType.ALL` + +- Remove deprecated support for passing dicts to repository\'s `merge(...)`, + `merge_commits(...)` and `merge_trees(...)`. Instead pass `MergeFlag` for `flags`, and + `MergeFileFlag` for `file_flags`. + +- Remove deprecated support for passing a string for the favor argument to repository\'s + `merge(...)`, `merge_commits(...)` and `merge_trees(...)`. Instead pass `MergeFavor`. + + +# 1.14.1 (2024-02-10) + +- Update wheels to libgit2 v1.7.2 + +- Now `Object.filemode` returns `enums.FileMode` and `Reference.type` returns + `enums.ReferenceType` + [#1273](https://github.com/libgit2/pygit2/pull/1273) + +- Fix tests on Fedora 40 + [#1275](https://github.com/libgit2/pygit2/pull/1275) + +Deprecations: + +- Deprecate `ReferenceType.OID`, use `ReferenceType.DIRECT` +- Deprecate `ReferenceType.LISTALL`, use `ReferenceType.ALL` + +# 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) + +Breaking changes: + +- Remove deprecated `Repository.create_remote(...)` function, use + instead `Repository.remotes.create(...)` + +Deprecations: + +- Deprecate `Repository.add_submodule(...)`, use `Repository.submodules.add(...)` +- Deprecate `Repository.lookup_submodule(...)`, use `Repository.submodules[...]` +- Deprecate `Repository.init_submodules(...)`, use `Repository.submodules.init(...)` +- Deprecate `Repository.update_submodule(...)`, use `Repository.submodules.update(...)` +- Deprecate `GIT_*` constants, use `pygit2.enums` + +- Passing dicts to repository\'s `merge(...)`, `merge_commits(...)` and `merge_trees(...)` + is deprecated. Instead pass `MergeFlag` for the `flags` argument, and `MergeFileFlag` for + `file_flags`. + +- Passing a string for the favor argument to repository\'s `merge(...)`, `merge_commits(...)` + and `merge_trees(...)` is deprecated. Instead pass `MergeFavor`. + +# 1.13.3 (2023-11-21) + +- New API for filters in Python + [#1237](https://github.com/libgit2/pygit2/pull/1237) + [#1244](https://github.com/libgit2/pygit2/pull/1244) +- Shallow repositories: New `depth` optional argument for + `clone_repository(...)` and `Remote.fetch(...)` + [#1245](https://github.com/libgit2/pygit2/pull/1245) + [#1246](https://github.com/libgit2/pygit2/pull/1246) +- New submodule `init(...)`, `update(...)` and `reload(...)` functions + [#1248](https://github.com/libgit2/pygit2/pull/1248) +- Release GIL in `Walker.__next__` + [#1249](https://github.com/libgit2/pygit2/pull/1249) +- Type hints for submodule functions in `Repository` + [#1247](https://github.com/libgit2/pygit2/pull/1247) + +# 1.13.2 (2023-10-30) + +- Support Python 3.12 +- Documentation updates + [#1242](https://github.com/libgit2/pygit2/pull/1242) + +# 1.13.1 (2023-09-24) + +- Fix crash in reference rename + [#1233](https://github.com/libgit2/pygit2/issues/1233) + +# 1.13.0 (2023-09-07) + +- Upgrade to libgit2 v1.7.1 +- Don\'t distribute wheels for pypy, only universal wheels for macOS +- New `Repository.remotes.create_anonymous(url)` + [#1229](https://github.com/libgit2/pygit2/pull/1229) +- docs: update links to pypi, pygit2.org + [#1228](https://github.com/libgit2/pygit2/pull/1228) +- Prep work for Python 3.12 (not yet supported) + [#1223](https://github.com/libgit2/pygit2/pull/1223) + +# 1.12.2 (2023-06-25) + +- Update wheels to bundle libssh2 1.11.0 and OpenSSL 3.0.9 +- Remove obsolete `Remote.save()` + [#1219](https://github.com/libgit2/pygit2/issues/1219) + +# 1.12.1 (2023-05-07) + +- Fix segfault in signature when encoding is incorrect + [#1210](https://github.com/libgit2/pygit2/pull/1210) +- Typing improvements + [#1212](https://github.com/libgit2/pygit2/pull/1212) + [#1214](https://github.com/libgit2/pygit2/pull/1214) +- Update wheels to libgit2 v1.6.4 + +# 1.12.0 (2023-04-01) + +- Upgrade to libgit2 v1.6.3 +- Update Linux wheels to bundle OpenSSL 3.0.8 +- Downgrade Linux wheels to manylinux2014 +- New `ConflictCollection.__contains__` + [#1181](https://github.com/libgit2/pygit2/pull/1181) +- New `Repository.references.iterator(...)` + [#1191](https://github.com/libgit2/pygit2/pull/1191) +- New `favor`, `flags` and `file_flags` optional arguments for + `Repository.merge(...)` + [#1192](https://github.com/libgit2/pygit2/pull/1192) +- New `keep_all` and `paths` optional arguments for + `Repository.stash(...)` + [#1202](https://github.com/libgit2/pygit2/pull/1202) +- New `Repository.state()` + [#1204](https://github.com/libgit2/pygit2/pull/1204) +- Improve `Repository.write_archive(...)` performance + [#1183](https://github.com/libgit2/pygit2/pull/1183) +- Sync type annotations + [#1203](https://github.com/libgit2/pygit2/pull/1203) + +# 1.11.1 (2022-11-09) + +- Fix Linux wheels, downgrade to manylinux 2_24 + [#1176](https://github.com/libgit2/pygit2/issues/1176) +- Windows wheels for Python 3.11 + [#1177](https://github.com/libgit2/pygit2/pull/1177) +- CI: Use 3.11 final release for testing + [#1178](https://github.com/libgit2/pygit2/pull/1178) + +# 1.11.0 (2022-11-06) + +- Drop support for Python 3.7 +- Update Linux wheels to manylinux 2_28 + [#1136](https://github.com/libgit2/pygit2/issues/1136) +- Fix crash in signature representation + [#1162](https://github.com/libgit2/pygit2/pull/1162) +- Fix memory leak in `Signature` + [#1173](https://github.com/libgit2/pygit2/pull/1173) +- New optional argument `raise_error` in `Repository.applies(...)` + [#1166](https://github.com/libgit2/pygit2/pull/1166) +- New notify/progress callbacks for checkout and stash + [#1167](https://github.com/libgit2/pygit2/pull/1167) + [#1169](https://github.com/libgit2/pygit2/pull/1169) +- New `Repository.remotes.names()` + [#1159](https://github.com/libgit2/pygit2/pull/1159) +- Now `refname` argument in + `RemoteCallbacks.push_update_reference(...)` is a string, not bytes + [#1168](https://github.com/libgit2/pygit2/pull/1168) +- Add missing newline at end of `pygit2/decl/pack.h` + [#1163](https://github.com/libgit2/pygit2/pull/1163) + +# 1.10.1 (2022-08-28) + +- Fix segfault in `Signature` repr + [#1155](https://github.com/libgit2/pygit2/pull/1155) +- Linux and macOS wheels for Python 3.11 + [#1154](https://github.com/libgit2/pygit2/pull/1154) + +# 1.10.0 (2022-07-24) + +- Upgrade to libgit2 1.5 +- Add support for `GIT_OPT_GET_OWNER_VALIDATION` and + `GIT_OPT_SET_OWNER_VALIDATION` + [#1150](https://github.com/libgit2/pygit2/pull/1150) +- New `untracked_files` and `ignored` optional arguments for + `Repository.status(...)` + [#1151](https://github.com/libgit2/pygit2/pull/1151) + +# 1.9.2 (2022-05-24) + +- New `Repository.create_commit_string(...)` and + `Repository.create_commit_with_signature(...)` + [#1142](https://github.com/libgit2/pygit2/pull/1142) +- Linux and macOS wheels updated to libgit2 v1.4.3 +- Remove redundant line + [#1139](https://github.com/libgit2/pygit2/pull/1139) + +# 1.9.1 (2022-03-22) + +- Type hints: added to C code and Branches/References + [#1121](https://github.com/libgit2/pygit2/pull/1121) + [#1132](https://github.com/libgit2/pygit2/pull/1132) +- New `Signature` supports `str()` and `repr()` + [#1135](https://github.com/libgit2/pygit2/pull/1135) +- Fix ODB backend\'s read in big endian architectures + [#1130](https://github.com/libgit2/pygit2/pull/1130) +- Fix install with poetry + [#1129](https://github.com/libgit2/pygit2/pull/1129) + [#1128](https://github.com/libgit2/pygit2/issues/1128) +- Wheels: update to libgit2 v1.4.2 +- Tests: fix testing `parse_diff` + [#1131](https://github.com/libgit2/pygit2/pull/1131) +- CI: various fixes after migration to libgit2 v1.4 + +# 1.9.0 (2022-02-22) + +- Upgrade to libgit2 v1.4 +- Documentation, new recipes for committing and cloning + [#1125](https://github.com/libgit2/pygit2/pull/1125) + +# 1.8.0 (2022-02-04) + +- Rename `RemoteCallbacks.progress(...)` callback to + `.sideband_progress(...)` + [#1120](https://github.com/libgit2/pygit2/pull/1120) +- New `Repository.merge_base_many(...)` and + `Repository.merge_base_octopus(...)` + [#1112](https://github.com/libgit2/pygit2/pull/1112) +- New `Repository.listall_stashes()` + [#1117](https://github.com/libgit2/pygit2/pull/1117) +- Code cleanup [#1118](https://github.com/libgit2/pygit2/pull/1118) + +Backward incompatible changes: + +- The `RemoteCallbacks.progress(...)` callback has been renamed to + `RemoteCallbacks.sideband_progress(...)`. This matches the + documentation, but may break existing code that still uses the old + name. + +# 1.7.2 (2021-12-06) + +- Universal wheels for macOS + [#1109](https://github.com/libgit2/pygit2/pull/1109) + +# 1.7.1 (2021-11-19) + +- New `Repository.amend_commit(...)` + [#1098](https://github.com/libgit2/pygit2/pull/1098) +- New `Commit.message_trailers` + [#1101](https://github.com/libgit2/pygit2/pull/1101) +- Windows wheels for Python 3.10 + [#1103](https://github.com/libgit2/pygit2/pull/1103) +- Changed: now `DiffDelta.is_binary` returns `None` if the file data + has not yet been loaded, cf. + [#962](https://github.com/libgit2/pygit2/issues/962) +- Document `Repository.get_attr(...)` and update theme + [#1017](https://github.com/libgit2/pygit2/issues/1017) + [#1105](https://github.com/libgit2/pygit2/pull/1105) + +# 1.7.0 (2021-10-08) + +- Upgrade to libgit2 1.3.0 + [#1089](https://github.com/libgit2/pygit2/pull/1089) +- Linux wheels now bundled with libssh2 1.10.0 (instead of 1.9.0) +- macOS wheels now include libssh2 +- Add support for Python 3.10 + [#1092](https://github.com/libgit2/pygit2/pull/1092) + [#1093](https://github.com/libgit2/pygit2/pull/1093) +- Drop support for Python 3.6 +- New [pygit2.GIT_CHECKOUT_SKIP_LOCKED_DIRECTORIES]{.title-ref} + [#1087](https://github.com/libgit2/pygit2/pull/1087) +- New optional argument `location` in `Repository.applies(..)` and + `Repository.apply(..)` + [#1091](https://github.com/libgit2/pygit2/pull/1091) +- Fix: Now the [flags]{.title-ref} argument in + [Repository.blame()]{.title-ref} is passed through + [#1083](https://github.com/libgit2/pygit2/pull/1083) +- CI: Stop using Travis, move to GitHub actions + +Caveats: + +- Windows wheels for Python 3.10 not yet available. + +# 1.6.1 (2021-06-19) + +- Fix a number of reference leaks +- Review custom object backends + +Breaking changes: + +- In custom backends the callbacks have been renamed from `read` to + `read_cb`, `write` to `write_cb`, and so on. + +# 1.6.0 (2021-06-01) + +- New optional `proxy` argument in `Remote` methods + [#642](https://github.com/libgit2/pygit2/issues/642) + [#1063](https://github.com/libgit2/pygit2/pull/1063) + [#1069](https://github.com/libgit2/pygit2/issues/1069) +- New GIT_MERGE_PREFERENCE constants + [#1071](https://github.com/libgit2/pygit2/pull/1071) +- Don\'t require cached-property with Python 3.8 or later + [#1066](https://github.com/libgit2/pygit2/pull/1066) +- Add wheels for aarch64 + [#1077](https://github.com/libgit2/pygit2/issues/1077) + [#1078](https://github.com/libgit2/pygit2/pull/1078) +- Documentation fixes + [#1068](https://github.com/libgit2/pygit2/pull/1068) + [#1072](https://github.com/libgit2/pygit2/pull/1072) +- Refactored build and CI, new `build.sh` script + +Breaking changes: + +- Remove deprecated `GIT_CREDTYPE_XXX` constants, use + `GIT_CREDENTIAL_XXX` instead. +- Remove deprecated `Patch.patch` getter, use `Patch.text` instead. + +# 1.5.0 (2021-01-23) + +- New `PackBuilder` class and `Repository.pack(...)` + [#1048](https://github.com/libgit2/pygit2/pull/1048) +- New `Config.delete_multivar(...)` + [#1056](https://github.com/libgit2/pygit2/pull/1056) +- New `Repository.is_shallow` + [#1058](https://github.com/libgit2/pygit2/pull/1058) +- New optional `message` argument in + `Repository.create_reference(...)` + [#1061](https://github.com/libgit2/pygit2/issues/1061) + [#1062](https://github.com/libgit2/pygit2/pull/1062) +- Fix truncated diff when there are nulls + [#1047](https://github.com/libgit2/pygit2/pull/1047) + [#1043](https://github.com/libgit2/pygit2/issues/1043) +- Unit tests & Continuous integration + [#1039](https://github.com/libgit2/pygit2/issues/1039) + [#1052](https://github.com/libgit2/pygit2/pull/1052) + +Breaking changes: + +- Fix `Index.add(...)` raise `TypeError` instead of `AttributeError` + when arguments are of unexpected type + +# 1.4.0 (2020-11-06) + +- Upgrade to libgit2 1.1, new `GIT_BLAME_IGNORE_WHITESPACE` constant + [#1040](https://github.com/libgit2/pygit2/issues/1040) +- Add wheels for Python 3.9 + [#1038](https://github.com/libgit2/pygit2/issues/1038) +- Drop support for PyPy3 7.2 +- New optional `flags` argument in `Repository.__init__(...)`, new + `GIT_REPOSITORY_OPEN_*` constants + [#1044](https://github.com/libgit2/pygit2/pull/1044) +- Documentation [#509](https://github.com/libgit2/pygit2/issues/509) + [#752](https://github.com/libgit2/pygit2/issues/752) + [#1037](https://github.com/libgit2/pygit2/issues/1037) + [#1045](https://github.com/libgit2/pygit2/issues/1045) + +# 1.3.0 (2020-09-18) + +- New `Repository.add_submodule(...)` + [#1011](https://github.com/libgit2/pygit2/pull/1011) +- New `Repository.applies(...)` + [#1019](https://github.com/libgit2/pygit2/pull/1019) +- New `Repository.revparse(...)` and `Repository.revparse_ext(...)` + [#1022](https://github.com/libgit2/pygit2/pull/1022) +- New optional `flags` and `file_flags` arguments in + `Repository.merge_commits` and `Repository.merge_trees` + [#1008](https://github.com/libgit2/pygit2/pull/1008) +- New `Reference.raw_target`, `Repository.raw_listall_branches(...)` + and `Repository.raw_listall_references()`; allow bytes in + `Repository.lookup_branch(...)` and `Repository.diff(...)` + [#1029](https://github.com/libgit2/pygit2/pull/1029) +- New `GIT_BLAME_FIRST_PARENT` and `GIT_BLAME_USE_MAILMAP` constants + [#1031](https://github.com/libgit2/pygit2/pull/1031) +- New `IndexEntry` supports `repr()`, `str()`, `==` and `!=` + [#1009](https://github.com/libgit2/pygit2/pull/1009) +- New `Object` supports `repr()` + [#1022](https://github.com/libgit2/pygit2/pull/1022) +- New accept tuples of strings (not only lists) in a number of places + [#1025](https://github.com/libgit2/pygit2/pull/1025) +- Fix compatibility with old macOS 10.9 + [#1026](https://github.com/libgit2/pygit2/issues/1026) + [#1027](https://github.com/libgit2/pygit2/pull/1027) +- Fix check argument type in `Repository.apply(...)` + [#1033](https://github.com/libgit2/pygit2/issues/1033) +- Fix raise exception if error in `Repository.listall_submodules()` + commit 32133974 +- Fix a couple of refcount errors in `OdbBackend.refresh()` and + `Worktree_is_prunable` commit fed0c19c +- Unit tests [#800](https://github.com/libgit2/pygit2/issues/800) + [#1015](https://github.com/libgit2/pygit2/pull/1015) +- Documentation [#705](https://github.com/libgit2/pygit2/pull/705) + +# 1.2.1 (2020-05-01) + +- Fix segfault in `Object.raw_name` when not reached through a tree + [#1002](https://github.com/libgit2/pygit2/pull/1002) +- Internal: Use \@ffi.def_extern instead of \@ffi.callback + [#899](https://github.com/libgit2/pygit2/issues/899) +- Internal: callbacks code refactored +- Test suite completely switched to pytest + [#824](https://github.com/libgit2/pygit2/issues/824) +- New unit tests [#538](https://github.com/libgit2/pygit2/pull/538) + [#996](https://github.com/libgit2/pygit2/issues/996) +- Documentation changes + [#999](https://github.com/libgit2/pygit2/issues/999) + +Deprecations: + +- Deprecate `Repository.create_remote(...)`, use instead + `Repository.remotes.create(...)` +- Deprecate `GIT_CREDTYPE_XXX` constants, use `GIT_CREDENTIAL_XXX` + instead. + +# 1.2.0 (2020-04-05) + +- Drop support for Python 3.5 + [#991](https://github.com/libgit2/pygit2/issues/991) +- Upgrade to libgit2 1.0 + [#982](https://github.com/libgit2/pygit2/pull/982) +- New support for custom reference database backends + [#982](https://github.com/libgit2/pygit2/pull/982) +- New support for path objects + [#990](https://github.com/libgit2/pygit2/pull/990) + [#955](https://github.com/libgit2/pygit2/issues/955) +- New `index` optional parameter in `Repository.checkout_index` + [#987](https://github.com/libgit2/pygit2/pull/987) +- New MacOS wheels [#988](https://github.com/libgit2/pygit2/pull/988) +- Fix re-raise exception from credentials callback in clone_repository + [#996](https://github.com/libgit2/pygit2/issues/996) +- Fix warning with `pip install pygit2` + [#986](https://github.com/libgit2/pygit2/issues/986) +- Tests: disable global Git config + [#989](https://github.com/libgit2/pygit2/issues/989) + +# 1.1.1 (2020-03-06) + +- Fix crash in tree iteration + [#984](https://github.com/libgit2/pygit2/pull/984) + [#980](https://github.com/libgit2/pygit2/issues/980) +- Do not include the docs in dist files, so they\'re much smaller now + +# 1.1.0 (2020-03-01) + +- Upgrade to libgit2 0.99 + [#959](https://github.com/libgit2/pygit2/pull/959) +- Continued work on custom odb backends + [#948](https://github.com/libgit2/pygit2/pull/948) +- New `Diff.patchid` getter + [#960](https://github.com/libgit2/pygit2/pull/960) + [#877](https://github.com/libgit2/pygit2/issues/877) +- New `settings.disable_pack_keep_file_checks(...)` + [#908](https://github.com/libgit2/pygit2/pull/908) +- New `GIT_DIFF_` and `GIT_DELTA_` constants + [#738](https://github.com/libgit2/pygit2/issues/738) +- Fix crash in iteration of config entries + [#970](https://github.com/libgit2/pygit2/issues/970) +- Travis: fix printing features when building Linux wheels + [#977](https://github.com/libgit2/pygit2/pull/977) +- Move `_pygit2` to `pygit2._pygit2` + [#978](https://github.com/libgit2/pygit2/pull/978) + +Requirements changes: + +- Now libgit2 0.99 is required +- New requirement: cached-property + +Breaking changes: + +- In the rare case you\'re directly importing the low level `_pygit2`, + the import has changed: + + # Before + import _pygit2 + + # Now + from pygit2 import _pygit2 + +# 1.0.3 (2020-01-31) + +- Fix memory leak in DiffFile + [#943](https://github.com/libgit2/pygit2/issues/943) + +# 1.0.2 (2020-01-11) + +- Fix enumerating tree entries with submodules + [#967](https://github.com/libgit2/pygit2/issues/967) + +# 1.0.1 (2019-12-21) + +- Fix build in Mac OS + [#963](https://github.com/libgit2/pygit2/issues/963) + +# 1.0.0 (2019-12-06) + +- Drop Python 2.7 and 3.4 support, six no longer required + [#941](https://github.com/libgit2/pygit2/issues/941) +- Add Python 3.8 support + [#918](https://github.com/libgit2/pygit2/issues/918) +- New support for `/` operator to traverse trees + [#903](https://github.com/libgit2/pygit2/pull/903) + [#924](https://github.com/libgit2/pygit2/issues/924) +- New `Branch.raw_branch_name` + [#954](https://github.com/libgit2/pygit2/pull/954) +- New `Index.remove_all()` + [#920](https://github.com/libgit2/pygit2/pull/920) +- New `Remote.ls_remotes(..)` + [#935](https://github.com/libgit2/pygit2/pull/935) + [#936](https://github.com/libgit2/pygit2/issues/936) +- New `Repository.lookup_reference_dwim(..)` and + `Repository.resolve_refish(..)` + [#922](https://github.com/libgit2/pygit2/issues/922) + [#923](https://github.com/libgit2/pygit2/pull/923) +- New `Repository.odb` returns new `Odb` type instance. And new + `OdbBackend` type. + [#940](https://github.com/libgit2/pygit2/pull/940) + [#942](https://github.com/libgit2/pygit2/pull/942) +- New `Repository.references.compress()` + [#961](https://github.com/libgit2/pygit2/pull/961) +- Optimization: Load notes lazily + [#958](https://github.com/libgit2/pygit2/pull/958) +- Fix spurious exception in config + [#916](https://github.com/libgit2/pygit2/issues/916) + [#917](https://github.com/libgit2/pygit2/pull/917) +- Minor documentation and cosmetic changes + [#919](https://github.com/libgit2/pygit2/pull/919) + [#921](https://github.com/libgit2/pygit2/pull/921) + [#946](https://github.com/libgit2/pygit2/pull/946) + [#950](https://github.com/libgit2/pygit2/pull/950) + +Breaking changes: + +- Now the Repository has a new attribute `odb` for object database: + + # Before + repository.read(...) + repository.write(...) + + # Now + repository.odb.read(...) + repository.odb.write(...) + +- Now `Tree[x]` returns a `Object` instance instead of a `TreeEntry`; + `Object.type` returns an integer while `TreeEntry.type` returned a + string: + + # Before + if tree[x].type == 'tree': + + # Now + if tree[x].type == GIT_OBJ_TREE: + if tree[x].type_str == 'tree': + +- Renamed `TreeEntry._name` to `Object.raw_name`: + + # Before + tree[x]._name + + # Now + tree[x].raw_name + +- Object comparison is done by id. In the rare case you need to do + tree-entry comparison or sorting: + + # Before + tree[x] < tree[y] + sorted(list(tree)) + + # Now + pygit2.tree_entry_cmp(x, y) < 0 + sorted(list(tree), key=pygit2.tree_entry_key) + +# 0.28.2 (2019-05-26) + +- Fix crash in reflog iteration + [#901](https://github.com/libgit2/pygit2/issues/901) +- Support symbolic references in `branches.with_commit(..)` + [#910](https://github.com/libgit2/pygit2/issues/910) +- Documentation updates + [#909](https://github.com/libgit2/pygit2/pull/909) +- Test updates [#911](https://github.com/libgit2/pygit2/pull/911) + +# 0.28.1 (2019-04-19) + +- Now works with pycparser 2.18 and above + [#846](https://github.com/libgit2/pygit2/issues/846) +- Now `Repository.write_archive(..)` keeps the file mode + [#616](https://github.com/libgit2/pygit2/issues/616) + [#898](https://github.com/libgit2/pygit2/pull/898) +- New `Patch.data` returns the raw contents of the patch as a byte + string [#790](https://github.com/libgit2/pygit2/pull/790) + [#893](https://github.com/libgit2/pygit2/pull/893) +- New `Patch.text` returns the contents of the patch as a text string, + deprecates [Patch.patch]{.title-ref} + [#790](https://github.com/libgit2/pygit2/pull/790) + [#893](https://github.com/libgit2/pygit2/pull/893) + +Deprecations: + +- `Patch.patch` is deprecated, use `Patch.text` instead + +# 0.28.0 (2019-03-19) + +- Upgrade to libgit2 0.28 + [#878](https://github.com/libgit2/pygit2/issues/878) +- Add binary wheels for Linux + [#793](https://github.com/libgit2/pygit2/issues/793) + [#869](https://github.com/libgit2/pygit2/pull/869) + [#874](https://github.com/libgit2/pygit2/pull/874) + [#875](https://github.com/libgit2/pygit2/pull/875) + [#883](https://github.com/libgit2/pygit2/pull/883) +- New `pygit2.Mailmap`, see documentation + [#804](https://github.com/libgit2/pygit2/pull/804) +- New `Repository.apply(...)` wraps `git_apply(..)` + [#841](https://github.com/libgit2/pygit2/issues/841) + [#843](https://github.com/libgit2/pygit2/pull/843) +- Now `Repository.merge_analysis(...)` accepts an optional reference + parameter [#888](https://github.com/libgit2/pygit2/pull/888) + [#891](https://github.com/libgit2/pygit2/pull/891) +- Now `Repository.add_worktree(...)` accepts an optional reference + parameter [#814](https://github.com/libgit2/pygit2/issues/814) + [#889](https://github.com/libgit2/pygit2/pull/889) +- Now it\'s possible to set SSL certificate locations + [#876](https://github.com/libgit2/pygit2/issues/876) + [#879](https://github.com/libgit2/pygit2/pull/879) + [#884](https://github.com/libgit2/pygit2/pull/884) + [#886](https://github.com/libgit2/pygit2/pull/886) +- Test and documentation improvements + [#873](https://github.com/libgit2/pygit2/pull/873) + [#887](https://github.com/libgit2/pygit2/pull/887) + +Breaking changes: + +- Now `worktree.path` returns the path to the worktree directory, not + to the [.git]{.title-ref} file within + [#803](https://github.com/libgit2/pygit2/issues/803) +- Remove undocumented `worktree.git_path` + [#803](https://github.com/libgit2/pygit2/issues/803) + +# 0.27.4 (2019-01-19) + +- New `pygit2.LIBGIT2_VER` tuple + [#845](https://github.com/libgit2/pygit2/issues/845) + [#848](https://github.com/libgit2/pygit2/pull/848) +- New objects now support (in)equality comparison and hash + [#852](https://github.com/libgit2/pygit2/issues/852) + [#853](https://github.com/libgit2/pygit2/pull/853) +- New references now support (in)equality comparison + [#860](https://github.com/libgit2/pygit2/issues/860) + [#862](https://github.com/libgit2/pygit2/pull/862) +- New `paths` optional argument in `Repository.checkout()` + [#858](https://github.com/libgit2/pygit2/issues/858) + [#859](https://github.com/libgit2/pygit2/pull/859) +- Fix speed and windows package regression + [#849](https://github.com/libgit2/pygit2/issues/849) + [#857](https://github.com/libgit2/pygit2/issues/857) + [#851](https://github.com/libgit2/pygit2/pull/851) +- Fix deprecation warning + [#850](https://github.com/libgit2/pygit2/pull/850) +- Documentation fixes + [#855](https://github.com/libgit2/pygit2/pull/855) +- Add Python classifiers to setup.py + [#861](https://github.com/libgit2/pygit2/pull/861) +- Speeding up tests in Travis + [#854](https://github.com/libgit2/pygit2/pull/854) + +Breaking changes: + +- Remove deprecated [Reference.get_object()]{.title-ref}, use + [Reference.peel()]{.title-ref} instead + +# 0.27.3 (2018-12-15) + +- Move to pytest, drop support for Python 3.3 and cffi 0.x + [#824](https://github.com/libgit2/pygit2/issues/824) + [#826](https://github.com/libgit2/pygit2/pull/826) + [#833](https://github.com/libgit2/pygit2/pull/833) + [#834](https://github.com/libgit2/pygit2/pull/834) +- New support comparing signatures for (in)equality +- New `Submodule.head_id` + [#817](https://github.com/libgit2/pygit2/pull/817) +- New `Remote.prune(...)` + [#825](https://github.com/libgit2/pygit2/pull/825) +- New `pygit2.reference_is_valid_name(...)` + [#827](https://github.com/libgit2/pygit2/pull/827) +- New `AlreadyExistsError` and `InvalidSpecError` + [#828](https://github.com/libgit2/pygit2/issues/828) + [#829](https://github.com/libgit2/pygit2/pull/829) +- New `Reference.raw_name`, `Reference.raw_shorthand`, `Tag.raw_name`, + `Tag.raw_message` and `DiffFile.raw_path` + [#840](https://github.com/libgit2/pygit2/pull/840) +- Fix decode error in commit messages and signatures + [#839](https://github.com/libgit2/pygit2/issues/839) +- Fix, raise error in `Repository.descendant_of(...)` if commit + doesn\'t exist [#822](https://github.com/libgit2/pygit2/issues/822) + [#842](https://github.com/libgit2/pygit2/pull/842) +- Documentation fixes + [#821](https://github.com/libgit2/pygit2/pull/821) + +Breaking changes: + +- Remove undocumented `Tag._message`, replaced by `Tag.raw_message` + +# 0.27.2 (2018-09-16) + +- Add support for Python 3.7 + [#809](https://github.com/libgit2/pygit2/issues/809) +- New `Object.short_id` + [#799](https://github.com/libgit2/pygit2/issues/799) + [#806](https://github.com/libgit2/pygit2/pull/806) + [#807](https://github.com/libgit2/pygit2/pull/807) +- New `Repository.descendant_of` and `Repository.branches.with_commit` + [#815](https://github.com/libgit2/pygit2/issues/815) + [#816](https://github.com/libgit2/pygit2/pull/816) +- Fix repository initialization in `clone_repository(...)` + [#818](https://github.com/libgit2/pygit2/issues/818) +- Fix several warnings and errors, commits + [cd896ddc](https://github.com/libgit2/pygit2/commit/cd896ddc) and + [dfa536a3](https://github.com/libgit2/pygit2/commit/dfa536a3) +- Documentation fixes and improvements + [#805](https://github.com/libgit2/pygit2/pull/805) + [#808](https://github.com/libgit2/pygit2/pull/808) + +# 0.27.1 (2018-06-02) + +Breaking changes: + +- Now `discover_repository` returns `None` if repository not found, + instead of raising `KeyError` + [#531](https://github.com/libgit2/pygit2/issues/531) + +Other changes: + +- New `DiffLine.raw_content` + [#610](https://github.com/libgit2/pygit2/issues/610) +- Fix tests failing in some cases + [#795](https://github.com/libgit2/pygit2/issues/795) +- Automate wheels upload to pypi + [#563](https://github.com/libgit2/pygit2/issues/563) + +# 0.27.0 (2018-03-30) + +- Update to libgit2 v0.27 + [#783](https://github.com/libgit2/pygit2/pull/783) +- Fix for GCC 4 [#786](https://github.com/libgit2/pygit2/pull/786) + +# 0.26.4 (2018-03-23) + +Backward incompatible changes: + +- Now iterating over a configuration returns `ConfigEntry` objects + [#778](https://github.com/libgit2/pygit2/pull/778) + + # Before + for name in config: + value = config[name] + + # Now + for entry in config: + name = entry.name + value = entry.value + +Other changes: + +- Added support for worktrees + [#779](https://github.com/libgit2/pygit2/pull/779) +- New `Commit.gpg_signature` + [#766](https://github.com/libgit2/pygit2/pull/766) +- New static `Diff.parse_diff(...)` + [#774](https://github.com/libgit2/pygit2/pull/774) +- New optional argument `callbacks` in + `Repository.update_submodules(...)` + [#763](https://github.com/libgit2/pygit2/pull/763) +- New `KeypairFromMemory` credentials + [#771](https://github.com/libgit2/pygit2/pull/771) +- Add missing status constants + [#781](https://github.com/libgit2/pygit2/issues/781) +- Fix segfault [#775](https://github.com/libgit2/pygit2/issues/775) +- Fix some unicode decode errors with Python 2 + [#767](https://github.com/libgit2/pygit2/pull/767) + [#768](https://github.com/libgit2/pygit2/pull/768) +- Documentation improvements + [#721](https://github.com/libgit2/pygit2/pull/721) + [#769](https://github.com/libgit2/pygit2/pull/769) + [#770](https://github.com/libgit2/pygit2/pull/770) + +# 0.26.3 (2017-12-24) + +- New `Diff.deltas` + [#736](https://github.com/libgit2/pygit2/issues/736) +- Improvements to `Patch.create_from` + [#753](https://github.com/libgit2/pygit2/pull/753) + [#756](https://github.com/libgit2/pygit2/pull/756) + [#759](https://github.com/libgit2/pygit2/pull/759) +- Fix build and tests in Windows, broken in the previous release + [#749](https://github.com/libgit2/pygit2/pull/749) + [#751](https://github.com/libgit2/pygit2/pull/751) +- Review `Patch.patch` + [#757](https://github.com/libgit2/pygit2/issues/757) +- Workaround bug + [#4442](https://github.com/libgit2/libgit2/issues/4442) in libgit2, + and improve unit tests + [#748](https://github.com/libgit2/pygit2/issues/748) + [#754](https://github.com/libgit2/pygit2/issues/754) + [#758](https://github.com/libgit2/pygit2/pull/758) + [#761](https://github.com/libgit2/pygit2/pull/761) + +# 0.26.2 (2017-12-01) + +- New property `Patch.patch` + [#739](https://github.com/libgit2/pygit2/issues/739) + [#741](https://github.com/libgit2/pygit2/pull/741) +- New static method `Patch.create_from` + [#742](https://github.com/libgit2/pygit2/issues/742) + [#744](https://github.com/libgit2/pygit2/pull/744) +- New parameter `prune` in `Remote.fetch` + [#743](https://github.com/libgit2/pygit2/pull/743) +- Tests: skip tests that require network when there is not + [#737](https://github.com/libgit2/pygit2/issues/737) +- Tests: other improvements + [#740](https://github.com/libgit2/pygit2/pull/740) +- Documentation improvements + +# 0.26.1 (2017-11-19) + +- New `Repository.free()` + [#730](https://github.com/libgit2/pygit2/pull/730) +- Improve credentials handling for ssh cloning + [#718](https://github.com/libgit2/pygit2/pull/718) +- Documentation improvements + [#714](https://github.com/libgit2/pygit2/pull/714) + [#715](https://github.com/libgit2/pygit2/pull/715) + [#728](https://github.com/libgit2/pygit2/pull/728) + [#733](https://github.com/libgit2/pygit2/pull/733) + [#734](https://github.com/libgit2/pygit2/pull/734) + [#735](https://github.com/libgit2/pygit2/pull/735) + +# 0.26.0 (2017-07-06) + +- Update to libgit2 v0.26 + [#713](https://github.com/libgit2/pygit2/pull/713) +- Drop support for Python 3.2, add support for cffi 1.10 + [#706](https://github.com/libgit2/pygit2/pull/706) + [#694](https://github.com/libgit2/pygit2/issues/694) +- New `Repository.revert_commit(...)` + [#711](https://github.com/libgit2/pygit2/pull/711) + [#710](https://github.com/libgit2/pygit2/issues/710) +- New `Branch.is_checked_out()` + [#696](https://github.com/libgit2/pygit2/pull/696) +- Various fixes [#706](https://github.com/libgit2/pygit2/pull/706) + [#707](https://github.com/libgit2/pygit2/pull/707) + [#708](https://github.com/libgit2/pygit2/pull/708) + +# 0.25.1 (2017-04-25) + +- Add support for Python 3.6 +- New support for stash: repository methods `stash`, `stash_apply`, + `stash_drop` and `stash_pop` + [#695](https://github.com/libgit2/pygit2/pull/695) +- Improved support for submodules: new repository methods + `init_submodules` and `update_submodules` + [#692](https://github.com/libgit2/pygit2/pull/692) +- New friendlier API for branches & references: `Repository.branches` + and `Repository.references` + [#700](https://github.com/libgit2/pygit2/pull/700) + [#701](https://github.com/libgit2/pygit2/pull/701) +- New support for custom backends + [#690](https://github.com/libgit2/pygit2/pull/690) +- Fix `init_repository` crash on None input + [#688](https://github.com/libgit2/pygit2/issues/688) + [#697](https://github.com/libgit2/pygit2/pull/697) +- Fix checkout with an orphan master branch + [#669](https://github.com/libgit2/pygit2/issues/669) + [#685](https://github.com/libgit2/pygit2/pull/685) +- Better error messages for opening repositories + [#645](https://github.com/libgit2/pygit2/issues/645) + [#698](https://github.com/libgit2/pygit2/pull/698) + +# 0.25.0 (2016-12-26) + +- Upgrade to libgit2 0.25 + [#670](https://github.com/libgit2/pygit2/pull/670) +- Now Commit.tree raises an error if tree is not found + [#682](https://github.com/libgit2/pygit2/pull/682) +- New settings.mwindow_mapped_limit, cached_memory, enable_caching, + cache_max_size and cache_object_limit + [#677](https://github.com/libgit2/pygit2/pull/677) + +# 0.24.2 (2016-11-01) + +- Unit tests pass on Windows, integration with AppVeyor + [#641](https://github.com/libgit2/pygit2/pull/641) + [#655](https://github.com/libgit2/pygit2/issues/655) + [#657](https://github.com/libgit2/pygit2/pull/657) + [#659](https://github.com/libgit2/pygit2/pull/659) + [#660](https://github.com/libgit2/pygit2/pull/660) + [#661](https://github.com/libgit2/pygit2/pull/661) + [#667](https://github.com/libgit2/pygit2/pull/667) +- Fix when libgit2 error messages have non-ascii chars + [#651](https://github.com/libgit2/pygit2/pull/651) +- Documentation improvements + [#643](https://github.com/libgit2/pygit2/pull/643) + [#653](https://github.com/libgit2/pygit2/pull/653) + [#663](https://github.com/libgit2/pygit2/pull/663) + +# 0.24.1 (2016-06-21) + +- New `Repository.listall_reference_objects()` + [#634](https://github.com/libgit2/pygit2/pull/634) +- Fix `Repository.write_archive(...)` + [#619](https://github.com/libgit2/pygit2/pull/619) + [#621](https://github.com/libgit2/pygit2/pull/621) +- Reproducible builds + [#636](https://github.com/libgit2/pygit2/pull/636) +- Documentation fixes + [#606](https://github.com/libgit2/pygit2/pull/606) + [#607](https://github.com/libgit2/pygit2/pull/607) + [#609](https://github.com/libgit2/pygit2/pull/609) + [#623](https://github.com/libgit2/pygit2/pull/623) +- Test updates [#629](https://github.com/libgit2/pygit2/pull/629) + +# 0.24.0 (2016-03-05) + +- Update to libgit2 v0.24 + [#594](https://github.com/libgit2/pygit2/pull/594) +- Support Python 3.5 +- New dependency, [six](https://pypi.org/project/six/) +- New `Repository.path_is_ignored(path)` + [#589](https://github.com/libgit2/pygit2/pull/589) +- Fix error in `Repository(path)` when path is a bytes string + [#588](https://github.com/libgit2/pygit2/issues/588) + [#593](https://github.com/libgit2/pygit2/pull/593) +- Fix memory issue in `Repository.describe(...)` + [#592](https://github.com/libgit2/pygit2/issues/592) + [#597](https://github.com/libgit2/pygit2/issues/597) + [#599](https://github.com/libgit2/pygit2/pull/599) +- Allow testing with [tox](https://pypi.org/project/tox/) + [#600](https://github.com/libgit2/pygit2/pull/600) + +# 0.23.3 (2016-01-01) + +- New `Repository.create_blob_fromiobase(...)` + [#490](https://github.com/libgit2/pygit2/pull/490) + [#577](https://github.com/libgit2/pygit2/pull/577) +- New `Repository.describe(...)` + [#585](https://github.com/libgit2/pygit2/pull/585) +- Fix `Signature` default encoding, UTF-8 now + [#581](https://github.com/libgit2/pygit2/issues/581) +- Fixing `pip install pygit2`, should install cffi first +- Unit tests, fix binary diff test + [#586](https://github.com/libgit2/pygit2/pull/586) +- Document that `Diff.patch` can be `None` + [#587](https://github.com/libgit2/pygit2/pull/587) + +# 0.23.2 (2015-10-11) + +- Unify callbacks system for remotes and clone + [#568](https://github.com/libgit2/pygit2/pull/568) +- New `TreeEntry._name` + [#570](https://github.com/libgit2/pygit2/pull/570) +- Fix segfault in `Tag._message` + [#572](https://github.com/libgit2/pygit2/pull/572) +- Documentation improvements + [#569](https://github.com/libgit2/pygit2/pull/569) + [#574](https://github.com/libgit2/pygit2/pull/574) + +API changes to clone: + + # Before + clone_repository(..., credentials, certificate) + + # Now + callbacks = RemoteCallbacks(credentials, certificate) + clone_repository(..., callbacks) + +API changes to remote: + + # Before + def transfer_progress(stats): + ... + + remote.credentials = credentials + remote.transfer_progress = transfer_progress + remote.fetch() + remote.push(specs) + + # Now + class MyCallbacks(RemoteCallbacks): + def transfer_progress(self, stats): + ... + + callbacks = MyCallbacks(credentials) + remote.fetch(callbacks=callbacks) + remote.push(specs, callbacks=callbacks) + +# 0.23.1 (2015-09-26) + +- Improve support for cffi 1.0+ + [#529](https://github.com/libgit2/pygit2/pull/529) + [#561](https://github.com/libgit2/pygit2/pull/561) +- Fix `Remote.push` [#557](https://github.com/libgit2/pygit2/pull/557) +- New `TreeEntry.type` + [#560](https://github.com/libgit2/pygit2/pull/560) +- New `pygit2.GIT_DIFF_SHOW_BINARY` + [#566](https://github.com/libgit2/pygit2/pull/566) + +# 0.23.0 (2015-08-14) + +- Update to libgit2 v0.23 + [#540](https://github.com/libgit2/pygit2/pull/540) +- Now `Repository.merge_base(...)` returns `None` if no merge base is + found [#550](https://github.com/libgit2/pygit2/pull/550) +- Documentation updates + [#547](https://github.com/libgit2/pygit2/pull/547) + +API changes: + +- How to set identity (aka signature) in a reflog has changed: + + # Before + signature = Signature('foo', 'bar') + ... + reference.set_target(target, signature=signature, message=message) + repo.set_head(target, signature=signature) + remote.fetch(signature=signature) + remote.push(signature=signature) + + # Now + repo.set_ident('foo', 'bar') + ... + reference.set_target(target, message=message) + repo.set_head(target) + remote.push() + + # The current identity can be get with + repo.ident + +- Some remote setters have been replaced by methods: + + # Before # Now + Remote.url = url Repository.remotes.set_url(name, url) + Remote.push_url = url Repository.remotes.set_push_url(name, url) + + Remote.add_fetch(refspec) Repository.remotes.add_fetch(name, refspec) + Remote.add_push(refspec) Repository.remotes.add_push(name, refspec) + + Remote.fetch_refspecs = [...] removed, use the config API instead + Remote.push_refspecs = [...] removed, use the config API instead + +# 0.22.1 (2015-07-12) + +Diff interface refactoring +[#346](https://github.com/libgit2/pygit2/pull/346) (in progress): + +- New `iter(pygit2.Blame)` + +- New `pygit2.DiffDelta`, `pygit2.DiffFile` and `pygit.DiffLine` + +- API changes, translation table: + + Hunk => DiffHunk + Patch.old_file_path => Patch.delta.old_file.path + Patch.new_file_path => Patch.delta.new_file.path + Patch.old_id => Patch.delta.old_file.id + Patch.new_id => Patch.delta.new_file.id + Patch.status => Patch.delta.status + Patch.similarity => Patch.delta.similarity + Patch.is_binary => Patch.delta.is_binary + Patch.additions => Patch.line_stats[1] + Patch.deletions => Patch.line_stats[2] + +- `DiffHunk.lines` is now a list of `DiffLine` objects, not tuples + +New features: + +- New `Repository.expand_id(...)` and `Repository.ahead_behind(...)` + [#448](https://github.com/libgit2/pygit2/pull/448) +- New `prefix` parameter in `Repository.write_archive` + [#481](https://github.com/libgit2/pygit2/pull/481) +- New `Repository.merge_trees(...)` + [#489](https://github.com/libgit2/pygit2/pull/489) +- New `Repository.cherrypick(...)` + [#436](https://github.com/libgit2/pygit2/issues/436) + [#492](https://github.com/libgit2/pygit2/pull/492) +- New support for submodules + [#499](https://github.com/libgit2/pygit2/pull/499) + [#514](https://github.com/libgit2/pygit2/pull/514) +- New `Repository.merge_file_from_index(...)` + [#503](https://github.com/libgit2/pygit2/pull/503) +- Now `Repository.diff` supports diffing two blobs + [#508](https://github.com/libgit2/pygit2/pull/508) +- New optional `fetch` parameter in `Remote.create` + [#526](https://github.com/libgit2/pygit2/pull/526) +- New `pygit2.DiffStats` + [#406](https://github.com/libgit2/pygit2/issues/406) + [#525](https://github.com/libgit2/pygit2/pull/525) +- New `Repository.get_attr(...)` + [#528](https://github.com/libgit2/pygit2/pull/528) +- New `level` optional parameter in `Index.remove` + [#533](https://github.com/libgit2/pygit2/pull/533) +- New `repr(TreeEntry)` + [#543](https://github.com/libgit2/pygit2/pull/543) + +Build and install improvements: + +- Make pygit work in a frozen environment + [#453](https://github.com/libgit2/pygit2/pull/453) +- Make pygit2 work with pyinstaller + [#510](https://github.com/libgit2/pygit2/pull/510) + +Bugs fixed: + +- Fix memory issues + [#477](https://github.com/libgit2/pygit2/issues/477) + [#487](https://github.com/libgit2/pygit2/pull/487) + [#520](https://github.com/libgit2/pygit2/pull/520) +- Fix TreeEntry equality testing + [#458](https://github.com/libgit2/pygit2/issues/458) + [#488](https://github.com/libgit2/pygit2/pull/488) +- `Repository.write_archive` fix handling of symlinks + [#480](https://github.com/libgit2/pygit2/pull/480) +- Fix type check in `Diff[...]` + [#495](https://github.com/libgit2/pygit2/issues/495) +- Fix error when merging files with unicode content + [#505](https://github.com/libgit2/pygit2/pull/505) + +Other: + +- Documentation improvements and fixes + [#448](https://github.com/libgit2/pygit2/pull/448) + [#491](https://github.com/libgit2/pygit2/pull/491) + [#497](https://github.com/libgit2/pygit2/pull/497) + [#507](https://github.com/libgit2/pygit2/pull/507) + [#517](https://github.com/libgit2/pygit2/pull/517) + [#518](https://github.com/libgit2/pygit2/pull/518) + [#519](https://github.com/libgit2/pygit2/pull/519) + [#521](https://github.com/libgit2/pygit2/pull/521) + [#523](https://github.com/libgit2/pygit2/pull/523) + [#527](https://github.com/libgit2/pygit2/pull/527) + [#536](https://github.com/libgit2/pygit2/pull/536) +- Expose the `pygit2.GIT_REPOSITORY_INIT_*` constants + [#483](https://github.com/libgit2/pygit2/issues/483) + +# 0.22.0 (2015-01-16) + +New: + +- Update to libgit2 v0.22 + [#459](https://github.com/libgit2/pygit2/pull/459) +- Add support for libgit2 feature detection (new `pygit2.features` and + `pygit2.GIT_FEATURE_*`) + [#475](https://github.com/libgit2/pygit2/pull/475) +- New `Repository.remotes` (`RemoteCollection`) + [#447](https://github.com/libgit2/pygit2/pull/447) + +API Changes: + +- Prototype of `clone_repository` changed, check documentation +- Removed `clone_into`, use `clone_repository` with callbacks instead +- Use `Repository.remotes.rename(name, new_name)` instead of + `Remote.rename(new_name)` +- Use `Repository.remotes.delete(name)` instead of `Remote.delete()` +- Now `Remote.push(...)` takes a list of refspecs instead of just one +- Change `Patch.old_id`, `Patch.new_id`, `Note.annotated_id`, + `RefLogEntry.oid_old` and `RefLogEntry.oid_new` to be `Oid` objects + instead of strings + [#449](https://github.com/libgit2/pygit2/pull/449) + +Other: + +- Fix `init_repository` when passing optional parameters + `workdir_path`, `description`, `template_path`, `initial_head` or + `origin_url` [#466](https://github.com/libgit2/pygit2/issues/466) + [#471](https://github.com/libgit2/pygit2/pull/471) +- Fix use-after-free when patch outlives diff + [#457](https://github.com/libgit2/pygit2/issues/457) + [#461](https://github.com/libgit2/pygit2/pull/461) + [#474](https://github.com/libgit2/pygit2/pull/474) +- Documentation improvements + [#456](https://github.com/libgit2/pygit2/issues/456) + [#462](https://github.com/libgit2/pygit2/pull/462) + [#465](https://github.com/libgit2/pygit2/pull/465) + [#472](https://github.com/libgit2/pygit2/pull/472) + [#473](https://github.com/libgit2/pygit2/pull/473) +- Make the GPL exception explicit in setup.py + [#450](https://github.com/libgit2/pygit2/pull/450) + +# 0.21.4 (2014-11-04) + +- Fix credentials callback not set when pushing + [#431](https://github.com/libgit2/pygit2/pull/431) + [#435](https://github.com/libgit2/pygit2/issues/435) + [#437](https://github.com/libgit2/pygit2/issues/437) + [#438](https://github.com/libgit2/pygit2/pull/438) +- Fix `Repository.diff(...)` when treeish is \"empty\" + [#432](https://github.com/libgit2/pygit2/issues/432) +- New `Reference.peel(...)` renders `Reference.get_object()` obsolete + [#434](https://github.com/libgit2/pygit2/pull/434) +- New, authenticate using ssh agent + [#424](https://github.com/libgit2/pygit2/pull/424) +- New `Repository.merge_commits(...)` + [#445](https://github.com/libgit2/pygit2/pull/445) +- Make it easier to run when libgit2 not in a standard location + [#441](https://github.com/libgit2/pygit2/issues/441) +- Documentation: review install chapter +- Documentation: many corrections + [#427](https://github.com/libgit2/pygit2/pull/427) + [#429](https://github.com/libgit2/pygit2/pull/429) + [#439](https://github.com/libgit2/pygit2/pull/439) + [#440](https://github.com/libgit2/pygit2/pull/440) + [#442](https://github.com/libgit2/pygit2/pull/442) + [#443](https://github.com/libgit2/pygit2/pull/443) + [#444](https://github.com/libgit2/pygit2/pull/444) + +# 0.21.3 (2014-09-15) + +Breaking changes: + +- Now `Repository.blame(...)` returns `Oid` instead of string + [#413](https://github.com/libgit2/pygit2/pull/413) +- New `Reference.set_target(...)` replaces the `Reference.target` + setter and `Reference.log_append(...)` + [#414](https://github.com/libgit2/pygit2/pull/414) +- New `Repository.set_head(...)` replaces the `Repository.head` setter + [#414](https://github.com/libgit2/pygit2/pull/414) +- `Repository.merge(...)` now uses the `SAFE_CREATE` strategy by + default [#417](https://github.com/libgit2/pygit2/pull/417) + +Other changes: + +- New `Remote.delete()` + [#418](https://github.com/libgit2/pygit2/issues/418) + [#420](https://github.com/libgit2/pygit2/pull/420) +- New `Repository.write_archive(...)` + [#421](https://github.com/libgit2/pygit2/pull/421) +- Now `Repository.checkout(...)` accepts branch objects + [#408](https://github.com/libgit2/pygit2/pull/408) +- Fix refcount leak in remotes + [#403](https://github.com/libgit2/pygit2/issues/403) + [#404](https://github.com/libgit2/pygit2/pull/404) + [#419](https://github.com/libgit2/pygit2/pull/419) +- Various fixes to `clone_repository(...)` + [#399](https://github.com/libgit2/pygit2/issues/399) + [#411](https://github.com/libgit2/pygit2/pull/411) + [#425](https://github.com/libgit2/pygit2/issues/425) + [#426](https://github.com/libgit2/pygit2/pull/426) +- Fix build error in Python 3 + [#401](https://github.com/libgit2/pygit2/pull/401) +- Now `pip install pygit2` installs cffi first + [#380](https://github.com/libgit2/pygit2/issues/380) + [#407](https://github.com/libgit2/pygit2/pull/407) +- Add support for PyPy3 + [#422](https://github.com/libgit2/pygit2/pull/422) +- Documentation improvements + [#398](https://github.com/libgit2/pygit2/pull/398) + [#409](https://github.com/libgit2/pygit2/pull/409) + +# 0.21.2 (2014-08-09) + +- Fix regression with Python 2, `IndexEntry.path` returns str (bytes + in Python 2 and unicode in Python 3) +- Get back `IndexEntry.oid` for backwards compatibility +- Config, iterate over the keys (instead of the key/value pairs) + [#395](https://github.com/libgit2/pygit2/pull/395) +- `Diff.find_similar` supports new threshold arguments + [#396](https://github.com/libgit2/pygit2/pull/396) +- Optimization, do not load the object when expanding an oid prefix + [#397](https://github.com/libgit2/pygit2/pull/397) + +# 0.21.1 (2014-07-22) + +- Install fix [#382](https://github.com/libgit2/pygit2/pull/382) +- Documentation improved, including + [#383](https://github.com/libgit2/pygit2/pull/383) + [#385](https://github.com/libgit2/pygit2/pull/385) + [#388](https://github.com/libgit2/pygit2/pull/388) +- Documentation, use the read-the-docs theme + [#387](https://github.com/libgit2/pygit2/pull/387) +- Coding style improvements + [#392](https://github.com/libgit2/pygit2/pull/392) +- New `Repository.state_cleanup()` + [#386](https://github.com/libgit2/pygit2/pull/386) +- New `Index.conflicts` + [#345](https://github.com/libgit2/pygit2/issues/345) + [#389](https://github.com/libgit2/pygit2/pull/389) +- New checkout option to define the target directory + [#390](https://github.com/libgit2/pygit2/pull/390) + +Backward incompatible changes: + +- Now the checkout strategy must be a keyword argument. + + Change `Repository.checkout(refname, strategy)` to + `Repository.checkout(refname, strategy=strategy)` + + Idem for `checkout_head`, `checkout_index` and `checkout_tree` + +# 0.21.0 (2014-06-27) + +Highlights: + +- Drop official support for Python 2.6, and add support for Python 3.4 + [#376](https://github.com/libgit2/pygit2/pull/376) +- Upgrade to libgit2 v0.21.0 + [#374](https://github.com/libgit2/pygit2/pull/374) +- Start using cffi [#360](https://github.com/libgit2/pygit2/pull/360) + [#361](https://github.com/libgit2/pygit2/pull/361) + +Backward incompatible changes: + +- Replace `oid` by `id` through the API to follow libgit2 conventions. +- Merge API overhaul following changes in libgit2. +- New `Remote.rename(...)` replaces `Remote.name = ...` +- Now `Remote.fetch()` returns a `TransferProgress` object. +- Now `Config.get_multivar(...)` returns an iterator instead of a + list. + +New features: + +- New `Config.snapshot()` and `Repository.config_snapshot()` +- New `Config` methods: `get_bool(...)`, `get_int(...)`, + `parse_bool(...)` and `parse_int(...)` + [#357](https://github.com/libgit2/pygit2/pull/357) +- Blob: implement the memory buffer interface + [#362](https://github.com/libgit2/pygit2/pull/362) +- New `clone_into(...)` function + [#368](https://github.com/libgit2/pygit2/pull/368) +- Now `Index` can be used alone, without a repository + [#372](https://github.com/libgit2/pygit2/pull/372) +- Add more options to `init_repository` + [#347](https://github.com/libgit2/pygit2/pull/347) +- Support `Repository.workdir = ...` and support setting detached + heads `Repository.head = ` + [#377](https://github.com/libgit2/pygit2/pull/377) + +Other: + +- Fix again build with VS2008 + [#364](https://github.com/libgit2/pygit2/pull/364) +- Fix `Blob.diff(...)` and `Blob.diff_to_buffer(...)` arguments + passing [#366](https://github.com/libgit2/pygit2/pull/366) +- Fail gracefully when compiling against the wrong version of libgit2 + [#365](https://github.com/libgit2/pygit2/pull/365) +- Several documentation improvements and updates + [#359](https://github.com/libgit2/pygit2/pull/359) + [#375](https://github.com/libgit2/pygit2/pull/375) + [#378](https://github.com/libgit2/pygit2/pull/378) + +# 0.20.3 (2014-04-02) + +- A number of memory issues fixed + [#328](https://github.com/libgit2/pygit2/pull/328) + [#348](https://github.com/libgit2/pygit2/pull/348) + [#353](https://github.com/libgit2/pygit2/pull/353) + [#355](https://github.com/libgit2/pygit2/pull/355) + [#356](https://github.com/libgit2/pygit2/pull/356) +- Compatibility fixes for PyPy + ([#338](https://github.com/libgit2/pygit2/pull/338)), Visual Studio + 2008 ([#343](https://github.com/libgit2/pygit2/pull/343)) and Python + 3.3 ([#351](https://github.com/libgit2/pygit2/pull/351)) +- Make the sort mode parameter in `Repository.walk(...)` optional + [#337](https://github.com/libgit2/pygit2/pull/337) +- New `Object.peel(...)` + [#342](https://github.com/libgit2/pygit2/pull/342) +- New `Index.add_all(...)` + [#344](https://github.com/libgit2/pygit2/pull/344) +- Introduce support for libgit2 options + [#350](https://github.com/libgit2/pygit2/pull/350) +- More informative repr for `Repository` objects + [#352](https://github.com/libgit2/pygit2/pull/352) +- Introduce support for credentials + [#354](https://github.com/libgit2/pygit2/pull/354) +- Several documentation fixes + [#302](https://github.com/libgit2/pygit2/issues/302) + [#336](https://github.com/libgit2/pygit2/issues/336) +- Tests, remove temporary files + [#341](https://github.com/libgit2/pygit2/pull/341) + +# 0.20.2 (2014-02-04) + +- Support PyPy [#209](https://github.com/libgit2/pygit2/issues/209) + [#327](https://github.com/libgit2/pygit2/pull/327) + [#333](https://github.com/libgit2/pygit2/pull/333) + +Repository: + +- New `Repository.default_signature` + [#310](https://github.com/libgit2/pygit2/pull/310) + +Oid: + +- New `str(Oid)` deprecates `Oid.hex` + [#322](https://github.com/libgit2/pygit2/pull/322) + +Object: + +- New `Object.id` deprecates `Object.oid` + [#322](https://github.com/libgit2/pygit2/pull/322) +- New `TreeEntry.id` deprecates `TreeEntry.oid` + [#322](https://github.com/libgit2/pygit2/pull/322) +- New `Blob.diff(...)` and `Blob.diff_to_buffer(...)` + [#307](https://github.com/libgit2/pygit2/pull/307) +- New `Commit.tree_id` and `Commit.parent_ids` + [#73](https://github.com/libgit2/pygit2/issues/73) + [#311](https://github.com/libgit2/pygit2/pull/311) +- New rich comparison between tree entries + [#305](https://github.com/libgit2/pygit2/issues/305) + [#313](https://github.com/libgit2/pygit2/pull/313) +- Now `Tree.__contains__(key)` supports paths + [#306](https://github.com/libgit2/pygit2/issues/306) + [#316](https://github.com/libgit2/pygit2/pull/316) + +Index: + +- Now possible to create `IndexEntry(...)` + [#325](https://github.com/libgit2/pygit2/pull/325) +- Now `IndexEntry.path`, `IndexEntry.oid` and `IndexEntry.mode` are + writable [#325](https://github.com/libgit2/pygit2/pull/325) +- Now `Index.add(...)` accepts an `IndexEntry` too + [#325](https://github.com/libgit2/pygit2/pull/325) +- Now `Index.write_tree(...)` is able to write to a different + repository [#325](https://github.com/libgit2/pygit2/pull/325) +- Fix memory leak in `IndexEntry.path` setter + [#335](https://github.com/libgit2/pygit2/pull/335) + +Config: + +- New `Config` iterator replaces `Config.foreach` + [#183](https://github.com/libgit2/pygit2/issues/183) + [#312](https://github.com/libgit2/pygit2/pull/312) + +Remote: + +- New type `Refspec` + [#314](https://github.com/libgit2/pygit2/pull/314) +- New `Remote.push_url` + [#315](https://github.com/libgit2/pygit2/pull/314) +- New `Remote.add_push` and `Remote.add_fetch` + [#255](https://github.com/libgit2/pygit2/issues/255) + [#318](https://github.com/libgit2/pygit2/pull/318) +- New `Remote.fetch_refspecs` replaces `Remote.get_fetch_refspecs()` + and `Remote.set_fetch_refspecs(...)` + [#319](https://github.com/libgit2/pygit2/pull/319) +- New `Remote.push_refspecs` replaces `Remote.get_push_refspecs()` and + `Remote.set_push_refspecs(...)` + [#319](https://github.com/libgit2/pygit2/pull/319) +- New `Remote.progress`, `Remote.transfer_progress` and + `Remote.update_tips` + [#274](https://github.com/libgit2/pygit2/issues/274) + [#324](https://github.com/libgit2/pygit2/pull/324) +- New type `TransferProgress` + [#274](https://github.com/libgit2/pygit2/issues/274) + [#324](https://github.com/libgit2/pygit2/pull/324) +- Fix refcount leak in `Repository.remotes` + [#321](https://github.com/libgit2/pygit2/issues/321) + [#332](https://github.com/libgit2/pygit2/pull/332) + +Other: [#331](https://github.com/libgit2/pygit2/pull/331) + +# 0.20.1 (2013-12-24) + +- New remote ref-specs API: + [#290](https://github.com/libgit2/pygit2/pull/290) +- New `Repository.reset(...)`: + [#292](https://github.com/libgit2/pygit2/pull/292), + [#294](https://github.com/libgit2/pygit2/pull/294) +- Export `GIT_DIFF_MINIMAL`: + [#293](https://github.com/libgit2/pygit2/pull/293) +- New `Repository.merge(...)`: + [#295](https://github.com/libgit2/pygit2/pull/295) +- Fix `Repository.blame` argument handling: + [#297](https://github.com/libgit2/pygit2/pull/297) +- Fix build error on Windows: + [#298](https://github.com/libgit2/pygit2/pull/298) +- Fix typo in the README file, Blog → Blob: + [#301](https://github.com/libgit2/pygit2/pull/301) +- Now `Diff.patch` returns `None` if no patch: + [#232](https://github.com/libgit2/pygit2/pull/232), + [#303](https://github.com/libgit2/pygit2/pull/303) +- New `Walker.simplify_first_parent()`: + [#304](https://github.com/libgit2/pygit2/pull/304) + +# 0.20.0 (2013-11-24) + +- Upgrade to libgit2 v0.20.0: + [#288](https://github.com/libgit2/pygit2/pull/288) +- New `Repository.head_is_unborn` replaces + `Repository.head_is_orphaned` +- Changed `pygit2.clone_repository(...)`. Drop `push_url`, + `fetch_spec` and `push_spec` parameters. Add `ignore_cert_errors`. +- New `Patch.additions` and `Patch.deletions`: + [#275](https://github.com/libgit2/pygit2/pull/275) +- New `Patch.is_binary`: + [#276](https://github.com/libgit2/pygit2/pull/276) +- New `Reference.log_append(...)`: + [#277](https://github.com/libgit2/pygit2/pull/277) +- New `Blob.is_binary`: + [#278](https://github.com/libgit2/pygit2/pull/278) +- New `len(Diff)` shows the number of patches: + [#281](https://github.com/libgit2/pygit2/pull/281) +- Rewrite `Repository.status()`: + [#283](https://github.com/libgit2/pygit2/pull/283) +- New `Reference.shorthand`: + [#284](https://github.com/libgit2/pygit2/pull/284) +- New `Repository.blame(...)`: + [#285](https://github.com/libgit2/pygit2/pull/285) +- Now `Repository.listall_references()` and + `Repository.listall_branches()` return a list, not a tuple: + [#289](https://github.com/libgit2/pygit2/pull/289) diff --git a/COPYING b/COPYING index 3de06906b..631492395 100644 --- a/COPYING +++ b/COPYING @@ -1,11 +1,17 @@ + pygit2 is Copyright (C) the pygit2 contributors, + unless otherwise stated. See the AUTHORS.md file for details. Note that the only valid version of the GPL as far as this project is concerned is _this_ particular version of the license (ie v2, not v2.2 or v3.x or whatever), unless explicitly otherwise stated. +---------------------------------------------------------------------- + + LINKING EXCEPTION + 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, + version of this library 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 diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..9cfe1226f --- /dev/null +++ b/Makefile @@ -0,0 +1,7 @@ +.PHONY: build html + +build: + OPENSSL_VERSION=3.3.3 LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.1 sh build.sh + +html: build + make -C docs html diff --git a/README.md b/README.md new file mode 100644 index 000000000..5286c52a6 --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# 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+ + +[![test-ci-badge][test-ci-badge]][test-ci-link] +[![deploy-ci-badge][deploy-ci-badge]][deploy-ci-link] + +[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 - + +## Sponsors + +Add your name and link here, [become a +sponsor](https://github.com/sponsors/jdavid). + +## License: GPLv2 with linking exception + +This program 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 program is distributed in the hope that it will be useful, but +WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General +Public License for more details. + +You should have received a copy of the GNU General Public License along +with this program; see the file COPYING. If not, write to the Free +Software Foundation, 51 Franklin Street, Fifth Floor, Boston, MA +02110-1301, USA. diff --git a/README.rst b/README.rst deleted file mode 100644 index a64219bda..000000000 --- a/README.rst +++ /dev/null @@ -1,128 +0,0 @@ - -###################################################################### -pygit2 - libgit2 bindings in Python -###################################################################### - -.. image:: https://secure.travis-ci.org/libgit2/pygit2.png - :target: http://travis-ci.org/libgit2/pygit2 - -Pygit2 is a set of Python bindings to the libgit2 shared library, libgit2 -implements the core of Git. Pygit2 works with Python 2.6, 2.7, 3.1, 3.2 and -3.3 - -Pygit2 links: - -- http://github.com/libgit2/pygit2 -- Source code and issue tracker -- http://www.pygit2.org/ -- Documentation -- http://pypi.python.org/pypi/pygit2 -- Download - - -Quick install guide -=================== - -1. Checkout the libgit2 stable branch:: - - $ git clone git://github.com/libgit2/libgit2.git -b master - -2. Build and install libgit2 - https://github.com/libgit2/libgit2/#building-libgit2---using-cmake - -3. Install pygit2 with *pip*:: - - $ pip install pygit2 - -For detailed instructions check the documentation, -http://www.pygit2.org/install.html - - -Contributing -============ - -Fork libgit2/pygit2 on GitHub, make it awesomer (preferably in a branch named -for the topic), send a pull request. - - -Authors -============== - -This is the list of authors of pygit2, sorted by number of commits (as shown by -``git shortlog -sn``): - -- J David Ibáñez -- Nico von Geyso -- W Trevor King -- Dave Borowitz -- Carlos Martín Nieto -- Daniel Rodríguez Troitiño -- Richo Healey -- Christian Boos -- Julien Miotte -- Martin Lenders -- Xavier Delannoy -- Yonggang Luo -- Valentin Haenel -- Bernardo Heynemann -- John Szakmeister -- David Versmisse -- Petr Hosek -- Rémi Duraffort -- Sebastian Thiel -- Han-Wen Nienhuys -- Petr Viktorin -- Alex Chamberlain -- Amit Bakshi -- Andrey Devyatkin -- Ben Davis -- Hervé Cauwelier -- Jared Flatow -- Jiunn Haur Lim -- Sarath Lakshman -- Vicent Marti -- Zoran Zaric -- András Veres-Szentkirályi -- Benjamin Kircher -- Benjamin Pollack -- Bryan O'Sullivan -- David Fischer -- David Sanders -- Eric Davis -- Eric Schrijver -- Erik van Zijst -- Ferengee -- Fraser Tweedale -- Hugh Cole-Baker -- Josh Bleecher Snyder -- Jun Omae -- Ridge Kennedy -- Rui Abreu Ferreira -- Xu Tao -- pistacchio - - -License -============== - -**GPLv2 with linking exception.** - -This program 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 program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program; see the file COPYING. If not, write to -the Free Software Foundation, 51 Franklin Street, Fifth Floor, -Boston, MA 02110-1301, USA. diff --git a/SPONSORS.md b/SPONSORS.md new file mode 100644 index 000000000..e824ee2fb --- /dev/null +++ b/SPONSORS.md @@ -0,0 +1,12 @@ +Friends of pygit2: + +- Add your name to the list, + [become a friend of pygit2](https://github.com/sponsors/jdavid). + +Past sponsors: + +- [Microsoft](https://github.com/microsoft) +- [Iterative](https://iterative.ai/) +- [SourceHut](https://sourcehut.org) +- [GitHub](https://github.com/github) +- [omniproc](https://github.com/omniproc) diff --git a/TODO.txt b/TODO.txt deleted file mode 100644 index 4a48a284c..000000000 --- a/TODO.txt +++ /dev/null @@ -1,20 +0,0 @@ -Signature -========= -- Implement equality interface -- In Repository's create_commit/create_tag check signatures encoding is right - -References -========== -- Wrap missing functions: git_reference_foreach, git_reference_is_packed. -- Write more twisted tests. Like accessing a reference deleted by someone - else. - -Other -========= -- Make the Py_LOCAL_INLINE macro to work with Python 2.6, 2.7 and 3.1 -- Use surrogateescape in Python 3, see PEP-383 -- Expose the ODB (Repository.odb) -- According to Python documentation, tp_dealloc must call tp_free (instead of - PyObject_Del or similar) if the type is subclassable. So, go through the - code and switch to tp_free, or make the type not subclassable, on a case by - case basis. diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 000000000..204a201b8 --- /dev/null +++ b/build.ps1 @@ -0,0 +1,21 @@ +if (!(Test-Path -Path "build")) { + # in case the pygit2 package build/ workspace has not been created by cibuildwheel yet + mkdir build +} +if (Test-Path -Path "$env:LIBGIT2_SRC") { + Set-Location "$env:LIBGIT2_SRC" + # for local runs, reuse build/libgit_src if it exists + if (Test-Path -Path build) { + # purge previous build env (likely for a different arch type) + Remove-Item -Recurse -Force build + } + # ensure we are checked out to the right version + git fetch --depth=1 --tags + git checkout "v$env:LIBGIT2_VERSION" +} else { + # from a fresh run (like in CI) + git clone --depth=1 -b "v$env:LIBGIT2_VERSION" https://github.com/libgit2/libgit2.git $env:LIBGIT2_SRC + Set-Location "$env:LIBGIT2_SRC" +} +cmake -B build -S . -DBUILD_TESTS=OFF +cmake --build build/ --config=Release --target install diff --git a/build.sh b/build.sh new file mode 100644 index 000000000..c710a076c --- /dev/null +++ b/build.sh @@ -0,0 +1,281 @@ +#!/bin/sh + +# +# Synopsis: +# +# sh build.sh - Build inplace +# sh build.sh test - Build inplace, and run the tests +# sh build.sh wheel - Build a wheel, install, and run the tests +# +# Environment variables: +# +# AUDITWHEEL_PLAT - Linux platform for auditwheel repair +# LIBSSH2_OPENSSL - Where to find openssl +# 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) +# +# Examples. +# +# Build inplace, libgit2 must be available in the path: +# +# sh build.sh +# +# Build libgit2 1.9.0 (will use libssh2 if available), then build pygit2 +# inplace: +# +# LIBGIT2_VERSION=1.9.0 sh build.sh +# +# Build libssh2 1.11.1 and libgit2 1.9.0, then build pygit2 inplace: +# +# LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.0 sh build.sh +# +# Build inplace and run the tests: +# +# sh build.sh test +# +# Build a wheel: +# +# sh build.sh wheel +# + +set -x # Print every command and variable +set -e # Fail fast + +# Variables +ARCH=`uname -m` +KERNEL=`uname -s` +BUILD_TYPE=${BUILD_TYPE:-Debug} +PYTHON=${PYTHON:-python3} + +if [ "$CIBUILDWHEEL" != "1" ]; then + PYTHON_TAG=$($PYTHON build_tag.py) +fi + +PREFIX="${PREFIX:-$(pwd)/ci/$PYTHON_TAG}" +export LDFLAGS="-Wl,-rpath,$PREFIX/lib" + +if [ "$CIBUILDWHEEL" = "1" ]; then + if [ -f /usr/bin/apt-get ]; then + apt-get update + apt-get install wget -y + if [ -z "$OPENSSL_VERSION" ]; then + apt-get install libssl-dev -y + fi + elif [ -f /usr/bin/yum ]; then + yum install wget zlib-devel -y + if [ -z "$OPENSSL_VERSION" ]; then + yum install openssl-devel -y + else + yum install perl-IPC-Cmd -y + yum install perl-Pod-Html -y + fi + elif [ -f /sbin/apk ]; then + apk add wget + if [ -z "$OPENSSL_VERSION" ]; then + apk add openssl-dev + fi + fi + rm -rf ci + mkdir ci || true + cd ci +else + # Create a virtual environment + $PYTHON -m venv $PREFIX + cd ci +fi + +# Install zlib +# XXX Build libgit2 with USE_BUNDLED_ZLIB instead? +if [ -n "$ZLIB_VERSION" ]; then + FILENAME=zlib-$ZLIB_VERSION + wget https://www.zlib.net/$FILENAME.tar.gz -N + tar xf $FILENAME.tar.gz + cd $FILENAME + ./configure --prefix=$PREFIX + make + make install + cd .. +fi + +# Install openssl +if [ -n "$OPENSSL_VERSION" ]; then + FILENAME=openssl-$OPENSSL_VERSION + wget https://www.openssl.org/source/$FILENAME.tar.gz -N --no-check-certificate + + if [ "$KERNEL" = "Darwin" ]; then + 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 + 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 .. + else + # Linux + tar xf $FILENAME.tar.gz + cd $FILENAME + ./Configure shared --prefix=$PREFIX --libdir=$PREFIX/lib + make + make install + OPENSSL_PREFIX=$(pwd) + cd .. + fi +fi + +# Install libssh2 +if [ -n "$LIBSSH2_VERSION" ]; then + FILENAME=libssh2-$LIBSSH2_VERSION + wget https://www.libssh2.org/download/$FILENAME.tar.gz -N --no-check-certificate + tar xf $FILENAME.tar.gz + cd $FILENAME + if [ "$KERNEL" = "Darwin" ] && [ "$CIBUILDWHEEL" = "1" ]; then + 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" \ + -DBUILD_TESTING=OFF + else + cmake . \ + -DCMAKE_INSTALL_PREFIX=$PREFIX \ + -DBUILD_SHARED_LIBS=ON \ + -DBUILD_EXAMPLES=OFF \ + -DBUILD_TESTING=OFF + fi + cmake --build . --target install + cd .. + USE_SSH=ON +else + USE_SSH=OFF +fi + +# Install libgit2 +if [ -n "$LIBGIT2_VERSION" ]; then + FILENAME=libgit2-$LIBGIT2_VERSION + 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 -p build + cd build + if [ "$KERNEL" = "Darwin" ] && [ "$CIBUILDWHEEL" = "1" ]; then + CMAKE_PREFIX_PATH=$OPENSSL_PREFIX:$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_INSTALL_PREFIX=$PREFIX \ + -DUSE_SSH=$USE_SSH + else + export CFLAGS=-I$PREFIX/include + CMAKE_PREFIX_PATH=$OPENSSL_PREFIX:$PREFIX cmake .. \ + -DBUILD_SHARED_LIBS=ON \ + -DBUILD_TESTS=OFF \ + -DCMAKE_BUILD_TYPE=$BUILD_TYPE \ + -DCMAKE_INSTALL_PREFIX=$PREFIX \ + -DUSE_SSH=$USE_SSH + fi + cmake --build . --target install + cd .. + cd .. + export LIBGIT2=$PREFIX +fi + +if [ "$CIBUILDWHEEL" = "1" ]; then + if [ "$KERNEL" = "Darwin" ]; then + cp $OPENSSL_PREFIX/*.dylib $PREFIX/lib/ + cp $OPENSSL_PREFIX/*.dylib $PREFIX/lib/ + echo "PREFIX " $PREFIX + echo "OPENSSL_PREFIX" $OPENSSL_PREFIX + ls -l /Users/runner/work/pygit2/pygit2/ci/ + ls -l $PREFIX/lib + fi + # we're done building dependencies, cibuildwheel action will take over + exit 0 +fi + +# Build pygit2 +cd .. +$PREFIX/bin/pip install -U pip wheel +if [ "$1" = "wheel" ]; then + shift + $PREFIX/bin/pip install wheel + $PREFIX/bin/python setup.py bdist_wheel + WHEELDIR=dist +else + # Install Python requirements & build inplace + $PREFIX/bin/pip install -r requirements.txt + $PREFIX/bin/python setup.py build_ext --inplace +fi + +# Bundle libraries +if [ "$1" = "bundle" ]; then + shift + WHEELDIR=wheelhouse + case "${KERNEL}" in + Darwin*) + $PREFIX/bin/pip install delocate + $PREFIX/bin/delocate-listdeps dist/pygit2-*macosx*.whl + $PREFIX/bin/delocate-wheel -v -w $WHEELDIR dist/pygit2-*macosx*.whl + $PREFIX/bin/delocate-listdeps $WHEELDIR/pygit2-*macosx*.whl + ;; + *) # LINUX + $PREFIX/bin/pip install auditwheel + $PREFIX/bin/auditwheel repair dist/pygit2*-$PYTHON_TAG-*_$ARCH.whl + $PREFIX/bin/auditwheel show $WHEELDIR/pygit2*-$PYTHON_TAG-*_$ARCH.whl + ;; + esac +fi + +# Tests +if [ "$1" = "test" ]; then + shift + if [ -n "$WHEELDIR" ]; then + $PREFIX/bin/pip install $WHEELDIR/pygit2*-$PYTHON_TAG-*.whl + fi + $PREFIX/bin/pip install -r requirements-test.txt + $PREFIX/bin/pytest --cov=pygit2 +fi + +# Type checking +if [ "$1" = "mypy" ]; then + shift + if [ -n "$WHEELDIR" ]; then + $PREFIX/bin/pip install $WHEELDIR/pygit2*-$PYTHON_TAG-*.whl + fi + $PREFIX/bin/pip install -r requirements-test.txt + $PREFIX/bin/mypy pygit2 test +fi + +# Test .pyi stub file +if [ "$1" = "stubtest" ]; then + shift + $PREFIX/bin/pip install mypy + PYTHONPATH=. $PREFIX/bin/stubtest --mypy-config-file mypy-stubtest.ini pygit2._pygit2 + [ $? == 0 ] && echo "stubtest OK" +fi diff --git a/build_tag.py b/build_tag.py new file mode 100644 index 000000000..5051f9979 --- /dev/null +++ b/build_tag.py @@ -0,0 +1,5 @@ +import platform +import sys + +py = {'CPython': 'cp', 'PyPy': 'pp'}[platform.python_implementation()] +print(f'{py}{sys.version_info.major}{sys.version_info.minor}') diff --git a/docs/Makefile b/docs/Makefile index d394e42d8..298ea9e21 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -1,153 +1,19 @@ -# Makefile for Sphinx documentation +# Minimal makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build -PAPER = +SOURCEDIR = . BUILDDIR = _build -# Internal variables. -PAPEROPT_a4 = -D latex_paper_size=a4 -PAPEROPT_letter = -D latex_paper_size=letter -ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . -# the i18n builder cannot share the environment and doctrees with the others -I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . - -.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext - +# Put it first so that "make" without argument is like "make help". help: - @echo "Please use \`make ' where is one of" - @echo " html to make standalone HTML files" - @echo " dirhtml to make HTML files named index.html in directories" - @echo " singlehtml to make a single large HTML file" - @echo " pickle to make pickle files" - @echo " json to make JSON files" - @echo " htmlhelp to make HTML files and a HTML help project" - @echo " qthelp to make HTML files and a qthelp project" - @echo " devhelp to make HTML files and a Devhelp project" - @echo " epub to make an epub" - @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" - @echo " latexpdf to make LaTeX files and run them through pdflatex" - @echo " text to make text files" - @echo " man to make manual pages" - @echo " texinfo to make Texinfo files" - @echo " info to make Texinfo files and run them through makeinfo" - @echo " gettext to make PO message catalogs" - @echo " changes to make an overview of all changed/added/deprecated items" - @echo " linkcheck to check all external links for integrity" - @echo " doctest to run all doctests embedded in the documentation (if enabled)" - -clean: - -rm -rf $(BUILDDIR)/* - -html: - $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." - -dirhtml: - $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." - -singlehtml: - $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml - @echo - @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." - -pickle: - $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle - @echo - @echo "Build finished; now you can process the pickle files." - -json: - $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json - @echo - @echo "Build finished; now you can process the JSON files." - -htmlhelp: - $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp - @echo - @echo "Build finished; now you can run HTML Help Workshop with the" \ - ".hhp project file in $(BUILDDIR)/htmlhelp." - -qthelp: - $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp - @echo - @echo "Build finished; now you can run "qcollectiongenerator" with the" \ - ".qhcp project file in $(BUILDDIR)/qthelp, like this:" - @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/pygit2.qhcp" - @echo "To view the help file:" - @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/pygit2.qhc" - -devhelp: - $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp - @echo - @echo "Build finished." - @echo "To view the help file:" - @echo "# mkdir -p $$HOME/.local/share/devhelp/pygit2" - @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/pygit2" - @echo "# devhelp" - -epub: - $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub - @echo - @echo "Build finished. The epub file is in $(BUILDDIR)/epub." - -latex: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo - @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." - @echo "Run \`make' in that directory to run these through (pdf)latex" \ - "(use \`make latexpdf' here to do that automatically)." - -latexpdf: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through pdflatex..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -text: - $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text - @echo - @echo "Build finished. The text files are in $(BUILDDIR)/text." - -man: - $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man - @echo - @echo "Build finished. The manual pages are in $(BUILDDIR)/man." - -texinfo: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo - @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." - @echo "Run \`make' in that directory to run these through makeinfo" \ - "(use \`make info' here to do that automatically)." - -info: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo "Running Texinfo files through makeinfo..." - make -C $(BUILDDIR)/texinfo info - @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." - -gettext: - $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale - @echo - @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." - -changes: - $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes - @echo - @echo "The overview file is in $(BUILDDIR)/changes." + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -linkcheck: - $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck - @echo - @echo "Link check complete; look for any errors in the above output " \ - "or in $(BUILDDIR)/linkcheck/output.txt." +.PHONY: help Makefile -doctest: - $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest - @echo "Testing of doctests in the sources finished, look at the " \ - "results in $(BUILDDIR)/doctest/output.txt." +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) \ No newline at end of file diff --git a/docs/_templates/.keep b/docs/_templates/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/docs/backends.rst b/docs/backends.rst new file mode 100644 index 000000000..f693c7649 --- /dev/null +++ b/docs/backends.rst @@ -0,0 +1,42 @@ +********************************************************************** +Backends +********************************************************************** + +The use of custom backends for the git object database (odb) and reference +database (refdb) are supported by pygit2. + +.. contents:: Contents + :local: + +The OdbBackend class +=================================== + +The OdbBackend class is subclassable and can be used to build a custom object +database. + +.. autoclass:: pygit2.OdbBackend + :members: + +Built-in OdbBackend implementations +=================================== + +.. autoclass:: pygit2.OdbBackendLoose + :members: + +.. autoclass:: pygit2.OdbBackendPack + :members: + +The RefdbBackend class +=================================== + +The RefdbBackend class is subclassable and can be used to build a custom +reference database. + +.. autoclass:: pygit2.RefdbBackend + :members: + +Built-in RefdbBackend implementations +===================================== + +.. autoclass:: pygit2.RefdbFsBackend + :members: diff --git a/docs/blame.rst b/docs/blame.rst new file mode 100644 index 000000000..15e1e371d --- /dev/null +++ b/docs/blame.rst @@ -0,0 +1,49 @@ +********************************************************************** +Blame +********************************************************************** + +.. contents:: + + +.. automethod:: pygit2.Repository.blame + + +The Blame type +============== + +.. automethod:: pygit2.Blame.for_line +.. method:: Blame.__iter__() +.. method:: Blame.__len__() +.. method:: Blame.__getitem__(n) + + +The BlameHunk type +================== + +Attributes: + +.. autoattribute:: pygit2.BlameHunk.lines_in_hunk +.. autoattribute:: pygit2.BlameHunk.final_commit_id +.. autoattribute:: pygit2.BlameHunk.final_start_line_number +.. autoattribute:: pygit2.BlameHunk.orig_commit_id +.. autoattribute:: pygit2.BlameHunk.orig_path +.. autoattribute:: pygit2.BlameHunk.orig_start_line_number +.. autoattribute:: pygit2.BlameHunk.boundary + +Getters: + +.. autoattribute:: pygit2.BlameHunk.final_committer +.. autoattribute:: pygit2.BlameHunk.orig_committer + + +Constants +========= + +.. py:data:: enums.BlameFlag.NORMAL +.. py:data:: enums.BlameFlag.TRACK_COPIES_SAME_FILE +.. py:data:: enums.BlameFlag.TRACK_COPIES_SAME_COMMIT_MOVES +.. py:data:: enums.BlameFlag.TRACK_COPIES_SAME_COMMIT_COPIES +.. py:data:: enums.BlameFlag.TRACK_COPIES_ANY_COMMIT_COPIES +.. py:data:: enums.BlameFlag.FIRST_PARENT +.. py:data:: enums.BlameFlag.USE_MAILMAP +.. py:data:: enums.BlameFlag.IGNORE_WHITESPACE diff --git a/docs/branches.rst b/docs/branches.rst new file mode 100644 index 000000000..5afe6673e --- /dev/null +++ b/docs/branches.rst @@ -0,0 +1,46 @@ +********************************************************************** +Branches +********************************************************************** + +.. autoclass:: pygit2.Repository + :members: lookup_branch, raw_listall_branches + :noindex: + + .. attribute:: branches + +Branches inherit from References, and additionally provide specialized +accessors for some unique features. + +.. autoclass:: pygit2.repository.Branches + :members: + :undoc-members: + :special-members: __getitem__, __iter__, __contains__ + +Example:: + + >>> # Listing all branches + >>> branches_list = list(repo.branches) + >>> # Local only + >>> local_branches = list(repo.branches.local) + >>> # Remote only + >>> remote_branches = list(repo.branches.remote) + + >>> # Get a branch + >>> 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, branching from master + >>> new_branch = repo.branches.local.create('new-branch', repo[master_branch.target]) + + >>> And delete it + >>> repo.branches.delete('new-branch') + + +The Branch type +==================== + +.. autoclass:: pygit2.Branch + :members: diff --git a/docs/commit_log.rst b/docs/commit_log.rst new file mode 100644 index 000000000..440d54b8c --- /dev/null +++ b/docs/commit_log.rst @@ -0,0 +1,12 @@ +********************************************************************** +Commit log +********************************************************************** + +.. automethod:: pygit2.Repository.walk + + +.. automethod:: pygit2.Walker.hide +.. automethod:: pygit2.Walker.push +.. automethod:: pygit2.Walker.reset +.. automethod:: pygit2.Walker.sort +.. automethod:: pygit2.Walker.simplify_first_parent diff --git a/docs/conf.py b/docs/conf.py index bb653d7c2..cf7980861 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,242 +1,58 @@ -# -*- coding: utf-8 -*- +# Configuration file for the Sphinx documentation builder. # -# pygit2 documentation build configuration file, created by -# sphinx-quickstart on Sun Jan 6 09:55:26 2013. -# -# This file is execfile()d with the current directory set to its containing dir. -# -# Note that not all possible configuration values are present in this -# autogenerated file. -# -# All configuration values have a default; values that are commented out -# serve to show the default. +# This file only contains a selection of the most common options. For a full +# list see the documentation: +# http://www.sphinx-doc.org/en/master/config -import sys, os +import os +import sys + +# -- Path setup -------------------------------------------------------------- # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. -sys.path.insert(0, os.path.abspath('../build/lib.linux-x86_64-2.7')) - -# -- General configuration ----------------------------------------------------- - -# If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' +# +sys.path.insert(0, os.path.abspath('..')) -# Add any Sphinx extension module names here, as strings. They can be extensions -# coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.autodoc'] -# Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +# -- Project information ----------------------------------------------------- -# The suffix of source filenames. -source_suffix = '.rst' +project = 'pygit2' +copyright = '2010-2025 The pygit2 contributors' +# author = '' -# The encoding of source files. -#source_encoding = 'utf-8-sig' +# The full version, including alpha/beta/rc tags +release = '1.18.2' -# The master toctree document. -master_doc = 'index' -# General information about the project. -project = u'pygit2' -copyright = u'2013, J. David Ibáñez' +# -- General configuration --------------------------------------------------- -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -# -# The short X.Y version. -version = '0.19' -# The full version, including alpha/beta/rc tags. -release = '0.19.0' - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -#language = None +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.autodoc', +] -# There are two options for replacing |today|: either, you set today to some -# non-false value, then it is used: -#today = '' -# Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. -exclude_patterns = ['_build'] - -# The reST default role (used for this markup: `text`) to use for all documents. -#default_role = None - -# If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True - -# If true, the current module name will be prepended to all description -# unit titles (such as .. function::). -#add_module_names = True +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] -# If true, sectionauthor and moduleauthor directives will be shown in the -# output. They are ignored by default. -#show_authors = False -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' - -# A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] - - -# -- Options for HTML output --------------------------------------------------- +# -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = 'default' - -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. -#html_theme_options = {} - -# Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] - -# The name for this set of Sphinx documents. If None, it defaults to -# " v documentation". -#html_title = None - -# A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None - -# The name of an image file (relative to this directory) to place at the top -# of the sidebar. -#html_logo = None - -# The name of an image file (within the static path) to use as favicon of the -# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 -# pixels large. -#html_favicon = None +# +html_theme = 'sphinx_rtd_theme' +html_theme_path = ['_themes'] # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] - -# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, -# using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' - -# If true, SmartyPants will be used to convert quotes and dashes to -# typographically correct entities. -#html_use_smartypants = True - -# Custom sidebar templates, maps document names to template names. -#html_sidebars = {} - -# Additional templates that should be rendered to pages, maps page names to -# template names. -#html_additional_pages = {} - -# If false, no module index is generated. -#html_domain_indices = True - -# If false, no index is generated. -#html_use_index = True - -# If true, the index is split into individual pages for each letter. -#html_split_index = False - -# If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True - -# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True - -# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True - -# If true, an OpenSearch description file will be output, and all pages will -# contain a tag referring to it. The value of this option must be the -# base URL from which the finished HTML is served. -#html_use_opensearch = '' - -# This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None - -# Output file base name for HTML help builder. -htmlhelp_basename = 'pygit2doc' - - -# -- Options for LaTeX output -------------------------------------------------- - -latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', - -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', - -# Additional stuff for the LaTeX preamble. -#'preamble': '', -} - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, author, documentclass [howto/manual]). -latex_documents = [ - ('index', 'pygit2.tex', u'pygit2 Documentation', - u'J. David Ibáñez', 'manual'), -] - -# The name of an image file (relative to this directory) to place at the top of -# the title page. -#latex_logo = None - -# For "manual" documents, if this is true, then toplevel headings are parts, -# not chapters. -#latex_use_parts = False - -# If true, show page references after internal links. -#latex_show_pagerefs = False - -# If true, show URL addresses after external links. -#latex_show_urls = False - -# Documents to append as an appendix to all manuals. -#latex_appendices = [] - -# If false, no module index is generated. -#latex_domain_indices = True - - -# -- Options for manual page output -------------------------------------------- - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [ - ('index', 'pygit2', u'pygit2 Documentation', - [u'J. David Ibáñez'], 1) -] - -# If true, show URL addresses after external links. -#man_show_urls = False - - -# -- Options for Texinfo output ------------------------------------------------ - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - ('index', 'pygit2', u'pygit2 Documentation', - u'J. David Ibáñez', 'pygit2', 'One line description of project.', - 'Miscellaneous'), -] - -# Documents to append as an appendix to all manuals. -#texinfo_appendices = [] - -# If false, no module index is generated. -#texinfo_domain_indices = True - -# How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' diff --git a/docs/config.rst b/docs/config.rst index e69fbca83..19258aad2 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -2,17 +2,22 @@ Configuration files ********************************************************************** -.. autoattribute:: pygit2.Repository.config +.. autoclass:: pygit2.Repository + :members: config + :noindex: The Config type ================ -.. automethod:: pygit2.Config.get_system_config -.. automethod:: pygit2.Config.get_global_config -.. automethod:: pygit2.Config.foreach -.. automethod:: pygit2.Config.add_file -.. automethod:: pygit2.Config.get_multivar -.. automethod:: pygit2.Config.set_multivar +.. autoclass:: pygit2.Config + :members: + :undoc-members: + :special-members: __contains__, __delitem__, __getitem__, __iter__, __setitem__ -The :class:`Config` Mapping interface. + +The ConfigEntry type +==================== + +.. autoclass:: pygit2.config.ConfigEntry + :members: name, value, level diff --git a/docs/development.rst b/docs/development.rst new file mode 100644 index 000000000..79d7e6bf3 --- /dev/null +++ b/docs/development.rst @@ -0,0 +1,89 @@ +********************************************************************** +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://github.com/libgit2/pygit2/actions/workflows/wheels.yml/badge.svg + :target: https://github.com/libgit2/pygit2/actions/workflows/wheels.yml + +.. contents:: Contents + :local: + +Unit tests +========== + +.. code-block:: sh + + $ git clone git://github.com/libgit2/pygit2.git + $ cd pygit2 + $ python setup.py build_ext --inplace + $ pytest test + +Coding style: documentation strings +=================================== + +Example:: + + def f(a, b): + """ + The general description goes here. + + Returns: bla bla. + + Parameters: + + a : + Bla bla. + + b : + Bla bla. + + Examples:: + + >>> f(...) + """ + + +Building the docs +=================================== + +To build the documentation first you need to install sphinx-rtd-theme:: + + $ pip install sphinx-rtd-theme + +Then you have to build pygit2 inplace:: + + $ make + +And finally you can build the documentation:: + + $ make -C docs html + + +Running Valgrind +=================================== + +Step 1. Build libc and libgit2 with debug symbols. See your distribution +documentation. + +Step 2. Build Python to be used with valgrind, e.g.:: + + $ ./configure --prefix=~/Python-3.9.18 --without-pymalloc --with-pydebug --with-valgrind + $ make + $ make install + $ export PYTHONBIN=~/Python-3.9.18/bin + +Step 3. Build pygit2 with debug symbols:: + + $ rm build -rf && $PYTHONBIN/python3 setup.py build_ext --inplace -g + +Step 4. Install requirements:: + + $ $PYTHONBIN/python3 setup.py install + $ pip install pytest + +Step 4. Run valgrind:: + + $ valgrind -v --leak-check=full --suppressions=misc/valgrind-python.supp $PYTHONBIN/pytest &> valgrind.txt diff --git a/docs/diff.rst b/docs/diff.rst index d81fa9c9c..13a2acf7e 100644 --- a/docs/diff.rst +++ b/docs/diff.rst @@ -23,6 +23,10 @@ Examples >>> diff = repo.diff('HEAD^', 'HEAD~3') >>> patches = [p for p in diff] + # Get the stats for a diff + >>> diff = repo.diff('HEAD^', 'HEAD~3') + >>> diff.stats + # Diffing the empty tree >>> tree = revparse_single('HEAD').tree >>> tree.diff_to_tree() @@ -34,28 +38,52 @@ Examples The Diff type ==================== -.. autoattribute:: pygit2.Diff.patch -.. automethod:: pygit2.Diff.merge -.. automethod:: pygit2.Diff.find_similar +.. autoclass:: pygit2.Diff + :members: deltas, find_similar, merge, parse_diff, patch, patchid, stats + + .. method:: Diff.__iter__() + + Returns an iterator over the deltas/patches in this diff. + + .. method:: Diff.__len__() + + Returns the number of deltas/patches in this diff. The Patch type ==================== -.. autoattribute:: pygit2.Patch.old_file_path -.. autoattribute:: pygit2.Patch.new_file_path -.. autoattribute:: pygit2.Patch.old_oid -.. autoattribute:: pygit2.Patch.new_oid -.. autoattribute:: pygit2.Patch.status -.. autoattribute:: pygit2.Patch.similarity -.. autoattribute:: pygit2.Patch.hunks +Attributes: + +.. autoclass:: pygit2.Patch + :members: create_from, data, delta, hunks, line_stats, text + +The DiffDelta type +==================== + +.. autoclass:: pygit2.DiffDelta + :members: + +The DiffFile type +==================== + +.. autoclass:: pygit2.DiffFile + :members: + +The DiffHunk type +==================== + +.. autoclass:: pygit2.DiffHunk + :members: + +The DiffStats type +==================== +.. autoclass:: pygit2.DiffStats + :members: -The Hunk type +The DiffLine type ==================== -.. autoattribute:: pygit2.Hunk.old_start -.. autoattribute:: pygit2.Hunk.old_lines -.. autoattribute:: pygit2.Hunk.new_start -.. autoattribute:: pygit2.Hunk.new_lines -.. autoattribute:: pygit2.Hunk.lines +.. autoclass:: pygit2.DiffLine + :members: diff --git a/docs/features.rst b/docs/features.rst new file mode 100644 index 000000000..fa7d1d030 --- /dev/null +++ b/docs/features.rst @@ -0,0 +1,8 @@ +********************************************************************** +Feature detection +********************************************************************** + +.. py:data:: pygit2.features + + This variable contains a combination of `enums.Feature` flags, + indicating which features a particular build of libgit2 supports. diff --git a/docs/filters.rst b/docs/filters.rst new file mode 100644 index 000000000..6c29a753b --- /dev/null +++ b/docs/filters.rst @@ -0,0 +1,63 @@ +********************************************************************** +Filters +********************************************************************** + +pygit2 supports defining and registering libgit2 blob filters implemented +in Python. + +The Filter type +=============== + +.. autoclass:: pygit2.Filter + :members: + +.. autoclass:: pygit2.FilterSource + +Registering filters +=================== + +.. autofunction:: pygit2.filter_register +.. autofunction:: pygit2.filter_unregister + +Example +======= + +The following example is a simple Python implementation of a filter which +enforces that blobs are stored with unix LF line-endings in the ODB, and +checked out with line-endings in accordance with the .gitattributes ``eol`` +setting. + +.. code-block:: python + + class CRLFFilter(pygit2.Filter): + attributes = "text eol=*" + + def __init__(self): + super().__init__() + self.linesep = b'\r\n' if os.name == 'nt' else b'\n' + self.buffer = io.BytesIO() + + def check(self, src, attr_values): + if src.mode == pygit2.enums.FilterMode.SMUDGE: + # attr_values contains the values of the 'text' and 'eol' + # attributes in that order (as they are defined in + # CRLFFilter.attributes + eol = attr_values[1] + + if eol == 'crlf': + self.linesep = b'\r\n' + elif eol == 'lf': + self.linesep = b'\n' + else: # src.mode == pygit2.enums.FilterMode.CLEAN + # always use LF line-endings when writing to the ODB + self.linesep = b'\n' + + def write(data, src, write_next): + # buffer input data in case line-ending sequences span chunk boundaries + self.buffer.write(data) + + def close(self, write_next): + # apply line-ending conversion to our buffered input and write all + # of our output data + self.buffer.seek(0) + write_next(self.linesep.join(self.buffer.read().splitlines())) diff --git a/docs/general.rst b/docs/general.rst index bf0b3c164..7f1edf333 100644 --- a/docs/general.rst +++ b/docs/general.rst @@ -6,19 +6,77 @@ General :local: -Constants +Top level constants and exceptions from the library. + +Version ========= +The following constants provide information about the version of the libgit2 +library that has been built against. The version number has a +``MAJOR.MINOR.REVISION`` format. + .. py:data:: LIBGIT2_VER_MAJOR + + Integer value of the major version number. For example, for the version + ``0.26.0``:: + + >>> print(pygit2.LIBGIT2_VER_MAJOR) + 0 + .. py:data:: LIBGIT2_VER_MINOR + + Integer value of the minor version number. For example, for the version + ``0.26.0``:: + + >>> print(pygit2.LIBGIT2_VER_MINOR) + 26 + .. py:data:: LIBGIT2_VER_REVISION -.. py:data:: LIBGIT2_VER_VERSION + Integer value of the revision version number. For example, for the version + ``0.26.0``:: + + >>> print(pygit2.LIBGIT2_VER_REVISION) + 0 + +.. py:data:: LIBGIT2_VER + + Tuple value of the revision version numbers. For example, for the version + ``0.26.0``:: + + >>> print(pygit2.LIBGIT2_VER) + (0, 26, 0) + +.. py:data:: LIBGIT2_VERSION + + The libgit2 version number as a string:: -Errors -====== + >>> print(pygit2.LIBGIT2_VERSION) + '0.26.0' + +Options +========= + +.. autofunction:: pygit2.option + +Exceptions +========== .. autoexception:: pygit2.GitError :members: :show-inheritance: :undoc-members: + +.. autoexception:: pygit2.AlreadyExistsError + :members: + :show-inheritance: + :undoc-members: + +Exception when trying to create an object (reference, etc) that already exists. + +.. autoexception:: pygit2.InvalidSpecError + :members: + :show-inheritance: + :undoc-members: + +Exception when an input specification such as a reference name is invalid. diff --git a/docs/index.rst b/docs/index.rst index 5d25e6862..838fd07af 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,55 +1,90 @@ -.. pygit2 documentation master file, created by - sphinx-quickstart on Sun Jan 6 09:55:26 2013. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. +###################################################################### +pygit2 - libgit2 bindings in Python +###################################################################### -Welcome to pygit2's documentation! -================================== +Bindings to the libgit2 shared library, implements Git plumbing. +Supports Python 3.10 to 3.13 and PyPy3 7.3+ -.. image:: https://secure.travis-ci.org/libgit2/pygit2.png - :target: http://travis-ci.org/libgit2/pygit2 +Links +===================================== -Pygit2 is a set of Python bindings to the libgit2 shared library, libgit2 -implements the core of Git. Pygit2 works with Python 2.6, 2.7, 3.1, 3.2 and -3.3 +- Documentation - https://www.pygit2.org/ +- Install - https://www.pygit2.org/install.html +- Download - https://pypi.org/project/pygit2/ +- Source code and issue tracker - https://github.com/libgit2/pygit2 +- Changelog - https://github.com/libgit2/pygit2/blob/master/CHANGELOG.md +- Authors - https://github.com/libgit2/pygit2/blob/master/AUTHORS.md -Pygit2 links: -- http://github.com/libgit2/pygit2 -- Source code and issue tracker -- http://www.pygit2.org/ -- Documentation -- http://pypi.python.org/pypi/pygit2 -- Download +Sponsors +===================================== -Start: +Add your name and link here, `become a sponsor `_. -.. toctree:: - :maxdepth: 1 - install - recipes +License: GPLv2 with linking exception +===================================== + +This program 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 program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. -Usage guide: +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. + + +Table of Contents +===================================== .. toctree:: :maxdepth: 1 + install + recipes general - repository - oid - objects - references - revparse - log - working-copy + + backends + blame + branches + commit_log + config diff + features + filters + index_file + mailmap merge - config + objects + oid + packing + references + transactions remotes + repository + revparse + settings + submodule + worktree + development Indices and tables -================== +===================================== * :ref:`genindex` -* :ref:`modindex` -* :ref:`search` - diff --git a/docs/index_file.rst b/docs/index_file.rst new file mode 100644 index 000000000..86c70f56e --- /dev/null +++ b/docs/index_file.rst @@ -0,0 +1,121 @@ +********************************************************************** +Index file & Working copy +********************************************************************** + +.. autoattribute:: pygit2.Repository.index + +Index read:: + + >>> index = repo.index + >>> index.read() + >>> id = index['path/to/file'].id # from path to object id + >>> blob = repo[id] # from object id to object + +Iterate over all entries of the index:: + + >>> for entry in index: + ... print(entry.path, entry.id) + +Index write:: + + >>> index.add('path/to/file') # git add + >>> index.remove('path/to/file') # git rm + >>> index.remove_directory('path/to/directory/') # git rm -r + >>> index.write() # don't forget to save the changes + +Custom entries:: + >>> entry = pygit2.IndexEntry('README.md', blob_id, blob_filemode) + >>> repo.index.add(entry) + +The index fulfills a dual role as the in-memory representation of the +index file and data structure which represents a flat list of a +tree. You can use it independently of the index file, e.g. + + >>> index = pygit2.Index() + >>> entry = pygit2.IndexEntry('README.md', blob_id, blob_filemode) + >>> index.add(entry) + +The Index type +==================== + +.. autoclass:: pygit2.Index + :members: + +The IndexEntry type +==================== + +.. autoclass:: pygit2.IndexEntry + :members: + + .. automethod:: __eq__ + .. automethod:: __ne__ + .. automethod:: __repr__ + .. automethod:: __str__ + +The Stash type +==================== + +.. autoclass:: pygit2.Stash + :members: commit_id, message + +Status +==================== + +.. autoclass:: pygit2.Repository + :members: status_file + :noindex: + + .. automethod:: Repository.status + + Example, inspect the status of the repository:: + + from pygit2.enums import FileStatus + status = repo.status() + for filepath, flags in status.items(): + if flags != FileStatus.CURRENT: + print(f"Filepath {filepath} isn't clean") + +This is the list of status flags for a single file:: + + enums.FileStatus.CURRENT + enums.FileStatus.INDEX_NEW + enums.FileStatus.INDEX_MODIFIED + enums.FileStatus.INDEX_DELETED + enums.FileStatus.INDEX_RENAMED + enums.FileStatus.INDEX_TYPECHANGE + enums.FileStatus.WT_NEW + enums.FileStatus.WT_MODIFIED + enums.FileStatus.WT_DELETED + enums.FileStatus.WT_TYPECHANGE + enums.FileStatus.WT_RENAMED + enums.FileStatus.WT_UNREADABLE + enums.FileStatus.IGNORED + enums.FileStatus.CONFLICTED + +A combination of these values will be returned to indicate the status of a +file. Status compares the working directory, the index, and the current HEAD +of the repository. The `INDEX_...` set of flags represents the status +of file in the index relative to the HEAD, and the `WT_...` set of flags +represents the status of the file in the working directory relative to the +index. + + +Checkout +==================== + +.. automethod:: pygit2.Repository.checkout + +Lower level API: + +.. automethod:: pygit2.Repository.checkout_head +.. automethod:: pygit2.Repository.checkout_tree +.. automethod:: pygit2.Repository.checkout_index + +Stash +==================== + +.. automethod:: pygit2.Repository.stash +.. automethod:: pygit2.Repository.stash_apply +.. automethod:: pygit2.Repository.stash_drop +.. automethod:: pygit2.Repository.stash_pop +.. automethod:: pygit2.Repository.listall_stashes diff --git a/docs/install.rst b/docs/install.rst index 93a5563d1..140adffa1 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -1,123 +1,328 @@ ********************************************************************** -How to Install +Installation ********************************************************************** +.. |lq| unicode:: U+00AB +.. |rq| unicode:: U+00BB -.. contents:: +.. contents:: Contents + :local: -First you need to install the latest release of libgit2. If you clone -the repository, make sure to use the ``master`` branch. You can find -platform-specific instructions to build the library in the libgit2 -website: - http://libgit2.github.com +Quick install +============= -Also, make sure you have Python 2.6+ installed together with the Python -development headers. +Install pygit2: -When those are installed, you can install pygit2: +.. code-block:: sh + + $ pip install -U pip + $ pip install pygit2 + +The line above will install binary wheels if available in your platform. + +.. note:: + + It is recommended to first update the version of pip, as it will increase + the chances for it to install a binary wheel instead of the source + distribution. At least version 19.3 of pip is required. + +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. +Then: + +- Verify pip is updated +- Verify there is a binary wheel of pygit2 for your platform +- Otherwise install from the source distribution + +Caveats: + +- Binary wheels for Windows are available, but they don't have support for ssh. + + +Requirements +============ + +Supported versions of Python: + +- Python 3.10 to 3.13 +- PyPy3 7.3+ + +Python requirements (these are specified in ``setup.py``): + +- cffi 2.0 or later + +Libgit2 **v1.9.x**; binary wheels already include libgit2, so you only need to +worry about this if you install the source package. + +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 + +To run the tests: + +- pytest + +Version numbers +=============== + +The version number of pygit2 is composed of three numbers separated by dots +|lq| *major.medium.minor* |rq|: + +- *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 + versions of libgit2. +- *minor* will increase for bug fixes. + +The table below summarizes the latest pygit2 versions with the supported versions +of Python and the required libgit2 version. + ++-------------+----------------+------------+ +| pygit2 | Python | libgit2 | ++-------------+----------------+------------+ +| 1.17 - 1.18 | 3.10 - 3.13 | 1.9 | ++-------------+----------------+------------+ +| 1.16 | 3.10 - 3.13 | 1.8 | ++-------------+----------------+------------+ +| 1.15 | 3.9 - 3.12 | 1.8 | ++-------------+----------------+------------+ +| 1.14 | 3.9 - 3.12 | 1.7 | ++-------------+----------------+------------+ +| 1.13 | 3.8 - 3.12 | 1.7 | ++-------------+----------------+------------+ +| 1.12 | 3.8 - 3.11 | 1.6 | ++-------------+----------------+------------+ +| 1.11 | 3.8 - 3.11 | 1.5 | ++-------------+----------------+------------+ +| 1.10 | 3.7 - 3.10 | 1.5 | ++-------------+----------------+------------+ +| 1.9 | 3.7 - 3.10 | 1.4 | ++-------------+----------------+------------+ +| 1.7 - 1.8 | 3.7 - 3.10 | 1.3 | ++-------------+----------------+------------+ +| 1.4 - 1.6 | 3.6 - 3.9 | 1.1 | ++-------------+----------------+------------+ +| 1.2 - 1.3 | 3.6 - 3.8 | 1.0 | ++-------------+----------------+------------+ +| 1.1 | 3.5 - 3.8 | 0.99 - 1.0 | ++-------------+----------------+------------+ +| 1.0 | 3.5 - 3.8 | 0.28 | ++-------------+----------------+------------+ +| 0.28.2 | 2.7, 3.4 - 3.7 | 0.28 | ++-------------+----------------+------------+ + +.. warning:: + + It is recommended to use the latest 1.x.y release. Because only the latest + is supported. + +.. warning:: + + Backwards compatibility is not guaranteed in minor releases. Please check + the release notes for incompatible changes before upgrading to a new + release. + +History: the 0.x series +----------------------- + +The development of pygit2 started in October 2010, the release of 1.0.0 +happened in December 2019. In the 0.x series the version numbering was +lockstep with libgit2, e.g. pygit2 0.28.x worked with libgit2 0.28.x + + +Advanced +=========================== + +Install libgit2 from source +--------------------------- + +To install the latest version of libgit2 system wide, in the ``/usr/local`` +directory, do: .. code-block:: sh - $ git clone git://github.com/libgit2/pygit2.git - $ cd pygit2 - $ python setup.py install - $ python setup.py test + $ 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 -.. note:: A minor version of pygit2 must be used with the corresponding minor - version of libgit2. For example, pygit2 v0.19.x must be used with libgit2 - v0.19.0. +.. seealso:: -Building on \*nix (including OS X) -=================================== + For detailed instructions on building libgit2 check + https://libgit2.github.com/docs/guides/build-and-link/ + +Now install pygit2, and then verify it is correctly installed: + +.. code-block:: sh -If you installed libgit2 and pygit2 in one of the usual places, you -should be able to skip this section and just use the generic pygit2 -installation steps described above. This is the recommended -procedure. + $ pip install pygit2 + ... + $ python -c 'import pygit2' -`Shared libraries`_ packaged by your distribution are usually in -``/usr/lib``. To keep manually installed libraries separate, they are -usually installed in ``/usr/local/lib``. If you installed libgit2 -using the default installation procedure (e.g. without specifying -``CMAKE_INSTALL_PREFIX``), you probably installed it under -``/usr/local/lib``. On some distributions (e.g. Ubuntu), -``/usr/local/lib`` is not in the linker's default search path (see the -`ld man page`_ for details), and you will get errors like: + +Troubleshooting +--------------------------- + +The verification step may fail if the dynamic linker does not find the libgit2 +library: .. code-block:: sh - $ python -c 'import pygit2' - Traceback (most recent call last): - File "", line 1, in - File "pygit2/__init__.py", line 29, in - from _pygit2 import * - ImportError: libgit2.so.0: cannot open shared object file: No such file or directory + $ python -c 'import pygit2' + Traceback (most recent call last): + File "", line 1, in + File "pygit2/__init__.py", line 29, in + from ._pygit2 import * + ImportError: libgit2.so.0: cannot open shared object file: No such file or directory -The following recipe shows how to install libgit2 and pygit2 on these -systems. First, download and install libgit2 (following the -instructions in the libgit2 ``README.md``): +This happens for instance in Ubuntu, the libgit2 library is installed within +the ``/usr/local/lib`` directory, but the linker does not look for it there. To +fix this call ``ldconfig``: .. code-block:: sh - $ git clone -b master git://github.com/libgit2/libgit2.git - $ mkdir libgit2/build - $ cd libgit2/build - $ cmake .. - $ cmake --build . - $ sudo cmake --build . --target install - $ cd ../.. + $ sudo ldconfig + $ python -c 'import pygit2' + +If it still does not work, please open an issue at +https://github.com/libgit2/pygit2/issues + + +Build options +--------------------------- + +``LIBGIT2`` -- If you install libgit2 in an unusual place, you will need to set +the ``LIBGIT2`` environment variable before installing pygit2. This variable +tells pygit2 where libgit2 is installed. We will see a concrete example later, +when explaining how to install libgit2 within a virtual environment. + +``LIBGIT2_LIB`` -- This is a more rarely used build option, it allows to +override the library directory where libgit2 is installed, useful if different +from ``$LIBGIT2/lib`` and ``$LIBGIT2/lib64``. + + +libgit2 within a virtual environment +------------------------------------ + +This is how to install both libgit2 and pygit2 within a virtual environment. + +This is useful if you don't have root access to install libgit2 system wide. +Or if you wish to have different versions of libgit2/pygit2 installed in +different virtual environments, isolated from each other. -Now, download and install pygit2. You will probably have to set the -``LIBGIT2`` environment variable so the compiler can find the libgit2 -headers and libraries: +Create the virtualenv, activate it, and set the ``LIBGIT2`` environment +variable: .. code-block:: sh - $ git clone git://github.com/libgit2/pygit2.git - $ cd pygit2 - $ export LIBGIT2="/usr/local" - $ export LDFLAGS="-Wl,-rpath='$LIBGIT2/lib',--enable-new-dtags $LDFLAGS" - $ python setup.py build - $ sudo python setup.py install + $ virtualenv venv + $ source venv/bin/activate + $ export LIBGIT2=$VIRTUAL_ENV -This compiles the pygit2 libraries with a ``RUNPATH``, which bakes -extra library search paths directly into the binaries (see the `ld man -page`_ for details). With ``RUNPATH`` compiled in, you won't have to -use ``LD_LIBRARY_PATH``. You can check to ensure ``RUNPATH`` was set -with readelf_: +Install libgit2 (see we define the installation prefix): .. code-block:: sh - $ readelf --dynamic build/lib.linux-x86_64-3.2/_pygit2.cpython-32.so | grep PATH - 0x000000000000000f (RPATH) Library rpath: [/usr/local/lib] - 0x000000000000001d (RUNPATH) Library runpath: [/usr/local/lib] + $ 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 + +Install pygit2: + +.. code-block:: sh + + $ 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' + + +The run-path +------------------------------------------ + +Did you notice we set the `rpath `_ before +installing pygit2? Since libgit2 is installed in a non standard location, the +dynamic linker will not find it at run-time, and ``lddconfig`` will not help +this time. + +So you need to either set ``LD_LIBRARY_PATH`` before using pygit2, like: + +.. code-block:: sh -.. _Shared libraries: http://tldp.org/HOWTO/Program-Library-HOWTO/shared-libraries.html -.. _ld man page: http://linux.die.net/man/1/ld -.. _readelf: http://www.gnu.org/software/binutils/ + $ export LD_LIBRARY_PATH=$LIBGIT2/lib + $ python -c 'import pygit2' -Building on Windows +Or, like we have done in the instructions above, use the `rpath +`_, it hard-codes extra search paths within +the pygit2 extension modules, so you don't need to set ``LD_LIBRARY_PATH`` +every time. Verify yourself if curious: + +.. code-block:: sh + + $ 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`: + +.. 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: + +.. code-block:: sh + + $ 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 + +At this point, you're ready to execute the generic `pygit2` installation steps +described at the start of this page. + + +Installing on OS X =================================== -pygit2 expects to find the libgit2 installed files in the directory specified -in the ``LIBGIT2`` environment variable. +There are not binary wheels available for OS X, so you will need to install the +source package. + +.. note:: + + You will need the `XCode `_ Developer + Tools from Apple. This free download from the Mac App Store will provide the + clang compiler needed for the installation of pygit2. -In addition, make sure that libgit2 is build in "__cdecl" mode. -The following recipe shows you how to do it, assuming you're working -from a bash shell: + This section was tested on OS X 10.9 Mavericks and OS X 10.10 Yosemite with + Python 3.3 in a virtual environment. + +The easiest way is to first install libgit2 with the `Homebrew `_ +package manager and then use pip3 for pygit2. The following example assumes that +XCode and Homebrew are already installed. .. code-block:: sh - $ export LIBGIT2=C:/Dev/libgit2 - $ git clone -b master git://github.com/libgit2/libgit2.git - $ cd libgit2 - $ mkdir build - $ cd build - $ cmake .. -DSTDCALL=OFF -DCMAKE_INSTALL_PREFIX=$LIBGIT2 -G "Visual Studio 9 2008" - $ cmake --build . --config release --target install - $ ctest -v - -At this point, you're ready to execute the generic pygit2 installation -steps described above. + $ 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/log.rst b/docs/mailmap.rst similarity index 73% rename from docs/log.rst rename to docs/mailmap.rst index c63bc41c6..369447b5b 100644 --- a/docs/log.rst +++ b/docs/mailmap.rst @@ -1,5 +1,6 @@ ********************************************************************** -Commit log +Mailmap ********************************************************************** -.. automethod:: pygit2.Repository.walk +.. autoclass:: pygit2.Mailmap + :members: diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 000000000..27f573b87 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% + +:end +popd diff --git a/docs/merge.rst b/docs/merge.rst index 55e411ac1..dcc8c5286 100644 --- a/docs/merge.rst +++ b/docs/merge.rst @@ -1,5 +1,72 @@ ********************************************************************** -Merge +Merge & Cherrypick ********************************************************************** +.. contents:: + .. automethod:: pygit2.Repository.merge_base +.. automethod:: pygit2.Repository.merge +.. automethod:: pygit2.Repository.merge_analysis + +The merge method +================= + +The method does a merge over the current working copy. +It gets an Oid object as a parameter. + +As its name says, it only does the merge, does not commit nor update the +branch reference in the case of a fastforward. + +Example:: + + >>> from pygit2.enums import MergeFavor, MergeFlag + >>> other_branch_tip = '5ebeeebb320790caf276b9fc8b24546d63316533' + >>> repo.merge(other_branch_tip) + >>> repo.merge(other_branch_tip, favor=MergeFavor.OURS) + >>> repo.merge(other_branch_tip, flags=MergeFlag.FIND_RENAMES | MergeFlag.NO_RECURSIVE) + >>> repo.merge(other_branch_tip, flags=0) # turn off FIND_RENAMES (on by default if flags omitted) + +You can now inspect the index file for conflicts and get back to the +user to resolve if there are. Once there are no conflicts left, you +can create a commit with these two parents. + + >>> user = repo.default_signature + >>> tree = repo.index.write_tree() + >>> message = "Merging branches" + >>> new_commit = repo.create_commit('HEAD', user, user, message, tree, + [repo.head.target, other_branch_tip]) + + +Cherrypick +=================== + +.. automethod:: pygit2.Repository.cherrypick + +Note that after a successful cherrypick you have to run +:py:meth:`.Repository.state_cleanup` in order to get the repository out +of cherrypicking mode. + + +Lower-level methods +=================== + +These methods allow more direct control over how to perform the +merging. They do not modify the working directory and return an +in-memory Index representing the result of the merge. + +.. automethod:: pygit2.Repository.merge_commits +.. automethod:: pygit2.Repository.merge_trees + + +N-way merges +============ + +The following methods perform the calculation for a base to an n-way merge. + +.. automethod:: pygit2.Repository.merge_base_many +.. automethod:: pygit2.Repository.merge_base_octopus + +With this base at hand one can do repeated invocations of +:py:meth:`.Repository.merge_commits` and :py:meth:`.Repository.merge_trees` +to perform the actual merge into one tree (and deal with conflicts along the +way). \ No newline at end of file diff --git a/docs/objects.rst b/docs/objects.rst index 36edf958f..9aed0282a 100644 --- a/docs/objects.rst +++ b/docs/objects.rst @@ -1,5 +1,5 @@ ********************************************************************** -Git Objects +Objects ********************************************************************** There are four types of Git objects: blobs, trees, commits and tags. For each @@ -14,34 +14,37 @@ type. Object lookup ================= -In the previous chapter we learnt about Object IDs. With an oid we can ask the +In the previous chapter we learnt about Object IDs. With an Oid we can ask the repository to get the associated object. To do that the ``Repository`` class -implementes a subset of the mapping interface. +implements a subset of the mapping interface. -.. method:: Repository.get(oid, default=None) +.. autoclass:: pygit2.Repository + :noindex: - Return the Git object for the given *oid*, returns the *default* value if - there's no object in the repository with that oid. The oid can be an Oid - object, or an hexadecimal string. + .. automethod:: Repository.get - Example:: + Return the Git object for the given *id*, returns the *default* value if + there's no object in the repository with that id. The id can be an Oid + object, or an hexadecimal string. - >>> from pygit2 import Repository - >>> repo = Repository('path/to/pygit2') - >>> obj = repo.get("101715bf37440d32291bde4f58c3142bcf7d8adb") - >>> obj - <_pygit2.Commit object at 0x7ff27a6b60f0> + Example:: -.. method:: Repository[oid] + >>> from pygit2 import Repository + >>> repo = Repository('path/to/pygit2') + >>> obj = repo.get("101715bf37440d32291bde4f58c3142bcf7d8adb") + >>> obj + - Return the Git object for the given oid, raise ``KeyError`` if there's no - object in the repository with that oid. The oid can be an Oid object, or - an hexadecimal string. + .. method:: Repository.__getitem__(id) -.. method:: oid in Repository + Return the Git object for the given id, raise ``KeyError`` if there's no + object in the repository with that id. The id can be an Oid object, or + an hexadecimal string. - Returns True if there is an object in the Repository with that oid, False - if there is not. The oid can be an Oid object, or an hexadecimal string. + .. method:: Repository.__contains__(id) + + Returns True if there is an object in the Repository with that id, False + if there is not. The id can be an Oid object, or an hexadecimal string. The Object base type @@ -55,7 +58,7 @@ it is possible to check whether a Python value is an Object or not:: >>> from pygit2 import Object >>> commit = repository.revparse_single('HEAD') - >>> print isinstance(commit, Object) + >>> print(isinstance(commit, Object)) True All Objects are immutable, they cannot be modified once they are created:: @@ -78,36 +81,22 @@ New objects are created using an specific API we will see later. This is the common interface for all Git objects: -.. autoattribute:: pygit2.Object.oid -.. autoattribute:: pygit2.Object.hex -.. autoattribute:: pygit2.Object.type -.. automethod:: pygit2.Object.read_raw +.. autoclass:: pygit2.Object + :members: id, type, type_str, short_id, read_raw, peel, name, filemode + :special-members: __eq__, __ne__, __hash__, __repr__ Blobs ================= A blob is just a raw byte string. They are the Git equivalent to files in -a filesytem. +a filesystem. This is their API: -.. autoattribute:: pygit2.Blob.data - - Example, print the contents of the ``.gitignore`` file:: - - >>> blob = repo["d8022420bf6db02e906175f64f66676df539f2fd"] - >>> print blob.data - MANIFEST - build - dist - -.. autoattribute:: pygit2.Blob.size - - Example:: +.. autoclass:: pygit2.Blob + :members: - >>> print blob.size - 130 Creating blobs -------------- @@ -115,61 +104,88 @@ Creating blobs There are a number of methods in the repository to create new blobs, and add them to the Git object database: -.. automethod:: pygit2.Repository.create_blob +.. autoclass:: pygit2.Repository + :members: create_blob_fromworkdir, create_blob_fromdisk, create_blob_fromiobase + :noindex: - Example: + .. automethod:: Repository.create_blob - >>> oid = repo.create_blob('foo bar') # Creates blob from bytes string - >>> blob = repo[oid] - >>> blob.data - 'foo bar' + Example: -.. automethod:: pygit2.Repository.create_blob_fromworkdir -.. automethod:: pygit2.Repository.create_blob_fromdisk + >>> id = repo.create_blob('foo bar') # Creates blob from a byte string + >>> blob = repo[id] + >>> blob.data + 'foo bar' -There are also some functions to calculate the oid for a byte string without +There are also some functions to calculate the id for a byte string without creating the blob object: .. autofunction:: pygit2.hash .. autofunction:: pygit2.hashfile +Streaming blob content +---------------------- + +`pygit2.Blob.data` and `pygit2.Blob.read_raw()` read the full contents of the +blob into memory and return Python ``bytes``. They also return the raw contents +of the blob, and do not apply any filters which would be applied upon checkout +to the working directory. + +Raw and filtered blob data can be accessed as a Python Binary I/O stream +(i.e. a file-like object): + +.. autoclass:: pygit2.BlobIO + :members: + Trees ================= -A tree is a sorted collection of tree entries. It is similar to a folder or -directory in a file system. Each entry points to another tree or a blob. A -tree can be iterated, and partially implements the sequence and mapping +At the low level (libgit2) a tree is a sorted collection of tree entries. In +pygit2 accessing an entry directly returns the object. + +A tree can be iterated, and partially implements the sequence and mapping interfaces. -.. method:: Tree[name] +.. autoclass:: pygit2.Tree + :members: diff_to_tree, diff_to_workdir, diff_to_index - Return the TreeEntry object for the given *name*. Raise ``KeyError`` if - there is not a tree entry with that name. + .. method:: Tree.__getitem__(name) -.. method:: name in Tree + ``Tree[name]`` - Return True if there is a tree entry with the given name, False otherwise. + Return the Object subclass instance for the given *name*. Raise ``KeyError`` + if there is not a tree entry with that name. -.. method:: len(Tree) + .. method:: Tree.__truediv__(name) - Return the number of entries in the tree. + ``Tree / name`` -.. method:: iter(Tree) + Return the Object subclass instance for the given *name*. Raise ``KeyError`` + if there is not a tree entry with that name. This allows navigating the tree + similarly to Pathlib using the slash operator via. - Return an iterator over the entries of the tree. + Example:: -.. automethod:: pygit2.Tree.diff_to_tree -.. automethod:: pygit2.Tree.diff_to_workdir -.. automethod:: pygit2.Tree.diff_to_index + >>> entry = tree / 'path' / 'deeper' / 'some.file' -Tree entries ------------- + .. method:: Tree.__contains__(name) -.. autoattribute:: pygit2.TreeEntry.name -.. autoattribute:: pygit2.TreeEntry.oid -.. autoattribute:: pygit2.TreeEntry.hex -.. autoattribute:: pygit2.TreeEntry.filemode + ``name in Tree`` + + Return True if there is a tree entry with the given name, False otherwise. + + .. method:: Tree.__len__() + + ``len(Tree)`` + + Return the number of objects in the tree. + + .. method:: Tree.__iter__() + + ``for object in Tree`` + + Return an iterator over the objects in the tree. Example:: @@ -177,49 +193,39 @@ Example:: >>> len(tree) # Number of entries 6 - >>> for entry in tree: # Iteration - ... print(entry.hex, entry.name) + >>> for obj in tree: # Iteration + ... print(obj.id, obj.type_str, obj.name) ... - 7151ca7cd3e59f3eab19c485cfbf3cb30928d7fa .gitignore - c36f4cf1e38ec1bb9d9ad146ed572b89ecfc9f18 COPYING - 32b30b90b062f66957d6790c3c155c289c34424e README.md - c87dae4094b3a6d10e08bc6c5ef1f55a7e448659 pygit2.c - 85a67270a49ef16cdd3d328f06a3e4b459f09b27 setup.py - 3d8985bbec338eb4d47c5b01b863ee89d044bd53 test + 7151ca7cd3e59f3eab19c485cfbf3cb30928d7fa blob .gitignore + c36f4cf1e38ec1bb9d9ad146ed572b89ecfc9f18 blob COPYING + 32b30b90b062f66957d6790c3c155c289c34424e blob README.md + c87dae4094b3a6d10e08bc6c5ef1f55a7e448659 blob pygit2.c + 85a67270a49ef16cdd3d328f06a3e4b459f09b27 blob setup.py + 3d8985bbec338eb4d47c5b01b863ee89d044bd53 tree test - >>> entry = tree['pygit2.c'] # Get an entry by name - >>> entry - - - >>> blob = repo[entry.oid] # Get the object the entry points to - >>> blob - + >>> obj = tree / 'pygit2.c' # Get an object by name + >>> obj + <_pygit2.Blob at 0x7f08a70acc10> Creating trees -------------------- -.. automethod:: pygit2.Repository.TreeBuilder +.. autoclass:: pygit2.Repository + :members: TreeBuilder + :noindex: -.. automethod:: pygit2.TreeBuilder.insert -.. automethod:: pygit2.TreeBuilder.remove -.. automethod:: pygit2.TreeBuilder.clear -.. automethod:: pygit2.TreeBuilder.write +.. autoclass:: pygit2.TreeBuilder + :members: Commits ================= -A commit is a snapshot of the working dir with meta informations like author, +A commit is a snapshot of the working dir with meta information like author, committer and others. -.. autoattribute:: pygit2.Commit.author -.. autoattribute:: pygit2.Commit.committer -.. autoattribute:: pygit2.Commit.message -.. autoattribute:: pygit2.Commit.message_encoding -.. autoattribute:: pygit2.Commit.tree -.. autoattribute:: pygit2.Commit.parents -.. autoattribute:: pygit2.Commit.commit_time -.. autoattribute:: pygit2.Commit.commit_time_offset +.. autoclass:: pygit2.Commit + :members: Signatures @@ -229,18 +235,20 @@ The author and committer attributes of commit objects are ``Signature`` objects:: >>> commit.author - + pygit2.Signature('Foo Ibáñez', 'foo@example.com', 1322174594, 60, 'utf-8') + +Signatures can be compared for (in)equality. -.. autoattribute:: pygit2.Signature.name -.. autoattribute:: pygit2.Signature.email -.. autoattribute:: pygit2.Signature.time -.. autoattribute:: pygit2.Signature.offset +.. autoclass:: pygit2.Signature + :members: Creating commits ---------------- -.. automethod:: pygit2.Repository.create_commit +.. autoclass:: pygit2.Repository + :members: create_commit + :noindex: Commits can be created by calling the ``create_commit`` method of the repository with the following parameters:: @@ -262,13 +270,13 @@ Tags A tag is a static label for a commit. See references for more information. -.. autoattribute:: pygit2.Tag.name -.. autoattribute:: pygit2.Tag.target -.. autoattribute:: pygit2.Tag.tagger -.. autoattribute:: pygit2.Tag.message +.. autoclass:: pygit2.Tag + :members: Creating tags -------------------- -.. automethod:: pygit2.Repository.create_tag +.. autoclass:: pygit2.Repository + :members: create_tag + :noindex: diff --git a/docs/oid.rst b/docs/oid.rst index 1ffc08c61..97de26884 100644 --- a/docs/oid.rst +++ b/docs/oid.rst @@ -26,11 +26,6 @@ Hex oid form can be used to create Oid objects, just like raw oids. Also, the pygit2 API directly accepts hex oids everywhere. - .. note:: - - In Python 3 hexadecimal oids are represented using the ``str`` type. - In Python 2 both ``str`` and ``unicode`` are accepted. - Oid object An ``Oid`` object can be built from the raw or hexadecimal representations (see below). The pygit2 API always returns, and accepts, ``Oid`` objects. @@ -61,10 +56,11 @@ The Oid type >>> raw = unhexlify("cff3ceaefc955f0dbe1957017db181bc49913781") >>> oid2 = Oid(raw=raw) -An the other way around, from an Oid object we can get the hexadecimal and raw -forms. +And the other way around, from an Oid object we can get the raw form via +`oid.raw`. You can use `str(oid)` to get the hexadecimal representation of the +Oid. -.. autoattribute:: pygit2.Oid.hex +.. method:: Oid.__str__() .. autoattribute:: pygit2.Oid.raw The Oid type supports: @@ -72,8 +68,11 @@ The Oid type supports: - rich comparisons, not just for equality, also: lesser-than, lesser-or-equal, etc. -- hashing, so Oid objects can be used as keys in a dictionary. +- `hash(oid)`, so Oid objects can be used as keys in a dictionary. + +- `bool(oid)`, returning False if the Oid is a null SHA-1 (all zeros). +- `str(oid)`, returning the hexadecimal representation of the Oid. Constants ========= diff --git a/docs/packing.rst b/docs/packing.rst new file mode 100644 index 000000000..ee99725a1 --- /dev/null +++ b/docs/packing.rst @@ -0,0 +1,16 @@ +********************************************************************** +Packing +********************************************************************** + +.. autoclass:: pygit2.Repository + :members: pack + :noindex: + + +The PackBuilder +================ + +.. autoclass:: pygit2.PackBuilder + :members: + :undoc-members: + :special-members: __len__ diff --git a/docs/recipes.rst b/docs/recipes.rst index 019f863a8..deb015727 100644 --- a/docs/recipes.rst +++ b/docs/recipes.rst @@ -1,5 +1,5 @@ ********************************************************************** -pygit2 Recipes +Recipes ********************************************************************** A list of some standard git commands and their pygit2 equivalents. This @@ -17,10 +17,15 @@ Main porcelain commands .. toctree:: :maxdepth: 1 - git-branch (List, create, or delete branches.) + git-cherry-pick (Apply the changes introduced by some existing commits.) git-init (Create an empty git repository or reinitialize an existing one.) git-log (Show commit logs.) git-show (Show various types of objects.) git-tag (Create, list, delete or verify a tag object signed with GPG.) + git clone (Clone with progress monitor) + git clone --mirror (Clone with a mirroring configuration) + git clone username@hostname (Clone over ssh) + git-add / git-reset HEAD (Add file contents to the index / Unstage) + git commit (Make an initial commit, and a subsequent commit) .. _git man page: https://www.kernel.org/pub/software/scm/git/docs/git.html diff --git a/docs/recipes/git-add-reset.rst b/docs/recipes/git-add-reset.rst new file mode 100644 index 000000000..ba482a288 --- /dev/null +++ b/docs/recipes/git-add-reset.rst @@ -0,0 +1,67 @@ +********************************************************************** +git-add / git-reset +********************************************************************** + +---------------------------------------------------------------------- +Add file contents to the index / Stage +---------------------------------------------------------------------- + +We can add a new (untracked) file or a modified file to the index. + +.. code-block:: bash + + $ git add foo.txt + +.. code-block:: python + + >>> index = repo.index + >>> index.add(path) + >>> index.write() + +---------------------------------------------------------------------- +Restore the entry in the index / Unstage +---------------------------------------------------------------------- + +.. code-block:: bash + + $ git reset HEAD src/tree.c + +.. code-block:: python + + >>> index = repo.index + + # Remove path from the index + >>> path = 'src/tree.c' + >>> index.remove(path) + + # Restore object from db + >>> obj = repo.revparse_single('HEAD').tree[path] # Get object from db + >>> index.add(pygit2.IndexEntry(path, obj.id, obj.filemode)) # Add to index + + # Write index + >>> index.write() + +---------------------------------------------------------------------- +Query the index state / Is file staged ? +---------------------------------------------------------------------- + +.. code-block:: bash + + $ git status foo.txt + +.. code-block:: python + + # Return True is the file is modified in the working tree + >>> repo.status_file(path) & pygit2.enums.FileStatus.WT_MODIFIED + +---------------------------------------------------------------------- +References +---------------------------------------------------------------------- + +- git-add_. + +.. _git-add: https://www.kernel.org/pub/software/scm/git/docs/git-add.html + +- git-reset_. + +.. _git-reset: https://www.kernel.org/pub/software/scm/git/docs/git-reset.html diff --git a/docs/recipes/git-branch.rst b/docs/recipes/git-branch.rst deleted file mode 100644 index 5db822bc7..000000000 --- a/docs/recipes/git-branch.rst +++ /dev/null @@ -1,30 +0,0 @@ -********************************************************************** -git-branch -********************************************************************** - ----------------------------------------------------------------------- -Listing branches ----------------------------------------------------------------------- - -====================================================================== -List all branches -====================================================================== - -.. code-block:: bash - - $> git branch - -.. code-block:: python - - >>> regex = re.compile('^refs/heads/') - >>> branches = filter(lambda r: regex.match(r), repo.listall_references()) - -`Note that the next release will probably allow` ``repo.listall_branches()``. - ----------------------------------------------------------------------- -References ----------------------------------------------------------------------- - -- git-branch_. - -.. _git-branch: https://www.kernel.org/pub/software/scm/git/docs/git-branch.html diff --git a/docs/recipes/git-cherry-pick.rst b/docs/recipes/git-cherry-pick.rst new file mode 100644 index 000000000..028d3a13b --- /dev/null +++ b/docs/recipes/git-cherry-pick.rst @@ -0,0 +1,72 @@ +********************************************************************** +git-cherry-pick +********************************************************************** + +The convenient way to cherry-pick a commit is to use +:py:meth:`.Repository.cherrypick()`. It is limited to cherry-picking with a +working copy and on-disk index. + +.. code-block:: bash + + $ cd /path/to/repo + $ git checkout basket + $ git cherry-pick 9e044d03c + +.. code-block:: python + + repo = pygit2.Repository('/path/to/repo') + repo.checkout('basket') + + cherry_id = pygit2.Oid('9e044d03c') + repo.cherrypick(cherry_id) + + if repo.index.conflicts is None: + tree_id = repo.index.write_tree() + + cherry = repo.get(cherry_id) + committer = pygit2.Signature('Archimedes', 'archy@jpl-classics.org') + + repo.create_commit(basket.name, cherry.author, committer, + cherry.message, tree_id, [basket.target]) + del basket # outdated, prevent from accidentally using it + + repo.state_cleanup() + + +---------------------------------------------------------------------- +Cherry-picking a commit without a working copy +---------------------------------------------------------------------- + +This way of cherry-picking gives you more control over the process and works +on bare repositories as well as repositories with a working copy. +:py:meth:`~.Repository.merge_trees()` can also be used for other tasks, for +example `three-argument rebases`_. + +.. _`three-argument rebases`: https://www.kernel.org/pub/software/scm/git/docs/git-rebase.html + +.. code-block:: python + + repo = pygit2.Repository('/path/to/repo') + + cherry = repo.revparse_single('9e044d03c') + basket = repo.branches.get('basket') + + base_tree = cherry.parents[0].tree + + index = repo.merge_trees(base_tree, basket, cherry) + tree_id = index.write_tree(repo) + + author = cherry.author + committer = pygit2.Signature('Archimedes', 'archy@jpl-classics.org') + + repo.create_commit(basket.name, author, committer, cherry.message, + tree_id, [basket.target]) + del None # outdated, prevent from accidentally using it + +---------------------------------------------------------------------- +References +---------------------------------------------------------------------- + +- git-cherry-pick_. + +.. _git-cherry-pick: https://www.kernel.org/pub/software/scm/git/docs/git-cherry-pick.html diff --git a/docs/recipes/git-clone-mirror.rst b/docs/recipes/git-clone-mirror.rst new file mode 100644 index 000000000..2a738f2c5 --- /dev/null +++ b/docs/recipes/git-clone-mirror.rst @@ -0,0 +1,26 @@ +********************************************************************** +git-clone --mirror +********************************************************************** + +git provides an argument to set up the repository as a mirror, which +involves setting the refspec to one which copies all refs and a mirror +option for push in the remote. + +.. code-block:: bash + + $ git clone --mirror https://github.com/libgit2/pygit2 + +.. code-block:: python + + def init_remote(repo, name, url): + # Create the remote with a mirroring url + remote = repo.remotes.create(name, url, "+refs/*:refs/*") + # And set the configuration option to true for the push command + mirror_var = f"remote.{name.decode()}.mirror" + repo.config[mirror_var] = True + # Return the remote, which pygit2 will use to perform the clone + return remote + + print("Cloning pygit2 as mirror") + pygit2.clone_repository("https://github.com/libgit2/pygit2", "pygit2.git", bare=True, + remote=init_remote) diff --git a/docs/recipes/git-clone-progress.rst b/docs/recipes/git-clone-progress.rst new file mode 100644 index 000000000..3817c035d --- /dev/null +++ b/docs/recipes/git-clone-progress.rst @@ -0,0 +1,20 @@ +********************************************************************** +git-clone with progress monitor +********************************************************************** + +Example for cloning a git repository with progress monitoring: + +.. code-block:: bash + + $ git clone https://github.com/libgit2/pygit2 + +.. code-block:: python + + class MyRemoteCallbacks(pygit2.RemoteCallbacks): + + def transfer_progress(self, stats): + print(f'{stats.indexed_objects}/{stats.total_objects}') + + print("Cloning pygit2") + pygit2.clone_repository("https://github.com/libgit2/pygit2", "pygit2.git", + callbacks=MyRemoteCallbacks()) diff --git a/docs/recipes/git-clone-ssh.rst b/docs/recipes/git-clone-ssh.rst new file mode 100644 index 000000000..d54ddc876 --- /dev/null +++ b/docs/recipes/git-clone-ssh.rst @@ -0,0 +1,31 @@ +********************************************************************** +git-clone ssh://git@example.com +********************************************************************** + +Example for cloning a git repository over ssh. + +.. code-block:: bash + + $ git clone git@example.com + +.. code-block:: python + + class MyRemoteCallbacks(pygit2.RemoteCallbacks): + + def credentials(self, url, username_from_url, allowed_types): + if allowed_types & pygit2.enums.CredentialType.USERNAME: + return pygit2.Username("git") + elif allowed_types & pygit2.enums.CredentialType.SSH_KEY: + return pygit2.Keypair("git", "id_rsa.pub", "id_rsa", "") + else: + return None + + print("Cloning pygit2 over ssh") + pygit2.clone_repository("ssh://github.com/libgit2/pygit2", "pygit2.git", + callbacks=MyRemoteCallbacks()) + + print("Cloning pygit2 over ssh with the username in the URL") + keypair = pygit2.Keypair("git", "id_rsa.pub", "id_rsa", "") + callbacks = pygit2.RemoteCallbacks(credentials=keypair) + pygit2.clone_repository("ssh://git@github.com/libgit2/pygit2", "pygit2.git", + callbacks=callbacks) diff --git a/docs/recipes/git-commit.rst b/docs/recipes/git-commit.rst new file mode 100644 index 000000000..2be8bbdbe --- /dev/null +++ b/docs/recipes/git-commit.rst @@ -0,0 +1,118 @@ +********************************************************************** +git-commit +********************************************************************** + +---------------------------------------------------------------------- +Initial commit +---------------------------------------------------------------------- + +Add everything, and make an initial commit: + +.. code-block:: bash + + $ git add . + $ git commit -m "Initial commit" + +.. code-block:: python + + >>> index = repo.index + >>> index.add_all() + >>> index.write() + >>> ref = "HEAD" + >>> author = Signature('Alice Author', 'alice@authors.tld') + >>> committer = Signature('Cecil Committer', 'cecil@committers.tld') + >>> message = "Initial commit" + >>> tree = index.write_tree() + >>> parents = [] + >>> repo.create_commit(ref, author, committer, message, tree, parents) + + +---------------------------------------------------------------------- +Subsequent commit +---------------------------------------------------------------------- + +Once ``HEAD`` has a commit to point to, you can use ``repo.head.name`` as the +reference to be updated by the commit, and you should name parents: + +.. code-block:: python + + >>> ref = repo.head.name + >>> parents = [repo.head.target] + +The rest is the same: + +.. code-block:: python + + >>> index = repo.index + >>> index.add_all() + >>> index.write() + >>> author = Signature('Alice Author', 'alice@authors.tld') + >>> committer = Signature('Cecil Committer', 'cecil@committers.tld') + >>> message = "Initial commit" + >>> tree = index.write_tree() + >>> repo.create_commit(ref, author, committer, message, tree, parents) + + +---------------------------------------------------------------------- +Signing a commit +---------------------------------------------------------------------- + +Add everything, and commit with a GPG signature: + +.. code-block:: bash + + $ git add . + $ git commit -S -m "Signed commit" + +.. code-block:: python + + >>> index = repo.index + >>> index.add_all() + >>> index.write() + >>> author = Signature('Alice Author', 'alice@authors.tld') + >>> committer = Signature('Cecil Committer', 'cecil@committers.tld') + >>> message = "Signed commit" + >>> tree = index.write_tree() + >>> parents = [] + >>> commit_string = repo.create_commit_string( + >>> author, committer, message, tree, parents + >>> ) + +The ``commit_string`` can then be signed by a third party library: + +.. code-block:: python + + >>> gpg = YourGPGToolHere() + >>> signed_commit = gpg.sign( + >>> commit_string, + >>> passphrase='secret', + >>> detach=True, + >>> ) + +.. note:: + The commit signature should resemble: + + .. code-block:: none + + >>> -----BEGIN PGP SIGNATURE----- + >>> + >>> < base64 encoded hash here > + >>> -----END PGP SIGNATURE----- + +The signed commit can then be added to the branch: + +.. code-block:: python + + >>> commit = repo.create_commit_with_signature( + >>> commit_string, signed_commit.data.decode('utf-8') + >>> ) + >>> repo.head.set_target(commit) + + +---------------------------------------------------------------------- +References +---------------------------------------------------------------------- + +- git-commit_. + +.. _git-commit: https://www.kernel.org/pub/software/scm/git/docs/git-commit.html diff --git a/docs/recipes/git-init.rst b/docs/recipes/git-init.rst index 75211a99a..9af6af0a8 100644 --- a/docs/recipes/git-init.rst +++ b/docs/recipes/git-init.rst @@ -12,11 +12,11 @@ Create bare repository .. code-block:: bash - $> git init --bare relative/path + $ git init --bare path/to/git .. code-block:: python - >>> pygit2.init_repository('relative/path', True) + >>> pygit2.init_repository('path/to/git', True) ====================================================================== @@ -25,17 +25,17 @@ Create standard repository .. code-block:: bash - $> git init relative/path + $ git init path/to/git .. code-block:: python - >>> pygit2.init_repository('relative/path', False) + >>> pygit2.init_repository('path/to/git', False) ---------------------------------------------------------------------- References ---------------------------------------------------------------------- -- git-init_. +- git-init_ .. _git-init: https://www.kernel.org/pub/software/scm/git/docs/git-init.html diff --git a/docs/recipes/git-log.rst b/docs/recipes/git-log.rst index e9c10b736..2d68ae45a 100644 --- a/docs/recipes/git-log.rst +++ b/docs/recipes/git-log.rst @@ -12,11 +12,11 @@ Show HEAD commit .. code-block:: bash - $> git log -1 + $ git log -1 .. code-block:: python - >>> commit = repo[repo.head.oid] + >>> commit = repo[repo.head.target] >>> commit.message 'commit message' @@ -26,18 +26,37 @@ Traverse commit history .. code-block:: bash - $> git log + $ git log .. code-block:: python - >>> last = repo[repo.head.oid] - >>> for commit in repo.walk(last.oid, pygit2.GIT_SORT_TIME): + >>> last = repo[repo.head.target] + >>> for commit in repo.walk(last.id, pygit2.enums.SortMode.TIME): >>> print(commit.message) # or some other operation +====================================================================== +Show trailers from the last commit +====================================================================== + +.. code-block:: bash + + $ git log --format='%(trailers:key=Bug)' + +.. code-block:: python + + >>> last = repo[repo.head.target] + >>> for commit in repo.walk(last.id, pygit2.enums.SortMode.TIME): + >>> print(commit.message_trailers.get('Bug')) + + ---------------------------------------------------------------------- References ---------------------------------------------------------------------- - git-log_. +- `libgit2 discussion about walker behavior + `_. Note that the libgit2's + walker functions differently than ``git-log`` in some ways. + .. _git-log: https://www.kernel.org/pub/software/scm/git/docs/git-log.html diff --git a/docs/recipes/git-show.rst b/docs/recipes/git-show.rst index 0cba72d19..604106e3c 100644 --- a/docs/recipes/git-show.rst +++ b/docs/recipes/git-show.rst @@ -8,7 +8,7 @@ Showing a commit .. code-block:: bash - $> git show d370f56 + $ git show d370f56 .. code-block:: python @@ -25,13 +25,13 @@ Show log message Show SHA hash ====================================================================== - >>> hash = commit.hex + >>> hash = str(commit.id) ====================================================================== Show diff ====================================================================== - >>> diff = commit.tree.diff() + >>> diff = repo.diff(commit.parents[0], commit) ====================================================================== Show all files in commit @@ -40,6 +40,23 @@ Show all files in commit >>> for e in commit.tree: >>> print(e.name) +====================================================================== +Produce something like a ``git show`` message +====================================================================== + +Then you can make your message: + + >>> from datetime import datetime, timezone, timedelta + >>> tzinfo = timezone( timedelta(minutes=commit.author.offset) ) + >>> + >>> dt = datetime.fromtimestamp(float(commit.author.time), tzinfo) + >>> timestr = dt.strftime('%c %z') + >>> msg = '\n'.join([f'commit {commit.tree_id}', + ... f'Author: {commit.author.name} <{commit.author.email}>', + ... f'Date: {timestr}', + ... '', + ... commit.message]) + ---------------------------------------------------------------------- References ---------------------------------------------------------------------- diff --git a/docs/recipes/git-tag.rst b/docs/recipes/git-tag.rst index 8ed899ed0..a788f5ab2 100644 --- a/docs/recipes/git-tag.rst +++ b/docs/recipes/git-tag.rst @@ -8,12 +8,13 @@ Showing all tags .. code-block:: bash - $> git tag + $ git tag .. code-block:: python - >>> regex = re.compile('^refs/tags') - >>> filter(lambda r: regex.match(r), repo.listall_references()) + >>> import re + >>> regex = re.compile('^refs/tags/') + >>> [r for r in repo.references if regex.match(r)] ---------------------------------------------------------------------- References diff --git a/docs/references.rst b/docs/references.rst index 4db5eae7a..56387855c 100644 --- a/docs/references.rst +++ b/docs/references.rst @@ -2,98 +2,106 @@ References ********************************************************************** -.. contents:: +.. autoclass:: pygit2.Repository + :members: lookup_reference, lookup_reference_dwim, raw_listall_references, + resolve_refish + :noindex: -.. automethod:: pygit2.Repository.listall_references -.. automethod:: pygit2.Repository.lookup_reference + .. attribute:: references -Example:: + Returns an instance of the References class (see below). - >>> all_refs = repo.listall_references() - >>> master_ref = repo.lookup_reference("refs/heads/master") - >>> commit = master_ref.get_object() # or repo[master_ref.target] +.. autoclass:: pygit2.repository.References + :members: + :undoc-members: + :special-members: __getitem__, __iter__, __contains__ -The Reference type -==================== +Example:: -.. autoattribute:: pygit2.Reference.name -.. autoattribute:: pygit2.Reference.target -.. autoattribute:: pygit2.Reference.type + >>> all_refs = list(repo.references) -.. automethod:: pygit2.Reference.delete -.. automethod:: pygit2.Reference.rename -.. automethod:: pygit2.Reference.resolve -.. automethod:: pygit2.Reference.log -.. automethod:: pygit2.Reference.get_object + >>> master_ref = repo.references["refs/heads/master"] + >>> commit = master_ref.peel() # or repo[master_ref.target] + # Create a reference + >>> ref = repo.references.create('refs/tags/version1', LAST_COMMIT) -The HEAD -==================== + # Delete a reference + >>> repo.references.delete('refs/tags/version1') -Example. These two lines are equivalent:: + # Pack loose references + >>> repo.references.compress() - >>> head = repo.lookup_reference('HEAD').resolve() - >>> head = repo.head - -.. autoattribute:: pygit2.Repository.head -.. autoattribute:: pygit2.Repository.head_is_detached -.. autoattribute:: pygit2.Repository.head_is_orphaned -Branches -==================== +Functions +=================================== -Branches inherit from References, and additionally provide spetialized -accessors for some unique features. +.. autofunction:: pygit2.reference_is_valid_name -.. automethod:: pygit2.Repository.listall_branches -.. automethod:: pygit2.Repository.lookup_branch -.. automethod:: pygit2.Repository.create_branch +Check if the passed string is a valid reference name. -Example:: + Example:: - >>> local_branches = repo.listall_branches() - >>> # equivalent to - >>> local_branches = repo.listall_branches(pygit2.GIT_BRANCH_LOCAL) + >>> from pygit2 import reference_is_valid_name + >>> reference_is_valid_name("refs/heads/master") + True + >>> reference_is_valid_name("HEAD") + True + >>> reference_is_valid_name("refs/heads/..") + False - >>> remote_branches = repo.listall_branches(pygit2.GIT_BRANCH_REMOTE) - >>> all_branches = repo.listall_branches(pygit2.GIT_BRANCH_REMOTE | - pygit2.GIT_BRANCH_LOCAL) +The Reference type +==================== - >>> master_branch = repo.lookup_branch('master') - >>> # equivalent to - >>> master_branch = repo.lookup_branch('master', - pygit2.GIT_BRANCH_LOCAL) +.. autoclass:: pygit2.Reference + :members: + :special-members: __eq__, __ne__ + :exclude-members: log - >>> remote_branch = repo.lookup_branch('upstream/feature', - pygit2.GIT_BRANCH_REMOTE) + .. automethod:: log -The Branch type +The HEAD ==================== -.. autoattribute:: pygit2.Branch.branch_name -.. autoattribute:: pygit2.Branch.remote_name -.. autoattribute:: pygit2.Branch.upstream -.. autoattribute:: pygit2.Branch.upstream_name +Example. These two lines are equivalent:: -.. automethod:: pygit2.Branch.rename -.. automethod:: pygit2.Branch.delete -.. automethod:: pygit2.Branch.is_head + >>> head = repo.references['HEAD'].resolve() + >>> head = repo.head + +.. autoattribute:: pygit2.Repository.head +.. autoattribute:: pygit2.Repository.head_is_detached +.. autoattribute:: pygit2.Repository.head_is_unborn The reference log ==================== Example:: - >>> head = repo.lookup_reference('refs/heads/master') + >>> head = repo.references.get('refs/heads/master') # Returns None if not found + >>> # Almost equivalent to + >>> head = repo.references['refs/heads/master'] # Raises KeyError if not found >>> for entry in head.log(): ... print(entry.message) -.. autoattribute:: pygit2.RefLogEntry.oid_new -.. autoattribute:: pygit2.RefLogEntry.oid_old -.. autoattribute:: pygit2.RefLogEntry.message -.. autoattribute:: pygit2.RefLogEntry.committer +.. 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 ==================== @@ -107,6 +115,6 @@ The Note type -------------------- .. autoattribute:: pygit2.Note.annotated_id -.. autoattribute:: pygit2.Note.oid +.. autoattribute:: pygit2.Note.id .. autoattribute:: pygit2.Note.message .. automethod:: pygit2.Note.remove diff --git a/docs/remotes.rst b/docs/remotes.rst index 91a69d055..3b9968911 100644 --- a/docs/remotes.rst +++ b/docs/remotes.rst @@ -2,17 +2,60 @@ Remotes ********************************************************************** +.. py:attribute:: Repository.remotes -.. autoattribute:: pygit2.Repository.remotes -.. automethod:: pygit2.Repository.create_remote + The collection of configured remotes, an instance of + :py:class:`pygit2.remotes.RemoteCollection` +The remote collection +========================== + +.. autoclass:: pygit2.remotes.RemoteCollection + :members: The Remote type ==================== -.. autoattribute:: pygit2.Remote.name -.. autoattribute:: pygit2.Remote.url -.. autoattribute:: pygit2.Remote.refspec_count -.. automethod:: pygit2.Remote.get_refspec -.. automethod:: pygit2.Remote.fetch -.. automethod:: pygit2.Remote.save +.. autoclass:: pygit2.Remote + :members: + +The RemoteCallbacks type +======================== + +.. autoclass:: pygit2.RemoteCallbacks + :members: + +The TransferProgress type +=========================== + +This class contains the data which is available to us during a fetch. + +.. autoclass:: pygit2.remotes.TransferProgress + :members: + +The Refspec type +=================== + +Refspecs objects are not constructed directly, but returned by +:meth:`pygit2.Remote.get_refspec`. To create a new a refspec on a Remote, use +:meth:`pygit2.Remote.add_fetch` or :meth:`pygit2.Remote.add_push`. + +.. autoclass:: pygit2.refspec.Refspec + :members: + +Credentials +================ + +There are several types of credentials. All of them are callable objects, with +the appropriate signature for the credentials callback. + +They will ignore all the arguments and return themselves. This is useful for +scripts where the credentials are known ahead of time. More complete interfaces +would want to look up in their keychain or ask the user for the data to use in +the credentials. + +.. autoclass:: pygit2.Username +.. autoclass:: pygit2.UserPass +.. autoclass:: pygit2.Keypair +.. autoclass:: pygit2.KeypairFromAgent +.. autoclass:: pygit2.KeypairFromMemory diff --git a/docs/repository.rst b/docs/repository.rst index 034ed5692..0e22178d5 100644 --- a/docs/repository.rst +++ b/docs/repository.rst @@ -1,5 +1,5 @@ ********************************************************************** -The repository +Repository ********************************************************************** Everything starts either by creating a new repository, or by opening an @@ -29,35 +29,72 @@ Functions >>> repo_path = '/path/to/create/repository' >>> repo = clone_repository(repo_url, repo_path) # Clones a non-bare repository >>> repo = clone_repository(repo_url, repo_path, bare=True) # Clones a bare repository + >>> repo = clone_repository(repo_url, repo_path, proxy=True) # Enable automatic proxy detection .. autofunction:: pygit2.discover_repository + Example:: + + >>> current_working_directory = os.getcwd() + >>> repository_path = discover_repository(current_working_directory) + >>> repo = Repository(repository_path) + +.. autofunction:: pygit2.tree_entry_cmp(object, other) The Repository class =================================== -.. py:class:: pygit2.Repository(path) +The API of the Repository class is quite large. Since this documentation is +organized by features, the related bits are explained in the related chapters, +for instance the :py:meth:`pygit2.Repository.checkout` method is explained in +the Checkout section. + +Below there are some general attributes and methods: + +.. autoclass:: pygit2.Repository + :members: ahead_behind, amend_commit, applies, apply, create_reference, + default_signature, descendant_of, describe, free, get_attr, + is_bare, is_empty, is_shallow, odb, path, + path_is_ignored, reset, revert_commit, state_cleanup, workdir, + write, write_archive, set_odb, set_refdb + + The Repository constructor will most commonly be called with one argument, the path of the repository to open. + + Alternatively, constructing a repository with no arguments will create a repository with no backends. You can + use this path to create repositories with custom backends. Note that most operations on the repository are + considered invalid and may lead to undefined behavior if attempted before providing an odb and refdb via + :py:meth:`set_odb` and :py:meth:`set_refdb`. - The Repository constructor only takes one argument, the path of the - repository to open. + Parameters: + + path + The path to open — if not provided, the repository will have no backend. + + flags + Flags controlling how to open the repository can optionally be provided — any combination of: + + * enums.RepositoryOpenFlag.NO_SEARCH + * enums.RepositoryOpenFlag.CROSS_FS + * enums.RepositoryOpenFlag.BARE + * enums.RepositoryOpenFlag.NO_DOTGIT + * enums.RepositoryOpenFlag.FROM_ENV Example:: >>> from pygit2 import Repository >>> repo = Repository('pygit2/.git') -The API of the Repository class is quite large. Since this documentation is -orgaized by features, the related bits are explained in the related chapters, -for instance the :py:meth:`pygit2.Repository.checkout` method are explained in -the Checkout section. -Below there are some general attributes and methods: +The Odb class +=================================== + +.. autoclass:: pygit2.Odb + :members: + +The Refdb class +=================================== -.. autoattribute:: pygit2.Repository.path -.. autoattribute:: pygit2.Repository.workdir -.. autoattribute:: pygit2.Repository.is_bare -.. autoattribute:: pygit2.Repository.is_empty -.. automethod:: pygit2.Repository.read -.. automethod:: pygit2.Repository.write +.. autoclass:: pygit2.Refdb + :members: diff --git a/docs/revparse.rst b/docs/revparse.rst index f3ee4d49e..5d1cc812e 100644 --- a/docs/revparse.rst +++ b/docs/revparse.rst @@ -2,8 +2,20 @@ Revision parsing ********************************************************************** -.. automethod:: pygit2.Repository.revparse_single +.. autoclass:: pygit2.Repository + :members: revparse, revparse_ext, revparse_single + :noindex: You can use any of the fancy `` forms supported by libgit2:: >>> commit = repo.revparse_single('HEAD^') + +.. autoclass:: pygit2.RevSpec + :members: + + +Constants: + +.. py:data:: pygit2.enums.RevSpecFlag.SINGLE +.. py:data:: pygit2.enums.RevSpecFlag.RANGE +.. py:data:: pygit2.enums.RevSpecFlag.MERGE_BASE diff --git a/docs/settings.rst b/docs/settings.rst new file mode 100644 index 000000000..f1d7a9871 --- /dev/null +++ b/docs/settings.rst @@ -0,0 +1,8 @@ +********************************************************************** +Settings +********************************************************************** + +.. contents:: + +.. autoclass:: pygit2.Settings + :members: diff --git a/docs/submodule.rst b/docs/submodule.rst new file mode 100644 index 000000000..578806f45 --- /dev/null +++ b/docs/submodule.rst @@ -0,0 +1,24 @@ +********************************************************************** +Submodules +********************************************************************** + +A submodule is a foreign repository that is embedded within a +dedicated subdirectory of the repositories tree. + +.. autoclass:: pygit2.Repository + :members: listall_submodules + + .. py:attribute:: Repository.submodules + + The collection of submodules, an instance of + :py:class:`pygit2.submodules.SubmoduleCollection` + +.. autoclass:: pygit2.submodules.SubmoduleCollection + :members: + + +The Submodule type +==================== + +.. autoclass:: pygit2.Submodule + :members: 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/docs/working-copy.rst b/docs/working-copy.rst deleted file mode 100644 index bb10f7156..000000000 --- a/docs/working-copy.rst +++ /dev/null @@ -1,73 +0,0 @@ -********************************************************************** -The Index file and the Working copy -********************************************************************** - -.. autoattribute:: pygit2.Repository.index - -Index read:: - - >>> index = repo.index - >>> index.read() - >>> oid = index['path/to/file'].oid # from path to object id - >>> blob = repo[oid] # from object id to object - -Iterate over all entries of the index:: - - >>> for entry in index: - ... print entry.path, entry.hex - -Index write:: - - >>> index.add('path/to/file') # git add - >>> del index['path/to/file'] # git rm - >>> index.write() # don't forget to save the changes - - -The Index type -==================== - -.. automethod:: pygit2.Index.add -.. automethod:: pygit2.Index.remove -.. automethod:: pygit2.Index.clear -.. automethod:: pygit2.Index.read -.. automethod:: pygit2.Index.write -.. automethod:: pygit2.Index.read_tree -.. automethod:: pygit2.Index.write_tree -.. automethod:: pygit2.Index.diff_to_tree -.. automethod:: pygit2.Index.diff_to_workdir - - -The IndexEntry type --------------------- - -.. autoattribute:: pygit2.IndexEntry.oid -.. autoattribute:: pygit2.IndexEntry.hex -.. autoattribute:: pygit2.IndexEntry.path -.. autoattribute:: pygit2.IndexEntry.mode - - -Status -==================== - -.. automethod:: pygit2.Repository.status -.. automethod:: pygit2.Repository.status_file - -Inspect the status of the repository:: - - >>> from pygit2 import GIT_STATUS_CURRENT - >>> status = repo.status() - >>> for filepath, flags in status.items(): - ... if flags != GIT_STATUS_CURRENT: - ... print "Filepath %s isn't clean" % filepath - - -Checkout -==================== - -.. automethod:: pygit2.Repository.checkout - -Lower level API: - -.. automethod:: pygit2.Repository.checkout_head -.. automethod:: pygit2.Repository.checkout_tree -.. automethod:: pygit2.Repository.checkout_index diff --git a/docs/worktree.rst b/docs/worktree.rst new file mode 100644 index 000000000..3a4c8be7c --- /dev/null +++ b/docs/worktree.rst @@ -0,0 +1,10 @@ +********************************************************************** +Worktrees +********************************************************************** + +.. automethod:: pygit2.Repository.add_worktree +.. automethod:: pygit2.Repository.list_worktrees +.. automethod:: pygit2.Repository.lookup_worktree + +.. autoclass:: pygit2.Worktree + :members: diff --git a/misc/valgrind-python.supp b/misc/valgrind-python.supp index 15982cc3e..bc8f77f26 100644 --- a/misc/valgrind-python.supp +++ b/misc/valgrind-python.supp @@ -5,13 +5,13 @@ # # cd python/dist/src # valgrind --tool=memcheck --suppressions=Misc/valgrind-python.supp \ -# ./python -E -tt ./Lib/test/regrtest.py -u bsddb,network +# ./python -E ./Lib/test/regrtest.py -u gui,network # # You must edit Objects/obmalloc.c and uncomment Py_USING_MEMORY_DEBUGGER -# to use the preferred suppressions with Py_ADDRESS_IN_RANGE. +# to use the preferred suppressions with address_in_range. # # If you do not want to recompile Python, you can uncomment -# suppressions for PyObject_Free and PyObject_Realloc. +# suppressions for _PyObject_Free and _PyObject_Realloc. # # See Misc/README.valgrind for more information. @@ -19,25 +19,25 @@ { ADDRESS_IN_RANGE/Invalid read of size 4 Memcheck:Addr4 - fun:Py_ADDRESS_IN_RANGE + fun:address_in_range } { ADDRESS_IN_RANGE/Invalid read of size 4 Memcheck:Value4 - fun:Py_ADDRESS_IN_RANGE + fun:address_in_range } { ADDRESS_IN_RANGE/Invalid read of size 8 (x86_64 aka amd64) Memcheck:Value8 - fun:Py_ADDRESS_IN_RANGE + fun:address_in_range } { ADDRESS_IN_RANGE/Conditional jump or move depends on uninitialised value Memcheck:Cond - fun:Py_ADDRESS_IN_RANGE + fun:address_in_range } # @@ -124,41 +124,65 @@ fun:_dl_allocate_tls } -{ - ADDRESS_IN_RANGE/Invalid read of size 4 - Memcheck:Addr4 - fun:PyObject_Free -} - -{ - ADDRESS_IN_RANGE/Invalid read of size 4 - Memcheck:Value4 - fun:PyObject_Free -} - -{ - ADDRESS_IN_RANGE/Conditional jump or move depends on uninitialised value - Memcheck:Cond - fun:PyObject_Free -} - -{ - ADDRESS_IN_RANGE/Invalid read of size 4 - Memcheck:Addr4 - fun:PyObject_Realloc -} - -{ - ADDRESS_IN_RANGE/Invalid read of size 4 - Memcheck:Value4 - fun:PyObject_Realloc -} +###{ +### ADDRESS_IN_RANGE/Invalid read of size 4 +### Memcheck:Addr4 +### fun:_PyObject_Free +###} +### +###{ +### ADDRESS_IN_RANGE/Invalid read of size 4 +### Memcheck:Value4 +### fun:_PyObject_Free +###} +### +###{ +### ADDRESS_IN_RANGE/Use of uninitialised value of size 8 +### Memcheck:Addr8 +### fun:_PyObject_Free +###} +### +###{ +### ADDRESS_IN_RANGE/Use of uninitialised value of size 8 +### Memcheck:Value8 +### fun:_PyObject_Free +###} +### +###{ +### ADDRESS_IN_RANGE/Conditional jump or move depends on uninitialised value +### Memcheck:Cond +### fun:_PyObject_Free +###} -{ - ADDRESS_IN_RANGE/Conditional jump or move depends on uninitialised value - Memcheck:Cond - fun:PyObject_Realloc -} +###{ +### ADDRESS_IN_RANGE/Invalid read of size 4 +### Memcheck:Addr4 +### fun:_PyObject_Realloc +###} +### +###{ +### ADDRESS_IN_RANGE/Invalid read of size 4 +### Memcheck:Value4 +### fun:_PyObject_Realloc +###} +### +###{ +### ADDRESS_IN_RANGE/Use of uninitialised value of size 8 +### Memcheck:Addr8 +### fun:_PyObject_Realloc +###} +### +###{ +### ADDRESS_IN_RANGE/Use of uninitialised value of size 8 +### Memcheck:Value8 +### fun:_PyObject_Realloc +###} +### +###{ +### ADDRESS_IN_RANGE/Conditional jump or move depends on uninitialised value +### Memcheck:Cond +### fun:_PyObject_Realloc +###} ### ### All the suppressions below are for errors that occur within libraries @@ -286,6 +310,38 @@ ### fun:MD5_Update ###} +# Fedora's package "openssl-1.0.1-0.1.beta2.fc17.x86_64" on x86_64 +# See http://bugs.python.org/issue14171 +{ + openssl 1.0.1 prng 1 + Memcheck:Cond + fun:bcmp + fun:fips_get_entropy + fun:FIPS_drbg_instantiate + fun:RAND_init_fips + fun:OPENSSL_init_library + fun:SSL_library_init + fun:init_hashlib +} + +{ + openssl 1.0.1 prng 2 + Memcheck:Cond + fun:fips_get_entropy + fun:FIPS_drbg_instantiate + fun:RAND_init_fips + fun:OPENSSL_init_library + fun:SSL_library_init + fun:init_hashlib +} + +{ + openssl 1.0.1 prng 3 + Memcheck:Value8 + fun:_x86_64_AES_encrypt_compact + fun:AES_encrypt +} + # # All of these problems come from using test_socket_ssl # @@ -388,4 +444,37 @@ fun:SHA1_Update } +{ + test_buffer_non_debug + Memcheck:Addr4 + fun:PyUnicodeUCS2_FSConverter +} + +{ + test_buffer_non_debug + Memcheck:Addr4 + fun:PyUnicode_FSConverter +} + +{ + wcscmp_false_positive + Memcheck:Addr8 + fun:wcscmp + fun:_PyOS_GetOpt + fun:Py_Main + fun:main +} + +# Additional suppressions for the unified decimal tests: +{ + test_decimal + Memcheck:Addr4 + fun:PyUnicodeUCS2_FSConverter +} + +{ + test_decimal2 + Memcheck:Addr4 + fun:PyUnicode_FSConverter +} diff --git a/mypy-stubtest.ini b/mypy-stubtest.ini new file mode 100644 index 000000000..a8022cbc0 --- /dev/null +++ b/mypy-stubtest.ini @@ -0,0 +1,16 @@ +# Config file for testing the stub file (_pygit2.pyi) with "stubtest" +# (tool shipped with mypy). +# +# Run "build.sh stubtest", or: +# stubtest --mypy-config-file test/mypy-stubtest.ini pygit2._pygit2 +# +# Format info: +# https://mypy.readthedocs.io/en/stable/config_file.html + +[mypy] +warn_unused_configs = True +disallow_any_explicit = True + +# don't follow import pygit2 from _pygit2.pyi, we only want to check the pyi file. +[mypy-pygit2] +follow_imports = skip 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 e59d3c31d..518f34361 100644 --- a/pygit2/__init__.py +++ b/pygit2/__init__.py @@ -1,6 +1,4 @@ -# -*- coding: utf-8 -*- -# -# Copyright 2010-2013 The pygit2 contributors +# Copyright 2010-2025 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -25,64 +23,964 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. -# Import from the future -from __future__ import absolute_import +# ruff: noqa: F401 F403 F405 -# Low level API -import _pygit2 -from _pygit2 import * +# Standard Library +import functools +import os +import typing # High level API +from . import enums +from ._build import __version__ + +# Low level API +from ._pygit2 import ( + GIT_APPLY_LOCATION_BOTH, + GIT_APPLY_LOCATION_INDEX, + GIT_APPLY_LOCATION_WORKDIR, + GIT_BLAME_FIRST_PARENT, + GIT_BLAME_IGNORE_WHITESPACE, + GIT_BLAME_NORMAL, + GIT_BLAME_TRACK_COPIES_ANY_COMMIT_COPIES, + GIT_BLAME_TRACK_COPIES_SAME_COMMIT_COPIES, + GIT_BLAME_TRACK_COPIES_SAME_COMMIT_MOVES, + GIT_BLAME_TRACK_COPIES_SAME_FILE, + GIT_BLAME_USE_MAILMAP, + GIT_BLOB_FILTER_ATTRIBUTES_FROM_COMMIT, + GIT_BLOB_FILTER_ATTRIBUTES_FROM_HEAD, + GIT_BLOB_FILTER_CHECK_FOR_BINARY, + GIT_BLOB_FILTER_NO_SYSTEM_ATTRIBUTES, + GIT_BRANCH_ALL, + GIT_BRANCH_LOCAL, + GIT_BRANCH_REMOTE, + GIT_CHECKOUT_ALLOW_CONFLICTS, + GIT_CHECKOUT_CONFLICT_STYLE_DIFF3, + GIT_CHECKOUT_CONFLICT_STYLE_MERGE, + GIT_CHECKOUT_CONFLICT_STYLE_ZDIFF3, + GIT_CHECKOUT_DISABLE_PATHSPEC_MATCH, + GIT_CHECKOUT_DONT_OVERWRITE_IGNORED, + GIT_CHECKOUT_DONT_REMOVE_EXISTING, + GIT_CHECKOUT_DONT_UPDATE_INDEX, + GIT_CHECKOUT_DONT_WRITE_INDEX, + GIT_CHECKOUT_DRY_RUN, + GIT_CHECKOUT_FORCE, + GIT_CHECKOUT_NO_REFRESH, + GIT_CHECKOUT_NONE, + GIT_CHECKOUT_RECREATE_MISSING, + GIT_CHECKOUT_REMOVE_IGNORED, + GIT_CHECKOUT_REMOVE_UNTRACKED, + GIT_CHECKOUT_SAFE, + GIT_CHECKOUT_SKIP_LOCKED_DIRECTORIES, + GIT_CHECKOUT_SKIP_UNMERGED, + GIT_CHECKOUT_UPDATE_ONLY, + GIT_CHECKOUT_USE_OURS, + GIT_CHECKOUT_USE_THEIRS, + GIT_CONFIG_HIGHEST_LEVEL, + GIT_CONFIG_LEVEL_APP, + GIT_CONFIG_LEVEL_GLOBAL, + GIT_CONFIG_LEVEL_LOCAL, + GIT_CONFIG_LEVEL_PROGRAMDATA, + GIT_CONFIG_LEVEL_SYSTEM, + GIT_CONFIG_LEVEL_WORKTREE, + GIT_CONFIG_LEVEL_XDG, + GIT_DELTA_ADDED, + GIT_DELTA_CONFLICTED, + GIT_DELTA_COPIED, + GIT_DELTA_DELETED, + GIT_DELTA_IGNORED, + GIT_DELTA_MODIFIED, + GIT_DELTA_RENAMED, + GIT_DELTA_TYPECHANGE, + GIT_DELTA_UNMODIFIED, + GIT_DELTA_UNREADABLE, + GIT_DELTA_UNTRACKED, + GIT_DESCRIBE_ALL, + GIT_DESCRIBE_DEFAULT, + GIT_DESCRIBE_TAGS, + GIT_DIFF_BREAK_REWRITES, + GIT_DIFF_BREAK_REWRITES_FOR_RENAMES_ONLY, + GIT_DIFF_DISABLE_PATHSPEC_MATCH, + GIT_DIFF_ENABLE_FAST_UNTRACKED_DIRS, + GIT_DIFF_FIND_ALL, + GIT_DIFF_FIND_AND_BREAK_REWRITES, + GIT_DIFF_FIND_BY_CONFIG, + GIT_DIFF_FIND_COPIES, + GIT_DIFF_FIND_COPIES_FROM_UNMODIFIED, + GIT_DIFF_FIND_DONT_IGNORE_WHITESPACE, + GIT_DIFF_FIND_EXACT_MATCH_ONLY, + GIT_DIFF_FIND_FOR_UNTRACKED, + GIT_DIFF_FIND_IGNORE_LEADING_WHITESPACE, + GIT_DIFF_FIND_IGNORE_WHITESPACE, + GIT_DIFF_FIND_REMOVE_UNMODIFIED, + GIT_DIFF_FIND_RENAMES, + GIT_DIFF_FIND_RENAMES_FROM_REWRITES, + GIT_DIFF_FIND_REWRITES, + GIT_DIFF_FLAG_BINARY, + GIT_DIFF_FLAG_EXISTS, + GIT_DIFF_FLAG_NOT_BINARY, + GIT_DIFF_FLAG_VALID_ID, + GIT_DIFF_FLAG_VALID_SIZE, + GIT_DIFF_FORCE_BINARY, + GIT_DIFF_FORCE_TEXT, + GIT_DIFF_IGNORE_BLANK_LINES, + GIT_DIFF_IGNORE_CASE, + GIT_DIFF_IGNORE_FILEMODE, + GIT_DIFF_IGNORE_SUBMODULES, + GIT_DIFF_IGNORE_WHITESPACE, + GIT_DIFF_IGNORE_WHITESPACE_CHANGE, + GIT_DIFF_IGNORE_WHITESPACE_EOL, + GIT_DIFF_INCLUDE_CASECHANGE, + GIT_DIFF_INCLUDE_IGNORED, + GIT_DIFF_INCLUDE_TYPECHANGE, + GIT_DIFF_INCLUDE_TYPECHANGE_TREES, + GIT_DIFF_INCLUDE_UNMODIFIED, + GIT_DIFF_INCLUDE_UNREADABLE, + GIT_DIFF_INCLUDE_UNREADABLE_AS_UNTRACKED, + GIT_DIFF_INCLUDE_UNTRACKED, + GIT_DIFF_INDENT_HEURISTIC, + GIT_DIFF_MINIMAL, + GIT_DIFF_NORMAL, + GIT_DIFF_PATIENCE, + GIT_DIFF_RECURSE_IGNORED_DIRS, + GIT_DIFF_RECURSE_UNTRACKED_DIRS, + GIT_DIFF_REVERSE, + GIT_DIFF_SHOW_BINARY, + GIT_DIFF_SHOW_UNMODIFIED, + GIT_DIFF_SHOW_UNTRACKED_CONTENT, + GIT_DIFF_SKIP_BINARY_CHECK, + GIT_DIFF_STATS_FULL, + GIT_DIFF_STATS_INCLUDE_SUMMARY, + GIT_DIFF_STATS_NONE, + GIT_DIFF_STATS_NUMBER, + GIT_DIFF_STATS_SHORT, + GIT_DIFF_UPDATE_INDEX, + GIT_FILEMODE_BLOB, + GIT_FILEMODE_BLOB_EXECUTABLE, + GIT_FILEMODE_COMMIT, + GIT_FILEMODE_LINK, + GIT_FILEMODE_TREE, + GIT_FILEMODE_UNREADABLE, + GIT_FILTER_ALLOW_UNSAFE, + GIT_FILTER_ATTRIBUTES_FROM_COMMIT, + GIT_FILTER_ATTRIBUTES_FROM_HEAD, + GIT_FILTER_CLEAN, + GIT_FILTER_DEFAULT, + GIT_FILTER_DRIVER_PRIORITY, + GIT_FILTER_NO_SYSTEM_ATTRIBUTES, + GIT_FILTER_SMUDGE, + GIT_FILTER_TO_ODB, + GIT_FILTER_TO_WORKTREE, + GIT_MERGE_ANALYSIS_FASTFORWARD, + GIT_MERGE_ANALYSIS_NONE, + GIT_MERGE_ANALYSIS_NORMAL, + GIT_MERGE_ANALYSIS_UNBORN, + GIT_MERGE_ANALYSIS_UP_TO_DATE, + GIT_MERGE_PREFERENCE_FASTFORWARD_ONLY, + GIT_MERGE_PREFERENCE_NO_FASTFORWARD, + GIT_MERGE_PREFERENCE_NONE, + GIT_OBJECT_ANY, + GIT_OBJECT_BLOB, + GIT_OBJECT_COMMIT, + GIT_OBJECT_INVALID, + GIT_OBJECT_OFS_DELTA, + GIT_OBJECT_REF_DELTA, + GIT_OBJECT_TAG, + GIT_OBJECT_TREE, + GIT_OID_HEX_ZERO, + GIT_OID_HEXSZ, + GIT_OID_MINPREFIXLEN, + GIT_OID_RAWSZ, + GIT_REFERENCES_ALL, + GIT_REFERENCES_BRANCHES, + GIT_REFERENCES_TAGS, + GIT_RESET_HARD, + GIT_RESET_MIXED, + GIT_RESET_SOFT, + GIT_REVSPEC_MERGE_BASE, + GIT_REVSPEC_RANGE, + GIT_REVSPEC_SINGLE, + GIT_SORT_NONE, + GIT_SORT_REVERSE, + GIT_SORT_TIME, + GIT_SORT_TOPOLOGICAL, + GIT_STASH_APPLY_DEFAULT, + GIT_STASH_APPLY_REINSTATE_INDEX, + GIT_STASH_DEFAULT, + GIT_STASH_INCLUDE_IGNORED, + GIT_STASH_INCLUDE_UNTRACKED, + GIT_STASH_KEEP_ALL, + GIT_STASH_KEEP_INDEX, + GIT_STATUS_CONFLICTED, + GIT_STATUS_CURRENT, + GIT_STATUS_IGNORED, + GIT_STATUS_INDEX_DELETED, + GIT_STATUS_INDEX_MODIFIED, + GIT_STATUS_INDEX_NEW, + GIT_STATUS_INDEX_RENAMED, + GIT_STATUS_INDEX_TYPECHANGE, + GIT_STATUS_WT_DELETED, + GIT_STATUS_WT_MODIFIED, + GIT_STATUS_WT_NEW, + GIT_STATUS_WT_RENAMED, + GIT_STATUS_WT_TYPECHANGE, + GIT_STATUS_WT_UNREADABLE, + GIT_SUBMODULE_IGNORE_ALL, + GIT_SUBMODULE_IGNORE_DIRTY, + GIT_SUBMODULE_IGNORE_NONE, + GIT_SUBMODULE_IGNORE_UNSPECIFIED, + GIT_SUBMODULE_IGNORE_UNTRACKED, + GIT_SUBMODULE_STATUS_IN_CONFIG, + GIT_SUBMODULE_STATUS_IN_HEAD, + GIT_SUBMODULE_STATUS_IN_INDEX, + GIT_SUBMODULE_STATUS_IN_WD, + GIT_SUBMODULE_STATUS_INDEX_ADDED, + GIT_SUBMODULE_STATUS_INDEX_DELETED, + GIT_SUBMODULE_STATUS_INDEX_MODIFIED, + GIT_SUBMODULE_STATUS_WD_ADDED, + GIT_SUBMODULE_STATUS_WD_DELETED, + GIT_SUBMODULE_STATUS_WD_INDEX_MODIFIED, + GIT_SUBMODULE_STATUS_WD_MODIFIED, + GIT_SUBMODULE_STATUS_WD_UNINITIALIZED, + GIT_SUBMODULE_STATUS_WD_UNTRACKED, + GIT_SUBMODULE_STATUS_WD_WD_MODIFIED, + LIBGIT2_VER_MAJOR, + LIBGIT2_VER_MINOR, + LIBGIT2_VER_REVISION, + LIBGIT2_VERSION, + AlreadyExistsError, + Blob, + Branch, + Commit, + Diff, + DiffDelta, + DiffFile, + DiffHunk, + DiffLine, + DiffStats, + FilterSource, + GitError, + InvalidSpecError, + Mailmap, + Note, + Object, + Odb, + OdbBackend, + OdbBackendLoose, + OdbBackendPack, + Oid, + Patch, + Refdb, + RefdbBackend, + RefdbFsBackend, + Reference, + RefLogEntry, + RevSpec, + Signature, + Stash, + Tag, + Tree, + TreeBuilder, + Walker, + Worktree, + _cache_enums, + discover_repository, + filter_register, + filter_unregister, + hash, + hashfile, + init_file_backend, + reference_is_valid_name, + tree_entry_cmp, +) +from .blame import Blame, BlameHunk +from .blob import BlobIO +from .callbacks import ( + CheckoutCallbacks, + Payload, + RemoteCallbacks, + StashApplyCallbacks, + get_credentials, + git_clone_options, + git_fetch_options, + git_proxy_options, +) +from .config import Config +from .credentials import * +from .errors import Passthrough, check_error +from .ffi import C, ffi +from .filter import Filter +from .index import Index, IndexEntry +from .legacyenums import * +from .options import ( + GIT_OPT_ADD_SSL_X509_CERT, + GIT_OPT_DISABLE_PACK_KEEP_FILE_CHECKS, + GIT_OPT_ENABLE_CACHING, + GIT_OPT_ENABLE_FSYNC_GITDIR, + GIT_OPT_ENABLE_HTTP_EXPECT_CONTINUE, + GIT_OPT_ENABLE_OFS_DELTA, + GIT_OPT_ENABLE_STRICT_HASH_VERIFICATION, + GIT_OPT_ENABLE_STRICT_OBJECT_CREATION, + GIT_OPT_ENABLE_STRICT_SYMBOLIC_REF_CREATION, + GIT_OPT_ENABLE_UNSAVED_INDEX_SAFETY, + GIT_OPT_GET_CACHED_MEMORY, + GIT_OPT_GET_EXTENSIONS, + GIT_OPT_GET_HOMEDIR, + GIT_OPT_GET_MWINDOW_FILE_LIMIT, + GIT_OPT_GET_MWINDOW_MAPPED_LIMIT, + GIT_OPT_GET_MWINDOW_SIZE, + GIT_OPT_GET_OWNER_VALIDATION, + GIT_OPT_GET_PACK_MAX_OBJECTS, + GIT_OPT_GET_SEARCH_PATH, + GIT_OPT_GET_SERVER_CONNECT_TIMEOUT, + GIT_OPT_GET_SERVER_TIMEOUT, + GIT_OPT_GET_TEMPLATE_PATH, + GIT_OPT_GET_USER_AGENT, + GIT_OPT_GET_USER_AGENT_PRODUCT, + GIT_OPT_GET_WINDOWS_SHAREMODE, + GIT_OPT_SET_ALLOCATOR, + GIT_OPT_SET_CACHE_MAX_SIZE, + GIT_OPT_SET_CACHE_OBJECT_LIMIT, + GIT_OPT_SET_EXTENSIONS, + GIT_OPT_SET_HOMEDIR, + GIT_OPT_SET_MWINDOW_FILE_LIMIT, + GIT_OPT_SET_MWINDOW_MAPPED_LIMIT, + GIT_OPT_SET_MWINDOW_SIZE, + GIT_OPT_SET_ODB_LOOSE_PRIORITY, + GIT_OPT_SET_ODB_PACKED_PRIORITY, + GIT_OPT_SET_OWNER_VALIDATION, + GIT_OPT_SET_PACK_MAX_OBJECTS, + GIT_OPT_SET_SEARCH_PATH, + GIT_OPT_SET_SERVER_CONNECT_TIMEOUT, + GIT_OPT_SET_SERVER_TIMEOUT, + GIT_OPT_SET_SSL_CERT_LOCATIONS, + GIT_OPT_SET_SSL_CIPHERS, + GIT_OPT_SET_TEMPLATE_PATH, + GIT_OPT_SET_USER_AGENT, + GIT_OPT_SET_USER_AGENT_PRODUCT, + GIT_OPT_SET_WINDOWS_SHAREMODE, + option, +) +from .packbuilder import PackBuilder +from .remotes import Remote from .repository import Repository -from .version import __version__ +from .settings import Settings +from .submodules import Submodule +from .transaction import ReferenceTransaction +from .utils import to_bytes, to_str +# Features +features = enums.Feature(C.git_libgit2_features()) -def init_repository(path, bare=False): +# libgit version tuple +LIBGIT2_VER = (LIBGIT2_VER_MAJOR, LIBGIT2_VER_MINOR, LIBGIT2_VER_REVISION) + +# Let _pygit2 cache references to Python enum types. +# This is separate from PyInit__pygit2() to avoid a circular import. +_cache_enums() + + +def init_repository( + path: str | bytes | os.PathLike[str] | os.PathLike[bytes] | None, + bare: bool = False, + flags: enums.RepositoryInitFlag = enums.RepositoryInitFlag.MKPATH, + mode: int | enums.RepositoryInitMode = enums.RepositoryInitMode.SHARED_UMASK, + workdir_path: typing.Optional[str] = None, + description: typing.Optional[str] = None, + template_path: typing.Optional[str] = None, + initial_head: typing.Optional[str] = None, + origin_url: typing.Optional[str] = None, +) -> Repository: """ Creates a new Git repository in the given *path*. If *bare* is True the repository will be bare, i.e. it will not have a working copy. + + The *flags* may be a combination of enums.RepositoryInitFlag constants: + + - BARE (overridden by the *bare* parameter) + - NO_REINIT + - NO_DOTGIT_DIR + - MKDIR + - MKPATH (set by default) + - EXTERNAL_TEMPLATE + + The *mode* parameter may be any of the predefined modes in + enums.RepositoryInitMode (SHARED_UMASK being the default), or a custom int. + + The *workdir_path*, *description*, *template_path*, *initial_head* and + *origin_url* are all strings. + + If a repository already exists at *path*, it may be opened successfully but + you must not rely on that behavior and should use the Repository + constructor directly instead. + + See libgit2's documentation on git_repository_init_ext for further details. """ - _pygit2.init_repository(path, bare) - return Repository(path) + # Pre-process input parameters + if path is None: + raise TypeError('Expected string type for path, found None.') + + if bare: + flags |= enums.RepositoryInitFlag.BARE + + # Options + options = ffi.new('git_repository_init_options *') + C.git_repository_init_options_init(options, C.GIT_REPOSITORY_INIT_OPTIONS_VERSION) + options.flags = int(flags) + options.mode = mode + + if workdir_path: + workdir_path_ref = ffi.new('char []', to_bytes(workdir_path)) + options.workdir_path = workdir_path_ref + + if description: + description_ref = ffi.new('char []', to_bytes(description)) + options.description = description_ref + + if template_path: + template_path_ref = ffi.new('char []', to_bytes(template_path)) + options.template_path = template_path_ref + + if initial_head: + initial_head_ref = ffi.new('char []', to_bytes(initial_head)) + options.initial_head = initial_head_ref + + if origin_url: + origin_url_ref = ffi.new('char []', to_bytes(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) + check_error(err) + + # Ok + return Repository(to_str(path)) def clone_repository( - url, path, bare=False, remote_name="origin", - push_url=None, fetch_spec=None, - push_spec=None, checkout_branch=None): + url: str | bytes | os.PathLike[str] | os.PathLike[bytes], + path: str | bytes | os.PathLike[str] | os.PathLike[bytes], + bare: bool = False, + repository: typing.Callable | None = None, + remote: typing.Callable | None = None, + checkout_branch: str | bytes | None = None, + callbacks: RemoteCallbacks | None = None, + depth: int = 0, + proxy: None | bool | str = None, +) -> Repository: """ Clones a new Git repository from *url* in the given *path*. - **bare** indicates whether a bare git repository should be created. + Returns: a Repository class pointing to the newly cloned repository. - **remote_name** is the name given to the "origin" remote. - The default is "origin". + Parameters: - **push_url** is a URL to be used for pushing. - None means use the *url* parameter. + url : str or bytes or pathlike object + URL of the repository to clone. + path : str or bytes or pathlike object + Local path to clone into. + bare : bool + Whether the local repository should be bare. + remote : callable + Callback for the remote to use. - **fetch_spec** defines the the default fetch spec. - None results in the same behavior as *GIT_REMOTE_DEFAULT_FETCH*. + The remote callback has `(Repository, name, url) -> Remote` as a + signature. The Remote it returns will be used instead of the default + one. + repository : callable + Callback for the repository to use. - **push_spec** is the fetch specification to be used for pushing. - None means use the same spec as for *fetch_spec*. + The repository callback has `(path, bare) -> Repository` as a + signature. The Repository it returns will be used instead of creating a + new one. + checkout_branch : str or bytes + Branch to checkout after the clone. The default is to use the remote's + default branch. + callbacks : RemoteCallbacks + Object which implements the callbacks as methods. - **checkout_branch** gives the name of the branch to checkout. - None means use the remote's *HEAD*. + The callbacks should be an object which inherits from + `pyclass:RemoteCallbacks`. + depth : int + Number of commits to clone. - Returns a Repository class pointing to the newly cloned repository. + If greater than 0, creates a shallow clone with a history truncated to + the specified number of commits. + The default is 0 (full commit history). + proxy : None or True or str + Proxy configuration. Can be one of: - If you wish to use the repo, you need to do a checkout for one of - the available branches, like this: + * `None` (the default) to disable proxy usage + * `True` to enable automatic proxy detection + * an url to a proxy (`http://proxy.example.org:3128/`) + """ - >>> repo = repo.clone_repository("url", "path") - >>> repo.checkout(branch) # i.e.: refs/heads/master + if callbacks is None: + callbacks = RemoteCallbacks() - """ + # Add repository and remote to the payload + payload = callbacks + payload.repository = repository + payload.remote = remote + + with git_clone_options(payload): + opts = payload.clone_options + opts.bare = bare + opts.fetch_opts.depth = depth + + if checkout_branch: + checkout_branch_ref = ffi.new('char []', to_bytes(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) + payload.check_error(err) + + # Ok + return Repository._from_c(crepo[0], owned=True) + + +tree_entry_key = functools.cmp_to_key(tree_entry_cmp) + +settings = Settings() - _pygit2.clone_repository( - url, path, bare, remote_name, push_url, - fetch_spec, push_spec, checkout_branch) - return Repository(path) +__all__ = ( + # Standard Library + 'functools', + 'os', + 'typing', + # Standard Library symbols + 'TYPE_CHECKING', + 'annotations', + # Low level API + 'GIT_OID_HEX_ZERO', + 'GIT_OID_HEXSZ', + 'GIT_OID_MINPREFIXLEN', + 'GIT_OID_RAWSZ', + 'LIBGIT2_VER_MAJOR', + 'LIBGIT2_VER_MINOR', + 'LIBGIT2_VER_REVISION', + 'LIBGIT2_VERSION', + 'Object', + 'Reference', + 'AlreadyExistsError', + 'Blob', + 'Branch', + 'Commit', + 'Diff', + 'DiffDelta', + 'DiffFile', + 'DiffHunk', + 'DiffLine', + 'DiffStats', + 'GitError', + 'InvalidSpecError', + 'Mailmap', + 'Note', + 'Odb', + 'OdbBackend', + 'OdbBackendLoose', + 'OdbBackendPack', + 'Oid', + 'Patch', + 'RefLogEntry', + 'Refdb', + 'RefdbBackend', + 'RefdbFsBackend', + 'RevSpec', + 'Signature', + 'Stash', + 'Tag', + 'Tree', + 'TreeBuilder', + 'Walker', + 'Worktree', + 'discover_repository', + 'hash', + 'hashfile', + 'init_file_backend', + 'option', + 'reference_is_valid_name', + 'tree_entry_cmp', + # Low Level API (not present in .pyi) + 'FilterSource', + 'filter_register', + 'filter_unregister', + 'GIT_APPLY_LOCATION_BOTH', + 'GIT_APPLY_LOCATION_INDEX', + 'GIT_APPLY_LOCATION_WORKDIR', + 'GIT_BLAME_FIRST_PARENT', + 'GIT_BLAME_IGNORE_WHITESPACE', + 'GIT_BLAME_NORMAL', + 'GIT_BLAME_TRACK_COPIES_ANY_COMMIT_COPIES', + 'GIT_BLAME_TRACK_COPIES_SAME_COMMIT_COPIES', + 'GIT_BLAME_TRACK_COPIES_SAME_COMMIT_MOVES', + 'GIT_BLAME_TRACK_COPIES_SAME_FILE', + 'GIT_BLAME_USE_MAILMAP', + 'GIT_BLOB_FILTER_ATTRIBUTES_FROM_COMMIT', + 'GIT_BLOB_FILTER_ATTRIBUTES_FROM_HEAD', + 'GIT_BLOB_FILTER_CHECK_FOR_BINARY', + 'GIT_BLOB_FILTER_NO_SYSTEM_ATTRIBUTES', + 'GIT_BRANCH_ALL', + 'GIT_BRANCH_LOCAL', + 'GIT_BRANCH_REMOTE', + 'GIT_CHECKOUT_ALLOW_CONFLICTS', + 'GIT_CHECKOUT_CONFLICT_STYLE_DIFF3', + 'GIT_CHECKOUT_CONFLICT_STYLE_MERGE', + 'GIT_CHECKOUT_CONFLICT_STYLE_ZDIFF3', + 'GIT_CHECKOUT_DISABLE_PATHSPEC_MATCH', + 'GIT_CHECKOUT_DONT_OVERWRITE_IGNORED', + 'GIT_CHECKOUT_DONT_REMOVE_EXISTING', + 'GIT_CHECKOUT_DONT_UPDATE_INDEX', + 'GIT_CHECKOUT_DONT_WRITE_INDEX', + 'GIT_CHECKOUT_DRY_RUN', + 'GIT_CHECKOUT_FORCE', + 'GIT_CHECKOUT_NO_REFRESH', + 'GIT_CHECKOUT_NONE', + 'GIT_CHECKOUT_RECREATE_MISSING', + 'GIT_CHECKOUT_REMOVE_IGNORED', + 'GIT_CHECKOUT_REMOVE_UNTRACKED', + 'GIT_CHECKOUT_SAFE', + 'GIT_CHECKOUT_SKIP_LOCKED_DIRECTORIES', + 'GIT_CHECKOUT_SKIP_UNMERGED', + 'GIT_CHECKOUT_UPDATE_ONLY', + 'GIT_CHECKOUT_USE_OURS', + 'GIT_CHECKOUT_USE_THEIRS', + 'GIT_CONFIG_HIGHEST_LEVEL', + 'GIT_CONFIG_LEVEL_APP', + 'GIT_CONFIG_LEVEL_GLOBAL', + 'GIT_CONFIG_LEVEL_LOCAL', + 'GIT_CONFIG_LEVEL_PROGRAMDATA', + 'GIT_CONFIG_LEVEL_SYSTEM', + 'GIT_CONFIG_LEVEL_WORKTREE', + 'GIT_CONFIG_LEVEL_XDG', + 'GIT_DELTA_ADDED', + 'GIT_DELTA_CONFLICTED', + 'GIT_DELTA_COPIED', + 'GIT_DELTA_DELETED', + 'GIT_DELTA_IGNORED', + 'GIT_DELTA_MODIFIED', + 'GIT_DELTA_RENAMED', + 'GIT_DELTA_TYPECHANGE', + 'GIT_DELTA_UNMODIFIED', + 'GIT_DELTA_UNREADABLE', + 'GIT_DELTA_UNTRACKED', + 'GIT_DESCRIBE_ALL', + 'GIT_DESCRIBE_DEFAULT', + 'GIT_DESCRIBE_TAGS', + 'GIT_DIFF_BREAK_REWRITES_FOR_RENAMES_ONLY', + 'GIT_DIFF_BREAK_REWRITES', + 'GIT_DIFF_DISABLE_PATHSPEC_MATCH', + 'GIT_DIFF_ENABLE_FAST_UNTRACKED_DIRS', + 'GIT_DIFF_FIND_ALL', + 'GIT_DIFF_FIND_AND_BREAK_REWRITES', + 'GIT_DIFF_FIND_BY_CONFIG', + 'GIT_DIFF_FIND_COPIES_FROM_UNMODIFIED', + 'GIT_DIFF_FIND_COPIES', + 'GIT_DIFF_FIND_DONT_IGNORE_WHITESPACE', + 'GIT_DIFF_FIND_EXACT_MATCH_ONLY', + 'GIT_DIFF_FIND_FOR_UNTRACKED', + 'GIT_DIFF_FIND_IGNORE_LEADING_WHITESPACE', + 'GIT_DIFF_FIND_IGNORE_WHITESPACE', + 'GIT_DIFF_FIND_REMOVE_UNMODIFIED', + 'GIT_DIFF_FIND_RENAMES_FROM_REWRITES', + 'GIT_DIFF_FIND_RENAMES', + 'GIT_DIFF_FIND_REWRITES', + 'GIT_DIFF_FLAG_BINARY', + 'GIT_DIFF_FLAG_EXISTS', + 'GIT_DIFF_FLAG_NOT_BINARY', + 'GIT_DIFF_FLAG_VALID_ID', + 'GIT_DIFF_FLAG_VALID_SIZE', + 'GIT_DIFF_FORCE_BINARY', + 'GIT_DIFF_FORCE_TEXT', + 'GIT_DIFF_IGNORE_BLANK_LINES', + 'GIT_DIFF_IGNORE_CASE', + 'GIT_DIFF_IGNORE_FILEMODE', + 'GIT_DIFF_IGNORE_SUBMODULES', + 'GIT_DIFF_IGNORE_WHITESPACE_CHANGE', + 'GIT_DIFF_IGNORE_WHITESPACE_EOL', + 'GIT_DIFF_IGNORE_WHITESPACE', + 'GIT_DIFF_INCLUDE_CASECHANGE', + 'GIT_DIFF_INCLUDE_IGNORED', + 'GIT_DIFF_INCLUDE_TYPECHANGE_TREES', + 'GIT_DIFF_INCLUDE_TYPECHANGE', + 'GIT_DIFF_INCLUDE_UNMODIFIED', + 'GIT_DIFF_INCLUDE_UNREADABLE_AS_UNTRACKED', + 'GIT_DIFF_INCLUDE_UNREADABLE', + 'GIT_DIFF_INCLUDE_UNTRACKED', + 'GIT_DIFF_INDENT_HEURISTIC', + 'GIT_DIFF_MINIMAL', + 'GIT_DIFF_NORMAL', + 'GIT_DIFF_PATIENCE', + 'GIT_DIFF_RECURSE_IGNORED_DIRS', + 'GIT_DIFF_RECURSE_UNTRACKED_DIRS', + 'GIT_DIFF_REVERSE', + 'GIT_DIFF_SHOW_BINARY', + 'GIT_DIFF_SHOW_UNMODIFIED', + 'GIT_DIFF_SHOW_UNTRACKED_CONTENT', + 'GIT_DIFF_SKIP_BINARY_CHECK', + 'GIT_DIFF_STATS_FULL', + 'GIT_DIFF_STATS_INCLUDE_SUMMARY', + 'GIT_DIFF_STATS_NONE', + 'GIT_DIFF_STATS_NUMBER', + 'GIT_DIFF_STATS_SHORT', + 'GIT_DIFF_UPDATE_INDEX', + 'GIT_FILEMODE_BLOB_EXECUTABLE', + 'GIT_FILEMODE_BLOB', + 'GIT_FILEMODE_COMMIT', + 'GIT_FILEMODE_LINK', + 'GIT_FILEMODE_TREE', + 'GIT_FILEMODE_UNREADABLE', + 'GIT_FILTER_ALLOW_UNSAFE', + 'GIT_FILTER_ATTRIBUTES_FROM_COMMIT', + 'GIT_FILTER_ATTRIBUTES_FROM_HEAD', + 'GIT_FILTER_CLEAN', + 'GIT_FILTER_DEFAULT', + 'GIT_FILTER_DRIVER_PRIORITY', + 'GIT_FILTER_NO_SYSTEM_ATTRIBUTES', + 'GIT_FILTER_SMUDGE', + 'GIT_FILTER_TO_ODB', + 'GIT_FILTER_TO_WORKTREE', + 'GIT_MERGE_ANALYSIS_FASTFORWARD', + 'GIT_MERGE_ANALYSIS_NONE', + 'GIT_MERGE_ANALYSIS_NORMAL', + 'GIT_MERGE_ANALYSIS_UNBORN', + 'GIT_MERGE_ANALYSIS_UP_TO_DATE', + 'GIT_MERGE_PREFERENCE_FASTFORWARD_ONLY', + 'GIT_MERGE_PREFERENCE_NO_FASTFORWARD', + 'GIT_MERGE_PREFERENCE_NONE', + 'GIT_OBJECT_ANY', + 'GIT_OBJECT_BLOB', + 'GIT_OBJECT_COMMIT', + 'GIT_OBJECT_INVALID', + 'GIT_OBJECT_OFS_DELTA', + 'GIT_OBJECT_REF_DELTA', + 'GIT_OBJECT_TAG', + 'GIT_OBJECT_TREE', + 'GIT_OPT_ADD_SSL_X509_CERT', + 'GIT_OPT_DISABLE_PACK_KEEP_FILE_CHECKS', + 'GIT_OPT_ENABLE_CACHING', + 'GIT_OPT_ENABLE_FSYNC_GITDIR', + 'GIT_OPT_ENABLE_HTTP_EXPECT_CONTINUE', + 'GIT_OPT_ENABLE_OFS_DELTA', + 'GIT_OPT_ENABLE_STRICT_HASH_VERIFICATION', + 'GIT_OPT_ENABLE_STRICT_OBJECT_CREATION', + 'GIT_OPT_ENABLE_STRICT_SYMBOLIC_REF_CREATION', + 'GIT_OPT_ENABLE_UNSAVED_INDEX_SAFETY', + 'GIT_OPT_GET_CACHED_MEMORY', + 'GIT_OPT_GET_EXTENSIONS', + 'GIT_OPT_GET_HOMEDIR', + 'GIT_OPT_GET_MWINDOW_FILE_LIMIT', + 'GIT_OPT_GET_MWINDOW_MAPPED_LIMIT', + 'GIT_OPT_GET_MWINDOW_SIZE', + 'GIT_OPT_GET_OWNER_VALIDATION', + 'GIT_OPT_GET_PACK_MAX_OBJECTS', + 'GIT_OPT_GET_SEARCH_PATH', + 'GIT_OPT_GET_SERVER_CONNECT_TIMEOUT', + 'GIT_OPT_GET_SERVER_TIMEOUT', + 'GIT_OPT_GET_TEMPLATE_PATH', + 'GIT_OPT_GET_USER_AGENT', + 'GIT_OPT_GET_USER_AGENT_PRODUCT', + 'GIT_OPT_GET_WINDOWS_SHAREMODE', + 'GIT_OPT_SET_ALLOCATOR', + 'GIT_OPT_SET_CACHE_MAX_SIZE', + 'GIT_OPT_SET_CACHE_OBJECT_LIMIT', + 'GIT_OPT_SET_EXTENSIONS', + 'GIT_OPT_SET_HOMEDIR', + 'GIT_OPT_SET_MWINDOW_FILE_LIMIT', + 'GIT_OPT_SET_MWINDOW_MAPPED_LIMIT', + 'GIT_OPT_SET_MWINDOW_SIZE', + 'GIT_OPT_SET_ODB_LOOSE_PRIORITY', + 'GIT_OPT_SET_ODB_PACKED_PRIORITY', + 'GIT_OPT_SET_OWNER_VALIDATION', + 'GIT_OPT_SET_PACK_MAX_OBJECTS', + 'GIT_OPT_SET_SEARCH_PATH', + 'GIT_OPT_SET_SERVER_CONNECT_TIMEOUT', + 'GIT_OPT_SET_SERVER_TIMEOUT', + 'GIT_OPT_SET_SSL_CERT_LOCATIONS', + 'GIT_OPT_SET_SSL_CIPHERS', + 'GIT_OPT_SET_TEMPLATE_PATH', + 'GIT_OPT_SET_USER_AGENT', + 'GIT_OPT_SET_USER_AGENT_PRODUCT', + 'GIT_OPT_SET_WINDOWS_SHAREMODE', + 'GIT_REFERENCES_ALL', + 'GIT_REFERENCES_BRANCHES', + 'GIT_REFERENCES_TAGS', + 'GIT_RESET_HARD', + 'GIT_RESET_MIXED', + 'GIT_RESET_SOFT', + 'GIT_REVSPEC_MERGE_BASE', + 'GIT_REVSPEC_RANGE', + 'GIT_REVSPEC_SINGLE', + 'GIT_SORT_NONE', + 'GIT_SORT_REVERSE', + 'GIT_SORT_TIME', + 'GIT_SORT_TOPOLOGICAL', + 'GIT_STASH_APPLY_DEFAULT', + 'GIT_STASH_APPLY_REINSTATE_INDEX', + 'GIT_STASH_DEFAULT', + 'GIT_STASH_INCLUDE_IGNORED', + 'GIT_STASH_INCLUDE_UNTRACKED', + 'GIT_STASH_KEEP_ALL', + 'GIT_STASH_KEEP_INDEX', + 'GIT_STATUS_CONFLICTED', + 'GIT_STATUS_CURRENT', + 'GIT_STATUS_IGNORED', + 'GIT_STATUS_INDEX_DELETED', + 'GIT_STATUS_INDEX_MODIFIED', + 'GIT_STATUS_INDEX_NEW', + 'GIT_STATUS_INDEX_RENAMED', + 'GIT_STATUS_INDEX_TYPECHANGE', + 'GIT_STATUS_WT_DELETED', + 'GIT_STATUS_WT_MODIFIED', + 'GIT_STATUS_WT_NEW', + 'GIT_STATUS_WT_RENAMED', + 'GIT_STATUS_WT_TYPECHANGE', + 'GIT_STATUS_WT_UNREADABLE', + 'GIT_SUBMODULE_IGNORE_ALL', + 'GIT_SUBMODULE_IGNORE_DIRTY', + 'GIT_SUBMODULE_IGNORE_NONE', + 'GIT_SUBMODULE_IGNORE_UNSPECIFIED', + 'GIT_SUBMODULE_IGNORE_UNTRACKED', + 'GIT_SUBMODULE_STATUS_IN_CONFIG', + 'GIT_SUBMODULE_STATUS_IN_HEAD', + 'GIT_SUBMODULE_STATUS_IN_INDEX', + 'GIT_SUBMODULE_STATUS_IN_WD', + 'GIT_SUBMODULE_STATUS_INDEX_ADDED', + 'GIT_SUBMODULE_STATUS_INDEX_DELETED', + 'GIT_SUBMODULE_STATUS_INDEX_MODIFIED', + 'GIT_SUBMODULE_STATUS_WD_ADDED', + 'GIT_SUBMODULE_STATUS_WD_DELETED', + 'GIT_SUBMODULE_STATUS_WD_INDEX_MODIFIED', + 'GIT_SUBMODULE_STATUS_WD_MODIFIED', + 'GIT_SUBMODULE_STATUS_WD_UNINITIALIZED', + 'GIT_SUBMODULE_STATUS_WD_UNTRACKED', + 'GIT_SUBMODULE_STATUS_WD_WD_MODIFIED', + # High level API. + 'enums', + 'blame', + 'Blame', + 'BlameHunk', + 'blob', + 'BlobIO', + 'callbacks', + 'Payload', + 'RemoteCallbacks', + 'CheckoutCallbacks', + 'StashApplyCallbacks', + 'git_clone_options', + 'git_fetch_options', + 'git_proxy_options', + 'get_credentials', + 'config', + 'Config', + 'credentials', + 'CredentialType', + 'Username', + 'UserPass', + 'Keypair', + 'KeypairFromAgent', + 'KeypairFromMemory', + 'errors', + 'check_error', + 'Passthrough', + 'ffi', + 'C', + 'filter', + 'Filter', + 'index', + 'Index', + 'IndexEntry', + 'legacyenums', + 'GIT_FEATURE_THREADS', + 'GIT_FEATURE_HTTPS', + 'GIT_FEATURE_SSH', + 'GIT_FEATURE_NSEC', + 'GIT_REPOSITORY_INIT_BARE', + 'GIT_REPOSITORY_INIT_NO_REINIT', + 'GIT_REPOSITORY_INIT_NO_DOTGIT_DIR', + 'GIT_REPOSITORY_INIT_MKDIR', + 'GIT_REPOSITORY_INIT_MKPATH', + 'GIT_REPOSITORY_INIT_EXTERNAL_TEMPLATE', + 'GIT_REPOSITORY_INIT_RELATIVE_GITLINK', + 'GIT_REPOSITORY_INIT_SHARED_UMASK', + 'GIT_REPOSITORY_INIT_SHARED_GROUP', + 'GIT_REPOSITORY_INIT_SHARED_ALL', + 'GIT_REPOSITORY_OPEN_NO_SEARCH', + 'GIT_REPOSITORY_OPEN_CROSS_FS', + 'GIT_REPOSITORY_OPEN_BARE', + 'GIT_REPOSITORY_OPEN_NO_DOTGIT', + 'GIT_REPOSITORY_OPEN_FROM_ENV', + 'GIT_REPOSITORY_STATE_NONE', + 'GIT_REPOSITORY_STATE_MERGE', + 'GIT_REPOSITORY_STATE_REVERT', + 'GIT_REPOSITORY_STATE_REVERT_SEQUENCE', + 'GIT_REPOSITORY_STATE_CHERRYPICK', + 'GIT_REPOSITORY_STATE_CHERRYPICK_SEQUENCE', + 'GIT_REPOSITORY_STATE_BISECT', + 'GIT_REPOSITORY_STATE_REBASE', + 'GIT_REPOSITORY_STATE_REBASE_INTERACTIVE', + 'GIT_REPOSITORY_STATE_REBASE_MERGE', + 'GIT_REPOSITORY_STATE_APPLY_MAILBOX', + 'GIT_REPOSITORY_STATE_APPLY_MAILBOX_OR_REBASE', + 'GIT_ATTR_CHECK_FILE_THEN_INDEX', + 'GIT_ATTR_CHECK_INDEX_THEN_FILE', + 'GIT_ATTR_CHECK_INDEX_ONLY', + 'GIT_ATTR_CHECK_NO_SYSTEM', + 'GIT_ATTR_CHECK_INCLUDE_HEAD', + 'GIT_ATTR_CHECK_INCLUDE_COMMIT', + 'GIT_FETCH_PRUNE_UNSPECIFIED', + 'GIT_FETCH_PRUNE', + 'GIT_FETCH_NO_PRUNE', + 'GIT_CHECKOUT_NOTIFY_NONE', + 'GIT_CHECKOUT_NOTIFY_CONFLICT', + 'GIT_CHECKOUT_NOTIFY_DIRTY', + 'GIT_CHECKOUT_NOTIFY_UPDATED', + 'GIT_CHECKOUT_NOTIFY_UNTRACKED', + 'GIT_CHECKOUT_NOTIFY_IGNORED', + 'GIT_CHECKOUT_NOTIFY_ALL', + 'GIT_STASH_APPLY_PROGRESS_NONE', + 'GIT_STASH_APPLY_PROGRESS_LOADING_STASH', + 'GIT_STASH_APPLY_PROGRESS_ANALYZE_INDEX', + 'GIT_STASH_APPLY_PROGRESS_ANALYZE_MODIFIED', + 'GIT_STASH_APPLY_PROGRESS_ANALYZE_UNTRACKED', + 'GIT_STASH_APPLY_PROGRESS_CHECKOUT_UNTRACKED', + 'GIT_STASH_APPLY_PROGRESS_CHECKOUT_MODIFIED', + 'GIT_STASH_APPLY_PROGRESS_DONE', + 'GIT_CREDENTIAL_USERPASS_PLAINTEXT', + 'GIT_CREDENTIAL_SSH_KEY', + 'GIT_CREDENTIAL_SSH_CUSTOM', + 'GIT_CREDENTIAL_DEFAULT', + 'GIT_CREDENTIAL_SSH_INTERACTIVE', + 'GIT_CREDENTIAL_USERNAME', + 'GIT_CREDENTIAL_SSH_MEMORY', + 'packbuilder', + 'PackBuilder', + 'refspec', + 'remotes', + 'Remote', + 'repository', + 'Repository', + 'branches', + 'references', + 'settings', + 'Settings', + 'submodules', + 'Submodule', + 'transaction', + 'ReferenceTransaction', + 'utils', + 'to_bytes', + 'to_str', + # __init__ module defined symbols + 'features', + 'LIBGIT2_VER', + 'init_repository', + 'clone_repository', + 'tree_entry_key', +) diff --git a/pygit2/_build.py b/pygit2/_build.py new file mode 100644 index 000000000..2baa021b5 --- /dev/null +++ b/pygit2/_build.py @@ -0,0 +1,74 @@ +# 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. + +""" +This is an special module, it provides stuff used by setup.py at build time. +But also used by pygit2 at run time. +""" + +import os +from pathlib import Path + +# +# The version number of pygit2 +# +__version__ = '1.18.2' + + +# +# Utility functions to get the paths required for building extensions +# +def _get_libgit2_path() -> Path: + # LIBGIT2 environment variable takes precedence + libgit2_path = os.getenv('LIBGIT2') + if libgit2_path is not None: + return Path(libgit2_path) + + # Default + if os.name == 'nt': + return Path(r'%s\libgit2' % os.getenv('ProgramFiles')) + return Path('/usr/local') + + +def get_libgit2_paths() -> tuple[Path, dict[str, list[str]]]: + # Base path + path = _get_libgit2_path() + + # Library dirs + libgit2_lib = os.getenv('LIBGIT2_LIB') + if libgit2_lib is None: + library_dirs = [path / 'lib', path / 'lib64'] + else: + library_dirs = [Path(libgit2_lib)] + + include_dirs = [path / 'include'] + return ( + path / 'bin', + { + 'libraries': ['git2'], + 'include_dirs': [str(x) for x in include_dirs], + 'library_dirs': [str(x) for x in library_dirs], + }, + ) diff --git a/pygit2/_libgit2/ffi.pyi b/pygit2/_libgit2/ffi.pyi new file mode 100644 index 000000000..e73a7ad7c --- /dev/null +++ b/pygit2/_libgit2/ffi.pyi @@ -0,0 +1,373 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +from typing import Any, Generic, Literal, NewType, SupportsIndex, TypeVar, overload + +T = TypeVar('T') + +NULL_TYPE = NewType('NULL_TYPE', object) +NULL: NULL_TYPE = ... + +char = NewType('char', object) +char_pointer = NewType('char_pointer', object) + +class size_t: + def __getitem__(self, item: Literal[0]) -> int: ... + +class int_c: + def __getitem__(self, item: Literal[0]) -> int: ... + +class int64_t: + def __getitem__(self, item: Literal[0]) -> int: ... + +class ssize_t: + def __getitem__(self, item: Literal[0]) -> int: ... + +class _Pointer(Generic[T]): + def __setitem__(self, item: Literal[0], a: T) -> None: ... + @overload + def __getitem__(self, item: Literal[0]) -> T: ... + @overload + def __getitem__(self, item: slice[None, None, None]) -> bytes: ... + +class _MultiPointer(Generic[T]): + def __getitem__(self, item: int) -> T: ... + +class ArrayC(Generic[T]): + # incomplete! + # def _len(self, ?) -> ?: ... + def __getitem__(self, index: int) -> T: ... + def __setitem__(self, index: int, value: T) -> None: ... + +class GitTimeC: + # incomplete + time: int + offset: int + +class GitSignatureC: + name: char_pointer + email: char_pointer + when: GitTimeC + +class GitHunkC: + # incomplete + boundary: char + final_start_line_number: int + final_signature: GitSignatureC + orig_signature: GitSignatureC + orig_start_line_number: int + orig_path: char_pointer + lines_in_hunk: int + +class GitRepositoryC: + # incomplete + # TODO: this has to be unified with pygit2._pygit2(pyi).Repository + # def _from_c(cls, ptr: 'GitRepositoryC', owned: bool) -> 'Repository': ... + pass + +class GitFetchOptionsC: + # TODO: FetchOptions exist in _pygit2.pyi + # incomplete + depth: int + +class GitSubmoduleC: + pass + +class GitSubmoduleUpdateOptionsC: + fetch_opts: GitFetchOptionsC + +class GitRemoteHeadC: + local: int + oid: GitOidC + loid: GitOidC + name: char_pointer + symref_target: char_pointer + +class UnsignedIntC: + def __getitem__(self, item: Literal[0]) -> int: ... + +class GitOidC: + id: _Pointer[bytes] + +class GitBlameOptionsC: + flags: int + min_match_characters: int + newest_commit: object + oldest_commit: object + min_line: int + max_line: int + +class GitBlameC: + # incomplete + pass + +class GitMergeOptionsC: + file_favor: int + flags: int + file_flags: int + +class GitAnnotatedCommitC: + pass + +class GitAttrOptionsC: + # incomplete + version: int + flags: int + +class GitBufC: + ptr: char_pointer + +class GitCheckoutOptionsC: + # incomplete + checkout_strategy: int + +class GitCommitC: + pass + +class GitConfigC: + # incomplete + pass + +class GitConfigIteratorC: + # incomplete + pass + +class GitConfigEntryC: + # incomplete + name: char_pointer + value: char_pointer + level: int + +class GitDescribeFormatOptionsC: + version: int + abbreviated_size: int + always_use_long_format: int + dirty_suffix: ArrayC[char] + +class GitDescribeOptionsC: + version: int + max_candidates_tags: int + describe_strategy: int + pattern: ArrayC[char] + only_follow_first_parent: int + show_commit_oid_as_fallback: int + +class GitDescribeResultC: + pass + +class GitIndexC: + pass + +class GitIndexEntryC: + # incomplete? + mode: int + path: ArrayC[char] + +class GitMergeFileResultC: + pass + +class GitObjectC: + pass + +class GitStashSaveOptionsC: + version: int + flags: int + stasher: GitSignatureC + message: ArrayC[char] + paths: GitStrrayC + +class GitStrrayC: + # incomplete? + strings: NULL_TYPE | ArrayC[char_pointer] + count: int + +class GitTreeC: + pass + +class GitRepositoryInitOptionsC: + version: int + flags: int + mode: int + workdir_path: ArrayC[char] + description: ArrayC[char] + template_path: ArrayC[char] + initial_head: ArrayC[char] + origin_url: ArrayC[char] + +class GitCloneOptionsC: + pass + +class GitPackbuilderC: + pass + +class GitProxyTC: + pass + +class GitProxyOptionsC: + version: int + type: GitProxyTC + url: char_pointer + # credentials + # certificate_check + # payload + +class GitRemoteC: + pass + +class GitReferenceC: + pass + +class GitTransactionC: + pass + +def string(a: char_pointer) -> bytes: ... +@overload +def new(a: Literal['git_repository **']) -> _Pointer[GitRepositoryC]: ... +@overload +def new(a: Literal['git_remote **']) -> _Pointer[GitRemoteC]: ... +@overload +def new(a: Literal['git_transaction **']) -> _Pointer[GitTransactionC]: ... +@overload +def new(a: Literal['git_repository_init_options *']) -> GitRepositoryInitOptionsC: ... +@overload +def new(a: Literal['git_submodule_update_options *']) -> GitSubmoduleUpdateOptionsC: ... +@overload +def new(a: Literal['git_submodule **']) -> _Pointer[GitSubmoduleC]: ... +@overload +def new(a: Literal['unsigned int *']) -> UnsignedIntC: ... +@overload +def new(a: Literal['git_proxy_options *']) -> GitProxyOptionsC: ... +@overload +def new(a: Literal['git_oid *']) -> GitOidC: ... +@overload +def new(a: Literal['git_blame **']) -> _Pointer[GitBlameC]: ... +@overload +def new(a: Literal['git_clone_options *']) -> GitCloneOptionsC: ... +@overload +def new(a: Literal['git_merge_options *']) -> GitMergeOptionsC: ... +@overload +def new(a: Literal['git_blame_options *']) -> GitBlameOptionsC: ... +@overload +def new(a: Literal['git_annotated_commit **']) -> _Pointer[GitAnnotatedCommitC]: ... +@overload +def new(a: Literal['git_attr_options *']) -> GitAttrOptionsC: ... +@overload +def new(a: Literal['git_buf *']) -> GitBufC: ... +@overload +def new(a: Literal['char *'], b: bytes) -> char_pointer: ... +@overload +def new(a: Literal['char *[]'], b: list[char_pointer]) -> ArrayC[char_pointer]: ... +@overload +def new(a: Literal['git_checkout_options *']) -> GitCheckoutOptionsC: ... +@overload +def new(a: Literal['git_commit **']) -> _Pointer[GitCommitC]: ... +@overload +def new(a: Literal['git_config *']) -> GitConfigC: ... +@overload +def new(a: Literal['git_config **']) -> _Pointer[GitConfigC]: ... +@overload +def new(a: Literal['git_config_iterator **']) -> _Pointer[GitConfigIteratorC]: ... +@overload +def new(a: Literal['git_config_entry **']) -> _Pointer[GitConfigEntryC]: ... +@overload +def new(a: Literal['git_describe_format_options *']) -> GitDescribeFormatOptionsC: ... +@overload +def new(a: Literal['git_describe_options *']) -> GitDescribeOptionsC: ... +@overload +def new(a: Literal['git_describe_result *']) -> GitDescribeResultC: ... +@overload +def new(a: Literal['git_describe_result **']) -> _Pointer[GitDescribeResultC]: ... +@overload +def new(a: Literal['struct git_reference **']) -> _Pointer[GitReferenceC]: ... +@overload +def new(a: Literal['git_index **']) -> _Pointer[GitIndexC]: ... +@overload +def new(a: Literal['git_index_entry *']) -> GitIndexEntryC: ... +@overload +def new(a: Literal['git_merge_file_result *']) -> GitMergeFileResultC: ... +@overload +def new(a: Literal['git_object *']) -> GitObjectC: ... +@overload +def new(a: Literal['git_object **']) -> _Pointer[GitObjectC]: ... +@overload +def new(a: Literal['git_packbuilder **']) -> _Pointer[GitPackbuilderC]: ... +@overload +def new(a: Literal['git_signature *']) -> GitSignatureC: ... +@overload +def new(a: Literal['git_signature **']) -> _Pointer[GitSignatureC]: ... +@overload +def new(a: Literal['int *']) -> int_c: ... +@overload +def new(a: Literal['int64_t *']) -> int64_t: ... +@overload +def new( + a: Literal['git_remote_head ***'], +) -> _Pointer[_MultiPointer[GitRemoteHeadC]]: ... +@overload +def new(a: Literal['size_t *', 'size_t*']) -> size_t: ... +@overload +def new(a: Literal['ssize_t *', 'ssize_t*']) -> ssize_t: ... +@overload +def new(a: Literal['git_stash_save_options *']) -> GitStashSaveOptionsC: ... +@overload +def new(a: Literal['git_strarray *']) -> GitStrrayC: ... +@overload +def new(a: Literal['git_tree **']) -> _Pointer[GitTreeC]: ... +@overload +def new(a: Literal['git_buf *'], b: tuple[NULL_TYPE, Literal[0]]) -> GitBufC: ... +@overload +def new(a: Literal['char **']) -> _Pointer[char_pointer]: ... +@overload +def new(a: Literal['void **'], b: bytes) -> _Pointer[bytes]: ... +@overload +def new(a: Literal['char[]', 'char []'], b: bytes | NULL_TYPE) -> ArrayC[char]: ... +@overload +def new( + a: Literal['char *[]'], b: int +) -> ArrayC[char_pointer]: ... # For ext_array in SET_EXTENSIONS +@overload +def new( + a: Literal['char *[]'], b: list[Any] +) -> ArrayC[char_pointer]: ... # For string arrays +def addressof(a: object, attribute: str) -> _Pointer[object]: ... + +class buffer(bytes): + def __init__(self, a: object) -> None: ... + def __setitem__(self, item: slice[None, None, None], value: bytes) -> None: ... + @overload + def __getitem__(self, item: SupportsIndex) -> int: ... + @overload + def __getitem__(self, item: slice[Any, Any, Any]) -> bytes: ... + +@overload +def cast(a: Literal['int'], b: object) -> int: ... +@overload +def cast(a: Literal['unsigned int'], b: object) -> int: ... +@overload +def cast(a: Literal['size_t'], b: object) -> int: ... +@overload +def cast(a: Literal['ssize_t'], b: object) -> int: ... +@overload +def cast(a: Literal['char *'], b: object) -> char_pointer: ... diff --git a/pygit2/_pygit2.pyi b/pygit2/_pygit2.pyi new file mode 100644 index 000000000..944f18ce2 --- /dev/null +++ b/pygit2/_pygit2.pyi @@ -0,0 +1,862 @@ +from collections.abc import Iterator, Sequence +from io import DEFAULT_BUFFER_SIZE, IOBase +from pathlib import Path +from queue import Queue +from threading import Event +from typing import ( # noqa: UP035 + Generic, + Literal, + Optional, + Type, + TypedDict, + TypeVar, + overload, +) + +from . import Index +from ._libgit2.ffi import ( + GitCommitC, + GitObjectC, + GitProxyOptionsC, + GitSignatureC, + _Pointer, +) +from .enums import ( + ApplyLocation, + BlobFilter, + BranchType, + DeltaStatus, + DiffFind, + DiffFlag, + DiffOption, + DiffStatsFormat, + FileMode, + MergeAnalysis, + MergePreference, + ObjectType, + ReferenceFilter, + ReferenceType, + ResetMode, + SortMode, +) +from .filter import Filter + +GIT_OBJ_BLOB = Literal[3] +GIT_OBJ_COMMIT = Literal[1] +GIT_OBJ_TAG = Literal[4] +GIT_OBJ_TREE = Literal[2] + +LIBGIT2_VER_MAJOR: int +LIBGIT2_VER_MINOR: int +LIBGIT2_VER_REVISION: int +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_str: "Literal['commit'] | Literal['tree'] | Literal['tag'] | Literal['blob']" + author: Signature + committer: Signature + tree: Tree + @overload + def peel( + self, target_type: 'Literal[GIT_OBJ_COMMIT, ObjectType.COMMIT] | Type[Commit]' + ) -> 'Commit': ... + @overload + def peel( + self, target_type: 'Literal[GIT_OBJ_TREE, ObjectType.TREE] | Type[Tree]' + ) -> 'Tree': ... + @overload + def peel( + self, target_type: 'Literal[GIT_OBJ_TAG, ObjectType.TAG] | Type[Tag]' + ) -> 'Tag': ... + @overload + def peel( + self, target_type: 'Literal[GIT_OBJ_BLOB, ObjectType.BLOB] | Type[Blob]' + ) -> 'Blob': ... + @overload + def peel(self, target_type: 'None') -> 'Commit|Tree|Tag|Blob': ... + def read_raw(self) -> bytes: ... + 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: ... + +class Object(_ObjectBase[GitObjectC]): + pass + +class Reference: + name: str + raw_name: bytes + raw_shorthand: bytes + raw_target: Oid | bytes + shorthand: str + target: Oid | str + type: ReferenceType + def __init__(self, *args) -> None: ... + def delete(self) -> None: ... + def log(self) -> Iterator[RefLogEntry]: ... + @overload + def peel(self, type: 'Literal[GIT_OBJ_COMMIT] | Type[Commit]') -> 'Commit': ... + @overload + def peel(self, type: 'Literal[GIT_OBJ_TREE] | Type[Tree]') -> 'Tree': ... + @overload + def peel(self, type: 'Literal[GIT_OBJ_TAG] | Type[Tag]') -> 'Tag': ... + @overload + def peel(self, type: 'Literal[GIT_OBJ_BLOB] | Type[Blob]') -> 'Blob': ... + @overload + def peel(self, type: 'None' = None) -> 'Commit|Tree|Tag|Blob': ... + def rename(self, new_name: str) -> None: ... + def resolve(self) -> Reference: ... + def set_target(self, target: _OidArg, message: str = ...) -> None: ... + 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): ... + +class Blob(Object): + data: bytes + is_binary: bool + size: int + def diff( + self, + blob: Blob = ..., + flag: int = ..., + old_as_path: str = ..., + new_as_path: str = ..., + ) -> Patch: ... + def diff_to_buffer( + self, + buffer: Optional[bytes | str] = None, + flag: DiffOption = DiffOption.NORMAL, + old_as_path: str = ..., + buffer_as_path: str = ..., + ) -> Patch: ... + def _write_to_queue( + self, + queue: Queue[bytes], + ready: Event, + done: Event, + chunk_size: int = DEFAULT_BUFFER_SIZE, + as_path: Optional[str] = None, + flags: BlobFilter = BlobFilter.CHECK_FOR_BINARY, + commit_id: Optional[Oid] = None, + ) -> None: ... + def __buffer__(self, flags: int) -> memoryview: ... + def __release_buffer__(self, buffer: memoryview) -> None: ... + +class Branch(Reference): + branch_name: str + raw_branch_name: bytes + remote_name: str + upstream: Branch + upstream_name: str + def delete(self) -> None: ... + def is_checked_out(self) -> bool: ... + def is_head(self) -> bool: ... + def rename(self, name: str, force: bool = False) -> 'Branch': ... # type: ignore[override] + +class FetchOptions: + # incomplete + depth: int + proxy_opts: GitProxyOptionsC + +class CloneOptions: + # incomplete + version: int + checkout_opts: object + fetch_opts: FetchOptions + bare: int + local: object + checkout_branch: object + repository_cb: object + repository_cb_payload: object + remote_cb: object + remote_cb_payload: object + +class Commit(_ObjectBase[GitCommitC]): + _pointer: _Pointer[GitCommitC] + author: Signature + commit_time: int + commit_time_offset: int + committer: Signature + gpg_signature: tuple[bytes, bytes] + message: str + message_encoding: str + message_trailers: dict[str, str] + parent_ids: list[Oid] + parents: list[Commit] + raw_message: bytes + tree: Tree + tree_id: Oid + +class Diff: + deltas: Iterator[DiffDelta] + patch: str | None + patchid: Oid + stats: DiffStats + text: str + def find_similar( + self, + flags: DiffFind = DiffFind.FIND_BY_CONFIG, + rename_threshold: int = 50, + copy_threshold: int = 50, + rename_from_rewrite_threshold: int = 50, + break_rewrite_threshold: int = 60, + rename_limit: int = 1000, + ) -> None: ... + def merge(self, diff: Diff) -> None: ... + @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 __len__(self) -> int: ... + +class DiffDelta: + flags: DiffFlag + is_binary: bool + nfiles: int + new_file: DiffFile + old_file: DiffFile + similarity: int + status: DeltaStatus + def status_char(self) -> str: ... + +class DiffFile: + flags: DiffFlag + id: Oid + mode: FileMode + path: str + raw_path: bytes + size: int + @staticmethod + def from_c(bytes) -> DiffFile: ... + +class DiffHunk: + header: str + lines: list[DiffLine] + new_lines: int + new_start: int + old_lines: int + old_start: int + +class DiffLine: + content: str + content_offset: int + new_lineno: int + num_lines: int + old_lineno: int + origin: str + raw_content: bytes + +class DiffStats: + deletions: int + files_changed: int + insertions: int + def format(self, format: DiffStatsFormat, width: int) -> str: ... + +class FilterSource: + # probably incomplete + repo: object + pass + +class GitError(Exception): ... +class InvalidSpecError(ValueError): ... + +class Mailmap: + def __init__(self, *args) -> None: ... + def add_entry( + self, + real_name: str | None = ..., + real_email: str | None = ..., + replace_name: str | None = ..., + replace_email: str = ..., + ) -> None: ... + @staticmethod + def from_buffer(buffer: str | bytes) -> Mailmap: ... + @staticmethod + def from_repository(repository: Repository) -> Mailmap: ... + def resolve(self, name: str, email: str) -> tuple[str, str]: ... + def resolve_signature(self, sig: Signature) -> Signature: ... + +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 Odb: + backends: Iterator[OdbBackend] + def __init__(self, *args, **kwargs) -> None: ... + def add_backend(self, backend: OdbBackend, priority: int) -> None: ... + def add_disk_alternate(self, path: str | Path) -> None: ... + def exists(self, oid: _OidArg) -> bool: ... + def read(self, oid: _OidArg) -> tuple[int, bytes]: ... + def write(self, type: int, data: bytes | str) -> Oid: ... + def __contains__(self, other: _OidArg) -> bool: ... + def __iter__(self) -> Iterator[Oid]: ... # Odb_as_iter + +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 refresh(self) -> None: ... + def __iter__(self) -> Iterator[Oid]: ... # OdbBackend_as_iter + +class OdbBackendLoose(OdbBackend): + def __init__(self, *args, **kwargs) -> None: ... + +class OdbBackendPack(OdbBackend): + def __init__(self, *args, **kwargs) -> None: ... + +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 __hash__(self) -> int: ... + def __le__(self, other) -> bool: ... + def __lt__(self, other) -> bool: ... + def __ne__(self, other) -> bool: ... + def __bool__(self) -> bool: ... + +class Patch: + data: bytes + delta: DiffDelta + hunks: list[DiffHunk] + line_stats: tuple[int, int, int] # context, additions, deletions + text: str | None + + @staticmethod + def create_from( + old: Blob | bytes | None, + new: Blob | bytes | None, + old_as_path: str = ..., + new_as_path: str = ..., + flag: DiffOption = DiffOption.NORMAL, + context_lines: int = 3, + interhunk_lines: int = 0, + ) -> Patch: ... + +class RefLogEntry: + committer: Signature + message: str + oid_new: Oid + oid_old: Oid + def __init__(self, *args, **kwargs) -> None: ... + +class Refdb: + def __init__(self, *args, **kwargs) -> None: ... + def compress(self) -> None: ... + @staticmethod + def new(repo: Repository) -> Refdb: ... + @staticmethod + def open(repo: Repository) -> Refdb: ... + def set_backend(self, backend: RefdbBackend) -> None: ... + +class RefdbBackend: + def __init__(self, *args, **kwargs) -> None: ... + def compress(self) -> None: ... + def delete( + self, ref_name: str, old_id: _OidArg, old_target: str | None + ) -> None: ... + def ensure_log(self, ref_name: str) -> bool: ... + def exists(self, refname: str) -> bool: ... + def has_log(self, ref_name: str) -> bool: ... + def lookup(self, refname: str) -> Reference: ... + def rename( + self, old_name: str, new_name: str, force: bool, who: Signature, message: str + ) -> Reference: ... + def write( + self, + ref: Reference, + force: bool, + who: Signature, + message: str, + old: None | _OidArg, + old_target: None | str, + ) -> None: ... + def __iter__(self) -> Iterator[Reference]: ... + +class RefdbFsBackend(RefdbBackend): + def __init__(self, *args, **kwargs) -> None: ... + +_Proxy = None | Literal[True] | str + +class _StrArray: + # incomplete + count: int + +class PushOptions: + version: int + pb_parallelism: int + callbacks: object # TODO + proxy_opts: GitProxyOptionsC + follow_redirects: object # TODO + custom_headers: _StrArray + remote_push_options: _StrArray + +class _LsRemotesDict(TypedDict): + local: bool + loid: Oid | None + name: str | None + symref_target: str | None + oid: Oid + +class Repository: + def TreeBuilder(self, src: Tree | _OidArg = ...) -> TreeBuilder: ... + def _disown(self, *args, **kwargs) -> None: ... + def add_worktree( + self, name: str, path: str | Path, ref: Reference = ... + ) -> Worktree: ... + def applies( + self, + diff: Diff, + location: ApplyLocation = ApplyLocation.INDEX, + raise_error: bool = False, + ) -> bool: ... + def apply( + self, diff: Diff, location: ApplyLocation = ApplyLocation.WORKDIR + ) -> None: ... + def cherrypick(self, id: _OidArg) -> None: ... + def compress_references(self) -> None: ... + def create_blob(self, data: str | bytes) -> Oid: ... + def create_blob_fromdisk(self, path: str) -> Oid: ... + def create_blob_fromiobase(self, iobase: IOBase) -> Oid: ... + def create_blob_fromworkdir(self, path: str | Path) -> Oid: ... + def create_branch(self, name: str, commit: Commit, force=False) -> Branch: ... + def create_commit( + self, + reference_name: Optional[str], + author: Signature, + committer: Signature, + message: str | bytes, + tree: _OidArg, + parents: Sequence[_OidArg], + encoding: str = ..., + ) -> Oid: ... + def create_commit_string( + self, + author: Signature, + committer: Signature, + message: str | bytes, + tree: _OidArg, + parents: list[_OidArg], + encoding: str = ..., + ) -> Oid: ... + def create_commit_with_signature( + self, content: str, signature: str, signature_field: Optional[str] = None + ) -> Oid: ... + def create_note( + self, + message: str, + author: Signature, + committer: Signature, + annotated_id: str, + ref: str = 'refs/notes/commits', + force: bool = False, + ) -> Oid: ... + def create_reference_direct( + self, name: str, target: _OidArg, force: bool, message: Optional[str] = None + ) -> Reference: ... + def create_reference_symbolic( + self, name: str, target: str, force: bool, message: Optional[str] = None + ) -> Reference: ... + def create_tag( + 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 free(self) -> None: ... + 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 | 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_worktree(self, name: str) -> Worktree: ... + def merge_analysis( + self, their_head: _OidArg, our_ref: str = 'HEAD' + ) -> tuple[MergeAnalysis, MergePreference]: ... + def merge_base(self, oid1: _OidArg, oid2: _OidArg) -> Oid: ... + def merge_base_many(self, oids: list[_OidArg]) -> Oid: ... + def merge_base_octopus(self, oids: list[_OidArg]) -> Oid: ... + def notes(self) -> Iterator[Note]: ... + def path_is_ignored(self, path: str) -> bool: ... + def raw_listall_branches( + self, flag: BranchType = BranchType.LOCAL + ) -> list[bytes]: ... + def raw_listall_references(self) -> list[bytes]: ... + def references_iterator_init(self) -> Iterator[Reference]: ... + def references_iterator_next( + self, + iter: Iterator[T], + references_return_type: ReferenceFilter = ReferenceFilter.ALL, + ) -> Reference: ... + def reset(self, oid: _OidArg, reset_type: ResetMode) -> None: ... + def revparse(self, revspec: str) -> RevSpec: ... + def revparse_ext(self, revision: str) -> tuple[Object, Reference]: ... + def revparse_single(self, revision: str) -> Object: ... + def set_odb(self, odb: Odb) -> None: ... + def set_refdb(self, refdb: Refdb) -> None: ... + def status( + self, untracked_files: str = 'all', ignored: bool = False + ) -> dict[str, int]: ... + def status_file(self, path: str) -> int: ... + def walk( + self, oid: _OidArg | None, sort_mode: SortMode = SortMode.NONE + ) -> Walker: ... + +class RevSpec: + flags: int + from_object: Object + to_object: Object + +class Signature: + _encoding: str | None + _pointer: _Pointer[GitSignatureC] + email: str + name: str + offset: int + raw_email: bytes + raw_name: bytes + time: int + def __init__( + self, + 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: ... + +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: ... + +class Tag(Object): + message: str + name: str + raw_message: bytes + raw_name: bytes + tagger: Signature + target: Oid + def get_object(self) -> Object: ... + +class Tree(Object): + def diff_to_index( + self, + index: Index, + flags: DiffOption = DiffOption.NORMAL, + context_lines: int = 3, + interhunk_lines: int = 0, + ) -> Diff: ... + def diff_to_tree( + self, + tree: Tree = ..., + flags: DiffOption = DiffOption.NORMAL, + context_lines: int = 3, + interhunk_lines: int = 3, + swap: bool = False, + ) -> Diff: ... + def diff_to_workdir( + self, + flags: DiffOption = DiffOption.NORMAL, + context_lines: int = 3, + interhunk_lines: int = 0, + ) -> Diff: ... + def __contains__(self, other: str) -> bool: ... # Tree_contains + def __getitem__(self, index: str | int) -> Tree | Blob: ... # Tree_subscript + def __iter__(self) -> Iterator[Object]: ... + def __len__(self) -> int: ... # Tree_len + def __rtruediv__(self, other: str) -> Tree | Blob: ... + def __truediv__(self, other: str) -> Tree | Blob: ... # Tree_divide + +class TreeBuilder: + def clear(self) -> None: ... + def get(self, name: str) -> Object: ... + def insert(self, name: str, oid: _OidArg, attr: int) -> None: ... + def remove(self, name: str) -> None: ... + def write(self) -> Oid: ... + def __len__(self) -> int: ... + +class Walker: + 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 __iter__(self) -> Iterator[Commit]: ... # Walker: ... + def __next__(self) -> Commit: ... + +class Worktree: + is_prunable: bool + name: str + path: str + def prune(self, force=False) -> None: ... + +def discover_repository( + path: str | Path, across_fs: bool = False, ceiling_dirs: str = ... +) -> str | None: ... +def hash(data: bytes | str) -> Oid: ... +def hashfile(path: str) -> Oid: ... +def init_file_backend(path: str, flags: int = 0) -> object: ... +def reference_is_valid_name(refname: str) -> bool: ... +def tree_entry_cmp(a: Object, b: Object) -> int: ... +def _cache_enums() -> None: ... +def filter_register(name: str, filter: type[Filter]) -> None: ... +def filter_unregister(name: str) -> None: ... + +_OidArg = str | Oid diff --git a/pygit2/_run.py b/pygit2/_run.py new file mode 100644 index 000000000..863565f6a --- /dev/null +++ b/pygit2/_run.py @@ -0,0 +1,109 @@ +# 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. + +""" +This is an special module, it provides stuff used by by pygit2 at run-time. +""" + +# Import from the Standard Library +import codecs +import sys +from pathlib import Path + +# Import from cffi +from cffi import FFI + +# Import from pygit2 +try: + from _build import get_libgit2_paths # type: ignore +except ImportError: + from ._build import get_libgit2_paths + + +# C_HEADER_SRC +if getattr(sys, 'frozen', False): + if hasattr(sys, '_MEIPASS'): + dir_path = Path(sys._MEIPASS) + else: + dir_path = Path(sys.executable).parent +else: + dir_path = Path(__file__).parent.absolute() + +# Order matters +h_files = [ + 'types.h', + 'oid.h', + 'attr.h', + 'blame.h', + 'buffer.h', + 'strarray.h', + 'diff.h', + 'checkout.h', + 'transport.h', + 'proxy.h', + 'indexer.h', + 'pack.h', + 'remote.h', + 'clone.h', + 'common.h', + 'config.h', + 'describe.h', + 'errors.h', + 'graph.h', + 'index.h', + 'merge.h', + 'net.h', + 'refspec.h', + 'repository.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 # type: ignore + with codecs.open(h_file, 'r', 'utf-8') as f: + h_source.append(f.read()) + +C_HEADER_SRC = '\n'.join(h_source) + +C_PREAMBLE = """\ +#include +#include +""" + +# ffi +_, libgit2_kw = get_libgit2_paths() +ffi = FFI() +ffi.set_source('pygit2._libgit2', C_PREAMBLE, **libgit2_kw) +ffi.cdef(C_HEADER_SRC) + + +if __name__ == '__main__': + ffi.compile() diff --git a/pygit2/blame.py b/pygit2/blame.py new file mode 100644 index 000000000..9854f3afd --- /dev/null +++ b/pygit2/blame.py @@ -0,0 +1,160 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +from collections.abc import Iterator +from typing import TYPE_CHECKING + +from ._pygit2 import Oid, Repository, Signature + +# Import from pygit2 +from .ffi import C, ffi +from .utils import GenericIterator + +if TYPE_CHECKING: + from ._libgit2.ffi import GitBlameC, GitHunkC, GitSignatureC + + +def wrap_signature(csig: 'GitSignatureC') -> None | Signature: + if not csig: + return None + + return Signature( + ffi.string(csig.name).decode('utf-8'), + ffi.string(csig.email).decode('utf-8'), + csig.when.time, + csig.when.offset, + 'utf-8', + ) + + +class BlameHunk: + _blame: 'Blame' + _hunk: 'GitHunkC' + + @classmethod + 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) -> int: + """Number of lines""" + return self._hunk.lines_in_hunk + + @property + 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) -> int: + """Final start line number""" + return self._hunk.final_start_line_number + + @property + def final_committer(self) -> None | Signature: + """Final committer""" + return wrap_signature(self._hunk.final_signature) + + @property + 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) -> int: + """Origin start line number""" + return self._hunk.orig_start_line_number + + @property + def orig_committer(self) -> None | Signature: + """Original committer""" + return wrap_signature(self._hunk.orig_signature) + + @property + def orig_commit_id(self) -> Oid: + return Oid( + raw=bytes(ffi.buffer(ffi.addressof(self._hunk, 'orig_commit_id'))[:]) + ) + + @property + 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') + + +class Blame: + _repo: Repository + _blame: 'GitBlameC' + + @classmethod + def _from_c(cls, repo: Repository, ptr: 'GitBlameC') -> 'Blame': + blame = cls.__new__(cls) + blame._repo = repo + blame._blame = ptr + return blame + + def __del__(self) -> None: + C.git_blame_free(self._blame) + + def __len__(self) -> int: + return C.git_blame_get_hunk_count(self._blame) + + def __getitem__(self, index: int) -> BlameHunk: + chunk = C.git_blame_get_hunk_byindex(self._blame, index) + if not chunk: + raise IndexError + + return BlameHunk._from_c(self, chunk) + + def for_line(self, line_no: int) -> BlameHunk: + """ + Returns the object for a given line given its number in the + current Blame. + + Parameters: + + line_no + Line number, starts at 1. + """ + if line_no < 0: + raise IndexError + + chunk = C.git_blame_get_hunk_byline(self._blame, line_no) + if not chunk: + raise IndexError + + return BlameHunk._from_c(self, chunk) + + def __iter__(self) -> Iterator[BlameHunk]: + return GenericIterator(self) diff --git a/pygit2/blob.py b/pygit2/blob.py new file mode 100644 index 000000000..1ee6a9eb7 --- /dev/null +++ b/pygit2/blob.py @@ -0,0 +1,155 @@ +import io +import threading +import time +from contextlib import AbstractContextManager +from queue import Queue +from typing import Optional + +from ._pygit2 import Blob, Oid +from .enums import BlobFilter + + +class _BlobIO(io.RawIOBase): + """Low-level wrapper for streaming blob content. + + The underlying libgit2 git_writestream filter chain will be run + in a separate thread. The GIL will be released while running + libgit2 filtering. + """ + + def __init__( + self, + blob: Blob, + as_path: Optional[str] = None, + flags: BlobFilter = BlobFilter.CHECK_FOR_BINARY, + commit_id: Optional[Oid] = None, + ): + super().__init__() + self._blob = blob + self._queue: Optional[Queue] = Queue(maxsize=1) + self._ready = threading.Event() + self._writer_closed = threading.Event() + self._chunk: Optional[bytes] = None + self._thread = threading.Thread( + target=self._blob._write_to_queue, + args=(self._queue, self._ready, self._writer_closed), + kwargs={ + 'as_path': as_path, + 'flags': int(flags), + 'commit_id': commit_id, + }, + daemon=True, + ) + self._thread.start() + + def __exit__(self, exc_type, exc_value, traceback): + self.close() + + def isatty(self): + return False + + def readable(self): + return True + + def writable(self): + return False + + def seekable(self): + return False + + def readinto(self, b, /): + try: + while self._chunk is None: + self._ready.wait() + if self._queue.empty(): + if self._writer_closed.is_set(): + # EOF + return 0 + self._ready.clear() + time.sleep(0) + continue + chunk = self._queue.get() + if chunk: + self._chunk = chunk + + if len(self._chunk) <= len(b): + bytes_written = len(self._chunk) + b[:bytes_written] = self._chunk + self._chunk = None + return bytes_written + bytes_written = len(b) + b[:] = self._chunk[:bytes_written] + self._chunk = self._chunk[bytes_written:] + return bytes_written + except KeyboardInterrupt: + return 0 + + def close(self) -> None: + try: + self._ready.wait() + self._writer_closed.wait() + while self._queue is not None and not self._queue.empty(): + self._queue.get() + self._thread.join() + except KeyboardInterrupt: + pass + self._queue = None + + +class BlobIO(io.BufferedReader, AbstractContextManager): + """Read-only wrapper for streaming blob content. + + Supports reading both raw and filtered blob content. + Implements io.BufferedReader. + + Example: + + >>> with BlobIO(blob) as f: + ... while True: + ... # Read blob data in 1KB chunks until EOF is reached + ... chunk = f.read(1024) + ... if not chunk: + ... break + + By default, `BlobIO` will stream the raw contents of the blob, but it + can also be used to stream filtered content (i.e. to read the content + after applying filters which would be used when checking out the blob + to the working directory). + + Example: + + >>> with BlobIO(blob, as_path='my_file.ext') as f: + ... # Read the filtered content which would be returned upon + ... # running 'git checkout -- my_file.txt' + ... filtered_data = f.read() + """ + + def __init__( + self, + blob: Blob, + as_path: Optional[str] = None, + flags: BlobFilter = BlobFilter.CHECK_FOR_BINARY, + commit_id: Optional[Oid] = None, + ): + """Wrap the specified blob. + + Parameters: + blob: The blob to wrap. + as_path: Filter the contents of the blob as if it had the specified + path. If `as_path` is None, the raw contents of the blob will + be read. + flags: A combination of enums.BlobFilter constants + (only applicable when `as_path` is set). + commit_id: Commit to load attributes from when + ATTRIBUTES_FROM_COMMIT is specified in `flags` + (only applicable when `as_path` is set). + """ + raw = _BlobIO(blob, as_path=as_path, flags=flags, commit_id=commit_id) + super().__init__(raw) + + def __exit__(self, exc_type, exc_value, traceback): + self.close() + + +io.RawIOBase.register(_BlobIO) +io.BufferedIOBase.register(BlobIO) diff --git a/pygit2/branches.py b/pygit2/branches.py new file mode 100644 index 000000000..b729a21e5 --- /dev/null +++ b/pygit2/branches.py @@ -0,0 +1,109 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +from __future__ import annotations + +from collections.abc import Iterator +from typing import TYPE_CHECKING + +from ._pygit2 import Branch, Commit, Oid +from .enums import BranchType, ReferenceType + +# Need BaseRepository for type hints, but don't let it cause a circular dependency +if TYPE_CHECKING: + from .repository import BaseRepository + + +class Branches: + local: 'Branches' + remote: 'Branches' + + def __init__( + 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: + if isinstance(commit, Commit): + commit = commit.id + elif not isinstance(commit, Oid): + commit = self._repository.expand_id(commit) + self._commit = commit + + if flag == BranchType.ALL: + self.local = Branches(repository, flag=BranchType.LOCAL, commit=commit) + self.remote = Branches(repository, flag=BranchType.REMOTE, commit=commit) + + def __getitem__(self, name: str) -> Branch: + branch = None + if self._flag & BranchType.LOCAL: + branch = self._repository.lookup_branch(name, BranchType.LOCAL) + + if branch is None and self._flag & BranchType.REMOTE: + branch = self._repository.lookup_branch(name, BranchType.REMOTE) + + if branch is None or not self._valid(branch): + raise KeyError(f'Branch not found: {name}') + + return branch + + def get(self, key: str) -> Branch: + try: + return self[key] + except KeyError: + return None # type:ignore # next commit + + 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: Commit, force: bool = False) -> Branch: + return self._repository.create_branch(name, commit, force) + + def delete(self, name: str) -> None: + self[name].delete() + + def _valid(self, branch: Branch) -> bool: + if branch.type == ReferenceType.SYMBOLIC: + branch_direct = branch.resolve() + else: + branch_direct = branch + + return ( + self._commit is None + or branch_direct.target == self._commit + or self._repository.descendant_of(branch_direct.target, 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: str) -> bool: + return self.get(name) is not None diff --git a/pygit2/callbacks.py b/pygit2/callbacks.py new file mode 100644 index 000000000..adcfca528 --- /dev/null +++ b/pygit2/callbacks.py @@ -0,0 +1,887 @@ +# 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. + +""" +In this module we keep everything concerning callback. This is how it works, +with an example: + +1. The pygit2 API calls libgit2, it passes a payload object + e.g. Remote.fetch calls git_remote_fetch + +2. libgit2 calls Python callbacks + e.g. git_remote_fetch calls _transfer_progress_cb + +3. Optionally, the Python callback may proxy to a user defined function + e.g. _transfer_progress_cb calls RemoteCallbacks.transfer_progress + +4. The user defined function may return something on success, or raise an + exception on error, or raise the special Passthrough exception. + +5. The callback may return in 3 different ways to libgit2: + + - Returns GIT_OK on success. + - Returns GIT_PASSTHROUGH if the user defined function raised Passthrough, + this tells libgit2 to act as if this callback didn't exist in the first + place. + - Returns GIT_EUSER if another exception was raised, and keeps the exception + in the payload to be re-raised later. + +6. libgit2 returns to the pygit2 API, with an error code + e.g. git_remote_fetch returns to Remote.fetch + +7. The pygit2 API will: + + - Return something on success. + - Raise the original exception if libgit2 returns GIT_EUSER + - Raise another exception if libgit2 returns another error code + +The payload object is passed all the way, so pygit2 API can send information to +the inner user defined function, and this can send back results to the pygit2 +API. +""" + +# Standard Library +from collections.abc import Callable, Generator +from contextlib import contextmanager +from functools import wraps +from typing import TYPE_CHECKING, Optional, ParamSpec, TypeVar + +# pygit2 +from ._pygit2 import DiffFile, Oid +from .credentials import Keypair, Username, UserPass +from .enums import CheckoutNotify, CheckoutStrategy, CredentialType, StashApplyProgress +from .errors import Passthrough, check_error +from .ffi import C, ffi +from .utils import StrArray, maybe_string, ptr_to_bytes, to_bytes + +_Credentials = Username | UserPass | Keypair + +if TYPE_CHECKING: + from pygit2._libgit2.ffi import GitProxyOptionsC + + from ._pygit2 import CloneOptions, PushOptions + from .remotes import PushUpdate, TransferProgress +# +# The payload is the way to pass information from the pygit2 API, through +# libgit2, to the Python callbacks. And back. +# + + +class Payload: + repository: Callable | None + remote: Callable | None + clone_options: 'CloneOptions' + + def __init__(self, **kw: object) -> None: + for key, value in kw.items(): + setattr(self, key, value) + self._stored_exception = None + + def check_error(self, error_code: int) -> None: + if error_code == C.GIT_EUSER: + assert self._stored_exception is not None + raise self._stored_exception + elif self._stored_exception is not None: + # A callback mapped to a C function returning void + # might still have raised an exception. + raise self._stored_exception + + check_error(error_code) + + +class RemoteCallbacks(Payload): + """Base class for pygit2 remote callbacks. + + Inherit from this class and override the callbacks which you want to use + in your class, which you can then pass to the network operations. + + For the credentials, you can either subclass and override the 'credentials' + 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). + """ + + push_options: 'PushOptions' + + def __init__( + self, + credentials: _Credentials | None = None, + certificate_check: Callable[[None, bool, bytes], bool] | None = None, + ) -> None: + super().__init__() + if credentials is not None: + self.credentials = credentials # type: ignore[method-assign, assignment] + if certificate_check is not None: + self.certificate_check = certificate_check # type: ignore[method-assign, assignment] + + def sideband_progress(self, string: str) -> None: + """ + Progress output callback. Override this function with your own + progress reporting function + + Parameters: + + string : str + Progress output from the remote. + """ + + def credentials( + self, + url: str, + username_from_url: str | None, + allowed_types: CredentialType, + ) -> _Credentials: + """ + Credentials callback. If the remote server requires authentication, + this function will be called and its return value used for + authentication. Override it if you want to be able to perform + authentication. + + Returns: credential + + Parameters: + + url : str + The url of the remote. + + username_from_url : str or None + Username extracted from the url, if any. + + allowed_types : CredentialType + A combination of CredentialType bitflags representing the + credential types supported by the remote. + """ + raise Passthrough + + def certificate_check(self, certificate: None, valid: bool, host: bytes) -> bool: + """ + Certificate callback. Override with your own function to determine + whether to accept the server's certificate. + + Returns: True to connect, False to abort. + + Parameters: + + certificate : None + The certificate. It is currently always None while we figure out + how to represent it cross-platform. + + valid : bool + Whether the TLS/SSH library thinks the certificate is valid. + + host : str + The hostname we want to connect to. + """ + + 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 + the indexer's progress. + + Override with your own function to report transfer progress. + + Parameters: + + stats : TransferProgress + The progress up to now. + """ + + def push_transfer_progress( + self, objects_pushed: int, total_objects: int, bytes_pushed: int + ) -> None: + """ + During the upload portion of a push, this will be regularly called + with progress information. + + Be aware that this is called inline with pack building operations, + so performance may be affected. + + Override with your own function to report push transfer progress. + """ + + def update_tips(self, refname: str, old: Oid, new: Oid) -> None: + """ + Update tips callback. Override with your own function to report + reference updates. + + Parameters: + + refname : str + The name of the reference that's being updated. + + old : Oid + The reference's old value. + + new : Oid + The reference's new value. + """ + + def push_update_reference(self, refname: str, message: str) -> None: + """ + Push update reference callback. Override with your own function to + report the remote's acceptance or rejection of reference updates. + + refname : str + The name of the reference (on the remote). + + message : str + Rejection message from the remote. If None, the update was accepted. + """ + + +class CheckoutCallbacks(Payload): + """Base class for pygit2 checkout callbacks. + + Inherit from this class and override the callbacks that you want to use + in your class, which you can then pass to checkout operations. + """ + + def __init__(self) -> None: + super().__init__() + + def checkout_notify_flags(self) -> CheckoutNotify: + """ + Returns a bit mask of the notifications to receive from a checkout + (a combination of enums.CheckoutNotify constants). + + By default, if you override `checkout_notify`, all notifications will + be enabled. You can fine tune the notification types to enable by + overriding `checkout_notify_flags`. + + Please note that the flags are only sampled once when checkout begins. + You cannot change the flags while a checkout is in progress. + """ + if type(self).checkout_notify == CheckoutCallbacks.checkout_notify: + # If the user hasn't overridden the notify function, + # filter out all notifications. + return CheckoutNotify.NONE + else: + # If the user provides their own notify function, + # enable all notifications by default. + return CheckoutNotify.ALL + + def checkout_notify( + self, + why: CheckoutNotify, + path: str, + baseline: Optional[DiffFile], + target: Optional[DiffFile], + workdir: Optional[DiffFile], + ) -> None: + """ + Checkout will invoke an optional notification callback for + certain cases - you pick which ones via `checkout_notify_flags`. + + Raising an exception from this callback will cancel the checkout. + The exception will be propagated back and raised by the + 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 + being modified. + """ + pass + + def checkout_progress( + self, path: str, completed_steps: int, total_steps: int + ) -> None: + """ + Optional callback to notify the consumer of checkout progress. + """ + pass + + +class StashApplyCallbacks(CheckoutCallbacks): + """Base class for pygit2 stash apply callbacks. + + Inherit from this class and override the callbacks that you want to use + in your class, which you can then pass to stash apply or pop operations. + """ + + def stash_apply_progress(self, progress: StashApplyProgress) -> None: + """ + Stash application progress notification function. + + `progress` is a StashApplyProgress constant. + + Raising an exception from this callback will abort the stash + application. + """ + pass + + +# +# The context managers below wrap the calls to libgit2 functions, which them in +# turn call to callbacks defined later in this module. These context managers +# are used in the pygit2 API, see for instance remote.py +# + + +@contextmanager +def git_clone_options(payload, opts=None): + if opts is None: + opts = ffi.new('git_clone_options *') + C.git_clone_options_init(opts, C.GIT_CLONE_OPTIONS_VERSION) + + handle = ffi.new_handle(payload) + + # Plug callbacks + if payload.repository: + opts.repository_cb = C._repository_create_cb + opts.repository_cb_payload = handle + if payload.remote: + opts.remote_cb = C._remote_create_cb + opts.remote_cb_payload = handle + + # Give back control + payload._stored_exception = None + payload.clone_options = opts + yield payload + + +@contextmanager +def git_fetch_options(payload, opts=None): + if payload is None: + payload = RemoteCallbacks() + + if opts is None: + opts = ffi.new('git_fetch_options *') + C.git_fetch_options_init(opts, C.GIT_FETCH_OPTIONS_VERSION) + + # Plug callbacks + opts.callbacks.sideband_progress = C._sideband_progress_cb + opts.callbacks.transfer_progress = C._transfer_progress_cb + opts.callbacks.update_tips = C._update_tips_cb + opts.callbacks.credentials = C._credentials_cb + opts.callbacks.certificate_check = C._certificate_check_cb + # Payload + handle = ffi.new_handle(payload) + opts.callbacks.payload = handle + + # Give back control + payload.fetch_options = opts + payload._stored_exception = None + yield payload + + +@contextmanager +def git_proxy_options( + payload: object, + opts: Optional['GitProxyOptionsC'] = None, + proxy: None | bool | str = None, +) -> Generator['GitProxyOptionsC', None, None]: + if opts is None: + opts = ffi.new('git_proxy_options *') + C.git_proxy_options_init(opts, C.GIT_PROXY_OPTIONS_VERSION) + if proxy is None: + opts.type = C.GIT_PROXY_NONE + elif proxy is True: + opts.type = C.GIT_PROXY_AUTO + elif type(proxy) is str: + opts.type = C.GIT_PROXY_SPECIFIED + # Keep url in memory, otherwise memory is freed and bad things happen + payload.__proxy_url = ffi.new('char[]', to_bytes(proxy)) # type: ignore[attr-defined] + opts.url = payload.__proxy_url # type: ignore[attr-defined] + else: + raise TypeError('Proxy must be None, True, or a string') + yield opts + + +@contextmanager +def git_push_options(payload, opts=None): + if payload is None: + payload = RemoteCallbacks() + + 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 + opts.callbacks.transfer_progress = C._transfer_progress_cb + opts.callbacks.update_tips = C._update_tips_cb + opts.callbacks.credentials = C._credentials_cb + opts.callbacks.certificate_check = C._certificate_check_cb + opts.callbacks.push_update_reference = C._push_update_reference_cb + opts.callbacks.push_negotiation = C._push_negotiation_cb + # Per libgit2 sources, push_transfer_progress may incur a performance hit. + # So, set it only if the user has overridden the no-op stub. + if ( + type(payload).push_transfer_progress + is not RemoteCallbacks.push_transfer_progress + ): + opts.callbacks.push_transfer_progress = C._push_transfer_progress_cb + # Payload + handle = ffi.new_handle(payload) + opts.callbacks.payload = handle + + # Give back control + payload.push_options = opts + payload._stored_exception = None + yield payload + + +@contextmanager +def git_remote_callbacks(payload): + if payload is None: + payload = RemoteCallbacks() + + cdata = ffi.new('git_remote_callbacks *') + C.git_remote_init_callbacks(cdata, C.GIT_REMOTE_CALLBACKS_VERSION) + + # Plug callbacks + cdata.credentials = C._credentials_cb + cdata.update_tips = C._update_tips_cb + cdata.certificate_check = C._certificate_check_cb + # Payload + handle = ffi.new_handle(payload) + cdata.payload = handle + + # Give back control + payload._stored_exception = None + payload.remote_callbacks = cdata + yield payload + + +# +# C callbacks +# +# These functions are called by libgit2. They cannot raise exceptions, since +# they return to libgit2, they can only send back error codes. +# +# They cannot be overridden, but sometimes the only thing these functions do is +# to proxy the call to a user defined function. If user defined functions +# raises an exception, the callback must store it somewhere and return +# GIT_EUSER to libgit2, then the outer Python code will be able to reraise the +# exception. +# + +P = ParamSpec('P') +T = TypeVar('T') + + +def libgit2_callback(f: Callable[P, T]) -> Callable[P, T]: + @wraps(f) + def wrapper(*args): + data = ffi.from_handle(args[-1]) + args = args[:-1] + (data,) + try: + return f(*args) + except Passthrough: + # A user defined callback can raise Passthrough to decline to act; + # then libgit2 will behave as if there was no callback set in the + # first place. + return C.GIT_PASSTHROUGH + except BaseException as e: + # Keep the exception to be re-raised later, and inform libgit2 that + # the user defined callback has failed. + data._stored_exception = e + return C.GIT_EUSER + + return ffi.def_extern()(wrapper) # type: ignore[attr-defined] + + +def libgit2_callback_void(f: Callable[P, T]) -> Callable[P, T]: + @wraps(f) + def wrapper(*args): + data = ffi.from_handle(args[-1]) + args = args[:-1] + (data,) + try: + f(*args) + except Passthrough: + # A user defined callback can raise Passthrough to decline to act; + # then libgit2 will behave as if there was no callback set in the + # first place. + pass # Function returns void + except BaseException as e: + # Keep the exception to be re-raised later + data._stored_exception = e + pass # Function returns void, so we can't do much here. + + return ffi.def_extern()(wrapper) # type: ignore[attr-defined] + + +@libgit2_callback +def _certificate_check_cb(cert_i, valid, host, data): + # We want to simulate what should happen if libgit2 supported pass-through + # for this callback. For SSH, 'valid' is always False, because it doesn't + # look at known_hosts, but we do want to let it through in order to do what + # libgit2 would if the callback were not set. + try: + is_ssh = cert_i.cert_type == C.GIT_CERT_HOSTKEY_LIBSSH2 + + # python's parsing is deep in the libraries and assumes an OpenSSL-owned cert + val = data.certificate_check(None, bool(valid), ffi.string(host)) + if not val: + return C.GIT_ECERTIFICATE + except Passthrough: + if is_ssh: + return 0 + elif valid: + return 0 + else: + return C.GIT_ECERTIFICATE + + return 0 + + +@libgit2_callback +def _credentials_cb(cred_out, url, username, allowed, data): + credentials = getattr(data, 'credentials', None) + if not credentials: + return 0 + + # convert int flags to enum before forwarding to user code + allowed = CredentialType(allowed) + + ccred = get_credentials(credentials, url, username, allowed) + cred_out[0] = ccred[0] + 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) + push_update_reference(refname, message) + return 0 + + +@libgit2_callback +def _remote_create_cb(remote_out, repo, name, url, data): + from .repository import Repository + + remote = data.remote( + Repository._from_c(repo, False), ffi.string(name), ffi.string(url) + ) + remote_out[0] = remote._remote + # we no longer own the C object + remote._remote = ffi.NULL + + return 0 + + +@libgit2_callback +def _repository_create_cb(repo_out, path, bare, data): + repository = data.repository(ffi.string(path), bare != 0) + # we no longer own the C object + repository._disown() + repo_out[0] = repository._repo + + return 0 + + +@libgit2_callback +def _sideband_progress_cb(string, length, data): + sideband_progress = getattr(data, 'sideband_progress', None) + if not sideband_progress: + return 0 + + s = ffi.string(string, length).decode('utf-8') + sideband_progress(s) + return 0 + + +@libgit2_callback +def _transfer_progress_cb(stats_ptr, data): + from .remotes import TransferProgress + + transfer_progress = getattr(data, 'transfer_progress', None) + if not transfer_progress: + return 0 + + transfer_progress(TransferProgress(stats_ptr)) + return 0 + + +@libgit2_callback +def _push_transfer_progress_cb(current, total, bytes_pushed, payload): + push_transfer_progress = getattr(payload, 'push_transfer_progress', None) + if not push_transfer_progress: + return 0 + + push_transfer_progress(current, total, bytes_pushed) + return 0 + + +@libgit2_callback +def _update_tips_cb(refname, a, b, data): + update_tips = getattr(data, 'update_tips', None) + if not update_tips: + return 0 + + s = maybe_string(refname) + a = Oid(raw=bytes(ffi.buffer(a)[:])) + b = Oid(raw=bytes(ffi.buffer(b)[:])) + update_tips(s, a, b) + return 0 + + +# +# Other functions, used above. +# + + +def get_credentials(fn, url, username, allowed): + """Call fn and return the credentials object.""" + url_str = maybe_string(url) + username_str = maybe_string(username) + + creds = fn(url_str, username_str, allowed) + + credential_type = getattr(creds, 'credential_type', None) + credential_tuple = getattr(creds, 'credential_tuple', None) + if not credential_type or not credential_tuple: + raise TypeError('credential does not implement interface') + + cred_type = credential_type + + if not (allowed & cred_type): + raise TypeError('invalid credential type') + + ccred = ffi.new('git_credential **') + if cred_type == CredentialType.USERPASS_PLAINTEXT: + name, passwd = credential_tuple + err = C.git_credential_userpass_plaintext_new( + ccred, to_bytes(name), to_bytes(passwd) + ) + + elif cred_type == CredentialType.SSH_KEY: + name, pubkey, privkey, passphrase = credential_tuple + name = to_bytes(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) + ) + + elif cred_type == CredentialType.USERNAME: + (name,) = credential_tuple + err = C.git_credential_username_new(ccred, to_bytes(name)) + + elif cred_type == CredentialType.SSH_MEMORY: + name, pubkey, privkey, passphrase = credential_tuple + if pubkey is None and privkey is None: + 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), + ) + else: + raise TypeError('unsupported credential type') + + check_error(err) + + return ccred + + +# +# Checkout callbacks +# + + +@libgit2_callback +def _checkout_notify_cb( + why, path_cstr, baseline, target, workdir, data: CheckoutCallbacks +): + pypath = maybe_string(path_cstr) + pybaseline = DiffFile.from_c(ptr_to_bytes(baseline)) + pytarget = DiffFile.from_c(ptr_to_bytes(target)) + pyworkdir = DiffFile.from_c(ptr_to_bytes(workdir)) + + try: + data.checkout_notify(why, pypath, pybaseline, pytarget, pyworkdir) # type: ignore[arg-type] + except Passthrough: + # Unlike most other operations with optional callbacks, checkout + # doesn't support the GIT_PASSTHROUGH return code, so we must bypass + # libgit2_callback's error handling and return 0 explicitly here. + pass + + # If the user's callback has raised any other exception type, + # it's caught by the libgit2_callback decorator by now. + # So, return success code to libgit2. + return 0 + + +@libgit2_callback_void +def _checkout_progress_cb(path, completed_steps, total_steps, data: CheckoutCallbacks): + data.checkout_progress(maybe_string(path), completed_steps, total_steps) # type: ignore[arg-type] + + +def _git_checkout_options( + callbacks=None, + strategy=None, + directory=None, + paths=None, + c_checkout_options_ptr=None, +): + if callbacks is None: + payload = CheckoutCallbacks() + else: + payload = callbacks + + # Get handle to payload + handle = ffi.new_handle(payload) + + # Create the options struct to pass + if not c_checkout_options_ptr: + opts = ffi.new('git_checkout_options *') + else: + opts = c_checkout_options_ptr + check_error(C.git_checkout_options_init(opts, 1)) + + # References we need to keep to strings and so forth + refs = [handle] + + # pygit2's default is SAFE | RECREATE_MISSING + if strategy is None: + strategy = CheckoutStrategy.SAFE | CheckoutStrategy.RECREATE_MISSING + opts.checkout_strategy = int(strategy) + + if directory: + target_dir = ffi.new('char[]', to_bytes(directory)) + refs.append(target_dir) + opts.target_directory = target_dir + + if paths: + strarray = StrArray(paths) + refs.append(strarray) + opts.paths = strarray.ptr[0] + + # If we want to receive any notifications, set up notify_cb in the options + notify_flags = payload.checkout_notify_flags() + if notify_flags != CheckoutNotify.NONE: + opts.notify_cb = C._checkout_notify_cb + opts.notify_flags = int(notify_flags) + opts.notify_payload = handle + + # Set up progress callback if the user has provided their own + if type(payload).checkout_progress != CheckoutCallbacks.checkout_progress: + opts.progress_cb = C._checkout_progress_cb + opts.progress_payload = handle + + # Give back control + payload.checkout_options = opts + payload._ffi_handle = handle + payload._refs = refs + payload._stored_exception = None + return payload + + +@contextmanager +def git_checkout_options(callbacks=None, strategy=None, directory=None, paths=None): + yield _git_checkout_options( + callbacks=callbacks, strategy=strategy, directory=directory, paths=paths + ) + + +# +# Stash callbacks +# + + +@libgit2_callback +def _stash_apply_progress_cb(progress: StashApplyProgress, data: StashApplyCallbacks): + try: + data.stash_apply_progress(progress) + except Passthrough: + # Unlike most other operations with optional callbacks, stash apply + # doesn't support the GIT_PASSTHROUGH return code, so we must bypass + # libgit2_callback's error handling and return 0 explicitly here. + pass + + # If the user's callback has raised any other exception type, + # it's caught by the libgit2_callback decorator by now. + # So, return success code to libgit2. + return 0 + + +@contextmanager +def git_stash_apply_options( + callbacks=None, reinstate_index=False, strategy=None, directory=None, paths=None +): + if callbacks is None: + callbacks = StashApplyCallbacks() + + # Set up stash options + stash_apply_options = ffi.new('git_stash_apply_options *') + check_error(C.git_stash_apply_options_init(stash_apply_options, 1)) + + flags = reinstate_index * C.GIT_STASH_APPLY_REINSTATE_INDEX + stash_apply_options.flags = flags + + # Now set up checkout options + c_checkout_options_ptr = ffi.addressof(stash_apply_options.checkout_options) + payload = _git_checkout_options( + callbacks=callbacks, + strategy=strategy, + directory=directory, + paths=paths, + c_checkout_options_ptr=c_checkout_options_ptr, + ) + assert payload == callbacks + assert payload.checkout_options == c_checkout_options_ptr + + # Set up stash progress callback if the user has provided their own + if type(callbacks).stash_apply_progress != StashApplyCallbacks.stash_apply_progress: + stash_apply_options.progress_cb = C._stash_apply_progress_cb + stash_apply_options.progress_payload = payload._ffi_handle + + # Give back control + payload.stash_apply_options = stash_apply_options + yield payload diff --git a/pygit2/config.py b/pygit2/config.py new file mode 100644 index 000000000..f05a8d13a --- /dev/null +++ b/pygit2/config.py @@ -0,0 +1,384 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +from collections.abc import Callable, Iterator +from os import PathLike +from pathlib import Path +from typing import TYPE_CHECKING + +try: + from functools import cached_property +except ImportError: + from cached_property import cached_property # type: ignore + +# Import from pygit2 +from .errors import check_error +from .ffi import C, ffi +from .utils import to_bytes + +if TYPE_CHECKING: + from ._libgit2.ffi import GitConfigC, GitConfigEntryC + from .repository import BaseRepository + + +def str_to_bytes(value: str | PathLike[str] | bytes, name: str) -> bytes: + if not isinstance(value, str): + raise TypeError(f'{name} must be a string') + + return to_bytes(value) + + +class ConfigIterator: + def __init__(self, config, ptr) -> None: + self._iter = ptr + self._config = config + + def __del__(self) -> None: + C.git_config_iterator_free(self._iter) + + def __iter__(self) -> 'ConfigIterator': + return self + + def __next__(self) -> 'ConfigEntry': + return self._next_entry() + + def _next_entry(self) -> 'ConfigEntry': + centry = ffi.new('git_config_entry **') + err = C.git_config_next(centry, self._iter) + check_error(err) + + return ConfigEntry._from_c(centry[0], self) + + +class ConfigMultivarIterator(ConfigIterator): + def __next__(self) -> str: # type: ignore[override] + entry = self._next_entry() + return entry.value + + +class Config: + """Git configuration management.""" + + _repo: 'BaseRepository' + _config: 'GitConfigC' + + def __init__(self, path: str | None = None) -> None: + cconfig = ffi.new('git_config **') + + if not path: + err = C.git_config_new(cconfig) + else: + path_bytes = str_to_bytes(path, 'path') + err = C.git_config_open_ondisk(cconfig, path_bytes) + + check_error(err, io=True) + self._config = cconfig[0] + + @classmethod + def from_c(cls, repo: 'BaseRepository', ptr: 'GitConfigC') -> 'Config': + config = cls.__new__(cls) + config._repo = repo + config._config = ptr + + return config + + def __del__(self) -> None: + try: + C.git_config_free(self._config) + except AttributeError: + pass + + def _get(self, key: str | bytes) -> tuple[int, 'ConfigEntry']: + key = str_to_bytes(key, 'key') + + entry = ffi.new('git_config_entry **') + err = C.git_config_get_entry(entry, self._config, key) + + return err, ConfigEntry._from_c(entry[0]) + + def _get_entry(self, key: str | bytes) -> 'ConfigEntry': + err, entry = self._get(key) + + if err == C.GIT_ENOTFOUND: + raise KeyError(key) + + check_error(err) + return entry + + def __contains__(self, key: str | bytes) -> bool: + err, cstr = self._get(key) + + if err == C.GIT_ENOTFOUND: + return False + + check_error(err) + + return True + + def __getitem__(self, key: str | bytes) -> str: + """ + When using the mapping interface, the value is returned as a string. In + order to apply the git-config parsing rules, you can use + :meth:`Config.get_bool` or :meth:`Config.get_int`. + """ + entry = self._get_entry(key) + + return entry.value + + def __setitem__(self, key: str | bytes, value: bool | int | str | bytes) -> None: + key = str_to_bytes(key, 'key') + + err = 0 + if isinstance(value, bool): + err = C.git_config_set_bool(self._config, 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)) + + check_error(err) + + 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) -> Iterator['ConfigEntry']: + """ + Iterate over configuration entries, returning a ``ConfigEntry`` + objects. These contain the name, level, and value of each configuration + variable. Be aware that this may return multiple versions of each entry + if they are set multiple times in the configuration files. + """ + citer = ffi.new('git_config_iterator **') + err = C.git_config_iterator_new(citer, self._config) + check_error(err) + + return ConfigIterator(self, citer[0]) + + 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_bytes = to_bytes(regex or None) + + citer = ffi.new('git_config_iterator **') + 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: 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. + """ + name = str_to_bytes(name, 'name') + regex = str_to_bytes(regex, 'regex') + value = str_to_bytes(value, 'value') + + err = C.git_config_set_multivar(self._config, name, regex, value) + check_error(err) + + 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. + """ + name = str_to_bytes(name, 'name') + regex = str_to_bytes(regex, 'regex') + + err = C.git_config_delete_multivar(self._config, name, regex) + check_error(err) + + 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). + + Truthy values are: 'true', 1, 'on' or 'yes'. Falsy values are: 'false', + 0, 'off' and 'no' + """ + + entry = self._get_entry(key) + res = ffi.new('int *') + err = C.git_config_parse_bool(res, entry.c_value) + check_error(err) + + return res[0] != 0 + + 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. + + A value can have a suffix 'k', 'm' or 'g' which stand for 'kilo', + 'mega' and 'giga' respectively. + """ + + entry = self._get_entry(key) + res = ffi.new('int64_t *') + err = C.git_config_parse_int64(res, entry.c_value) + check_error(err) + + return res[0] + + def add_file(self, path: str | Path, level: int = 0, force: int = 0) -> None: + """Add a config file instance to an existing config.""" + + err = C.git_config_add_file_ondisk( + self._config, to_bytes(path), level, ffi.NULL, force + ) + check_error(err) + + 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. + """ + ccfg = ffi.new('git_config **') + err = C.git_config_snapshot(ccfg, self._config) + check_error(err) + + return Config.from_c(self._repo, ccfg[0]) + + # + # Methods to parse a string according to the git-config rules + # + + @staticmethod + def parse_bool(text: str) -> bool: + res = ffi.new('int *') + err = C.git_config_parse_bool(res, to_bytes(text)) + check_error(err) + + return res[0] != 0 + + @staticmethod + def parse_int(text: str) -> int: + res = ffi.new('int64_t *') + err = C.git_config_parse_int64(res, to_bytes(text)) + check_error(err) + + return res[0] + + # + # Static methods to get specialized version of the config + # + + @staticmethod + def _from_found_config(fn: Callable) -> 'Config': + buf = ffi.new('git_buf *', (ffi.NULL, 0)) + err = fn(buf) + check_error(err, io=True) + cpath = ffi.string(buf.ptr).decode('utf-8') + C.git_buf_dispose(buf) + + return Config(cpath) + + @staticmethod + def get_system_config() -> 'Config': + """Return a object representing the system configuration file.""" + return Config._from_found_config(C.git_config_find_system) + + @staticmethod + def get_global_config() -> 'Config': + """Return a object representing the global configuration file.""" + return Config._from_found_config(C.git_config_find_global) + + @staticmethod + def get_xdg_config() -> 'Config': + """Return a object representing the global configuration file.""" + return 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: '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 + created during ``git_config_iterator`` actions. + """ + entry = cls.__new__(cls) + entry._entry = ptr + entry.iterator = iterator + + # It should be enough to keep a reference to iterator, so we only call + # git_config_iterator_free when we've deleted all ConfigEntry objects. + # But it's not, to reproduce the error comment the lines below and run + # the script in https://github.com/libgit2/pygit2/issues/970 + # So instead we load the Python object immediately. Ideally we should + # investigate libgit2 source code. + if iterator is not None: + entry.raw_name = entry.raw_name + entry.raw_value = entry.raw_value + entry.level = entry.level + + return entry + + def __del__(self) -> None: + if self.iterator is None: + C.git_config_entry_free(self._entry) + + @property + def c_value(self) -> 'ffi.char_pointer': + """The raw ``cData`` entry value.""" + return self._entry.value + + @cached_property + def raw_name(self) -> bytes: + return ffi.string(self._entry.name) + + @cached_property + def raw_value(self) -> bytes: + return ffi.string(self.c_value) + + @cached_property + def level(self) -> int: + """The entry's ``git_config_level_t`` value.""" + return self._entry.level + + @property + def name(self) -> str: + """The entry's name.""" + return self.raw_name.decode('utf-8') + + @property + def value(self) -> str: + """The entry's value as a string.""" + return self.raw_value.decode('utf-8') diff --git a/pygit2/credentials.py b/pygit2/credentials.py new file mode 100644 index 000000000..52edc8ce4 --- /dev/null +++ b/pygit2/credentials.py @@ -0,0 +1,144 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from .enums import CredentialType + +if TYPE_CHECKING: + from pathlib import Path + + +class Username: + """Username credentials + + This is an object suitable for passing to a remote's credentials + callback and for returning from said callback. + """ + + def __init__(self, username: str): + self._username = username + + @property + def credential_type(self) -> CredentialType: + return CredentialType.USERNAME + + @property + def credential_tuple(self) -> tuple[str]: + return (self._username,) + + def __call__( + self, _url: str, _username: str | None, _allowed: CredentialType + ) -> Username: + return self + + +class UserPass: + """Username/Password credentials + + This is an object suitable for passing to a remote's credentials + callback and for returning from said callback. + """ + + def __init__(self, username: str, password: str): + self._username = username + self._password = password + + @property + def credential_type(self) -> CredentialType: + return CredentialType.USERPASS_PLAINTEXT + + @property + def credential_tuple(self) -> tuple[str, str]: + return (self._username, self._password) + + def __call__( + self, _url: str, _username: str | None, _allowed: CredentialType + ) -> UserPass: + return self + + +class Keypair: + """ + SSH key pair credentials. + + This is an object suitable for passing to a remote's credentials + callback and for returning from said callback. + + Parameters: + + username : str + The username being used to authenticate with the remote server. + + pubkey : str + The path to the user's public key file. + + privkey : str + The path to the user's private key file. + + passphrase : str + The password used to decrypt the private key file, or empty string if + no passphrase is required. + """ + + def __init__( + self, + username: str, + pubkey: str | Path | None, + privkey: str | Path | None, + passphrase: str | None, + ): + self._username = username + self._pubkey = pubkey + self._privkey = privkey + self._passphrase = passphrase + + @property + def credential_type(self) -> CredentialType: + return CredentialType.SSH_KEY + + @property + def credential_tuple( + self, + ) -> tuple[str, str | Path | None, str | Path | None, str | None]: + return (self._username, self._pubkey, self._privkey, self._passphrase) + + def __call__( + self, _url: str, _username: str | None, _allowed: CredentialType + ) -> Keypair: + return self + + +class KeypairFromAgent(Keypair): + def __init__(self, username: str): + super().__init__(username, None, None, None) + + +class KeypairFromMemory(Keypair): + @property + def credential_type(self) -> CredentialType: + return CredentialType.SSH_MEMORY diff --git a/pygit2/decl/attr.h b/pygit2/decl/attr.h new file mode 100644 index 000000000..be44a0170 --- /dev/null +++ b/pygit2/decl/attr.h @@ -0,0 +1,31 @@ +#define GIT_ATTR_CHECK_FILE_THEN_INDEX 0 +#define GIT_ATTR_CHECK_INDEX_THEN_FILE 1 +#define GIT_ATTR_CHECK_INDEX_ONLY 2 +#define GIT_ATTR_CHECK_NO_SYSTEM 4 +#define GIT_ATTR_CHECK_INCLUDE_HEAD 8 +#define GIT_ATTR_CHECK_INCLUDE_COMMIT 16 + +#define GIT_ATTR_OPTIONS_VERSION ... + +typedef enum { + GIT_ATTR_VALUE_UNSPECIFIED = 0, /**< The attribute has been left unspecified */ + GIT_ATTR_VALUE_TRUE, /**< The attribute has been set */ + GIT_ATTR_VALUE_FALSE, /**< The attribute has been unset */ + GIT_ATTR_VALUE_STRING /**< This attribute has a value */ +} git_attr_value_t; + +typedef struct { + unsigned int version; + unsigned int flags; + git_oid *commit_id; + git_oid attr_commit_id; +} git_attr_options; + +int git_attr_get_ext( + const char **value_out, + git_repository *repo, + git_attr_options *opts, + const char *path, + const char *name); + +git_attr_value_t git_attr_value(const char *attr); diff --git a/pygit2/decl/blame.h b/pygit2/decl/blame.h new file mode 100644 index 000000000..362058404 --- /dev/null +++ b/pygit2/decl/blame.h @@ -0,0 +1,52 @@ +#define GIT_BLAME_OPTIONS_VERSION ... + +typedef struct git_blame git_blame; + +typedef struct git_blame_options { + unsigned int version; + uint32_t flags; + uint16_t min_match_characters; + git_oid newest_commit; + git_oid oldest_commit; + size_t min_line; + size_t max_line; +} git_blame_options; + +typedef struct git_blame_hunk { + size_t lines_in_hunk; + + git_oid final_commit_id; + size_t final_start_line_number; + git_signature *final_signature; + git_signature *final_committer; + + git_oid orig_commit_id; + const char *orig_path; + size_t orig_start_line_number; + git_signature *orig_signature; + git_signature *orig_committer; + + const char *summary; + char boundary; +} git_blame_hunk; + +int git_blame_options_init( + git_blame_options *opts, + unsigned int version); + +uint32_t git_blame_get_hunk_count(git_blame *blame); +const git_blame_hunk* git_blame_get_hunk_byindex( + git_blame *blame, + uint32_t index); + +const git_blame_hunk* git_blame_get_hunk_byline( + git_blame *blame, + size_t lineno); + +int git_blame_file( + git_blame **out, + git_repository *repo, + const char *path, + git_blame_options *options); + +void git_blame_free(git_blame *blame); diff --git a/pygit2/decl/buffer.h b/pygit2/decl/buffer.h new file mode 100644 index 000000000..c4e8dfc29 --- /dev/null +++ b/pygit2/decl/buffer.h @@ -0,0 +1,7 @@ +typedef struct { + char *ptr; + size_t reserved; + size_t size; +} git_buf; + +void git_buf_dispose(git_buf *buffer); diff --git a/pygit2/decl/callbacks.h b/pygit2/decl/callbacks.h new file mode 100644 index 000000000..64582718e --- /dev/null +++ b/pygit2/decl/callbacks.h @@ -0,0 +1,78 @@ +extern "Python" int _certificate_check_cb( + git_cert *cert, + int valid, + const char *host, + void *payload); + +extern "Python" int _credentials_cb( + git_credential **out, + const char *url, + const char *username_from_url, + unsigned int allowed_types, + void *payload); + +extern "Python" int _push_update_reference_cb( + const char *refname, + 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, + const char *name, + const char *url, + void *payload); + +extern "Python" int _repository_create_cb( + git_repository **out, + const char *path, + int bare, + void *payload); + +extern "Python" int _sideband_progress_cb( + const char *str, + int len, + void *payload); + +extern "Python" int _transfer_progress_cb( + const git_indexer_progress *stats, + void *payload); + +extern "Python" int _push_transfer_progress_cb( + unsigned int objects_pushed, + unsigned int total_objects, + size_t bytes_pushed, + void *payload); + +extern "Python" int _update_tips_cb( + const char *refname, + const git_oid *a, + const git_oid *b, + void *data); + +/* Checkout */ + +extern "Python" int _checkout_notify_cb( + git_checkout_notify_t why, + const char *path, + const git_diff_file *baseline, + const git_diff_file *target, + const git_diff_file *workdir, + void *payload); + +extern "Python" void _checkout_progress_cb( + const char *path, + size_t completed_steps, + size_t total_steps, + void *payload); + +/* Stash */ + +extern "Python" int _stash_apply_progress_cb( + git_stash_apply_progress_t progress, + void *payload); diff --git a/pygit2/decl/checkout.h b/pygit2/decl/checkout.h new file mode 100644 index 000000000..71924e78b --- /dev/null +++ b/pygit2/decl/checkout.h @@ -0,0 +1,87 @@ +typedef enum { + GIT_CHECKOUT_NOTIFY_NONE = 0, + GIT_CHECKOUT_NOTIFY_CONFLICT = 1, + GIT_CHECKOUT_NOTIFY_DIRTY = 2, + GIT_CHECKOUT_NOTIFY_UPDATED = 4, + GIT_CHECKOUT_NOTIFY_UNTRACKED = 8, + GIT_CHECKOUT_NOTIFY_IGNORED = 16, + + GIT_CHECKOUT_NOTIFY_ALL = 0x0FFFF +} git_checkout_notify_t; + +typedef int (*git_checkout_notify_cb)( + git_checkout_notify_t why, + const char *path, + const git_diff_file *baseline, + const git_diff_file *target, + const git_diff_file *workdir, + void *payload); + +typedef void (*git_checkout_progress_cb)( + const char *path, + size_t completed_steps, + size_t total_steps, + void *payload); + +typedef struct { + size_t mkdir_calls; + size_t stat_calls; + size_t chmod_calls; +} git_checkout_perfdata; + +typedef void (*git_checkout_perfdata_cb)( + const git_checkout_perfdata *perfdata, + void *payload); + +typedef struct git_checkout_options { + unsigned int version; + + unsigned int checkout_strategy; + + int disable_filters; + unsigned int dir_mode; + unsigned int file_mode; + int file_open_flags; + + unsigned int notify_flags; + git_checkout_notify_cb notify_cb; + void *notify_payload; + + git_checkout_progress_cb progress_cb; + void *progress_payload; + + git_strarray paths; + + git_tree *baseline; + + git_index *baseline_index; + + const char *target_directory; + + const char *ancestor_label; + const char *our_label; + const char *their_label; + + git_checkout_perfdata_cb perfdata_cb; + void *perfdata_payload; +} git_checkout_options; + + +int git_checkout_options_init( + git_checkout_options *opts, + unsigned int version); + +int git_checkout_tree( + git_repository *repo, + const git_object *treeish, + const git_checkout_options *opts); + +int git_checkout_head( + git_repository *repo, + const git_checkout_options *opts); + +int git_checkout_index( + git_repository *repo, + git_index *index, + const git_checkout_options *opts); + diff --git a/pygit2/decl/clone.h b/pygit2/decl/clone.h new file mode 100644 index 000000000..e006a809a --- /dev/null +++ b/pygit2/decl/clone.h @@ -0,0 +1,44 @@ +#define GIT_CLONE_OPTIONS_VERSION ... + +typedef int (*git_remote_create_cb)( + git_remote **out, + git_repository *repo, + const char *name, + const char *url, + void *payload); + +typedef int (*git_repository_create_cb)( + git_repository **out, + const char *path, + int bare, + void *payload); + +typedef enum { + GIT_CLONE_LOCAL_AUTO, + GIT_CLONE_LOCAL, + GIT_CLONE_NO_LOCAL, + GIT_CLONE_LOCAL_NO_LINKS, +} git_clone_local_t; + +typedef struct git_clone_options { + unsigned int version; + git_checkout_options checkout_opts; + git_fetch_options fetch_opts; + int bare; + git_clone_local_t local; + const char* checkout_branch; + git_repository_create_cb repository_cb; + void *repository_cb_payload; + git_remote_create_cb remote_cb; + void *remote_cb_payload; +} git_clone_options; + +int git_clone_options_init( + git_clone_options *opts, + unsigned int version); + +int git_clone( + git_repository **out, + const char *url, + const char *local_path, + const git_clone_options *options); diff --git a/pygit2/decl/commit.h b/pygit2/decl/commit.h new file mode 100644 index 000000000..fc83c6b1a --- /dev/null +++ b/pygit2/decl/commit.h @@ -0,0 +1,21 @@ +int git_commit_amend( + git_oid *id, + const git_commit *commit_to_amend, + const char *update_ref, + const git_signature *author, + const git_signature *committer, + const char *message_encoding, + const char *message, + const git_tree *tree); + +int git_annotated_commit_lookup( + git_annotated_commit **out, + git_repository *repo, + const git_oid *id); + +int git_annotated_commit_from_ref( + git_annotated_commit **out, + git_repository *repo, + const struct git_reference *ref); + +void git_annotated_commit_free(git_annotated_commit *commit); diff --git a/pygit2/decl/common.h b/pygit2/decl/common.h new file mode 100644 index 000000000..bb01c3388 --- /dev/null +++ b/pygit2/decl/common.h @@ -0,0 +1,10 @@ +#define GIT_PATH_MAX ... + +typedef enum { + GIT_FEATURE_THREADS = (1 << 0), + GIT_FEATURE_HTTPS = (1 << 1), + GIT_FEATURE_SSH = (1 << 2), + GIT_FEATURE_NSEC = (1 << 3) +} git_feature_t; + +int git_libgit2_features(void); diff --git a/pygit2/decl/config.h b/pygit2/decl/config.h new file mode 100644 index 000000000..82003d739 --- /dev/null +++ b/pygit2/decl/config.h @@ -0,0 +1,54 @@ +typedef struct git_config_iterator git_config_iterator; + +typedef enum { + GIT_CONFIG_LEVEL_PROGRAMDATA = 1, + GIT_CONFIG_LEVEL_SYSTEM = 2, + GIT_CONFIG_LEVEL_XDG = 3, + GIT_CONFIG_LEVEL_GLOBAL = 4, + GIT_CONFIG_LEVEL_LOCAL = 5, + GIT_CONFIG_LEVEL_WORKTREE = 6, + GIT_CONFIG_LEVEL_APP = 7, + GIT_CONFIG_HIGHEST_LEVEL = -1 +} git_config_level_t; + +typedef struct git_config_entry { + const char *name; + const char *value; + const char *backend_type; + const char *origin_path; + unsigned int include_depth; + git_config_level_t level; +} git_config_entry; + +void git_config_entry_free(git_config_entry *); +void git_config_free(git_config *cfg); +int git_config_get_entry( + git_config_entry **out, + const git_config *cfg, + const char *name); + +int git_config_get_string(const char **out, const git_config *cfg, const char *name); +int git_config_set_string(git_config *cfg, const char *name, const char *value); +int git_config_set_bool(git_config *cfg, const char *name, int value); +int git_config_set_int64(git_config *cfg, const char *name, int64_t value); +int git_config_parse_bool(int *out, const char *value); +int git_config_parse_int64(int64_t *out, const char *value); +int git_config_delete_entry(git_config *cfg, const char *name); +int git_config_add_file_ondisk( + git_config *cfg, + const char *path, + git_config_level_t level, + const git_repository *repo, + int force); +int git_config_iterator_new(git_config_iterator **out, const git_config *cfg); +int git_config_next(git_config_entry **entry, git_config_iterator *iter); +void git_config_iterator_free(git_config_iterator *iter); +int git_config_multivar_iterator_new(git_config_iterator **out, const git_config *cfg, const char *name, const char *regexp); +int git_config_set_multivar(git_config *cfg, const char *name, const char *regexp, const char *value); +int git_config_delete_multivar(git_config *cfg, const char *name, const char *regexp); +int git_config_new(git_config **out); +int git_config_snapshot(git_config **out, git_config *config); +int git_config_open_ondisk(git_config **out, const char *path); +int git_config_find_system(git_buf *out); +int git_config_find_global(git_buf *out); +int git_config_find_xdg(git_buf *out); diff --git a/pygit2/decl/describe.h b/pygit2/decl/describe.h new file mode 100644 index 000000000..4930f2196 --- /dev/null +++ b/pygit2/decl/describe.h @@ -0,0 +1,48 @@ +typedef enum { + GIT_DESCRIBE_DEFAULT, + GIT_DESCRIBE_TAGS, + GIT_DESCRIBE_ALL, +} git_describe_strategy_t; + +typedef struct git_describe_options { + unsigned int version; + unsigned int max_candidates_tags; + unsigned int describe_strategy; + const char *pattern; + int only_follow_first_parent; + int show_commit_oid_as_fallback; +} git_describe_options; + +#define GIT_DESCRIBE_OPTIONS_VERSION 1 + +int git_describe_options_init(git_describe_options *opts, unsigned int version); + +typedef struct { + unsigned int version; + unsigned int abbreviated_size; + int always_use_long_format; + const char *dirty_suffix; +} git_describe_format_options; + +#define GIT_DESCRIBE_FORMAT_OPTIONS_VERSION 1 + +int git_describe_init_format_options(git_describe_format_options *opts, unsigned int version); + +typedef struct git_describe_result git_describe_result; + +int git_describe_commit( + git_describe_result **result, + git_object *committish, + git_describe_options *opts); + +int git_describe_workdir( + git_describe_result **out, + git_repository *repo, + git_describe_options *opts); + +int git_describe_format( + git_buf *out, + const git_describe_result *result, + const git_describe_format_options *opts); + +void git_describe_result_free(git_describe_result *result); diff --git a/pygit2/decl/diff.h b/pygit2/decl/diff.h new file mode 100644 index 000000000..31a32d520 --- /dev/null +++ b/pygit2/decl/diff.h @@ -0,0 +1,91 @@ +typedef struct git_diff git_diff; + +typedef enum { + GIT_DELTA_UNMODIFIED = 0, + GIT_DELTA_ADDED = 1, + GIT_DELTA_DELETED = 2, + GIT_DELTA_MODIFIED = 3, + GIT_DELTA_RENAMED = 4, + GIT_DELTA_COPIED = 5, + GIT_DELTA_IGNORED = 6, + GIT_DELTA_UNTRACKED = 7, + GIT_DELTA_TYPECHANGE = 8, + GIT_DELTA_UNREADABLE = 9, + GIT_DELTA_CONFLICTED = 10, +} git_delta_t; + +typedef struct { + git_oid id; + const char *path; + git_off_t size; + uint32_t flags; + uint16_t mode; + uint16_t id_abbrev; +} git_diff_file; + +typedef struct { + git_delta_t status; + uint32_t flags; + uint16_t similarity; + uint16_t nfiles; + git_diff_file old_file; + git_diff_file new_file; +} git_diff_delta; + +typedef int (*git_diff_notify_cb)( + const git_diff *diff_so_far, + const git_diff_delta *delta_to_add, + const char *matched_pathspec, + void *payload); + +typedef int (*git_diff_progress_cb)( + const git_diff *diff_so_far, + const char *old_path, + const char *new_path, + void *payload); + +typedef struct { + unsigned int version; + uint32_t flags; + git_submodule_ignore_t ignore_submodules; + git_strarray pathspec; + git_diff_notify_cb notify_cb; + git_diff_progress_cb progress_cb; + void *payload; + uint32_t context_lines; + uint32_t interhunk_lines; + git_oid_t oid_type; + uint16_t id_abbrev; + git_off_t max_size; + const char *old_prefix; + const char *new_prefix; +} git_diff_options; + +int git_diff_options_init( + git_diff_options *opts, + unsigned int version); + +typedef struct { + int (*file_signature)( + void **out, const git_diff_file *file, + const char *fullpath, void *payload); + int (*buffer_signature)( + void **out, const git_diff_file *file, + const char *buf, size_t buflen, void *payload); + void (*free_signature)(void *sig, void *payload); + int (*similarity)(int *score, void *siga, void *sigb, void *payload); + void *payload; +} git_diff_similarity_metric; + +int git_diff_tree_to_index( + git_diff **diff, + git_repository *repo, + git_tree *old_tree, + git_index *index, + const git_diff_options *opts); + +int git_diff_index_to_workdir( + git_diff **diff, + git_repository *repo, + git_index *index, + const git_diff_options *opts); diff --git a/pygit2/decl/errors.h b/pygit2/decl/errors.h new file mode 100644 index 000000000..937835686 --- /dev/null +++ b/pygit2/decl/errors.h @@ -0,0 +1,51 @@ +typedef enum { + GIT_OK = 0, /**< No error */ + + GIT_ERROR = -1, /**< Generic error */ + GIT_ENOTFOUND = -3, /**< Requested object could not be found */ + GIT_EEXISTS = -4, /**< Object exists preventing operation */ + GIT_EAMBIGUOUS = -5, /**< More than one object matches */ + GIT_EBUFS = -6, /**< Output buffer too short to hold data */ + + /** + * GIT_EUSER is a special error that is never generated by libgit2 + * code. You can return it from a callback (e.g to stop an iteration) + * to know that it was generated by the callback and not by libgit2. + */ + GIT_EUSER = -7, + + GIT_EBAREREPO = -8, /**< Operation not allowed on bare repository */ + GIT_EUNBORNBRANCH = -9, /**< HEAD refers to branch with no commits */ + GIT_EUNMERGED = -10, /**< Merge in progress prevented operation */ + GIT_ENONFASTFORWARD = -11, /**< Reference was not fast-forwardable */ + GIT_EINVALIDSPEC = -12, /**< Name/ref spec was not in a valid format */ + GIT_ECONFLICT = -13, /**< Checkout conflicts prevented operation */ + GIT_ELOCKED = -14, /**< Lock file prevented operation */ + GIT_EMODIFIED = -15, /**< Reference value does not match expected */ + GIT_EAUTH = -16, /**< Authentication error */ + GIT_ECERTIFICATE = -17, /**< Server certificate is invalid */ + GIT_EAPPLIED = -18, /**< Patch/merge has already been applied */ + GIT_EPEEL = -19, /**< The requested peel operation is not possible */ + GIT_EEOF = -20, /**< Unexpected EOF */ + GIT_EINVALID = -21, /**< Invalid operation or input */ + GIT_EUNCOMMITTED = -22, /**< Uncommitted changes in index prevented operation */ + GIT_EDIRECTORY = -23, /**< The operation is not valid for a directory */ + GIT_EMERGECONFLICT = -24, /**< A merge conflict exists and cannot continue */ + + GIT_PASSTHROUGH = -30, /**< A user-configured callback refused to act */ + GIT_ITEROVER = -31, /**< Signals end of iteration with iterator */ + GIT_RETRY = -32, /**< Internal only */ + GIT_EMISMATCH = -33, /**< Hashsum mismatch in object */ + GIT_EINDEXDIRTY = -34, /**< Unsaved changes in the index would be overwritten */ + GIT_EAPPLYFAIL = -35, /**< Patch application failed */ + GIT_EOWNER = -36, /**< The object is not owned by the current user */ + GIT_TIMEOUT = -37 /**< The operation timed out */ +} git_error_code; + +typedef struct { + char *message; + int klass; +} git_error; + + +const git_error * git_error_last(void); diff --git a/pygit2/decl/graph.h b/pygit2/decl/graph.h new file mode 100644 index 000000000..1f30ab74e --- /dev/null +++ b/pygit2/decl/graph.h @@ -0,0 +1 @@ +int git_graph_ahead_behind(size_t *ahead, size_t *behind, git_repository *repo, const git_oid *local, const git_oid *upstream); diff --git a/pygit2/decl/index.h b/pygit2/decl/index.h new file mode 100644 index 000000000..11a498cb8 --- /dev/null +++ b/pygit2/decl/index.h @@ -0,0 +1,80 @@ +typedef struct { + int32_t seconds; + uint32_t nanoseconds; +} git_index_time; + +typedef struct git_index_entry { + git_index_time ctime; + git_index_time mtime; + + uint32_t dev; + uint32_t ino; + uint32_t mode; + uint32_t uid; + uint32_t gid; + uint32_t file_size; + + git_oid id; + + uint16_t flags; + uint16_t flags_extended; + + const char *path; +} git_index_entry; + +typedef int (*git_index_matched_path_cb)( + const char *path, const char *matched_pathspec, void *payload); + +void git_index_free(git_index *index); +int git_index_open(git_index **out, const char *index_path); +int git_index_read(git_index *index, int force); +int git_index_write(git_index *index); +size_t git_index_entrycount(const git_index *index); +int git_index_find(size_t *at_pos, git_index *index, const char *path); +int git_index_add_bypath(git_index *index, const char *path); +int git_index_add(git_index *index, const git_index_entry *source_entry); +int git_index_remove(git_index *index, const char *path, int stage); +int git_index_remove_directory(git_index *index, const char *path, int stage); +int git_index_read_tree(git_index *index, const git_tree *tree); +int git_index_clear(git_index *index); +int git_index_write_tree(git_oid *out, git_index *index); +int git_index_write_tree_to(git_oid *out, git_index *index, git_repository *repo); +const git_index_entry * git_index_get_bypath( + git_index *index, const char *path, int stage); +const git_index_entry * git_index_get_byindex( + git_index *index, size_t n); +int git_index_add_all( + git_index *index, + const git_strarray *pathspec, + unsigned int flags, + git_index_matched_path_cb callback, + void *payload); +int git_index_remove_all( + git_index *index, + const git_strarray *pathspec, + git_index_matched_path_cb callback, + void *payload); +int git_index_has_conflicts(const git_index *index); +void git_index_conflict_iterator_free( + git_index_conflict_iterator *iterator); +int git_index_conflict_iterator_new( + git_index_conflict_iterator **iterator_out, + git_index *index); +int git_index_conflict_add( + git_index *index, + const git_index_entry *ancestor_entry, + const git_index_entry *our_entry, + const git_index_entry *their_entry); +int git_index_conflict_get( + const git_index_entry **ancestor_out, + const git_index_entry **our_out, + const git_index_entry **their_out, + git_index *index, + const char *path); + +int git_index_conflict_next( + const git_index_entry **ancestor_out, + const git_index_entry **our_out, + const git_index_entry **their_out, + git_index_conflict_iterator *iterator); +int git_index_conflict_remove(git_index *index, const char *path); diff --git a/pygit2/decl/indexer.h b/pygit2/decl/indexer.h new file mode 100644 index 000000000..86769196b --- /dev/null +++ b/pygit2/decl/indexer.h @@ -0,0 +1,11 @@ +typedef struct git_indexer_progress { + unsigned int total_objects; + unsigned int indexed_objects; + unsigned int received_objects; + unsigned int local_objects; + unsigned int total_deltas; + unsigned int indexed_deltas; + size_t received_bytes; +} git_indexer_progress; + +typedef int (*git_indexer_progress_cb)(const git_indexer_progress *stats, void *payload); diff --git a/pygit2/decl/merge.h b/pygit2/decl/merge.h new file mode 100644 index 000000000..d4aa6affa --- /dev/null +++ b/pygit2/decl/merge.h @@ -0,0 +1,96 @@ +#define GIT_MERGE_OPTIONS_VERSION 1 + +typedef enum { + GIT_MERGE_FIND_RENAMES = 1, + GIT_MERGE_FAIL_ON_CONFLICT = 2, + GIT_MERGE_SKIP_REUC = 4, + GIT_MERGE_NO_RECURSIVE = 8, + GIT_MERGE_VIRTUAL_BASE = (1 << 4), +} git_merge_flag_t; + +typedef enum { + GIT_MERGE_FILE_FAVOR_NORMAL = 0, + GIT_MERGE_FILE_FAVOR_OURS = 1, + GIT_MERGE_FILE_FAVOR_THEIRS = 2, + GIT_MERGE_FILE_FAVOR_UNION = 3, +} git_merge_file_favor_t; + +typedef enum { + GIT_MERGE_FILE_DEFAULT = 0, + GIT_MERGE_FILE_STYLE_MERGE = 1, + GIT_MERGE_FILE_STYLE_DIFF3 = 2, + GIT_MERGE_FILE_SIMPLIFY_ALNUM = 4, + GIT_MERGE_FILE_IGNORE_WHITESPACE = 8, + GIT_MERGE_FILE_IGNORE_WHITESPACE_CHANGE = 16, + GIT_MERGE_FILE_IGNORE_WHITESPACE_EOL = 32, + GIT_MERGE_FILE_DIFF_PATIENCE = 64, + GIT_MERGE_FILE_DIFF_MINIMAL = 128, + GIT_MERGE_FILE_STYLE_ZDIFF3 = (1 << 8), + GIT_MERGE_FILE_ACCEPT_CONFLICTS = (1 << 9), +} git_merge_file_flag_t; + +typedef struct { + unsigned int version; + git_merge_flag_t flags; + unsigned int rename_threshold; + unsigned int target_limit; + git_diff_similarity_metric *metric; + unsigned int recursion_limit; + const char *default_driver; + git_merge_file_favor_t file_favor; + git_merge_file_flag_t file_flags; +} git_merge_options; + +typedef struct { + unsigned int automergeable; + const char *path; + unsigned int mode; + const char *ptr; + size_t len; +} git_merge_file_result; + +typedef struct { + unsigned int version; + const char *ancestor_label; + const char *our_label; + const char *their_label; + git_merge_file_favor_t favor; + git_merge_file_flag_t flags; + unsigned short marker_size; +} git_merge_file_options; + +int git_merge_options_init( + git_merge_options *opts, + unsigned int version); + +int git_merge_commits( + git_index **out, + git_repository *repo, + const git_commit *our_commit, + const git_commit *their_commit, + const git_merge_options *opts); + +int git_merge_trees( + git_index **out, + git_repository *repo, + const git_tree *ancestor_tree, + const git_tree *our_tree, + const git_tree *their_tree, + const git_merge_options *opts); + +int git_merge_file_from_index( + git_merge_file_result *out, + git_repository *repo, + const git_index_entry *ancestor, + const git_index_entry *ours, + const git_index_entry *theirs, + const git_merge_file_options *opts); + +int git_merge( + git_repository *repo, + const git_annotated_commit **their_heads, + size_t their_heads_len, + const git_merge_options *merge_opts, + const git_checkout_options *checkout_opts); + +void git_merge_file_result_free(git_merge_file_result *result); diff --git a/pygit2/decl/net.h b/pygit2/decl/net.h new file mode 100644 index 000000000..23d9c1770 --- /dev/null +++ b/pygit2/decl/net.h @@ -0,0 +1,5 @@ +typedef enum { + GIT_DIRECTION_FETCH = 0, + GIT_DIRECTION_PUSH = 1 +} git_direction; + diff --git a/pygit2/decl/oid.h b/pygit2/decl/oid.h new file mode 100644 index 000000000..0890dfdde --- /dev/null +++ b/pygit2/decl/oid.h @@ -0,0 +1,16 @@ +typedef enum { + GIT_OID_SHA1 = 1, /**< SHA1 */ +} git_oid_t; + +typedef struct git_oid { + unsigned char id[20]; +} git_oid; + +// This should go to net.h but due to h_order in _run.py, ffi won't compile properly. +typedef struct git_remote_head { + int local; + git_oid oid; + git_oid loid; + char *name; + char *symref_target; +} git_remote_head; 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/pack.h b/pygit2/decl/pack.h new file mode 100644 index 000000000..1dfefc270 --- /dev/null +++ b/pygit2/decl/pack.h @@ -0,0 +1,18 @@ +typedef int (*git_packbuilder_progress)( + int stage, + uint32_t current, + uint32_t total, + void *payload); + +int git_packbuilder_new(git_packbuilder **out, git_repository *repo); +void git_packbuilder_free(git_packbuilder *pb); + +int git_packbuilder_insert(git_packbuilder *pb, const git_oid *id, const char *name); +int git_packbuilder_insert_recur(git_packbuilder *pb, const git_oid *id, const char *name); + +size_t git_packbuilder_object_count(git_packbuilder *pb); + +int git_packbuilder_write(git_packbuilder *pb, const char *path, unsigned int mode, git_indexer_progress_cb progress_cb, void *progress_cb_payload); +uint32_t git_packbuilder_written(git_packbuilder *pb); + +unsigned int git_packbuilder_set_threads(git_packbuilder *pb, unsigned int n); diff --git a/pygit2/decl/proxy.h b/pygit2/decl/proxy.h new file mode 100644 index 000000000..fc7097635 --- /dev/null +++ b/pygit2/decl/proxy.h @@ -0,0 +1,18 @@ +#define GIT_PROXY_OPTIONS_VERSION ... + +typedef enum { + GIT_PROXY_NONE, + GIT_PROXY_AUTO, + GIT_PROXY_SPECIFIED, +} git_proxy_t; + +typedef struct { + unsigned int version; + git_proxy_t type; + const char *url; + git_credential_acquire_cb credentials; + git_transport_certificate_check_cb certificate_check; + void *payload; +} git_proxy_options; + +int git_proxy_options_init(git_proxy_options *opts, unsigned int version); diff --git a/pygit2/decl/refspec.h b/pygit2/decl/refspec.h new file mode 100644 index 000000000..4c7575f24 --- /dev/null +++ b/pygit2/decl/refspec.h @@ -0,0 +1,9 @@ +const char * git_refspec_src(const git_refspec *refspec); +const char * git_refspec_dst(const git_refspec *refspec); +int git_refspec_force(const git_refspec *refspec); +const char * git_refspec_string(const git_refspec *refspec); +git_direction git_refspec_direction(const git_refspec *spec); +int git_refspec_src_matches(const git_refspec *refspec, const char *refname); +int git_refspec_dst_matches(const git_refspec *refspec, const char *refname); +int git_refspec_transform(git_buf *out, const git_refspec *spec, const char *name); +int git_refspec_rtransform(git_buf *out, const git_refspec *spec, const char *name); diff --git a/pygit2/decl/remote.h b/pygit2/decl/remote.h new file mode 100644 index 000000000..d0e2c141b --- /dev/null +++ b/pygit2/decl/remote.h @@ -0,0 +1,152 @@ +#define GIT_FETCH_OPTIONS_VERSION ... +#define GIT_PUSH_OPTIONS_VERSION ... +#define GIT_REMOTE_CALLBACKS_VERSION ... + +typedef enum { + GIT_REMOTE_REDIRECT_NONE, + GIT_REMOTE_REDIRECT_INITIAL, + GIT_REMOTE_REDIRECT_ALL +} git_remote_redirect_t; + +typedef enum git_remote_completion_t { + GIT_REMOTE_COMPLETION_DOWNLOAD, + GIT_REMOTE_COMPLETION_INDEXING, + GIT_REMOTE_COMPLETION_ERROR, +} git_remote_completion_t; + +typedef int (*git_push_transfer_progress_cb)( + unsigned int current, + unsigned int total, + size_t bytes, + void* payload); + +typedef struct { + char *src_refname; + char *dst_refname; + git_oid src; + git_oid dst; +} git_push_update; + +typedef int (*git_push_negotiation)(const git_push_update **updates, size_t len, void *payload); +typedef int (*git_push_update_reference_cb)(const char *refname, const char *status, void *data); +typedef int (*git_remote_ready_cb)(git_remote *remote, int direction, void *payload); +typedef int (*git_url_resolve_cb)(git_buf *url_resolved, const char *url, int direction, void *payload); + +struct git_remote_callbacks { + unsigned int version; + git_transport_message_cb sideband_progress; + int (*completion)(git_remote_completion_t type, void *data); + git_credential_acquire_cb credentials; + git_transport_certificate_check_cb certificate_check; + git_indexer_progress_cb transfer_progress; + int (*update_tips)(const char *refname, const git_oid *a, const git_oid *b, void *data); + git_packbuilder_progress pack_progress; + git_push_transfer_progress_cb push_transfer_progress; + git_push_update_reference_cb push_update_reference; + git_push_negotiation push_negotiation; + git_transport_cb transport; + git_remote_ready_cb remote_ready; + void *payload; + git_url_resolve_cb resolve_url; + int (*update_refs)(const char *refname, const git_oid *a, const git_oid *b, git_refspec *spec, void *data); +}; + +typedef struct { + unsigned int version; + unsigned int pb_parallelism; + git_remote_callbacks callbacks; + git_proxy_options proxy_opts; + git_remote_redirect_t follow_redirects; + git_strarray custom_headers; + git_strarray remote_push_options; +} git_push_options; + +int git_push_options_init( + git_push_options *opts, + unsigned int version); + +typedef enum { + GIT_FETCH_PRUNE_UNSPECIFIED, + GIT_FETCH_PRUNE, + GIT_FETCH_NO_PRUNE, +} git_fetch_prune_t; + +typedef enum { + GIT_REMOTE_DOWNLOAD_TAGS_UNSPECIFIED = 0, + GIT_REMOTE_DOWNLOAD_TAGS_AUTO, + GIT_REMOTE_DOWNLOAD_TAGS_NONE, + GIT_REMOTE_DOWNLOAD_TAGS_ALL, +} git_remote_autotag_option_t; + +typedef struct { + int version; + git_remote_callbacks callbacks; + git_fetch_prune_t prune; + unsigned int update_fetchhead; + git_remote_autotag_option_t download_tags; + git_proxy_options proxy_opts; + int depth; + git_remote_redirect_t follow_redirects; + git_strarray custom_headers; +} git_fetch_options; + +int git_fetch_options_init( + git_fetch_options *opts, + unsigned int version); + +int git_remote_list(git_strarray *out, git_repository *repo); +int git_remote_lookup(git_remote **out, git_repository *repo, const char *name); +int git_remote_create( + git_remote **out, + git_repository *repo, + const char *name, + const char *url); +int git_remote_create_with_fetchspec( + git_remote **out, + git_repository *repo, + const char *name, + const char *url, + const char *fetch); +int git_remote_create_anonymous( + git_remote **out, + git_repository *repo, + const char *url); +int git_remote_delete(git_repository *repo, const char *name); +const char * git_remote_name(const git_remote *remote); +int git_remote_rename( + git_strarray *problems, + git_repository *repo, + const char *name, + const char *new_name); +const char * git_remote_url(const git_remote *remote); +int git_remote_set_url(git_repository *repo, const char *remote, const char* url); +const char * git_remote_pushurl(const git_remote *remote); +int git_remote_set_pushurl(git_repository *repo, const char *remote, const char* url); +int git_remote_fetch( + git_remote *remote, + const git_strarray *refspecs, + const git_fetch_options *opts, + const char *reflog_message); +int git_remote_prune(git_remote *remote, const git_remote_callbacks *callbacks); +int git_remote_push(git_remote *remote, + const git_strarray *refspecs, + const git_push_options *opts); +const git_indexer_progress * git_remote_stats(git_remote *remote); +int git_remote_add_push(git_repository *repo, const char *remote, const char *refspec); +int git_remote_add_fetch(git_repository *repo, const char *remote, const char *refspec); +int git_remote_init_callbacks( + git_remote_callbacks *opts, + unsigned int version); +size_t git_remote_refspec_count(const git_remote *remote); +const git_refspec * git_remote_get_refspec(const git_remote *remote, size_t n); +int git_remote_get_fetch_refspecs(git_strarray *array, const git_remote *remote); +int git_remote_get_push_refspecs(git_strarray *array, const git_remote *remote); +void git_remote_free(git_remote *remote); + +int git_remote_connect( + git_remote *remote, + int direction, + const git_remote_callbacks *callbacks, + const git_proxy_options *proxy_opts, + const git_strarray *custom_headers); +int git_remote_ls(const git_remote_head ***out, size_t *size, git_remote *remote); diff --git a/pygit2/decl/repository.h b/pygit2/decl/repository.h new file mode 100644 index 000000000..297d87103 --- /dev/null +++ b/pygit2/decl/repository.h @@ -0,0 +1,94 @@ +#define GIT_REPOSITORY_INIT_OPTIONS_VERSION ... + +void git_repository_free(git_repository *repo); +int git_repository_state_cleanup(git_repository *repo); +int git_repository_config(git_config **out, git_repository *repo); +int git_repository_config_snapshot(git_config **out, git_repository *repo); + +typedef enum { + GIT_REPOSITORY_INIT_BARE = 1, + GIT_REPOSITORY_INIT_NO_REINIT = 2, + GIT_REPOSITORY_INIT_NO_DOTGIT_DIR = 4, + GIT_REPOSITORY_INIT_MKDIR = 8, + GIT_REPOSITORY_INIT_MKPATH = 16, + GIT_REPOSITORY_INIT_EXTERNAL_TEMPLATE = 32, + GIT_REPOSITORY_INIT_RELATIVE_GITLINK = 64, +} git_repository_init_flag_t; + +typedef enum { + GIT_REPOSITORY_INIT_SHARED_UMASK = 0, + GIT_REPOSITORY_INIT_SHARED_GROUP = 0002775, + GIT_REPOSITORY_INIT_SHARED_ALL = 0002777, +} git_repository_init_mode_t; + +typedef enum { + 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_repository_state_t; + +typedef struct { + unsigned int version; + uint32_t flags; + uint32_t mode; + const char *workdir_path; + const char *description; + const char *template_path; + const char *initial_head; + const char *origin_url; +} git_repository_init_options; + +int git_repository_init_options_init( + git_repository_init_options *opts, + unsigned int version); + +int git_repository_init( + git_repository **out, + const char *path, + unsigned is_bare); + +int git_repository_init_ext( + git_repository **out, + const char *repo_path, + git_repository_init_options *opts); + +typedef enum { + GIT_REPOSITORY_OPEN_NO_SEARCH = 1, + GIT_REPOSITORY_OPEN_CROSS_FS = 2, + GIT_REPOSITORY_OPEN_BARE = 4, + GIT_REPOSITORY_OPEN_NO_DOTGIT = 8, + GIT_REPOSITORY_OPEN_FROM_ENV = 16, +} git_repository_open_flag_t; + +int git_repository_open_ext( + git_repository **out, + const char *path, + unsigned int flags, + const char *ceiling_dirs); + +int git_repository_set_head( + git_repository* repo, + const char* refname); + +int git_repository_set_head_detached( + git_repository* repo, + const git_oid* committish); + +int git_repository_hashfile(git_oid *out, git_repository *repo, const char *path, git_object_t type, const char *as_path); +int git_repository_ident(const char **name, const char **email, const git_repository *repo); +int git_repository_set_ident(git_repository *repo, const char *name, const char *email); +int git_repository_index(git_index **out, git_repository *repo); +git_repository_state_t git_repository_state(git_repository *repo); +int git_repository_message(git_buf *out, git_repository *repo); +int git_repository_message_remove(git_repository *repo); +int git_repository_submodule_cache_all(git_repository *repo); +int git_repository_submodule_cache_clear(git_repository *repo); diff --git a/pygit2/decl/revert.h b/pygit2/decl/revert.h new file mode 100644 index 000000000..3c9208994 --- /dev/null +++ b/pygit2/decl/revert.h @@ -0,0 +1,21 @@ +#define GIT_REVERT_OPTIONS_VERSION ... + +typedef struct { + unsigned int version; + unsigned int mainline; + git_merge_options merge_opts; + git_checkout_options checkout_opts; +} git_revert_options; + +int git_revert( + git_repository *repo, + git_commit *commit, + const git_revert_options *given_opts); + +int git_revert_commit( + git_index **out, + git_repository *repo, + git_commit *revert_commit, + git_commit *our_commit, + unsigned int mainline, + const git_merge_options *merge_options); diff --git a/pygit2/decl/stash.h b/pygit2/decl/stash.h new file mode 100644 index 000000000..566aeedec --- /dev/null +++ b/pygit2/decl/stash.h @@ -0,0 +1,90 @@ +#define GIT_STASH_APPLY_OPTIONS_VERSION 1 +#define GIT_STASH_SAVE_OPTIONS_VERSION ... + +typedef int (*git_stash_cb)( + size_t index, + const char* message, + const git_oid *stash_id, + void *payload); + +typedef enum { + GIT_STASH_APPLY_PROGRESS_NONE = 0, + 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_stash_apply_progress_t; + +typedef int (*git_stash_apply_progress_cb)( + git_stash_apply_progress_t progress, + void *payload); + +typedef enum { + GIT_STASH_DEFAULT = 0, + GIT_STASH_KEEP_INDEX = 1, + GIT_STASH_INCLUDE_UNTRACKED = 2, + GIT_STASH_INCLUDE_IGNORED = 4, + GIT_STASH_KEEP_ALL = 8, +} git_stash_flags; + +typedef enum { + GIT_STASH_APPLY_DEFAULT = 0, + GIT_STASH_APPLY_REINSTATE_INDEX = 1, +} git_stash_apply_flags; + +typedef struct git_stash_apply_options { + unsigned int version; + git_stash_apply_flags flags; + git_checkout_options checkout_options; + git_stash_apply_progress_cb progress_cb; + void *progress_payload; +} git_stash_apply_options; + +int git_stash_save( + git_oid *out, + git_repository *repo, + const git_signature *stasher, + const char *message, + uint32_t flags); + +int git_stash_apply_options_init( + git_stash_apply_options *opts, unsigned int version); + +int git_stash_apply( + git_repository *repo, + size_t index, + const git_stash_apply_options *options); + +typedef struct git_stash_save_options { + unsigned int version; + uint32_t flags; + const git_signature *stasher; + const char *message; + git_strarray paths; +} git_stash_save_options; + +int git_stash_save_options_init( + git_stash_save_options *opts, + unsigned int version); + +int git_stash_save_with_opts( + git_oid *out, + git_repository *repo, + const git_stash_save_options *opts); + +int git_stash_foreach( + git_repository *repo, + git_stash_cb callback, + void *payload); + +int git_stash_drop( + git_repository *repo, + size_t index); + +int git_stash_pop( + git_repository *repo, + size_t index, + const git_stash_apply_options *options); diff --git a/pygit2/decl/strarray.h b/pygit2/decl/strarray.h new file mode 100644 index 000000000..fdbf2aa45 --- /dev/null +++ b/pygit2/decl/strarray.h @@ -0,0 +1,6 @@ +typedef struct git_strarray { + char **strings; + size_t count; +} git_strarray; + +void git_strarray_dispose(git_strarray *array); diff --git a/pygit2/decl/submodule.h b/pygit2/decl/submodule.h new file mode 100644 index 000000000..b16f4b031 --- /dev/null +++ b/pygit2/decl/submodule.h @@ -0,0 +1,45 @@ +#define GIT_SUBMODULE_UPDATE_OPTIONS_VERSION ... + +typedef struct git_submodule_update_options { + unsigned int version; + git_checkout_options checkout_opts; + git_fetch_options fetch_opts; + int allow_fetch; +} git_submodule_update_options; + +int git_submodule_update_options_init( + git_submodule_update_options *opts, unsigned int version); + +int git_submodule_add_setup( + git_submodule **out, + git_repository *repo, + const char *url, + const char *path, + int use_gitlink); +int git_submodule_clone( + git_repository **out, + git_submodule *submodule, + const git_submodule_update_options *opts); +int git_submodule_add_finalize(git_submodule *submodule); + +int git_submodule_update(git_submodule *submodule, int init, git_submodule_update_options *options); + +int git_submodule_lookup( + git_submodule **out, + git_repository *repo, + const char *name); + +void git_submodule_free(git_submodule *submodule); +int git_submodule_open(git_repository **repo, git_submodule *submodule); +int git_submodule_init(git_submodule *submodule, int overwrite); +int git_submodule_reload(git_submodule *submodule, int force); + +const char * git_submodule_name(git_submodule *submodule); +const char * git_submodule_path(git_submodule *submodule); +const char * git_submodule_url(git_submodule *submodule); +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/transport.h b/pygit2/decl/transport.h new file mode 100644 index 000000000..c26fe78da --- /dev/null +++ b/pygit2/decl/transport.h @@ -0,0 +1,61 @@ +typedef struct git_credential git_credential; + +typedef enum { + GIT_CREDENTIAL_USERPASS_PLAINTEXT = (1u << 0), + GIT_CREDENTIAL_SSH_KEY = (1u << 1), + GIT_CREDENTIAL_SSH_CUSTOM = (1u << 2), + GIT_CREDENTIAL_DEFAULT = (1u << 3), + GIT_CREDENTIAL_SSH_INTERACTIVE = (1u << 4), + GIT_CREDENTIAL_USERNAME = (1u << 5), + GIT_CREDENTIAL_SSH_MEMORY = (1u << 6), +} git_credential_t; + +typedef enum { + GIT_CERT_SSH_MD5 = 1, + GIT_CERT_SSH_SHA1 = 2, +} git_cert_ssh_t; + +typedef struct { + git_cert parent; + git_cert_ssh_t type; + unsigned char hash_md5[16]; + unsigned char hash_sha1[20]; +} git_cert_hostkey; + +typedef struct { + git_cert parent; + void *data; + size_t len; +} git_cert_x509; + +typedef int (*git_credential_acquire_cb)( + git_credential **out, + const char *url, + const char *username_from_url, + unsigned int allowed_types, + void *payload); + +typedef int (*git_transport_cb)(git_transport **out, git_remote *owner, void *param); +int git_credential_username_new(git_credential **out, const char *username); +int git_credential_userpass_plaintext_new( + git_credential **out, + const char *username, + const char *password); + +int git_credential_ssh_key_new( + git_credential **out, + const char *username, + const char *publickey, + const char *privatekey, + const char *passphrase); + +int git_credential_ssh_key_from_agent( + git_credential **out, + const char *username); + +int git_credential_ssh_key_memory_new( + git_credential **out, + const char *username, + const char *publickey, + const char *privatekey, + const char *passphrase); diff --git a/pygit2/decl/types.h b/pygit2/decl/types.h new file mode 100644 index 000000000..0bef96d3e --- /dev/null +++ b/pygit2/decl/types.h @@ -0,0 +1,72 @@ +typedef struct git_commit git_commit; +typedef struct git_annotated_commit git_annotated_commit; +typedef struct git_config git_config; +typedef struct git_index git_index; +typedef struct git_index_conflict_iterator git_index_conflict_iterator; +typedef struct git_object git_object; +typedef struct git_refspec git_refspec; +typedef struct git_remote git_remote; +typedef struct git_remote_callbacks git_remote_callbacks; +typedef struct git_repository git_repository; +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; + +typedef enum { + GIT_REFERENCE_INVALID = 0, + GIT_REFERENCE_DIRECT = 1, + GIT_REFERENCE_SYMBOLIC = 2, + GIT_REFERENCE_ALL = 3, +} git_reference_t; + +typedef struct git_time { + git_time_t time; + int offset; + char sign; +} git_time; + +typedef struct git_signature { + char *name; + char *email; + git_time when; +} git_signature; + +typedef enum git_cert_t { + GIT_CERT_NONE, + GIT_CERT_X509, + GIT_CERT_HOSTKEY_LIBSSH2, + GIT_CERT_STRARRAY, +} git_cert_t; + +typedef struct { + git_cert_t cert_type; +} git_cert; + +typedef int (*git_transport_message_cb)(const char *str, int len, void *payload); +typedef int (*git_transport_certificate_check_cb)(git_cert *cert, int valid, const char *host, void *payload); + +typedef enum { + GIT_SUBMODULE_IGNORE_UNSPECIFIED = -1, + + GIT_SUBMODULE_IGNORE_NONE = 1, + GIT_SUBMODULE_IGNORE_UNTRACKED = 2, + GIT_SUBMODULE_IGNORE_DIRTY = 3, + GIT_SUBMODULE_IGNORE_ALL = 4, +} git_submodule_ignore_t; + +typedef enum { + GIT_OBJECT_ANY = ..., + GIT_OBJECT_INVALID = ..., + GIT_OBJECT_COMMIT = ..., + GIT_OBJECT_TREE = ..., + GIT_OBJECT_BLOB = ..., + GIT_OBJECT_TAG = ..., + GIT_OBJECT_OFS_DELTA = ..., + GIT_OBJECT_REF_DELTA = ..., +} git_object_t; diff --git a/pygit2/enums.py b/pygit2/enums.py new file mode 100644 index 000000000..d690e73ae --- /dev/null +++ b/pygit2/enums.py @@ -0,0 +1,1302 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +from enum import IntEnum, IntFlag + +from . import _pygit2, options +from .ffi import C + + +class ApplyLocation(IntEnum): + """Possible application locations for patches""" + + WORKDIR = _pygit2.GIT_APPLY_LOCATION_WORKDIR + """ + Apply the patch to the workdir, leaving the index untouched. + This is the equivalent of `git apply` with no location argument. + """ + + INDEX = _pygit2.GIT_APPLY_LOCATION_INDEX + """ + Apply the patch to the index, leaving the working directory + untouched. This is the equivalent of `git apply --cached`. + """ + + BOTH = _pygit2.GIT_APPLY_LOCATION_BOTH + """ + Apply the patch to both the working directory and the index. + This is the equivalent of `git apply --index`. + """ + + +class AttrCheck(IntFlag): + FILE_THEN_INDEX = C.GIT_ATTR_CHECK_FILE_THEN_INDEX + INDEX_THEN_FILE = C.GIT_ATTR_CHECK_INDEX_THEN_FILE + INDEX_ONLY = C.GIT_ATTR_CHECK_INDEX_ONLY + NO_SYSTEM = C.GIT_ATTR_CHECK_NO_SYSTEM + INCLUDE_HEAD = C.GIT_ATTR_CHECK_INCLUDE_HEAD + INCLUDE_COMMIT = C.GIT_ATTR_CHECK_INCLUDE_COMMIT + + +class BlameFlag(IntFlag): + NORMAL = _pygit2.GIT_BLAME_NORMAL + 'Normal blame, the default' + + TRACK_COPIES_SAME_FILE = _pygit2.GIT_BLAME_TRACK_COPIES_SAME_FILE + 'Not yet implemented and reserved for future use (as of libgit2 1.9.0).' + + TRACK_COPIES_SAME_COMMIT_MOVES = _pygit2.GIT_BLAME_TRACK_COPIES_SAME_COMMIT_MOVES + 'Not yet implemented and reserved for future use (as of libgit2 1.9.0).' + + TRACK_COPIES_SAME_COMMIT_COPIES = _pygit2.GIT_BLAME_TRACK_COPIES_SAME_COMMIT_COPIES + 'Not yet implemented and reserved for future use (as of libgit2 1.9.0).' + + TRACK_COPIES_ANY_COMMIT_COPIES = _pygit2.GIT_BLAME_TRACK_COPIES_ANY_COMMIT_COPIES + 'Not yet implemented and reserved for future use (as of libgit2 1.9.0).' + + FIRST_PARENT = _pygit2.GIT_BLAME_FIRST_PARENT + 'Restrict the search of commits to those reachable following only the first parents.' + + USE_MAILMAP = _pygit2.GIT_BLAME_USE_MAILMAP + """ + Use mailmap file to map author and committer names and email addresses + to canonical real names and email addresses. The mailmap will be read + from the working directory, or HEAD in a bare repository. + """ + + IGNORE_WHITESPACE = _pygit2.GIT_BLAME_IGNORE_WHITESPACE + 'Ignore whitespace differences' + + +class BlobFilter(IntFlag): + CHECK_FOR_BINARY = _pygit2.GIT_BLOB_FILTER_CHECK_FOR_BINARY + 'Do not apply filters to binary files.' + + NO_SYSTEM_ATTRIBUTES = _pygit2.GIT_BLOB_FILTER_NO_SYSTEM_ATTRIBUTES + 'Filters will not load configuration from the system-wide `gitattributes` in `/etc` (or system equivalent).' + + ATTRIBUTES_FROM_HEAD = _pygit2.GIT_BLOB_FILTER_ATTRIBUTES_FROM_HEAD + 'Load filters from a `.gitattributes` file in the HEAD commit.' + + ATTRIBUTES_FROM_COMMIT = _pygit2.GIT_BLOB_FILTER_ATTRIBUTES_FROM_COMMIT + 'Load filters from a `.gitattributes` file in the specified commit.' + + +class BranchType(IntFlag): + LOCAL = _pygit2.GIT_BRANCH_LOCAL + REMOTE = _pygit2.GIT_BRANCH_REMOTE + ALL = _pygit2.GIT_BRANCH_ALL + + +class CheckoutNotify(IntFlag): + """ + Checkout notification flags + + Checkout will invoke an options notification callback + (`CheckoutCallbacks.checkout_notify`) for certain cases - you pick which + ones via `CheckoutCallbacks.checkout_notify_flags`. + """ + + NONE = C.GIT_CHECKOUT_NOTIFY_NONE + + CONFLICT = C.GIT_CHECKOUT_NOTIFY_CONFLICT + 'Invokes checkout on conflicting paths.' + + DIRTY = C.GIT_CHECKOUT_NOTIFY_DIRTY + """ + Notifies about "dirty" files, i.e. those that do not need an update + but no longer match the baseline. Core git displays these files when + checkout runs, but won't stop the checkout. + """ + + UPDATED = C.GIT_CHECKOUT_NOTIFY_UPDATED + 'Sends notification for any file changed.' + + UNTRACKED = C.GIT_CHECKOUT_NOTIFY_UNTRACKED + 'Notifies about untracked files.' + + IGNORED = C.GIT_CHECKOUT_NOTIFY_IGNORED + 'Notifies about ignored files.' + + ALL = C.GIT_CHECKOUT_NOTIFY_ALL + + +class CheckoutStrategy(IntFlag): + NONE = _pygit2.GIT_CHECKOUT_NONE + 'Dry run, no actual updates' + + SAFE = _pygit2.GIT_CHECKOUT_SAFE + """ + Allow safe updates that cannot overwrite uncommitted data. + If the uncommitted changes don't conflict with the checked out files, + the checkout will still proceed, leaving the changes intact. + + Mutually exclusive with FORCE. + FORCE takes precedence over SAFE. + """ + + FORCE = _pygit2.GIT_CHECKOUT_FORCE + """ + Allow all updates to force working directory to look like index. + + Mutually exclusive with SAFE. + FORCE takes precedence over SAFE. + """ + + RECREATE_MISSING = _pygit2.GIT_CHECKOUT_RECREATE_MISSING + """ Allow checkout to recreate missing files """ + + ALLOW_CONFLICTS = _pygit2.GIT_CHECKOUT_ALLOW_CONFLICTS + """ Allow checkout to make safe updates even if conflicts are found """ + + REMOVE_UNTRACKED = _pygit2.GIT_CHECKOUT_REMOVE_UNTRACKED + """ Remove untracked files not in index (that are not ignored) """ + + REMOVE_IGNORED = _pygit2.GIT_CHECKOUT_REMOVE_IGNORED + """ Remove ignored files not in index """ + + UPDATE_ONLY = _pygit2.GIT_CHECKOUT_UPDATE_ONLY + """ Only update existing files, don't create new ones """ + + DONT_UPDATE_INDEX = _pygit2.GIT_CHECKOUT_DONT_UPDATE_INDEX + """ + Normally checkout updates index entries as it goes; this stops that. + Implies `DONT_WRITE_INDEX`. + """ + + NO_REFRESH = _pygit2.GIT_CHECKOUT_NO_REFRESH + """ Don't refresh index/config/etc before doing checkout """ + + SKIP_UNMERGED = _pygit2.GIT_CHECKOUT_SKIP_UNMERGED + """ Allow checkout to skip unmerged files """ + + USE_OURS = _pygit2.GIT_CHECKOUT_USE_OURS + """ For unmerged files, checkout stage 2 from index """ + + USE_THEIRS = _pygit2.GIT_CHECKOUT_USE_THEIRS + """ For unmerged files, checkout stage 3 from index """ + + DISABLE_PATHSPEC_MATCH = _pygit2.GIT_CHECKOUT_DISABLE_PATHSPEC_MATCH + """ Treat pathspec as simple list of exact match file paths """ + + SKIP_LOCKED_DIRECTORIES = _pygit2.GIT_CHECKOUT_SKIP_LOCKED_DIRECTORIES + """ Ignore directories in use, they will be left empty """ + + DONT_OVERWRITE_IGNORED = _pygit2.GIT_CHECKOUT_DONT_OVERWRITE_IGNORED + """ Don't overwrite ignored files that exist in the checkout target """ + + CONFLICT_STYLE_MERGE = _pygit2.GIT_CHECKOUT_CONFLICT_STYLE_MERGE + """ Write normal merge files for conflicts """ + + CONFLICT_STYLE_DIFF3 = _pygit2.GIT_CHECKOUT_CONFLICT_STYLE_DIFF3 + """ Include common ancestor data in diff3 format files for conflicts """ + + DONT_REMOVE_EXISTING = _pygit2.GIT_CHECKOUT_DONT_REMOVE_EXISTING + """ Don't overwrite existing files or folders """ + + DONT_WRITE_INDEX = _pygit2.GIT_CHECKOUT_DONT_WRITE_INDEX + """ Normally checkout writes the index upon completion; this prevents that. """ + + DRY_RUN = _pygit2.GIT_CHECKOUT_DRY_RUN + """ + Show what would be done by a checkout. Stop after sending + notifications; don't update the working directory or index. + """ + + CONFLICT_STYLE_ZDIFF3 = _pygit2.GIT_CHECKOUT_CONFLICT_STYLE_DIFF3 + """ Include common ancestor data in zdiff3 format for conflicts """ + + +class ConfigLevel(IntEnum): + """ + Priority level of a config file. + These priority levels correspond to the natural escalation logic + (from higher to lower) when searching for config entries in git.git. + """ + + PROGRAMDATA = _pygit2.GIT_CONFIG_LEVEL_PROGRAMDATA + 'System-wide on Windows, for compatibility with portable git' + + SYSTEM = _pygit2.GIT_CONFIG_LEVEL_SYSTEM + 'System-wide configuration file; /etc/gitconfig on Linux systems' + + XDG = _pygit2.GIT_CONFIG_LEVEL_XDG + 'XDG compatible configuration file; typically ~/.config/git/config' + + GLOBAL = _pygit2.GIT_CONFIG_LEVEL_GLOBAL + 'User-specific configuration file (also called Global configuration file); typically ~/.gitconfig' + + LOCAL = _pygit2.GIT_CONFIG_LEVEL_LOCAL + 'Repository specific configuration file; $WORK_DIR/.git/config on non-bare repos' + + WORKTREE = _pygit2.GIT_CONFIG_LEVEL_WORKTREE + 'Worktree specific configuration file; $GIT_DIR/config.worktree' + + APP = _pygit2.GIT_CONFIG_LEVEL_APP + 'Application specific configuration file; freely defined by applications' + + HIGHEST_LEVEL = _pygit2.GIT_CONFIG_HIGHEST_LEVEL + """Represents the highest level available config file (i.e. the most + specific config file available that actually is loaded)""" + + +class CredentialType(IntFlag): + """ + Supported credential types. This represents the various types of + authentication methods supported by the library. + """ + + USERPASS_PLAINTEXT = C.GIT_CREDENTIAL_USERPASS_PLAINTEXT + 'A vanilla user/password request' + + SSH_KEY = C.GIT_CREDENTIAL_SSH_KEY + 'An SSH key-based authentication request' + + SSH_CUSTOM = C.GIT_CREDENTIAL_SSH_CUSTOM + 'An SSH key-based authentication request, with a custom signature' + + DEFAULT = C.GIT_CREDENTIAL_DEFAULT + 'An NTLM/Negotiate-based authentication request.' + + SSH_INTERACTIVE = C.GIT_CREDENTIAL_SSH_INTERACTIVE + 'An SSH interactive authentication request.' + + USERNAME = C.GIT_CREDENTIAL_USERNAME + """ + Username-only authentication request. + Used as a pre-authentication step if the underlying transport (eg. SSH, + with no username in its URL) does not know which username to use. + """ + + SSH_MEMORY = C.GIT_CREDENTIAL_SSH_MEMORY + """ + An SSH key-based authentication request. + Allows credentials to be read from memory instead of files. + Note that because of differences in crypto backend support, it might + not be functional. + """ + + +class DeltaStatus(IntEnum): + """ + What type of change is described by a DiffDelta? + + `RENAMED` and `COPIED` will only show up if you run + `find_similar()` on the Diff object. + + `TYPECHANGE` only shows up given `INCLUDE_TYPECHANGE` + in the DiffOption option flags (otherwise type changes + will be split into ADDED / DELETED pairs). + """ + + UNMODIFIED = _pygit2.GIT_DELTA_UNMODIFIED + 'no changes' + + ADDED = _pygit2.GIT_DELTA_ADDED + 'entry does not exist in old version' + + DELETED = _pygit2.GIT_DELTA_DELETED + 'entry does not exist in new version' + + MODIFIED = _pygit2.GIT_DELTA_MODIFIED + 'entry content changed between old and new' + + RENAMED = _pygit2.GIT_DELTA_RENAMED + 'entry was renamed between old and new' + + COPIED = _pygit2.GIT_DELTA_COPIED + 'entry was copied from another old entry' + + IGNORED = _pygit2.GIT_DELTA_IGNORED + 'entry is ignored item in workdir' + + UNTRACKED = _pygit2.GIT_DELTA_UNTRACKED + 'entry is untracked item in workdir' + + TYPECHANGE = _pygit2.GIT_DELTA_TYPECHANGE + 'type of entry changed between old and new' + + UNREADABLE = _pygit2.GIT_DELTA_UNREADABLE + 'entry is unreadable' + + CONFLICTED = _pygit2.GIT_DELTA_CONFLICTED + 'entry in the index is conflicted' + + +class DescribeStrategy(IntEnum): + """ + Reference lookup strategy. + + These behave like the --tags and --all options to git-describe, + namely they say to look for any reference in either refs/tags/ or + refs/ respectively. + """ + + DEFAULT = _pygit2.GIT_DESCRIBE_DEFAULT + TAGS = _pygit2.GIT_DESCRIBE_TAGS + ALL = _pygit2.GIT_DESCRIBE_ALL + + +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. """ + + FIND_RENAMES = _pygit2.GIT_DIFF_FIND_RENAMES + """ Look for renames? (`--find-renames`) """ + + FIND_RENAMES_FROM_REWRITES = _pygit2.GIT_DIFF_FIND_RENAMES_FROM_REWRITES + """ Consider old side of MODIFIED for renames? (`--break-rewrites=N`) """ + + FIND_COPIES = _pygit2.GIT_DIFF_FIND_COPIES + """ Look for copies? (a la `--find-copies`). """ + + FIND_COPIES_FROM_UNMODIFIED = _pygit2.GIT_DIFF_FIND_COPIES_FROM_UNMODIFIED + """ + Consider UNMODIFIED as copy sources? (`--find-copies-harder`). + For this to work correctly, use INCLUDE_UNMODIFIED when the initial + `Diff` is being generated. + """ + + FIND_REWRITES = _pygit2.GIT_DIFF_FIND_REWRITES + """ Mark significant rewrites for split (`--break-rewrites=/M`) """ + + BREAK_REWRITES = _pygit2.GIT_DIFF_BREAK_REWRITES + """ Actually split large rewrites into delete/add pairs """ + + FIND_AND_BREAK_REWRITES = _pygit2.GIT_DIFF_FIND_AND_BREAK_REWRITES + """ Mark rewrites for split and break into delete/add pairs """ + + FIND_FOR_UNTRACKED = _pygit2.GIT_DIFF_FIND_FOR_UNTRACKED + """ + Find renames/copies for UNTRACKED items in working directory. + For this to work correctly, use INCLUDE_UNTRACKED when the initial + `Diff` is being generated (and obviously the diff must be against + the working directory for this to make sense). + """ + + FIND_ALL = _pygit2.GIT_DIFF_FIND_ALL + """ Turn on all finding features. """ + + FIND_IGNORE_LEADING_WHITESPACE = _pygit2.GIT_DIFF_FIND_IGNORE_LEADING_WHITESPACE + """ Measure similarity ignoring leading whitespace (default) """ + + FIND_IGNORE_WHITESPACE = _pygit2.GIT_DIFF_FIND_IGNORE_WHITESPACE + """ Measure similarity ignoring all whitespace """ + + FIND_DONT_IGNORE_WHITESPACE = _pygit2.GIT_DIFF_FIND_DONT_IGNORE_WHITESPACE + """ Measure similarity including all data """ + + FIND_EXACT_MATCH_ONLY = _pygit2.GIT_DIFF_FIND_EXACT_MATCH_ONLY + """ Measure similarity only by comparing SHAs (fast and cheap) """ + + BREAK_REWRITES_FOR_RENAMES_ONLY = _pygit2.GIT_DIFF_BREAK_REWRITES_FOR_RENAMES_ONLY + """ + Do not break rewrites unless they contribute to a rename. + + Normally, FIND_AND_BREAK_REWRITES will measure the self- + similarity of modified files and split the ones that have changed a + lot into a DELETE / ADD pair. Then the sides of that pair will be + considered candidates for rename and copy detection. + + If you add this flag in and the split pair is *not* used for an + actual rename or copy, then the modified record will be restored to + a regular MODIFIED record instead of being split. + """ + + FIND_REMOVE_UNMODIFIED = _pygit2.GIT_DIFF_FIND_REMOVE_UNMODIFIED + """ + Remove any UNMODIFIED deltas after find_similar is done. + + Using FIND_COPIES_FROM_UNMODIFIED to emulate the + --find-copies-harder behavior requires building a diff with the + INCLUDE_UNMODIFIED flag. If you do not want UNMODIFIED records + in the final result, pass this flag to have them removed. + """ + + +class DiffFlag(IntFlag): + """ + Flags for the delta object and the file objects on each side. + + These flags are used for both the `flags` value of the `DiffDelta` + and the flags for the `DiffFile` objects representing the old and + new sides of the delta. Values outside of this public range should be + considered reserved for internal or future use. + """ + + BINARY = _pygit2.GIT_DIFF_FLAG_BINARY + 'file(s) treated as binary data' + + NOT_BINARY = _pygit2.GIT_DIFF_FLAG_NOT_BINARY + 'file(s) treated as text data' + + VALID_ID = _pygit2.GIT_DIFF_FLAG_VALID_ID + '`id` value is known correct' + + EXISTS = _pygit2.GIT_DIFF_FLAG_EXISTS + 'file exists at this side of the delta' + + VALID_SIZE = _pygit2.GIT_DIFF_FLAG_VALID_SIZE + 'file size value is known correct' + + +class DiffOption(IntFlag): + """ + Flags for diff options. A combination of these flags can be passed + in via the `flags` value in `diff_*` functions. + """ + + NORMAL = _pygit2.GIT_DIFF_NORMAL + 'Normal diff, the default' + + REVERSE = _pygit2.GIT_DIFF_REVERSE + 'Reverse the sides of the diff' + + INCLUDE_IGNORED = _pygit2.GIT_DIFF_INCLUDE_IGNORED + 'Include ignored files in the diff' + + RECURSE_IGNORED_DIRS = _pygit2.GIT_DIFF_RECURSE_IGNORED_DIRS + """ + Even with INCLUDE_IGNORED, an entire ignored directory + will be marked with only a single entry in the diff; this flag + adds all files under the directory as IGNORED entries, too. + """ + + INCLUDE_UNTRACKED = _pygit2.GIT_DIFF_INCLUDE_UNTRACKED + 'Include untracked files in the diff' + + RECURSE_UNTRACKED_DIRS = _pygit2.GIT_DIFF_RECURSE_UNTRACKED_DIRS + """ + Even with INCLUDE_UNTRACKED, an entire untracked + directory will be marked with only a single entry in the diff + (a la what core Git does in `git status`); this flag adds *all* + files under untracked directories as UNTRACKED entries, too. + """ + + INCLUDE_UNMODIFIED = _pygit2.GIT_DIFF_INCLUDE_UNMODIFIED + 'Include unmodified files in the diff' + + INCLUDE_TYPECHANGE = _pygit2.GIT_DIFF_INCLUDE_TYPECHANGE + """ + Normally, a type change between files will be converted into a + DELETED record for the old and an ADDED record for the new; this + options enabled the generation of TYPECHANGE delta records. + """ + + INCLUDE_TYPECHANGE_TREES = _pygit2.GIT_DIFF_INCLUDE_TYPECHANGE_TREES + """ + Even with INCLUDE_TYPECHANGE, blob->tree changes still generally + show as a DELETED blob. This flag tries to correctly label + blob->tree transitions as TYPECHANGE records with new_file's + mode set to tree. Note: the tree SHA will not be available. + """ + + IGNORE_FILEMODE = _pygit2.GIT_DIFF_IGNORE_FILEMODE + 'Ignore file mode changes' + + IGNORE_SUBMODULES = _pygit2.GIT_DIFF_IGNORE_SUBMODULES + 'Treat all submodules as unmodified' + + IGNORE_CASE = _pygit2.GIT_DIFF_IGNORE_CASE + 'Use case insensitive filename comparisons' + + INCLUDE_CASECHANGE = _pygit2.GIT_DIFF_INCLUDE_CASECHANGE + """ + May be combined with IGNORE_CASE to specify that a file + that has changed case will be returned as an add/delete pair. + """ + + DISABLE_PATHSPEC_MATCH = _pygit2.GIT_DIFF_DISABLE_PATHSPEC_MATCH + """ + If the pathspec is set in the diff options, this flags indicates + that the paths will be treated as literal paths instead of + fnmatch patterns. Each path in the list must either be a full + path to a file or a directory. (A trailing slash indicates that + the path will _only_ match a directory). If a directory is + specified, all children will be included. + """ + + SKIP_BINARY_CHECK = _pygit2.GIT_DIFF_SKIP_BINARY_CHECK + """ + Disable updating of the `binary` flag in delta records. This is + useful when iterating over a diff if you don't need hunk and data + callbacks and want to avoid having to load file completely. + """ + + ENABLE_FAST_UNTRACKED_DIRS = _pygit2.GIT_DIFF_ENABLE_FAST_UNTRACKED_DIRS + """ + When diff finds an untracked directory, to match the behavior of + core Git, it scans the contents for IGNORED and UNTRACKED files. + If *all* contents are IGNORED, then the directory is IGNORED; if + any contents are not IGNORED, then the directory is UNTRACKED. + This is extra work that may not matter in many cases. This flag + turns off that scan and immediately labels an untracked directory + as UNTRACKED (changing the behavior to not match core Git). + """ + + UPDATE_INDEX = _pygit2.GIT_DIFF_UPDATE_INDEX + """ + When diff finds a file in the working directory with stat + information different from the index, but the OID ends up being the + same, write the correct stat information into the index. Note: + without this flag, diff will always leave the index untouched. + """ + + INCLUDE_UNREADABLE = _pygit2.GIT_DIFF_INCLUDE_UNREADABLE + 'Include unreadable files in the diff' + + INCLUDE_UNREADABLE_AS_UNTRACKED = _pygit2.GIT_DIFF_INCLUDE_UNREADABLE_AS_UNTRACKED + 'Include unreadable files in the diff' + + INDENT_HEURISTIC = _pygit2.GIT_DIFF_INDENT_HEURISTIC + """ + Use a heuristic that takes indentation and whitespace into account + which generally can produce better diffs when dealing with ambiguous + diff hunks. + """ + + IGNORE_BLANK_LINES = _pygit2.GIT_DIFF_IGNORE_BLANK_LINES + 'Ignore blank lines' + + FORCE_TEXT = _pygit2.GIT_DIFF_FORCE_TEXT + 'Treat all files as text, disabling binary attributes & detection' + + FORCE_BINARY = _pygit2.GIT_DIFF_FORCE_BINARY + 'Treat all files as binary, disabling text diffs' + + IGNORE_WHITESPACE = _pygit2.GIT_DIFF_IGNORE_WHITESPACE + 'Ignore all whitespace' + + IGNORE_WHITESPACE_CHANGE = _pygit2.GIT_DIFF_IGNORE_WHITESPACE_CHANGE + 'Ignore changes in amount of whitespace' + + IGNORE_WHITESPACE_EOL = _pygit2.GIT_DIFF_IGNORE_WHITESPACE_EOL + 'Ignore whitespace at end of line' + + SHOW_UNTRACKED_CONTENT = _pygit2.GIT_DIFF_SHOW_UNTRACKED_CONTENT + """ + When generating patch text, include the content of untracked files. + This automatically turns on INCLUDE_UNTRACKED but it does not turn + on RECURSE_UNTRACKED_DIRS. Add that flag if you want the content + of every single UNTRACKED file. + """ + + SHOW_UNMODIFIED = _pygit2.GIT_DIFF_SHOW_UNMODIFIED + """ + When generating output, include the names of unmodified files if + they are included in the git_diff. Normally these are skipped in + the formats that list files (e.g. name-only, name-status, raw). + Even with this, these will not be included in patch format. + """ + + PATIENCE = _pygit2.GIT_DIFF_PATIENCE + "Use the 'patience diff' algorithm" + + MINIMAL = _pygit2.GIT_DIFF_MINIMAL + 'Take extra time to find minimal diff' + + SHOW_BINARY = _pygit2.GIT_DIFF_SHOW_BINARY + """ + Include the necessary deflate / delta information so that `git-apply` + can apply given diff information to binary files. + """ + + +class DiffStatsFormat(IntFlag): + """Formatting options for diff stats""" + + NONE = _pygit2.GIT_DIFF_STATS_NONE + 'No stats' + + FULL = _pygit2.GIT_DIFF_STATS_FULL + 'Full statistics, equivalent of `--stat`' + + SHORT = _pygit2.GIT_DIFF_STATS_SHORT + 'Short statistics, equivalent of `--shortstat`' + + NUMBER = _pygit2.GIT_DIFF_STATS_NUMBER + 'Number statistics, equivalent of `--numstat`' + + INCLUDE_SUMMARY = _pygit2.GIT_DIFF_STATS_INCLUDE_SUMMARY + 'Extended header information such as creations, renames and mode changes, equivalent of `--summary`' + + +class Feature(IntFlag): + """ + Combinations of these values describe the features with which libgit2 + was compiled. + """ + + THREADS = C.GIT_FEATURE_THREADS + HTTPS = C.GIT_FEATURE_HTTPS + SSH = C.GIT_FEATURE_SSH + NSEC = C.GIT_FEATURE_NSEC + + +class FetchPrune(IntEnum): + """Acceptable prune settings when fetching.""" + + UNSPECIFIED = C.GIT_FETCH_PRUNE_UNSPECIFIED + 'Use the setting from the configuration' + + PRUNE = C.GIT_FETCH_PRUNE + """Force pruning on: remove any remote branch in the local repository + that does not exist in the remote.""" + + NO_PRUNE = C.GIT_FETCH_NO_PRUNE + """Force pruning off: always keep the remote branches.""" + + +class FileMode(IntFlag): + UNREADABLE = _pygit2.GIT_FILEMODE_UNREADABLE + TREE = _pygit2.GIT_FILEMODE_TREE + BLOB = _pygit2.GIT_FILEMODE_BLOB + BLOB_EXECUTABLE = _pygit2.GIT_FILEMODE_BLOB_EXECUTABLE + LINK = _pygit2.GIT_FILEMODE_LINK + COMMIT = _pygit2.GIT_FILEMODE_COMMIT + + +class FileStatus(IntFlag): + """ + Status flags for a single file. + + A combination of these values will be returned to indicate the status of + a file. Status compares the working directory, the index, and the current + HEAD of the repository. The `INDEX_...` set of flags represents the status + of the file in the index relative to the HEAD, and the `WT_...` set of + flags represents the status of the file in the working directory relative + to the index. + """ + + CURRENT = _pygit2.GIT_STATUS_CURRENT + + INDEX_NEW = _pygit2.GIT_STATUS_INDEX_NEW + INDEX_MODIFIED = _pygit2.GIT_STATUS_INDEX_MODIFIED + INDEX_DELETED = _pygit2.GIT_STATUS_INDEX_DELETED + INDEX_RENAMED = _pygit2.GIT_STATUS_INDEX_RENAMED + INDEX_TYPECHANGE = _pygit2.GIT_STATUS_INDEX_TYPECHANGE + + WT_NEW = _pygit2.GIT_STATUS_WT_NEW + WT_MODIFIED = _pygit2.GIT_STATUS_WT_MODIFIED + WT_DELETED = _pygit2.GIT_STATUS_WT_DELETED + WT_TYPECHANGE = _pygit2.GIT_STATUS_WT_TYPECHANGE + WT_RENAMED = _pygit2.GIT_STATUS_WT_RENAMED + WT_UNREADABLE = _pygit2.GIT_STATUS_WT_UNREADABLE + + IGNORED = _pygit2.GIT_STATUS_IGNORED + CONFLICTED = _pygit2.GIT_STATUS_CONFLICTED + + +class FilterFlag(IntFlag): + """Filter option flags.""" + + DEFAULT = _pygit2.GIT_FILTER_DEFAULT + + ALLOW_UNSAFE = _pygit2.GIT_FILTER_ALLOW_UNSAFE + "Don't error for `safecrlf` violations, allow them to continue." + + NO_SYSTEM_ATTRIBUTES = _pygit2.GIT_FILTER_NO_SYSTEM_ATTRIBUTES + "Don't load `/etc/gitattributes` (or the system equivalent)" + + ATTRIBUTES_FROM_HEAD = _pygit2.GIT_FILTER_ATTRIBUTES_FROM_HEAD + 'Load attributes from `.gitattributes` in the root of HEAD' + + ATTRIBUTES_FROM_COMMIT = _pygit2.GIT_FILTER_ATTRIBUTES_FROM_COMMIT + 'Load attributes from `.gitattributes` in a given commit. This can only be specified in a `git_filter_options`.' + + +class FilterMode(IntEnum): + """ + Filters are applied in one of two directions: smudging - which is + exporting a file from the Git object database to the working directory, + and cleaning - which is importing a file from the working directory to + the Git object database. These values control which direction of + change is being applied. + """ + + TO_WORKTREE = _pygit2.GIT_FILTER_TO_WORKTREE + SMUDGE = _pygit2.GIT_FILTER_SMUDGE + TO_ODB = _pygit2.GIT_FILTER_TO_ODB + CLEAN = _pygit2.GIT_FILTER_CLEAN + + +class MergeAnalysis(IntFlag): + """The results of `Repository.merge_analysis` indicate the merge opportunities.""" + + NONE = _pygit2.GIT_MERGE_ANALYSIS_NONE + 'No merge is possible. (Unused.)' + + NORMAL = _pygit2.GIT_MERGE_ANALYSIS_NORMAL + """ + A "normal" merge; both HEAD and the given merge input have diverged + from their common ancestor. The divergent commits must be merged. + """ + + UP_TO_DATE = _pygit2.GIT_MERGE_ANALYSIS_UP_TO_DATE + """ + All given merge inputs are reachable from HEAD, meaning the + repository is up-to-date and no merge needs to be performed. + """ + + FASTFORWARD = _pygit2.GIT_MERGE_ANALYSIS_FASTFORWARD + """ + The given merge input is a fast-forward from HEAD and no merge + needs to be performed. Instead, the client can check out the + given merge input. + """ + + UNBORN = _pygit2.GIT_MERGE_ANALYSIS_UNBORN + """ + The HEAD of the current repository is "unborn" and does not point to + a valid commit. No merge can be performed, but the caller may wish + to simply set HEAD to the target commit(s). + """ + + +class MergeFavor(IntEnum): + """ + Merge file favor options for `Repository.merge` instruct the file-level + merging functionality how to deal with conflicting regions of the files. + """ + + NORMAL = C.GIT_MERGE_FILE_FAVOR_NORMAL + """ + When a region of a file is changed in both branches, a conflict will be + recorded in the index so that `checkout` can produce a merge file with + conflict markers in the working directory. + + This is the default. + """ + + OURS = C.GIT_MERGE_FILE_FAVOR_OURS + """ + When a region of a file is changed in both branches, the file created in + the index will contain the "ours" side of any conflicting region. + + The index will not record a conflict. + """ + + THEIRS = C.GIT_MERGE_FILE_FAVOR_THEIRS + """ + When a region of a file is changed in both branches, the file created in + the index will contain the "theirs" side of any conflicting region. + + The index will not record a conflict. + """ + + UNION = C.GIT_MERGE_FILE_FAVOR_UNION + """ + When a region of a file is changed in both branches, the file + created in the index will contain each unique line from each side, + which has the result of combining both files. + + The index will not record a conflict. + """ + + +class MergeFileFlag(IntFlag): + """File merging flags""" + + DEFAULT = C.GIT_MERGE_FILE_DEFAULT + """ Defaults """ + + STYLE_MERGE = C.GIT_MERGE_FILE_STYLE_MERGE + """ Create standard conflicted merge files """ + + STYLE_DIFF3 = C.GIT_MERGE_FILE_STYLE_DIFF3 + """ Create diff3-style files """ + + SIMPLIFY_ALNUM = C.GIT_MERGE_FILE_SIMPLIFY_ALNUM + """ Condense non-alphanumeric regions for simplified diff file """ + + IGNORE_WHITESPACE = C.GIT_MERGE_FILE_IGNORE_WHITESPACE + """ Ignore all whitespace """ + + IGNORE_WHITESPACE_CHANGE = C.GIT_MERGE_FILE_IGNORE_WHITESPACE_CHANGE + """ Ignore changes in amount of whitespace """ + + IGNORE_WHITESPACE_EOL = C.GIT_MERGE_FILE_IGNORE_WHITESPACE_EOL + """ Ignore whitespace at end of line """ + + DIFF_PATIENCE = C.GIT_MERGE_FILE_DIFF_PATIENCE + """ Use the "patience diff" algorithm """ + + DIFF_MINIMAL = C.GIT_MERGE_FILE_DIFF_MINIMAL + """ Take extra time to find minimal diff """ + + STYLE_ZDIFF3 = C.GIT_MERGE_FILE_STYLE_ZDIFF3 + """ Create zdiff3 ("zealous diff3")-style files """ + + ACCEPT_CONFLICTS = C.GIT_MERGE_FILE_ACCEPT_CONFLICTS + """ + Do not produce file conflicts when common regions have changed; + keep the conflict markers in the file and accept that as the merge result. + """ + + +class MergeFlag(IntFlag): + """ + Flags for `Repository.merge` options. + A combination of these flags can be passed in via the `flags` value. + """ + + FIND_RENAMES = C.GIT_MERGE_FIND_RENAMES + """ + Detect renames that occur between the common ancestor and the "ours" + side or the common ancestor and the "theirs" side. This will enable + the ability to merge between a modified and renamed file. + """ + + FAIL_ON_CONFLICT = C.GIT_MERGE_FAIL_ON_CONFLICT + """ + If a conflict occurs, exit immediately instead of attempting to + continue resolving conflicts. The merge operation will raise GitError + (GIT_EMERGECONFLICT) and no index will be returned. + """ + + SKIP_REUC = C.GIT_MERGE_SKIP_REUC + """ + Do not write the REUC extension on the generated index. + """ + + NO_RECURSIVE = C.GIT_MERGE_NO_RECURSIVE + """ + If the commits being merged have multiple merge bases, do not build + a recursive merge base (by merging the multiple merge bases), + instead simply use the first base. This flag provides a similar + merge base to `git-merge-resolve`. + """ + + VIRTUAL_BASE = C.GIT_MERGE_VIRTUAL_BASE + """ + Treat this merge as if it is to produce the virtual base of a recursive + merge. This will ensure that there are no conflicts, any conflicting + regions will keep conflict markers in the merge result. + """ + + +class MergePreference(IntFlag): + """The user's stated preference for merges.""" + + NONE = _pygit2.GIT_MERGE_PREFERENCE_NONE + 'No configuration was found that suggests a preferred behavior for merge.' + + NO_FASTFORWARD = _pygit2.GIT_MERGE_PREFERENCE_NO_FASTFORWARD + """ + There is a `merge.ff=false` configuration setting, suggesting that + the user does not want to allow a fast-forward merge. + """ + + FASTFORWARD_ONLY = _pygit2.GIT_MERGE_PREFERENCE_FASTFORWARD_ONLY + """ + There is a `merge.ff=only` configuration setting, suggesting that + the user only wants fast-forward merges. + """ + + +class ObjectType(IntEnum): + ANY = _pygit2.GIT_OBJECT_ANY + 'Object can be any of the following' + + INVALID = _pygit2.GIT_OBJECT_INVALID + 'Object is invalid.' + + COMMIT = _pygit2.GIT_OBJECT_COMMIT + 'A commit object.' + + TREE = _pygit2.GIT_OBJECT_TREE + 'A tree (directory listing) object.' + + BLOB = _pygit2.GIT_OBJECT_BLOB + 'A file revision object.' + + TAG = _pygit2.GIT_OBJECT_TAG + 'An annotated tag object.' + + OFS_DELTA = _pygit2.GIT_OBJECT_OFS_DELTA + 'A delta, base is given by an offset.' + + REF_DELTA = _pygit2.GIT_OBJECT_REF_DELTA + 'A delta, base is given by object id.' + + +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 = 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 = ( + options.GIT_OPT_ENABLE_STRICT_SYMBOLIC_REF_CREATION + ) + SET_SSL_CIPHERS = options.GIT_OPT_SET_SSL_CIPHERS + GET_USER_AGENT = options.GIT_OPT_GET_USER_AGENT + ENABLE_OFS_DELTA = options.GIT_OPT_ENABLE_OFS_DELTA + ENABLE_FSYNC_GITDIR = options.GIT_OPT_ENABLE_FSYNC_GITDIR + GET_WINDOWS_SHAREMODE = options.GIT_OPT_GET_WINDOWS_SHAREMODE + SET_WINDOWS_SHAREMODE = options.GIT_OPT_SET_WINDOWS_SHAREMODE + ENABLE_STRICT_HASH_VERIFICATION = options.GIT_OPT_ENABLE_STRICT_HASH_VERIFICATION + SET_ALLOCATOR = options.GIT_OPT_SET_ALLOCATOR + ENABLE_UNSAVED_INDEX_SAFETY = options.GIT_OPT_ENABLE_UNSAVED_INDEX_SAFETY + GET_PACK_MAX_OBJECTS = options.GIT_OPT_GET_PACK_MAX_OBJECTS + SET_PACK_MAX_OBJECTS = options.GIT_OPT_SET_PACK_MAX_OBJECTS + DISABLE_PACK_KEEP_FILE_CHECKS = options.GIT_OPT_DISABLE_PACK_KEEP_FILE_CHECKS + ENABLE_HTTP_EXPECT_CONTINUE = options.GIT_OPT_ENABLE_HTTP_EXPECT_CONTINUE + GET_MWINDOW_FILE_LIMIT = options.GIT_OPT_GET_MWINDOW_FILE_LIMIT + SET_MWINDOW_FILE_LIMIT = options.GIT_OPT_SET_MWINDOW_FILE_LIMIT + SET_ODB_PACKED_PRIORITY = options.GIT_OPT_SET_ODB_PACKED_PRIORITY + SET_ODB_LOOSE_PRIORITY = options.GIT_OPT_SET_ODB_LOOSE_PRIORITY + GET_EXTENSIONS = options.GIT_OPT_GET_EXTENSIONS + SET_EXTENSIONS = options.GIT_OPT_SET_EXTENSIONS + GET_OWNER_VALIDATION = options.GIT_OPT_GET_OWNER_VALIDATION + SET_OWNER_VALIDATION = options.GIT_OPT_SET_OWNER_VALIDATION + GET_HOMEDIR = options.GIT_OPT_GET_HOMEDIR + SET_HOMEDIR = options.GIT_OPT_SET_HOMEDIR + SET_SERVER_CONNECT_TIMEOUT = options.GIT_OPT_SET_SERVER_CONNECT_TIMEOUT + GET_SERVER_CONNECT_TIMEOUT = options.GIT_OPT_GET_SERVER_CONNECT_TIMEOUT + SET_SERVER_TIMEOUT = options.GIT_OPT_SET_SERVER_TIMEOUT + GET_SERVER_TIMEOUT = options.GIT_OPT_GET_SERVER_TIMEOUT + GET_USER_AGENT_PRODUCT = options.GIT_OPT_GET_USER_AGENT_PRODUCT + SET_USER_AGENT_PRODUCT = options.GIT_OPT_SET_USER_AGENT_PRODUCT + ADD_SSL_X509_CERT = options.GIT_OPT_ADD_SSL_X509_CERT + + +class ReferenceFilter(IntEnum): + """Filters for References.iterator().""" + + ALL = _pygit2.GIT_REFERENCES_ALL + BRANCHES = _pygit2.GIT_REFERENCES_BRANCHES + TAGS = _pygit2.GIT_REFERENCES_TAGS + + +class ReferenceType(IntFlag): + """Basic type of any Git reference.""" + + INVALID = C.GIT_REFERENCE_INVALID + 'Invalid reference' + + DIRECT = C.GIT_REFERENCE_DIRECT + 'A reference that points at an object id' + + SYMBOLIC = C.GIT_REFERENCE_SYMBOLIC + 'A reference that points at another reference' + + ALL = C.GIT_REFERENCE_ALL + 'Bitwise OR of (DIRECT | SYMBOLIC)' + + +class RepositoryInitFlag(IntFlag): + """ + Option flags for pygit2.init_repository(). + """ + + BARE = C.GIT_REPOSITORY_INIT_BARE + 'Create a bare repository with no working directory.' + + NO_REINIT = C.GIT_REPOSITORY_INIT_NO_REINIT + 'Raise GitError if the path appears to already be a git repository.' + + NO_DOTGIT_DIR = C.GIT_REPOSITORY_INIT_NO_DOTGIT_DIR + """Normally a "/.git/" will be appended to the repo path for + non-bare repos (if it is not already there), but passing this flag + prevents that behavior.""" + + MKDIR = C.GIT_REPOSITORY_INIT_MKDIR + """Make the repo_path (and workdir_path) as needed. Init is always willing + to create the ".git" directory even without this flag. This flag tells + init to create the trailing component of the repo and workdir paths + as needed.""" + + MKPATH = C.GIT_REPOSITORY_INIT_MKPATH + 'Recursively make all components of the repo and workdir paths as necessary.' + + EXTERNAL_TEMPLATE = C.GIT_REPOSITORY_INIT_EXTERNAL_TEMPLATE + """libgit2 normally uses internal templates to initialize a new repo. + This flags enables external templates, looking at the "template_path" from + the options if set, or the `init.templatedir` global config if not, + or falling back on "/usr/share/git-core/templates" if it exists.""" + + RELATIVE_GITLINK = C.GIT_REPOSITORY_INIT_RELATIVE_GITLINK + """If an alternate workdir is specified, use relative paths for the gitdir + and core.worktree.""" + + +class RepositoryInitMode(IntEnum): + """ + Mode options for pygit2.init_repository(). + """ + + SHARED_UMASK = C.GIT_REPOSITORY_INIT_SHARED_UMASK + 'Use permissions configured by umask - the default.' + + SHARED_GROUP = C.GIT_REPOSITORY_INIT_SHARED_GROUP + """ + Use '--shared=group' behavior, chmod'ing the new repo to be group + writable and "g+sx" for sticky group assignment. + """ + + SHARED_ALL = C.GIT_REPOSITORY_INIT_SHARED_ALL + "Use '--shared=all' behavior, adding world readability." + + +class RepositoryOpenFlag(IntFlag): + """ + Option flags for Repository.__init__(). + """ + + DEFAULT = 0 + 'Default flags.' + + NO_SEARCH = C.GIT_REPOSITORY_OPEN_NO_SEARCH + """ + Only open the repository if it can be immediately found in the + start_path. Do not walk up from the start_path looking at parent + directories. + """ + + CROSS_FS = C.GIT_REPOSITORY_OPEN_CROSS_FS + """ + Unless this flag is set, open will not continue searching across + filesystem boundaries (i.e. when `st_dev` changes from the `stat` + system call). For example, searching in a user's home directory at + "/home/user/source/" will not return "/.git/" as the found repo if + "/" is a different filesystem than "/home". + """ + + BARE = C.GIT_REPOSITORY_OPEN_BARE + """ + Open repository as a bare repo regardless of core.bare config, and + defer loading config file for faster setup. + Unlike `git_repository_open_bare`, this can follow gitlinks. + """ + + NO_DOTGIT = C.GIT_REPOSITORY_OPEN_NO_DOTGIT + """ + Do not check for a repository by appending /.git to the start_path; + only open the repository if start_path itself points to the git + directory. + """ + + FROM_ENV = C.GIT_REPOSITORY_OPEN_FROM_ENV + """ + Find and open a git repository, respecting the environment variables + used by the git command-line tools. + If set, `git_repository_open_ext` will ignore the other flags and + the `ceiling_dirs` argument, and will allow a NULL `path` to use + `GIT_DIR` or search from the current directory. + The search for a repository will respect $GIT_CEILING_DIRECTORIES and + $GIT_DISCOVERY_ACROSS_FILESYSTEM. The opened repository will + respect $GIT_INDEX_FILE, $GIT_NAMESPACE, $GIT_OBJECT_DIRECTORY, and + $GIT_ALTERNATE_OBJECT_DIRECTORIES. + In the future, this flag will also cause `git_repository_open_ext` + to respect $GIT_WORK_TREE and $GIT_COMMON_DIR; currently, + `git_repository_open_ext` with this flag will error out if either + $GIT_WORK_TREE or $GIT_COMMON_DIR is set. + """ + + +class RepositoryState(IntEnum): + """ + Repository state: These values represent possible states for the repository + to be in, based on the current operation which is ongoing. + """ + + NONE = C.GIT_REPOSITORY_STATE_NONE + MERGE = C.GIT_REPOSITORY_STATE_MERGE + REVERT = C.GIT_REPOSITORY_STATE_REVERT + REVERT_SEQUENCE = C.GIT_REPOSITORY_STATE_REVERT_SEQUENCE + CHERRYPICK = C.GIT_REPOSITORY_STATE_CHERRYPICK + CHERRYPICK_SEQUENCE = C.GIT_REPOSITORY_STATE_CHERRYPICK_SEQUENCE + BISECT = C.GIT_REPOSITORY_STATE_BISECT + REBASE = C.GIT_REPOSITORY_STATE_REBASE + REBASE_INTERACTIVE = C.GIT_REPOSITORY_STATE_REBASE_INTERACTIVE + REBASE_MERGE = C.GIT_REPOSITORY_STATE_REBASE_MERGE + APPLY_MAILBOX = C.GIT_REPOSITORY_STATE_APPLY_MAILBOX + APPLY_MAILBOX_OR_REBASE = C.GIT_REPOSITORY_STATE_APPLY_MAILBOX_OR_REBASE + + +class ResetMode(IntEnum): + """Kinds of reset operation.""" + + SOFT = _pygit2.GIT_RESET_SOFT + 'Move the head to the given commit' + + MIXED = _pygit2.GIT_RESET_MIXED + 'SOFT plus reset index to the commit' + + HARD = _pygit2.GIT_RESET_HARD + 'MIXED plus changes in working tree discarded' + + +class RevSpecFlag(IntFlag): + """ + Revparse flags. + These indicate the intended behavior of the spec passed to Repository.revparse() + """ + + SINGLE = _pygit2.GIT_REVSPEC_SINGLE + 'The spec targeted a single object.' + + RANGE = _pygit2.GIT_REVSPEC_RANGE + 'The spec targeted a range of commits.' + + MERGE_BASE = _pygit2.GIT_REVSPEC_MERGE_BASE + "The spec used the '...' operator, which invokes special semantics." + + +class SortMode(IntFlag): + """ + Flags to specify the sorting which a revwalk should perform. + """ + + NONE = _pygit2.GIT_SORT_NONE + """ + Sort the output with the same default method from `git`: reverse + chronological order. This is the default sorting for new walkers. + """ + + TOPOLOGICAL = _pygit2.GIT_SORT_TOPOLOGICAL + """ + Sort the repository contents in topological order (no parents before + all of its children are shown); this sorting mode can be combined + with TIME sorting to produce `git`'s `--date-order``. + """ + + TIME = _pygit2.GIT_SORT_TIME + """ + Sort the repository contents by commit time; this sorting mode can be + combined with TOPOLOGICAL. + """ + + REVERSE = _pygit2.GIT_SORT_REVERSE + """ + Iterate through the repository contents in reverse order; + this sorting mode can be combined with any of the above. + """ + + +class StashApplyProgress(IntEnum): + """ + Stash apply progression states + """ + + NONE = C.GIT_STASH_APPLY_PROGRESS_NONE + + LOADING_STASH = C.GIT_STASH_APPLY_PROGRESS_LOADING_STASH + 'Loading the stashed data from the object database.' + + ANALYZE_INDEX = C.GIT_STASH_APPLY_PROGRESS_ANALYZE_INDEX + 'The stored index is being analyzed.' + + ANALYZE_MODIFIED = C.GIT_STASH_APPLY_PROGRESS_ANALYZE_MODIFIED + 'The modified files are being analyzed.' + + ANALYZE_UNTRACKED = C.GIT_STASH_APPLY_PROGRESS_ANALYZE_UNTRACKED + 'The untracked and ignored files are being analyzed.' + + CHECKOUT_UNTRACKED = C.GIT_STASH_APPLY_PROGRESS_CHECKOUT_UNTRACKED + 'The untracked files are being written to disk.' + + CHECKOUT_MODIFIED = C.GIT_STASH_APPLY_PROGRESS_CHECKOUT_MODIFIED + 'The modified files are being written to disk.' + + DONE = C.GIT_STASH_APPLY_PROGRESS_DONE + 'The stash was applied successfully.' + + +class SubmoduleIgnore(IntEnum): + UNSPECIFIED = _pygit2.GIT_SUBMODULE_IGNORE_UNSPECIFIED + "use the submodule's configuration" + + NONE = _pygit2.GIT_SUBMODULE_IGNORE_NONE + 'any change or untracked == dirty' + + UNTRACKED = _pygit2.GIT_SUBMODULE_IGNORE_UNTRACKED + 'dirty if tracked files change' + + DIRTY = _pygit2.GIT_SUBMODULE_IGNORE_DIRTY + 'only dirty if HEAD moved' + + ALL = _pygit2.GIT_SUBMODULE_IGNORE_ALL + 'never dirty' + + +class SubmoduleStatus(IntFlag): + IN_HEAD = _pygit2.GIT_SUBMODULE_STATUS_IN_HEAD + 'superproject head contains submodule' + + IN_INDEX = _pygit2.GIT_SUBMODULE_STATUS_IN_INDEX + 'superproject index contains submodule' + + IN_CONFIG = _pygit2.GIT_SUBMODULE_STATUS_IN_CONFIG + 'superproject gitmodules has submodule' + + IN_WD = _pygit2.GIT_SUBMODULE_STATUS_IN_WD + 'superproject workdir has submodule' + + INDEX_ADDED = _pygit2.GIT_SUBMODULE_STATUS_INDEX_ADDED + 'in index, not in head (flag available if ignore is not ALL)' + + INDEX_DELETED = _pygit2.GIT_SUBMODULE_STATUS_INDEX_DELETED + 'in head, not in index (flag available if ignore is not ALL)' + + INDEX_MODIFIED = _pygit2.GIT_SUBMODULE_STATUS_INDEX_MODIFIED + "index and head don't match (flag available if ignore is not ALL)" + + WD_UNINITIALIZED = _pygit2.GIT_SUBMODULE_STATUS_WD_UNINITIALIZED + 'workdir contains empty repository (flag available if ignore is not ALL)' + + WD_ADDED = _pygit2.GIT_SUBMODULE_STATUS_WD_ADDED + 'in workdir, not index (flag available if ignore is not ALL)' + + WD_DELETED = _pygit2.GIT_SUBMODULE_STATUS_WD_DELETED + 'in index, not workdir (flag available if ignore is not ALL)' + + WD_MODIFIED = _pygit2.GIT_SUBMODULE_STATUS_WD_MODIFIED + "index and workdir head don't match (flag available if ignore is not ALL)" + + WD_INDEX_MODIFIED = _pygit2.GIT_SUBMODULE_STATUS_WD_INDEX_MODIFIED + 'submodule workdir index is dirty (flag available if ignore is NONE or UNTRACKED)' + + WD_WD_MODIFIED = _pygit2.GIT_SUBMODULE_STATUS_WD_WD_MODIFIED + 'submodule workdir has modified files (flag available if ignore is NONE or UNTRACKED)' + + WD_UNTRACKED = _pygit2.GIT_SUBMODULE_STATUS_WD_UNTRACKED + 'submodule workdir contains untracked files (flag available if ignore is NONE)' diff --git a/pygit2/errors.py b/pygit2/errors.py new file mode 100644 index 000000000..02278ddb6 --- /dev/null +++ b/pygit2/errors.py @@ -0,0 +1,73 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +# Import from pygit2 +from ._pygit2 import GitError +from .ffi import C, ffi + +__all__ = ['GitError'] + +value_errors = set([C.GIT_EEXISTS, C.GIT_EINVALIDSPEC, C.GIT_EAMBIGUOUS]) + + +def check_error(err: int, io: bool = False) -> None: + if err >= 0: + return + + # These are special error codes, they should never reach here + test = err != C.GIT_EUSER and err != C.GIT_PASSTHROUGH + assert test, f'Unexpected error code {err}' + + # Error message + giterr = C.git_error_last() + if giterr != ffi.NULL: + message = ffi.string(giterr.message).decode('utf8', errors='surrogateescape') + else: + message = f'err {err} (no message provided)' + + # Translate to Python errors + if err in value_errors: + raise ValueError(message) + + if err == C.GIT_ENOTFOUND: + if io: + raise IOError(message) + + raise KeyError(message) + + if err == C.GIT_EINVALIDSPEC: + raise ValueError(message) + + if err == C.GIT_ITEROVER: + raise StopIteration() + + # Generic Git error + raise GitError(message) + + +# Indicate that we want libgit2 to pretend a function was not set +class Passthrough(Exception): + def __init__(self) -> None: + super().__init__('The function asked for pass-through') diff --git a/pygit2/version.py b/pygit2/ffi.py similarity index 85% rename from pygit2/version.py rename to pygit2/ffi.py index 3ac321ca4..2a5d86042 100644 --- a/pygit2/version.py +++ b/pygit2/ffi.py @@ -1,4 +1,4 @@ -# Copyright 2010-2013 The pygit2 contributors +# Copyright 2010-2025 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -23,4 +23,8 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. -__version__ = '0.19.0' +# Import from pygit2 +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 new file mode 100644 index 000000000..5c89a0d19 --- /dev/null +++ b/pygit2/filter.py @@ -0,0 +1,109 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +from collections.abc import Callable + +from ._pygit2 import FilterSource + + +class Filter: + """ + Base filter class to be used with libgit2 filters. + + Inherit from this class and override the `check()`, `write()` and `close()` + methods to define a filter which can then be registered via + `pygit2.filter_register()`. + + A new Filter instance will be instantiated for each stream which needs to + be filtered. For each stream, filter methods will be called in this order: + + - `check()` + - `write()` (may be called multiple times) + - `close()` + + Filtered output data should be written to the next filter in the chain + during `write()` and `close()` via the `write_next` method. All output data + must be written to the next filter before returning from `close()`. + + If a filter is dependent on reading the complete input data stream, the + filter should only write output data in `close()`. + """ + + #: Space-separated string list of attributes to be used in `check()` + attributes: str = '' + + @classmethod + def nattrs(cls) -> int: + return len(cls.attributes.split()) + + def check(self, src: FilterSource, attr_values: list[str | None]) -> None: + """ + Check whether this filter should be applied to the given source. + + `check` will be called once per stream. + + If `Passthrough` is raised, the filter will not be applied. + + Parameters: + + src: The source of the filtered blob. + + attr_values: The values of each attribute for the blob being filtered. + `attr_values` will be a sorted list containing attributes in the + order they were defined in ``cls.attributes``. + """ + + def write( + self, data: bytes, src: FilterSource, write_next: Callable[[bytes], None] + ) -> None: + """ + Write input `data` to this filter. + + `write()` may be called multiple times per stream. + + Parameters: + + data: Input data. + + src: The source of the filtered blob. + + write_next: The ``write()`` method of the next filter in the chain. + Filtered output data should be written to `write_next` whenever it is + available. + """ + write_next(data) + + def close(self, write_next: Callable[[bytes], None]) -> None: + """ + Close this filter. + + `close()` will be called once per stream whenever all writes() to this + stream have been completed. + + Parameters: + write_next: The ``write()`` method of the next filter in the chain. + Any remaining filtered output data must be written to + `write_next` before returning. + """ diff --git a/pygit2/index.py b/pygit2/index.py new file mode 100644 index 000000000..928a62223 --- /dev/null +++ b/pygit2/index.py @@ -0,0 +1,573 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +import typing +import warnings +from dataclasses import dataclass +from os import PathLike + +# Import from pygit2 +from ._pygit2 import Diff, Oid, Tree +from .enums import DiffOption, FileMode +from .errors import check_error +from .ffi import C, ffi +from .utils import GenericIterator, StrArray, to_bytes, to_str + +if typing.TYPE_CHECKING: + from .repository import Repository + + +class Index: + # XXX Implement the basic features in C (_pygit2.Index) and make + # pygit2.Index to inherit from _pygit2.Index? This would allow for + # a proper implementation in some places: e.g. checking the index type + # from C code (see Tree_diff_to_index) + + def __init__(self, path: str | 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)) + check_error(err) + + self._repo = None + self._index = cindex[0] + self._cindex = cindex + + @classmethod + def from_c(cls, repo, ptr): + index = cls.__new__(cls) + index._repo = repo + index._index = ptr[0] + index._cindex = ptr + + return index + + @property + def _pointer(self): + return bytes(ffi.buffer(self._cindex)[:]) + + def __del__(self) -> None: + C.git_index_free(self._index) + + def __len__(self) -> int: + return C.git_index_entrycount(self._index) + + def __contains__(self, path) -> bool: + err = C.git_index_find(ffi.NULL, self._index, to_bytes(path)) + if err == C.GIT_ENOTFOUND: + return False + + check_error(err) + return True + + 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) + elif isinstance(key, int): + if key >= 0: + centry = C.git_index_get_byindex(self._index, key) + else: + raise ValueError(key) + else: + raise TypeError(f'Expected str or int, got {type(key)}') + + if centry == ffi.NULL: + raise KeyError(key) + + return IndexEntry._from_c(centry) + + def __iter__(self): + return GenericIterator(self) + + def read(self, force: bool = True) -> None: + """ + Update the contents of the Index by reading from a file. + + Parameters: + + force + If True (the default) always reload. If False, only if the file + has changed. + """ + + err = C.git_index_read(self._index, force) + check_error(err, io=True) + + 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) -> None: + err = C.git_index_clear(self._index) + check_error(err) + + 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 ). + + The tree will be read recursively and all its children will also be + inserted into the Index. + """ + 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): + if repo is None: + raise TypeError('id given but no associated repository') + + tree = repo[tree] + elif not isinstance(tree, 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: 'Repository | None' = None) -> Oid: + """Create a tree out of the Index. Return the object of the + written tree. + + The contents of the index will be written out to the object + database. If there is no associated repository, 'repo' must be + passed. If there is an associated repository and 'repo' is + passed, then that repository will be used instead. + + It returns the id of the resulting tree. + """ + coid = ffi.new('git_oid *') + + repo = repo or self._repo + + if repo: + err = C.git_index_write_tree_to(coid, self._index, repo._repo) + else: + err = C.git_index_write_tree(coid, self._index) + + check_error(err) + return Oid(raw=bytes(ffi.buffer(coid)[:])) + + def remove(self, path: PathLike[str] | str, level: int = 0) -> None: + """Remove an entry from the Index.""" + err = C.git_index_remove(self._index, to_bytes(path), level) + check_error(err, io=True) + + def remove_directory(self, path: PathLike[str] | str, level: int = 0) -> None: + """Remove a directory from the Index.""" + err = C.git_index_remove_directory(self._index, to_bytes(path), level) + check_error(err, io=True) + + def remove_all(self, pathspecs: typing.Sequence[str | PathLike[str]]) -> None: + """Remove all index entries matching pathspecs.""" + with StrArray(pathspecs) as arr: + err = C.git_index_remove_all(self._index, arr.ptr, ffi.NULL, ffi.NULL) + check_error(err, io=True) + + def add_all(self, pathspecs: None | 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 + be added. + """ + pathspecs = pathspecs or [] + with StrArray(pathspecs) as arr: + 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: '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 + to the root of the worktree and the Index must be associated with a + repository. + + If an IndexEntry is given, that entry will be added or update in the + Index without checking for the existence of the path or id. + """ + if isinstance(path_or_entry, IndexEntry): + entry = path_or_entry + centry, str_ref = entry._to_c() + 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)) + else: + raise TypeError('argument must be string, Path or IndexEntry') + + check_error(err, io=True) + + def add_conflict( + self, ancestor: 'IndexEntry', ours: 'IndexEntry', theirs: 'IndexEntry | None' + ) -> None: + """ + Add or update index entries to represent a conflict. Any staged entries that + exist at the given paths will be removed. + + Parameters: + + ancestor + ancestor of the conflict + ours + ours side of the conflict + theirs + their side of the conflict + """ + + if ancestor and not isinstance(ancestor, IndexEntry): + raise TypeError('ancestor has to be an instance of IndexEntry or None') + if ours and not isinstance(ours, IndexEntry): + raise TypeError('ours has to be an instance of IndexEntry or None') + if theirs and not isinstance(theirs, IndexEntry): + raise TypeError('theirs has to be an instance of IndexEntry or None') + + centry_ancestor: ffi.NULL_TYPE | ffi.GitIndexEntryC = ffi.NULL + centry_ours: ffi.NULL_TYPE | ffi.GitIndexEntryC = ffi.NULL + centry_theirs: ffi.NULL_TYPE | ffi.GitIndexEntryC = ffi.NULL + if ancestor is not None: + centry_ancestor, _ = ancestor._to_c() + if ours is not None: + centry_ours, _ = ours._to_c() + if theirs is not None: + centry_theirs, _ = theirs._to_c() + err = C.git_index_conflict_add( + self._index, centry_ancestor, centry_ours, centry_theirs + ) + + check_error(err, io=True) + + def diff_to_workdir( + self, + flags: DiffOption = DiffOption.NORMAL, + context_lines: int = 3, + interhunk_lines: int = 0, + ) -> Diff: + """ + Diff the index against the working directory. Return a object + with the differences between the index and the working copy. + + Parameters: + + flags + A combination of enums.DiffOption constants. + + context_lines + The number of unchanged lines that define the boundary of a hunk + (and to display before and after). + + interhunk_lines + The maximum number of unchanged lines between hunk boundaries + before the hunks will be merged into a one. + """ + repo = self._repo + if repo is None: + raise ValueError('diff needs an associated repository') + + copts = ffi.new('git_diff_options *') + err = C.git_diff_options_init(copts, 1) + check_error(err) + + copts.flags = int(flags) + copts.context_lines = context_lines + copts.interhunk_lines = interhunk_lines + + cdiff = ffi.new('git_diff **') + err = C.git_diff_index_to_workdir(cdiff, repo._repo, self._index, copts) + check_error(err) + + return Diff.from_c(bytes(ffi.buffer(cdiff)[:]), repo) + + def diff_to_tree( + self, + tree: Tree, + flags: DiffOption = DiffOption.NORMAL, + context_lines: int = 3, + interhunk_lines: int = 0, + ) -> Diff: + """ + Diff the index against a tree. Return a object with the + differences between the index and the given tree. + + Parameters: + + tree + The tree to diff. + + flags + A combination of enums.DiffOption constants. + + context_lines + The number of unchanged lines that define the boundary of a hunk + (and to display before and after). + + interhunk_lines + The maximum number of unchanged lines between hunk boundaries + before the hunks will be merged into a one. + """ + repo = self._repo + if repo is None: + raise ValueError('diff needs an associated repository') + + if not isinstance(tree, Tree): + raise TypeError('tree must be a Tree') + + copts = ffi.new('git_diff_options *') + err = C.git_diff_options_init(copts, 1) + check_error(err) + + copts.flags = int(flags) + copts.context_lines = context_lines + copts.interhunk_lines = interhunk_lines + + ctree = ffi.new('git_tree **') + ffi.buffer(ctree)[:] = tree._pointer[:] + + cdiff = ffi.new('git_diff **') + err = C.git_diff_tree_to_index(cdiff, repo._repo, ctree[0], self._index, copts) + check_error(err) + + return Diff.from_c(bytes(ffi.buffer(cdiff)[:]), repo) + + # + # Conflicts + # + + @property + def conflicts(self): + """A collection of conflict information + + If there are no conflicts None is returned. Otherwise return an object + that represents the conflicts in the index. + + This object presents a mapping interface with the paths as keys. You + can use the ``del`` operator to remove a conflict from the Index. + + Each conflict is made up of three elements. Access or iteration + of the conflicts returns a three-tuple of + :py:class:`~pygit2.IndexEntry`. The first is the common + ancestor, the second is the "ours" side of the conflict, and the + third is the "theirs" side. + + These elements may be None depending on which sides exist for + the particular conflict. + """ + if not C.git_index_has_conflicts(self._index): + return None + + return ConflictCollection(self) + + +@dataclass +class MergeFileResult: + automergeable: bool + 'True if the output was automerged, false if the output contains conflict markers' + + path: str | None | PathLike[str] + 'The path that the resultant merge file should use, or None if a filename conflict would occur' + + mode: FileMode + 'The mode that the resultant merge file should use' + + contents: str + 'Contents of the file, which might include conflict markers' + + def __repr__(self): + t = type(self) + contents = ( + self.contents if len(self.contents) <= 20 else f'{self.contents[:20]}...' + ) + return ( + f'<{t.__module__}.{t.__qualname__} "' + f'automergeable={self.automergeable} "' + f'path={self.path} ' + f'mode={self.mode} ' + f'contents={contents}>' + ) + + @classmethod + def _from_c(cls, centry): + if centry == ffi.NULL: + return None + + automergeable = centry.automergeable != 0 + path = to_str(ffi.string(centry.path)) if centry.path else None + mode = FileMode(centry.mode) + contents = ffi.string(centry.ptr, centry.len).decode('utf-8') + + return MergeFileResult(automergeable, path, mode, contents) + + +class IndexEntry: + path: str | PathLike[str] + 'The path of this entry' + + id: Oid + 'The id of the referenced object' + + mode: FileMode + 'The mode of this entry, a FileMode value' + + 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 + 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'' + + def __repr__(self): + t = type(self) + return f'<{t.__module__}.{t.__qualname__} path={self.path} id={self.id} mode={self.mode}>' + + def __eq__(self, other): + if self is other: + return True + if not isinstance(other, IndexEntry): + return NotImplemented + return ( + self.path == other.path and self.id == other.id and self.mode == other.mode + ) + + 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 + the string we allocated, which we need to exist past this function + """ + centry = ffi.new('git_index_entry *') + # basically memcpy() + ffi.buffer(ffi.addressof(centry, 'id'))[:] = self.id.raw[:] + centry.mode = int(self.mode) + path = ffi.new('char[]', to_bytes(self.path)) + centry.path = path + + return centry, path + + @classmethod + def _from_c(cls, centry): + if centry == ffi.NULL: + return None + + entry = cls.__new__(cls) + entry.path = to_str(ffi.string(centry.path)) + entry.mode = FileMode(centry.mode) + entry.id = Oid(raw=bytes(ffi.buffer(ffi.addressof(centry, 'id'))[:])) + + return entry + + +class ConflictCollection: + def __init__(self, index): + self._index = index + + def __getitem__(self, path): + cancestor = ffi.new('git_index_entry **') + cours = ffi.new('git_index_entry **') + ctheirs = ffi.new('git_index_entry **') + + err = C.git_index_conflict_get( + cancestor, cours, ctheirs, self._index._index, to_bytes(path) + ) + check_error(err) + + ancestor = IndexEntry._from_c(cancestor[0]) + ours = IndexEntry._from_c(cours[0]) + theirs = IndexEntry._from_c(ctheirs[0]) + + return ancestor, ours, theirs + + def __delitem__(self, path): + err = C.git_index_conflict_remove(self._index._index, to_bytes(path)) + check_error(err) + + def __iter__(self): + return ConflictIterator(self._index) + + def __contains__(self, path): + cancestor = ffi.new('git_index_entry **') + cours = ffi.new('git_index_entry **') + ctheirs = ffi.new('git_index_entry **') + + err = C.git_index_conflict_get( + cancestor, cours, ctheirs, self._index._index, to_bytes(path) + ) + if err == C.GIT_ENOTFOUND: + return False + + check_error(err) + return True + + +class ConflictIterator: + def __init__(self, index): + citer = ffi.new('git_index_conflict_iterator **') + err = C.git_index_conflict_iterator_new(citer, index._index) + check_error(err) + self._index = index + self._iter = citer[0] + + def __del__(self): + C.git_index_conflict_iterator_free(self._iter) + + def __iter__(self): + return self + + def __next__(self): + cancestor = ffi.new('git_index_entry **') + cours = ffi.new('git_index_entry **') + ctheirs = ffi.new('git_index_entry **') + + err = C.git_index_conflict_next(cancestor, cours, ctheirs, self._iter) + if err == C.GIT_ITEROVER: + raise StopIteration + + check_error(err) + + ancestor = IndexEntry._from_c(cancestor[0]) + ours = IndexEntry._from_c(cours[0]) + theirs = IndexEntry._from_c(ctheirs[0]) + + return ancestor, ours, theirs diff --git a/pygit2/legacyenums.py b/pygit2/legacyenums.py new file mode 100644 index 000000000..176534a6b --- /dev/null +++ b/pygit2/legacyenums.py @@ -0,0 +1,113 @@ +# 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..ad96253bc --- /dev/null +++ b/pygit2/options.py @@ -0,0 +1,805 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +""" +Libgit2 global options management using CFFI. +""" + +from __future__ import annotations + +# Import only for type checking to avoid circular imports +from typing import TYPE_CHECKING, Any, Literal, cast, overload + +from .errors import check_error +from .ffi import C, ffi +from .utils import to_bytes, to_str + +if TYPE_CHECKING: + from ._libgit2.ffi import NULL_TYPE, ArrayC, char, char_pointer + from .enums import ConfigLevel, ObjectType, Option + +# Export GIT_OPT constants for backward compatibility +GIT_OPT_GET_MWINDOW_SIZE: int = C.GIT_OPT_GET_MWINDOW_SIZE +GIT_OPT_SET_MWINDOW_SIZE: int = C.GIT_OPT_SET_MWINDOW_SIZE +GIT_OPT_GET_MWINDOW_MAPPED_LIMIT: int = C.GIT_OPT_GET_MWINDOW_MAPPED_LIMIT +GIT_OPT_SET_MWINDOW_MAPPED_LIMIT: int = C.GIT_OPT_SET_MWINDOW_MAPPED_LIMIT +GIT_OPT_GET_SEARCH_PATH: int = C.GIT_OPT_GET_SEARCH_PATH +GIT_OPT_SET_SEARCH_PATH: int = C.GIT_OPT_SET_SEARCH_PATH +GIT_OPT_SET_CACHE_OBJECT_LIMIT: int = C.GIT_OPT_SET_CACHE_OBJECT_LIMIT +GIT_OPT_SET_CACHE_MAX_SIZE: int = C.GIT_OPT_SET_CACHE_MAX_SIZE +GIT_OPT_ENABLE_CACHING: int = C.GIT_OPT_ENABLE_CACHING +GIT_OPT_GET_CACHED_MEMORY: int = C.GIT_OPT_GET_CACHED_MEMORY +GIT_OPT_GET_TEMPLATE_PATH: int = C.GIT_OPT_GET_TEMPLATE_PATH +GIT_OPT_SET_TEMPLATE_PATH: int = C.GIT_OPT_SET_TEMPLATE_PATH +GIT_OPT_SET_SSL_CERT_LOCATIONS: int = C.GIT_OPT_SET_SSL_CERT_LOCATIONS +GIT_OPT_SET_USER_AGENT: int = C.GIT_OPT_SET_USER_AGENT +GIT_OPT_ENABLE_STRICT_OBJECT_CREATION: int = C.GIT_OPT_ENABLE_STRICT_OBJECT_CREATION +GIT_OPT_ENABLE_STRICT_SYMBOLIC_REF_CREATION: int = ( + C.GIT_OPT_ENABLE_STRICT_SYMBOLIC_REF_CREATION +) +GIT_OPT_SET_SSL_CIPHERS: int = C.GIT_OPT_SET_SSL_CIPHERS +GIT_OPT_GET_USER_AGENT: int = C.GIT_OPT_GET_USER_AGENT +GIT_OPT_ENABLE_OFS_DELTA: int = C.GIT_OPT_ENABLE_OFS_DELTA +GIT_OPT_ENABLE_FSYNC_GITDIR: int = C.GIT_OPT_ENABLE_FSYNC_GITDIR +GIT_OPT_GET_WINDOWS_SHAREMODE: int = C.GIT_OPT_GET_WINDOWS_SHAREMODE +GIT_OPT_SET_WINDOWS_SHAREMODE: int = C.GIT_OPT_SET_WINDOWS_SHAREMODE +GIT_OPT_ENABLE_STRICT_HASH_VERIFICATION: int = C.GIT_OPT_ENABLE_STRICT_HASH_VERIFICATION +GIT_OPT_SET_ALLOCATOR: int = C.GIT_OPT_SET_ALLOCATOR +GIT_OPT_ENABLE_UNSAVED_INDEX_SAFETY: int = C.GIT_OPT_ENABLE_UNSAVED_INDEX_SAFETY +GIT_OPT_GET_PACK_MAX_OBJECTS: int = C.GIT_OPT_GET_PACK_MAX_OBJECTS +GIT_OPT_SET_PACK_MAX_OBJECTS: int = C.GIT_OPT_SET_PACK_MAX_OBJECTS +GIT_OPT_DISABLE_PACK_KEEP_FILE_CHECKS: int = C.GIT_OPT_DISABLE_PACK_KEEP_FILE_CHECKS +GIT_OPT_GET_MWINDOW_FILE_LIMIT: int = C.GIT_OPT_GET_MWINDOW_FILE_LIMIT +GIT_OPT_SET_MWINDOW_FILE_LIMIT: int = C.GIT_OPT_SET_MWINDOW_FILE_LIMIT +GIT_OPT_GET_OWNER_VALIDATION: int = C.GIT_OPT_GET_OWNER_VALIDATION +GIT_OPT_SET_OWNER_VALIDATION: int = C.GIT_OPT_SET_OWNER_VALIDATION +GIT_OPT_ENABLE_HTTP_EXPECT_CONTINUE: int = C.GIT_OPT_ENABLE_HTTP_EXPECT_CONTINUE +GIT_OPT_SET_ODB_PACKED_PRIORITY: int = C.GIT_OPT_SET_ODB_PACKED_PRIORITY +GIT_OPT_SET_ODB_LOOSE_PRIORITY: int = C.GIT_OPT_SET_ODB_LOOSE_PRIORITY +GIT_OPT_GET_EXTENSIONS: int = C.GIT_OPT_GET_EXTENSIONS +GIT_OPT_SET_EXTENSIONS: int = C.GIT_OPT_SET_EXTENSIONS +GIT_OPT_GET_HOMEDIR: int = C.GIT_OPT_GET_HOMEDIR +GIT_OPT_SET_HOMEDIR: int = C.GIT_OPT_SET_HOMEDIR +GIT_OPT_SET_SERVER_CONNECT_TIMEOUT: int = C.GIT_OPT_SET_SERVER_CONNECT_TIMEOUT +GIT_OPT_GET_SERVER_CONNECT_TIMEOUT: int = C.GIT_OPT_GET_SERVER_CONNECT_TIMEOUT +GIT_OPT_SET_SERVER_TIMEOUT: int = C.GIT_OPT_SET_SERVER_TIMEOUT +GIT_OPT_GET_SERVER_TIMEOUT: int = C.GIT_OPT_GET_SERVER_TIMEOUT +GIT_OPT_GET_USER_AGENT_PRODUCT: int = C.GIT_OPT_GET_USER_AGENT_PRODUCT +GIT_OPT_SET_USER_AGENT_PRODUCT: int = C.GIT_OPT_SET_USER_AGENT_PRODUCT +GIT_OPT_ADD_SSL_X509_CERT: int = C.GIT_OPT_ADD_SSL_X509_CERT + + +NOT_PASSED = object() + + +def check_args(option: Option, arg1: Any, arg2: Any, expected: int) -> None: + if expected == 0 and (arg1 is not NOT_PASSED or arg2 is not NOT_PASSED): + raise TypeError(f'option({option}) takes no additional arguments') + + if expected == 1 and (arg1 is NOT_PASSED or arg2 is not NOT_PASSED): + raise TypeError(f'option({option}, x) requires 1 additional argument') + + if expected == 2 and (arg1 is NOT_PASSED or arg2 is NOT_PASSED): + raise TypeError(f'option({option}, x, y) requires 2 additional arguments') + + +@overload +def option( + option_type: Literal[ + Option.GET_MWINDOW_SIZE, + Option.GET_MWINDOW_MAPPED_LIMIT, + Option.GET_MWINDOW_FILE_LIMIT, + ], +) -> int: ... + + +@overload +def option( + option_type: Literal[ + Option.SET_MWINDOW_SIZE, + Option.SET_MWINDOW_MAPPED_LIMIT, + Option.SET_MWINDOW_FILE_LIMIT, + Option.SET_CACHE_MAX_SIZE, + ], + arg1: int, # value +) -> None: ... + + +@overload +def option( + option_type: Literal[Option.GET_SEARCH_PATH], + arg1: ConfigLevel, # value +) -> str: ... + + +@overload +def option( + option_type: Literal[Option.SET_SEARCH_PATH], + arg1: ConfigLevel, # type + arg2: str, # value +) -> None: ... + + +@overload +def option( + option_type: Literal[Option.SET_CACHE_OBJECT_LIMIT], + arg1: ObjectType, # type + arg2: int, # limit +) -> None: ... + + +@overload +def option(option_type: Literal[Option.GET_CACHED_MEMORY]) -> tuple[int, int]: ... + + +@overload +def option( + option_type: Literal[Option.SET_SSL_CERT_LOCATIONS], + arg1: str | bytes | None, # cert_file + arg2: str | bytes | None, # cert_dir +) -> None: ... + + +@overload +def option( + option_type: Literal[ + Option.ENABLE_CACHING, + Option.ENABLE_STRICT_OBJECT_CREATION, + Option.ENABLE_STRICT_SYMBOLIC_REF_CREATION, + Option.ENABLE_OFS_DELTA, + Option.ENABLE_FSYNC_GITDIR, + Option.ENABLE_STRICT_HASH_VERIFICATION, + Option.ENABLE_UNSAVED_INDEX_SAFETY, + Option.DISABLE_PACK_KEEP_FILE_CHECKS, + Option.SET_OWNER_VALIDATION, + ], + arg1: bool, # value +) -> None: ... + + +@overload +def option(option_type: Literal[Option.GET_OWNER_VALIDATION]) -> bool: ... + + +@overload +def option( + option_type: Literal[ + Option.GET_TEMPLATE_PATH, + Option.GET_USER_AGENT, + Option.GET_HOMEDIR, + Option.GET_USER_AGENT_PRODUCT, + ], +) -> str | None: ... + + +@overload +def option( + option_type: Literal[ + Option.SET_TEMPLATE_PATH, + Option.SET_USER_AGENT, + Option.SET_SSL_CIPHERS, + Option.SET_HOMEDIR, + Option.SET_USER_AGENT_PRODUCT, + ], + arg1: str | bytes, # value +) -> None: ... + + +@overload +def option( + option_type: Literal[ + Option.GET_WINDOWS_SHAREMODE, + Option.GET_PACK_MAX_OBJECTS, + Option.GET_SERVER_CONNECT_TIMEOUT, + Option.GET_SERVER_TIMEOUT, + ], +) -> int: ... + + +@overload +def option( + option_type: Literal[ + Option.SET_WINDOWS_SHAREMODE, + Option.SET_PACK_MAX_OBJECTS, + Option.ENABLE_HTTP_EXPECT_CONTINUE, + Option.SET_ODB_PACKED_PRIORITY, + Option.SET_ODB_LOOSE_PRIORITY, + Option.SET_SERVER_CONNECT_TIMEOUT, + Option.SET_SERVER_TIMEOUT, + ], + arg1: int, # value +) -> None: ... + + +@overload +def option(option_type: Literal[Option.GET_EXTENSIONS]) -> list[str]: ... + + +@overload +def option( + option_type: Literal[Option.SET_EXTENSIONS], + arg1: list[str], # extensions + arg2: int, # length +) -> None: ... + + +@overload +def option( + option_type: Literal[Option.ADD_SSL_X509_CERT], + arg1: str | bytes, # certificate +) -> None: ... + + +# Fallback overload for generic Option values (used in tests) +@overload +def option(option_type: Option, arg1: Any = ..., arg2: Any = ...) -> Any: ... + + +def option(option_type: Option, arg1: Any = NOT_PASSED, arg2: Any = NOT_PASSED) -> Any: + """ + Get or set a libgit2 option. + + Parameters: + + GIT_OPT_GET_SEARCH_PATH, level + Get the config search path for the given level. + + GIT_OPT_SET_SEARCH_PATH, level, path + Set the config search path for the given level. + + GIT_OPT_GET_MWINDOW_SIZE + Get the maximum mmap window size. + + GIT_OPT_SET_MWINDOW_SIZE, size + Set the maximum mmap window size. + + GIT_OPT_GET_MWINDOW_FILE_LIMIT + Get the maximum number of files that will be mapped at any time by the library. + + GIT_OPT_SET_MWINDOW_FILE_LIMIT, size + Set the maximum number of files that can be mapped at any time by the library. The default (0) is unlimited. + + GIT_OPT_GET_OWNER_VALIDATION + Gets the owner validation setting for repository directories. + + GIT_OPT_SET_OWNER_VALIDATION, enabled + Set that repository directories should be owned by the current user. + The default is to validate ownership. + + GIT_OPT_GET_TEMPLATE_PATH + Get the default template path. + + GIT_OPT_SET_TEMPLATE_PATH, path + Set the default template path. + + GIT_OPT_GET_USER_AGENT + Get the user agent string. + + GIT_OPT_SET_USER_AGENT, user_agent + Set the user agent string. + + GIT_OPT_GET_PACK_MAX_OBJECTS + Get the maximum number of objects to include in a pack. + + GIT_OPT_SET_PACK_MAX_OBJECTS, count + Set the maximum number of objects to include in a pack. + """ + + result: str | None | list[str] + + if option_type in ( + C.GIT_OPT_GET_MWINDOW_SIZE, + C.GIT_OPT_GET_MWINDOW_MAPPED_LIMIT, + C.GIT_OPT_GET_MWINDOW_FILE_LIMIT, + ): + check_args(option_type, arg1, arg2, 0) + + size_ptr = ffi.new('size_t *') + err = C.git_libgit2_opts(option_type, size_ptr) + check_error(err) + return size_ptr[0] + + elif option_type in ( + C.GIT_OPT_SET_MWINDOW_SIZE, + C.GIT_OPT_SET_MWINDOW_MAPPED_LIMIT, + C.GIT_OPT_SET_MWINDOW_FILE_LIMIT, + ): + check_args(option_type, arg1, arg2, 1) + + if not isinstance(arg1, int): + raise TypeError(f'option value must be an integer, not {type(arg1)}') + size = arg1 + if size < 0: + raise ValueError('size must be non-negative') + + err = C.git_libgit2_opts(option_type, ffi.cast('size_t', size)) + check_error(err) + return None + + elif option_type == C.GIT_OPT_GET_SEARCH_PATH: + check_args(option_type, arg1, arg2, 1) + + level = int(arg1) # Convert enum to int + buf = ffi.new('git_buf *') + err = C.git_libgit2_opts(option_type, ffi.cast('int', level), buf) + check_error(err) + + try: + if buf.ptr != ffi.NULL: + result = to_str(ffi.string(buf.ptr)) + else: + result = None + finally: + C.git_buf_dispose(buf) + + return result + + elif option_type == C.GIT_OPT_SET_SEARCH_PATH: + check_args(option_type, arg1, arg2, 2) + + level = int(arg1) # Convert enum to int + path = arg2 + + path_cdata: ArrayC[char] | NULL_TYPE + if path is None: + path_cdata = ffi.NULL + else: + path_bytes = to_bytes(path) + path_cdata = ffi.new('char[]', path_bytes) + + err = C.git_libgit2_opts(option_type, ffi.cast('int', level), path_cdata) + check_error(err) + return None + + elif option_type == C.GIT_OPT_SET_CACHE_OBJECT_LIMIT: + check_args(option_type, arg1, arg2, 2) + + object_type = int(arg1) # Convert enum to int + if not isinstance(arg2, int): + raise TypeError( + f'option value must be an integer, not {type(arg2).__name__}' + ) + size = arg2 + if size < 0: + raise ValueError('size must be non-negative') + + err = C.git_libgit2_opts( + option_type, ffi.cast('int', object_type), ffi.cast('size_t', size) + ) + check_error(err) + return None + + elif option_type == C.GIT_OPT_SET_CACHE_MAX_SIZE: + check_args(option_type, arg1, arg2, 1) + + size = arg1 + if not isinstance(size, int): + raise TypeError( + f'option value must be an integer, not {type(size).__name__}' + ) + + err = C.git_libgit2_opts(option_type, ffi.cast('ssize_t', size)) + check_error(err) + return None + + elif option_type == C.GIT_OPT_GET_CACHED_MEMORY: + check_args(option_type, arg1, arg2, 0) + + current_ptr = ffi.new('ssize_t *') + allowed_ptr = ffi.new('ssize_t *') + err = C.git_libgit2_opts(option_type, current_ptr, allowed_ptr) + check_error(err) + return (current_ptr[0], allowed_ptr[0]) + + elif option_type == C.GIT_OPT_SET_SSL_CERT_LOCATIONS: + check_args(option_type, arg1, arg2, 2) + + cert_file = arg1 + cert_dir = arg2 + + cert_file_cdata: ArrayC[char] | NULL_TYPE + if cert_file is None: + cert_file_cdata = ffi.NULL + else: + cert_file_bytes = to_bytes(cert_file) + cert_file_cdata = ffi.new('char[]', cert_file_bytes) + + cert_dir_cdata: ArrayC[char] | NULL_TYPE + if cert_dir is None: + cert_dir_cdata = ffi.NULL + else: + cert_dir_bytes = to_bytes(cert_dir) + cert_dir_cdata = ffi.new('char[]', cert_dir_bytes) + + err = C.git_libgit2_opts(option_type, cert_file_cdata, cert_dir_cdata) + check_error(err) + return None + + # Handle boolean/int enable/disable options + elif option_type in ( + C.GIT_OPT_ENABLE_CACHING, + C.GIT_OPT_ENABLE_STRICT_OBJECT_CREATION, + C.GIT_OPT_ENABLE_STRICT_SYMBOLIC_REF_CREATION, + C.GIT_OPT_ENABLE_OFS_DELTA, + C.GIT_OPT_ENABLE_FSYNC_GITDIR, + C.GIT_OPT_ENABLE_STRICT_HASH_VERIFICATION, + C.GIT_OPT_ENABLE_UNSAVED_INDEX_SAFETY, + C.GIT_OPT_DISABLE_PACK_KEEP_FILE_CHECKS, + C.GIT_OPT_SET_OWNER_VALIDATION, + ): + check_args(option_type, arg1, arg2, 1) + + enabled = arg1 + # Convert to int (0 or 1) + value = 1 if enabled else 0 + + err = C.git_libgit2_opts(option_type, ffi.cast('int', value)) + check_error(err) + return None + + elif option_type == C.GIT_OPT_GET_OWNER_VALIDATION: + check_args(option_type, arg1, arg2, 0) + + enabled_ptr = ffi.new('int *') + err = C.git_libgit2_opts(option_type, enabled_ptr) + check_error(err) + return bool(enabled_ptr[0]) + + elif option_type == C.GIT_OPT_GET_TEMPLATE_PATH: + check_args(option_type, arg1, arg2, 0) + + buf = ffi.new('git_buf *') + err = C.git_libgit2_opts(option_type, buf) + check_error(err) + + try: + if buf.ptr != ffi.NULL: + result = to_str(ffi.string(buf.ptr)) + else: + result = None + finally: + C.git_buf_dispose(buf) + + return result + + elif option_type == C.GIT_OPT_SET_TEMPLATE_PATH: + check_args(option_type, arg1, arg2, 1) + + path = arg1 + template_path_cdata: ArrayC[char] | NULL_TYPE + if path is None: + template_path_cdata = ffi.NULL + else: + path_bytes = to_bytes(path) + template_path_cdata = ffi.new('char[]', path_bytes) + + err = C.git_libgit2_opts(option_type, template_path_cdata) + check_error(err) + return None + + elif option_type == C.GIT_OPT_GET_USER_AGENT: + check_args(option_type, arg1, arg2, 0) + + buf = ffi.new('git_buf *') + err = C.git_libgit2_opts(option_type, buf) + check_error(err) + + try: + if buf.ptr != ffi.NULL: + result = to_str(ffi.string(buf.ptr)) + else: + result = None + finally: + C.git_buf_dispose(buf) + + return result + + elif option_type == C.GIT_OPT_SET_USER_AGENT: + check_args(option_type, arg1, arg2, 1) + + agent = arg1 + agent_bytes = to_bytes(agent) + agent_cdata = ffi.new('char[]', agent_bytes) + + err = C.git_libgit2_opts(option_type, agent_cdata) + check_error(err) + return None + + elif option_type == C.GIT_OPT_SET_SSL_CIPHERS: + check_args(option_type, arg1, arg2, 1) + + ciphers = arg1 + ciphers_bytes = to_bytes(ciphers) + ciphers_cdata = ffi.new('char[]', ciphers_bytes) + + err = C.git_libgit2_opts(option_type, ciphers_cdata) + check_error(err) + return None + + # Handle GET_WINDOWS_SHAREMODE + elif option_type == C.GIT_OPT_GET_WINDOWS_SHAREMODE: + check_args(option_type, arg1, arg2, 0) + + value_ptr = ffi.new('unsigned int *') + err = C.git_libgit2_opts(option_type, value_ptr) + check_error(err) + return value_ptr[0] + + # Handle SET_WINDOWS_SHAREMODE + elif option_type == C.GIT_OPT_SET_WINDOWS_SHAREMODE: + check_args(option_type, arg1, arg2, 1) + + if not isinstance(arg1, int): + raise TypeError( + f'option value must be an integer, not {type(arg1).__name__}' + ) + value = arg1 + if value < 0: + raise ValueError('value must be non-negative') + + err = C.git_libgit2_opts(option_type, ffi.cast('unsigned int', value)) + check_error(err) + return None + + # Handle GET_PACK_MAX_OBJECTS + elif option_type == C.GIT_OPT_GET_PACK_MAX_OBJECTS: + check_args(option_type, arg1, arg2, 0) + + size_ptr = ffi.new('size_t *') + err = C.git_libgit2_opts(option_type, size_ptr) + check_error(err) + return size_ptr[0] + + # Handle SET_PACK_MAX_OBJECTS + elif option_type == C.GIT_OPT_SET_PACK_MAX_OBJECTS: + check_args(option_type, arg1, arg2, 1) + + if not isinstance(arg1, int): + raise TypeError( + f'option value must be an integer, not {type(arg1).__name__}' + ) + size = arg1 + if size < 0: + raise ValueError('size must be non-negative') + + err = C.git_libgit2_opts(option_type, ffi.cast('size_t', size)) + check_error(err) + return None + + # Handle ENABLE_HTTP_EXPECT_CONTINUE + elif option_type == C.GIT_OPT_ENABLE_HTTP_EXPECT_CONTINUE: + check_args(option_type, arg1, arg2, 1) + + enabled = arg1 + # Convert to int (0 or 1) + value = 1 if enabled else 0 + + err = C.git_libgit2_opts(option_type, ffi.cast('int', value)) + check_error(err) + return None + + # Handle SET_ODB_PACKED_PRIORITY + elif option_type == C.GIT_OPT_SET_ODB_PACKED_PRIORITY: + check_args(option_type, arg1, arg2, 1) + + if not isinstance(arg1, int): + raise TypeError( + f'option value must be an integer, not {type(arg1).__name__}' + ) + priority = arg1 + + err = C.git_libgit2_opts(option_type, ffi.cast('int', priority)) + check_error(err) + return None + + # Handle SET_ODB_LOOSE_PRIORITY + elif option_type == C.GIT_OPT_SET_ODB_LOOSE_PRIORITY: + check_args(option_type, arg1, arg2, 1) + + if not isinstance(arg1, int): + raise TypeError( + f'option value must be an integer, not {type(arg1).__name__}' + ) + priority = arg1 + + err = C.git_libgit2_opts(option_type, ffi.cast('int', priority)) + check_error(err) + return None + + # Handle GET_EXTENSIONS + elif option_type == C.GIT_OPT_GET_EXTENSIONS: + check_args(option_type, arg1, arg2, 0) + + # GET_EXTENSIONS expects a git_strarray pointer + strarray = ffi.new('git_strarray *') + err = C.git_libgit2_opts(option_type, strarray) + check_error(err) + + result = [] + try: + if strarray.strings != ffi.NULL: + # Cast to the non-NULL type for type checking + strings = cast('ArrayC[char_pointer]', strarray.strings) + for i in range(strarray.count): + if strings[i] != ffi.NULL: + result.append(to_str(ffi.string(strings[i]))) + finally: + # Must dispose of the strarray to free the memory + C.git_strarray_dispose(strarray) + + return result + + # Handle SET_EXTENSIONS + elif option_type == C.GIT_OPT_SET_EXTENSIONS: + check_args(option_type, arg1, arg2, 2) + + extensions = arg1 + length = arg2 + + if not isinstance(extensions, list): + raise TypeError('extensions must be a list of strings') + if not isinstance(length, int): + raise TypeError('length must be an integer') + + # Create array of char pointers + # libgit2 will make its own copies with git__strdup + ext_array: ArrayC[char_pointer] = ffi.new('char *[]', len(extensions)) + ext_strings: list[ArrayC[char]] = [] # Keep references during the call + + for i, ext in enumerate(extensions): + ext_bytes = to_bytes(ext) + ext_string: ArrayC[char] = ffi.new('char[]', ext_bytes) + ext_strings.append(ext_string) + ext_array[i] = ffi.cast('char *', ext_string) + + err = C.git_libgit2_opts(option_type, ext_array, ffi.cast('size_t', length)) + check_error(err) + return None + + # Handle GET_HOMEDIR + elif option_type == C.GIT_OPT_GET_HOMEDIR: + check_args(option_type, arg1, arg2, 0) + + buf = ffi.new('git_buf *') + err = C.git_libgit2_opts(option_type, buf) + check_error(err) + + try: + if buf.ptr != ffi.NULL: + result = to_str(ffi.string(buf.ptr)) + else: + result = None + finally: + C.git_buf_dispose(buf) + + return result + + # Handle SET_HOMEDIR + elif option_type == C.GIT_OPT_SET_HOMEDIR: + check_args(option_type, arg1, arg2, 1) + + path = arg1 + homedir_cdata: ArrayC[char] | NULL_TYPE + if path is None: + homedir_cdata = ffi.NULL + else: + path_bytes = to_bytes(path) + homedir_cdata = ffi.new('char[]', path_bytes) + + err = C.git_libgit2_opts(option_type, homedir_cdata) + check_error(err) + return None + + # Handle GET_SERVER_CONNECT_TIMEOUT + elif option_type == C.GIT_OPT_GET_SERVER_CONNECT_TIMEOUT: + check_args(option_type, arg1, arg2, 0) + + timeout_ptr = ffi.new('int *') + err = C.git_libgit2_opts(option_type, timeout_ptr) + check_error(err) + return timeout_ptr[0] + + # Handle SET_SERVER_CONNECT_TIMEOUT + elif option_type == C.GIT_OPT_SET_SERVER_CONNECT_TIMEOUT: + check_args(option_type, arg1, arg2, 1) + + if not isinstance(arg1, int): + raise TypeError( + f'option value must be an integer, not {type(arg1).__name__}' + ) + timeout = arg1 + + err = C.git_libgit2_opts(option_type, ffi.cast('int', timeout)) + check_error(err) + return None + + # Handle GET_SERVER_TIMEOUT + elif option_type == C.GIT_OPT_GET_SERVER_TIMEOUT: + check_args(option_type, arg1, arg2, 0) + + timeout_ptr = ffi.new('int *') + err = C.git_libgit2_opts(option_type, timeout_ptr) + check_error(err) + return timeout_ptr[0] + + # Handle SET_SERVER_TIMEOUT + elif option_type == C.GIT_OPT_SET_SERVER_TIMEOUT: + check_args(option_type, arg1, arg2, 1) + + if not isinstance(arg1, int): + raise TypeError( + f'option value must be an integer, not {type(arg1).__name__}' + ) + timeout = arg1 + + err = C.git_libgit2_opts(option_type, ffi.cast('int', timeout)) + check_error(err) + return None + + # Handle GET_USER_AGENT_PRODUCT + elif option_type == C.GIT_OPT_GET_USER_AGENT_PRODUCT: + check_args(option_type, arg1, arg2, 0) + + buf = ffi.new('git_buf *') + err = C.git_libgit2_opts(option_type, buf) + check_error(err) + + try: + if buf.ptr != ffi.NULL: + result = to_str(ffi.string(buf.ptr)) + else: + result = None + finally: + C.git_buf_dispose(buf) + + return result + + # Handle SET_USER_AGENT_PRODUCT + elif option_type == C.GIT_OPT_SET_USER_AGENT_PRODUCT: + check_args(option_type, arg1, arg2, 1) + + product = arg1 + product_bytes = to_bytes(product) + product_cdata = ffi.new('char[]', product_bytes) + + err = C.git_libgit2_opts(option_type, product_cdata) + check_error(err) + return None + + # Not implemented - ADD_SSL_X509_CERT requires directly binding with OpenSSL + # as the API works accepts a X509* struct. Use GIT_OPT_SET_SSL_CERT_LOCATIONS + # instead. + elif option_type == C.GIT_OPT_ADD_SSL_X509_CERT: + raise NotImplementedError('Use GIT_OPT_SET_SSL_CERT_LOCATIONS instead') + + # Not implemented - SET_ALLOCATOR is not feasible from Python level + # because it requires providing C function pointers for memory management + # (malloc, free, etc.) that must handle raw memory at the C level, + # which cannot be safely implemented in pure Python. + elif option_type == C.GIT_OPT_SET_ALLOCATOR: + raise NotImplementedError('Setting a custom allocator not possible from Python') + + else: + raise ValueError(f'Invalid option {option_type}') diff --git a/pygit2/packbuilder.py b/pygit2/packbuilder.py new file mode 100644 index 000000000..baa546303 --- /dev/null +++ b/pygit2/packbuilder.py @@ -0,0 +1,87 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +from os import PathLike +from typing import TYPE_CHECKING + +# Import from pygit2 +from .errors import check_error +from .ffi import C, ffi +from .utils import to_bytes + +if TYPE_CHECKING: + from pygit2 import Oid, Repository + from pygit2.repository import BaseRepository + + +class PackBuilder: + def __init__(self, repo: 'Repository | BaseRepository') -> None: + cpackbuilder = ffi.new('git_packbuilder **') + err = C.git_packbuilder_new(cpackbuilder, repo._repo) + check_error(err) + + self._repo = repo + self._packbuilder = cpackbuilder[0] + self._cpackbuilder = cpackbuilder + + @property + def _pointer(self) -> bytes: + return bytes(ffi.buffer(self._packbuilder)[:]) + + def __del__(self) -> None: + C.git_packbuilder_free(self._packbuilder) + + def __len__(self) -> int: + return C.git_packbuilder_object_count(self._packbuilder) + + @staticmethod + def __convert_object_to_oid(oid: 'Oid') -> 'ffi.GitOidC': + git_oid = ffi.new('git_oid *') + ffi.buffer(git_oid)[:] = oid.raw[:] + return git_oid + + def add(self, oid: 'Oid') -> None: + git_oid = self.__convert_object_to_oid(oid) + err = C.git_packbuilder_insert(self._packbuilder, git_oid, ffi.NULL) + check_error(err) + + def add_recur(self, oid: 'Oid') -> None: + 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: int) -> int: + return C.git_packbuilder_set_threads(self._packbuilder, n_threads) + + def write(self, path: str | bytes | PathLike[str] | None = None) -> None: + path_bytes = ffi.NULL if path is None else to_bytes(path) + err = C.git_packbuilder_write( + self._packbuilder, path_bytes, 0, ffi.NULL, ffi.NULL + ) + check_error(err) + + @property + def written_objects_count(self) -> int: + return C.git_packbuilder_written(self._packbuilder) diff --git a/pygit2/py.typed b/pygit2/py.typed new file mode 100644 index 000000000..e1dc7fc5f --- /dev/null +++ b/pygit2/py.typed @@ -0,0 +1 @@ +# python type marker, see: https://peps.python.org/pep-0561/ diff --git a/pygit2/references.py b/pygit2/references.py new file mode 100644 index 000000000..93c370eab --- /dev/null +++ b/pygit2/references.py @@ -0,0 +1,111 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +from __future__ import annotations + +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) -> None: + self._repository = repository + + def __getitem__(self, name: str) -> 'Reference': + return self._repository.lookup_reference(name) + + def get(self, key: str) -> 'Reference' | None: + try: + return self[key] + except KeyError: + return None + + def __iter__(self) -> Iterator[str]: + iter = self._repository.references_iterator_init() + while True: + ref = self._repository.references_iterator_next(iter) + if ref: + yield ref.name + else: + return + + 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. + + Parameters: + + references_return_type: ReferenceFilter + Optional specifier to filter references. By default, all references are + returned. + + The following values are accepted: + - ReferenceFilter.ALL, fetches all refs, this is the default + - ReferenceFilter.BRANCHES, fetches only branches + - ReferenceFilter.TAGS, fetches only tags + + TODO: Add support for filtering by reference types notes and remotes. + """ + + # Enforce ReferenceFilter type - raises ValueError if we're given an invalid value + references_return_type = ReferenceFilter(references_return_type) + + iter = self._repository.references_iterator_init() + while True: + ref = self._repository.references_iterator_next( + iter, references_return_type + ) + if ref: + yield ref + else: + return + + 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) -> None: + self[name].delete() + + def __contains__(self, name: str) -> bool: + return self.get(name) is not None + + @property + def objects(self) -> list['Reference']: + return self._repository.listall_reference_objects() + + def compress(self) -> None: + return self._repository.compress_references() diff --git a/pygit2/refspec.py b/pygit2/refspec.py new file mode 100644 index 000000000..794e8d134 --- /dev/null +++ b/pygit2/refspec.py @@ -0,0 +1,97 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +from collections.abc import Callable + +# Import from pygit2 +from .errors import check_error +from .ffi import C, ffi +from .utils import to_bytes + + +class Refspec: + """The constructor is for internal use only.""" + + def __init__(self, owner, ptr) -> None: + self._owner = owner + self._refspec = ptr + + @property + 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) -> str: + """Destination or rhs of the refspec""" + return ffi.string(C.git_refspec_dst(self._refspec)).decode('utf-8') + + @property + def force(self) -> bool: + """Whether this refspeca llows non-fast-forward updates""" + return bool(C.git_refspec_force(self._refspec)) + + @property + def string(self) -> str: + """String which was used to create this refspec""" + return ffi.string(C.git_refspec_string(self._refspec)).decode('utf-8') + + @property + def direction(self): + """Direction of this refspec (fetch or push)""" + return C.git_refspec_direction(self._refspec) + + def src_matches(self, ref: str) -> bool: + """Return True if the given string matches the source of this refspec, + False otherwise. + """ + return bool(C.git_refspec_src_matches(self._refspec, to_bytes(ref))) + + def dst_matches(self, ref: str) -> bool: + """Return True if the given string matches the destination of this + refspec, False otherwise.""" + return bool(C.git_refspec_dst_matches(self._refspec, to_bytes(ref))) + + def _transform(self, ref: str, fn: Callable) -> str: + buf = ffi.new('git_buf *', (ffi.NULL, 0)) + err = fn(buf, self._refspec, to_bytes(ref)) + check_error(err) + + try: + return ffi.string(buf.ptr).decode('utf-8') + finally: + C.git_buf_dispose(buf) + + 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: 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_rtransform) diff --git a/pygit2/remotes.py b/pygit2/remotes.py new file mode 100644 index 000000000..0603a6f9d --- /dev/null +++ b/pygit2/remotes.py @@ -0,0 +1,531 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +from __future__ import annotations + +import warnings +from collections.abc import Generator, Iterator +from typing import TYPE_CHECKING, Any, Literal + +# Import from pygit2 +from pygit2 import RemoteCallbacks + +from . import utils +from ._pygit2 import Oid +from .callbacks import ( + git_fetch_options, + git_proxy_options, + git_push_options, + git_remote_callbacks, +) +from .enums import FetchPrune +from .errors import check_error +from .ffi import C, ffi +from .refspec import Refspec +from .utils import StrArray, maybe_string, strarray_to_strings, to_bytes + +# Need BaseRepository for type hints, but don't let it cause a circular dependency +if TYPE_CHECKING: + from ._libgit2.ffi import GitRemoteC, char_pointer + from .repository import BaseRepository + + +class RemoteHead: + """ + Description of a reference advertised by a remote server, + given out on `Remote.list_heads` calls. + """ + + local: bool + """Available locally""" + + oid: Oid + + loid: Oid + + name: str | None + + symref_target: str | None + """ + If the server sent a symref mapping for this ref, this will + point to the target. + """ + + def __init__(self, c_struct: Any) -> None: + self.local = bool(c_struct.local) + self.oid = Oid(raw=bytes(ffi.buffer(c_struct.oid.id)[:])) + self.loid = Oid(raw=bytes(ffi.buffer(c_struct.loid.id)[:])) + self.name = maybe_string(c_struct.name) + self.symref_target = maybe_string(c_struct.symref_target) + + +class PushUpdate: + """ + Represents an update which will be performed on the remote during push. + """ + + src_refname: str + """The source name of the reference""" + + dst_refname: str + """The name of the reference to update on the server""" + + src: Oid + """The current target of the reference""" + + dst: Oid + """The new target for the reference""" + + def __init__(self, c_struct: Any) -> None: + src_refname = maybe_string(c_struct.src_refname) + dst_refname = maybe_string(c_struct.dst_refname) + assert src_refname is not None, 'libgit2 returned null src_refname' + assert dst_refname is not None, 'libgit2 returned null dst_refname' + self.src_refname = src_refname + self.dst_refname = dst_refname + self.src = Oid(raw=bytes(ffi.buffer(c_struct.src.id)[:])) + self.dst = Oid(raw=bytes(ffi.buffer(c_struct.dst.id)[:])) + + +class TransferProgress: + """Progress downloading and indexing data during a fetch.""" + + total_objects: int + indexed_objects: int + received_objects: int + local_objects: int + total_deltas: int + indexed_deltas: int + received_bytes: int + + def __init__(self, tp: Any) -> None: + self.total_objects = tp.total_objects + """Total number of objects to download""" + + self.indexed_objects = tp.indexed_objects + """Objects which have been indexed""" + + self.received_objects = tp.received_objects + """Objects which have been received up to now""" + + self.local_objects = tp.local_objects + """Local objects which were used to fix the thin pack""" + + self.total_deltas = tp.total_deltas + """Total number of deltas in the pack""" + + self.indexed_deltas = tp.indexed_deltas + """Deltas which have been indexed""" + + self.received_bytes = tp.received_bytes + """"Number of bytes received up to now""" + + +class Remote: + 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) -> None: + C.git_remote_free(self._remote) + + @property + def name(self) -> str | None: + """Name of the remote""" + + return maybe_string(C.git_remote_name(self._remote)) + + @property + def url(self) -> str | None: + """Url of the remote""" + + return maybe_string(C.git_remote_url(self._remote)) + + @property + def push_url(self) -> str | None: + """Push url of the remote""" + + return maybe_string(C.git_remote_pushurl(self._remote)) + + def connect( + self, + callbacks: RemoteCallbacks | None = None, + direction: int = C.GIT_DIRECTION_FETCH, + proxy: None | bool | str = None, + ) -> None: + """Connect to the remote. + + Parameters: + + proxy : None or True or str + Proxy configuration. Can be one of: + + * `None` (the default) to disable proxy usage + * `True` to enable automatic proxy detection + * an url to a proxy (`http://proxy.example.org:3128/`) + """ + with git_proxy_options(self, proxy=proxy) as proxy_opts: + with git_remote_callbacks(callbacks) as payload: + err = C.git_remote_connect( + self._remote, + direction, + payload.remote_callbacks, + proxy_opts, + ffi.NULL, + ) + payload.check_error(err) + + def fetch( + self, + refspecs: list[str] | None = None, + message: str | None = None, + callbacks: RemoteCallbacks | None = None, + prune: FetchPrune = FetchPrune.UNSPECIFIED, + proxy: None | Literal[True] | str = None, + depth: int = 0, + ) -> TransferProgress: + """Perform a fetch against this remote. Returns a + object. + + Parameters: + + prune : enums.FetchPrune + * `UNSPECIFIED`: use the configuration from the repository. + * `PRUNE`: remove any remote branch in the local repository + that does not exist in the remote. + * `NO_PRUNE`: always keep the remote branches + + proxy : None or True or str + Proxy configuration. Can be one of: + + * `None` (the default) to disable proxy usage + * `True` to enable automatic proxy detection + * an url to a proxy (`http://proxy.example.org:3128/`) + + depth : int + Number of commits from the tip of each remote branch history to fetch. + + If non-zero, the number of commits from the tip of each remote + branch history to fetch. If zero, all history is fetched. + The default is 0 (all history is fetched). + """ + with git_fetch_options(callbacks) as payload: + opts = payload.fetch_options + opts.prune = prune + opts.depth = depth + with git_proxy_options(self, payload.fetch_options.proxy_opts, proxy): + with StrArray(refspecs) as arr: + err = C.git_remote_fetch( + self._remote, arr.ptr, opts, to_bytes(message) + ) + payload.check_error(err) + + return TransferProgress(C.git_remote_stats(self._remote)) + + def list_heads( + self, + callbacks: RemoteCallbacks | None = None, + proxy: str | None | bool = None, + connect: bool = True, + ) -> list[RemoteHead]: + """ + 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. + """ + + if connect: + self.connect(callbacks=callbacks, proxy=proxy) + + refs_ptr = ffi.new('git_remote_head ***') + size_ptr = ffi.new('size_t *') + + err = C.git_remote_ls(refs_ptr, size_ptr, self._remote) + check_error(err) + + num_refs = int(size_ptr[0]) + results = [RemoteHead(refs_ptr[0][i]) for i in range(num_refs)] + + return results + + def ls_remotes( + self, + callbacks: RemoteCallbacks | None = None, + proxy: str | None | bool = None, + connect: bool = True, + ) -> list[dict[str, Any]]: + """ + Deprecated interface to list_heads + """ + warnings.warn('Use list_heads', DeprecationWarning) + + heads = self.list_heads(callbacks, proxy, connect) + + return [ + { + 'local': h.local, + 'oid': h.oid, + 'loid': h.loid if h.local else None, + 'name': h.name, + 'symref_target': h.symref_target, + } + for h in heads + ] + + def prune(self, callbacks: RemoteCallbacks | None = None) -> None: + """Perform a prune against this remote.""" + with git_remote_callbacks(callbacks) as payload: + err = C.git_remote_prune(self._remote, payload.remote_callbacks) + payload.check_error(err) + + @property + 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: 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) -> list[str]: + """Refspecs that will be used for fetching""" + + specs = ffi.new('git_strarray *') + err = C.git_remote_get_fetch_refspecs(specs, self._remote) + check_error(err) + return strarray_to_strings(specs) + + @property + def push_refspecs(self) -> list[str]: + """Refspecs that will be used for pushing""" + + specs = ffi.new('git_strarray *') + err = C.git_remote_get_push_refspecs(specs, self._remote) + check_error(err) + return strarray_to_strings(specs) + + 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. + + When the remote has a githook installed, that denies the reference this + function will return successfully. Thus it is strongly recommended to + install a callback, that implements + :py:meth:`RemoteCallbacks.push_update_reference` and check the passed + parameters for successful operations. + + Parameters: + + specs : [str] + Push refspecs to use. + + callbacks : + + proxy : None or True or str + Proxy configuration. Can be one of: + + * `None` (the default) to disable proxy usage + * `True` to enable automatic proxy detection + * an url to a proxy (`http://proxy.example.org:3128/`) + + push_options : [str] + Push options to send to the server, which passes them to the + pre-receive as well as the post-receive hook. + + threads : int + If the transport being used to push to the remote requires the + creation of a pack file, this controls the number of worker threads + used by the packbuilder when creating that pack file to be sent to + the remote. + + If set to 0, the packbuilder will auto-detect the number of threads + to create. The default value is 1. + """ + with git_push_options(callbacks) as payload: + opts = payload.push_options + opts.pb_parallelism = threads + with git_proxy_options(self, payload.push_options.proxy_opts, proxy): + with StrArray(specs) as refspecs, StrArray(push_options) as pushopts: + pushopts.assign_to(opts.remote_push_options) + err = C.git_remote_push(self._remote, refspecs.ptr, opts) + payload.check_error(err) + + +class RemoteCollection: + """Collection of configured remotes + + You can use this class to look up and manage the remotes configured + in a repository. You can access repositories using index + access. E.g. to look up the "origin" remote, you can use + + >>> repo.remotes["origin"] + """ + + def __init__(self, repo: BaseRepository) -> None: + self._repo = repo + + 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) -> Iterator[Remote]: + cremote = ffi.new('git_remote **') + for name in self._ffi_names(): + err = C.git_remote_lookup(cremote, self._repo._repo, name) + check_error(err) + + yield Remote(self._repo, cremote[0]) + + 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)) + check_error(err) + + return Remote(self._repo, cremote[0]) + + 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] # type: ignore[index] + + def names(self) -> Generator[str | None, None, None]: + """An iterator over the names of the available remotes.""" + for name in self._ffi_names(): + yield maybe_string(name) + + def create(self, name: str, url: str, fetch: str | None = None) -> Remote: + """Create a new remote with the given name and url. Returns a + object. + + If 'fetch' is provided, this fetch refspec will be used instead of the + default. + """ + cremote = ffi.new('git_remote **') + + name_bytes = to_bytes(name) + url_bytes = to_bytes(url) + if fetch: + fetch_bytes = to_bytes(fetch) + err = C.git_remote_create_with_fetchspec( + cremote, self._repo._repo, name_bytes, url_bytes, fetch_bytes + ) + else: + 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: str) -> Remote: + """Create a new anonymous (in-memory only) remote with the given URL. + Returns a object. + """ + cremote = ffi.new('git_remote **') + url_bytes = to_bytes(url) + err = C.git_remote_create_anonymous(cremote, self._repo._repo, url_bytes) + check_error(err) + return Remote(self._repo, cremote[0]) + + def rename(self, name: str, new_name: str) -> list[str]: + """Rename a remote in the configuration. The refspecs in standard + format will be renamed. + + Returns a list of fetch refspecs (list of strings) which were not in + the standard format and thus could not be remapped. + """ + + if not name: + raise ValueError('Current remote name must be a non-empty string') + + if not new_name: + raise ValueError('New remote name must be a non-empty string') + + problems = ffi.new('git_strarray *') + err = C.git_remote_rename( + problems, self._repo._repo, to_bytes(name), to_bytes(new_name) + ) + check_error(err) + return strarray_to_strings(problems) + + 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)) + check_error(err) + + def set_url(self, name: str, url: str) -> None: + """Set the URL for a remote""" + err = C.git_remote_set_url(self._repo._repo, to_bytes(name), to_bytes(url)) + check_error(err) + + def set_push_url(self, name: str, url: str) -> None: + """Set the push-URL for a remote""" + err = C.git_remote_set_pushurl(self._repo._repo, to_bytes(name), to_bytes(url)) + check_error(err) + + def add_fetch(self, name: str, refspec: str) -> None: + """Add a fetch refspec (str) to the remote""" + + err = C.git_remote_add_fetch( + self._repo._repo, to_bytes(name), to_bytes(refspec) + ) + check_error(err) + + def add_push(self, name: str, refspec: str) -> None: + """Add a push refspec (str) to the remote""" + + err = C.git_remote_add_push(self._repo._repo, to_bytes(name), to_bytes(refspec)) + check_error(err) diff --git a/pygit2/repository.py b/pygit2/repository.py index fbb77ced3..18caa5e7e 100644 --- a/pygit2/repository.py +++ b/pygit2/repository.py @@ -1,6 +1,4 @@ -# -*- coding: utf-8 -*- -# -# Copyright 2010-2013 The pygit2 contributors +# Copyright 2010-2025 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -25,100 +23,454 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. -# Import from the Standard Library +import tarfile +import warnings +from collections.abc import Callable, Iterator +from io import BytesIO +from pathlib import Path from string import hexdigits +from time import time +from typing import TYPE_CHECKING, Optional, overload # Import from pygit2 -from _pygit2 import Repository as _Repository -from _pygit2 import GIT_BRANCH_LOCAL, GIT_BRANCH_REMOTE -from _pygit2 import Oid, GIT_OID_HEXSZ, GIT_OID_MINPREFIXLEN -from _pygit2 import GIT_CHECKOUT_SAFE_CREATE, GIT_DIFF_NORMAL -from _pygit2 import Reference, Tree, Commit, Blob +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 ( + StashApplyCallbacks, + git_checkout_options, + git_stash_apply_options, +) +from .config import Config +from .enums import ( + AttrCheck, + BlameFlag, + CheckoutStrategy, + DescribeStrategy, + DiffOption, + FileMode, + MergeFavor, + MergeFileFlag, + MergeFlag, + ObjectType, + RepositoryOpenFlag, + RepositoryState, +) +from .errors import check_error +from .ffi import C, ffi +from .index import Index, IndexEntry, MergeFileResult +from .packbuilder import PackBuilder +from .references import References +from .remotes import RemoteCollection +from .submodules import SubmoduleCollection +from .transaction import ReferenceTransaction +from .utils import StrArray, to_bytes + +if TYPE_CHECKING: + from pygit2._libgit2.ffi import ( + ArrayC, + GitMergeOptionsC, + GitRepositoryC, + _Pointer, + char, + ) + from pygit2._pygit2 import Odb, Refdb, RefdbBackend + + +class BaseRepository(_Repository): + _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) -> 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 + repo_cptr = ffi.new('git_repository **') + ffi.buffer(repo_cptr)[:] = self._pointer[:] + self._repo = repo_cptr[0] + + # Backwards compatible ODB access + 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(oid) + + 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(type, data) + + 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. + + Returns: the number of objects written to the pack + + Parameters: + + path + The path to which the `.pack` and `.idx` files should be written. `None` will write to the default location. + + pack_delegate + The method which will provide add the objects to the pack builder. Defaults to all objects. + + n_threads + The number of threads the `PackBuilder` will spawn. If set to 0, libgit2 will autodetect the number of CPUs. + """ + + def pack_all_objects(pack_builder): + for obj in self.odb: + pack_builder.add(obj) + + pack_delegate = pack_delegate or pack_all_objects + + builder = PackBuilder(self) + if n_threads is not None: + builder.set_threads(n_threads) + pack_delegate(builder) + builder.write(path=path) + + return builder.written_objects_count + + def hashfile( + self, + path: str, + object_type: ObjectType = ObjectType.BLOB, + 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, + you can just use `pygit2.hashfile()`. However, if you want to hash a file + in the repository and you want to apply filtering rules (e.g. crlf filters) + before generating the SHA, then use this function. + + Note: if the repository has `core.safecrlf` set to fail and the filtering + triggers that failure, then this function will raise an error and not + calculate the hash of the file. + Returns: Output value of calculated SHA (Oid) -class Repository(_Repository): + Parameters: + + path + Path to file on disk whose contents should be hashed. This may be + an absolute path or a relative path, in which case it will be treated + as a path within the working directory. + + object_type + The object type to hash (e.g. enums.ObjectType.BLOB) + + as_path + The path to use to look up filtering rules. If this is an empty string + then no filters will be applied when calculating the hash. + 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_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_oid = ffi.new('git_oid *') + + err = C.git_repository_hashfile( + c_oid, self._repo, c_path, int(object_type), c_as_path + ) + check_error(err) + + oid = Oid(raw=bytes(ffi.buffer(c_oid.id)[:])) + return oid + + 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) -> str: + return f'pygit2.Repository({repr(self.path)})' # - # References + # Configuration # - def create_reference(self, name, target, force=False): + @property + def config(self) -> Config: + """The configuration file for this repository. + + If a the configuration hasn't been set yet, the default config for + repository will be returned, including global and system configurations + (if they are available). """ - Create a new reference "name" which points to an object or to another - reference. + cconfig = ffi.new('git_config **') + err = C.git_repository_config(cconfig, self._repo) + check_error(err) + + return Config.from_c(self, cconfig[0]) + + @property + def config_snapshot(self): + """A snapshot for this repositiory's configuration + + This allows reads over multiple values to use the same version + of the configuration files. + """ + cconfig = ffi.new('git_config **') + err = C.git_repository_config_snapshot(cconfig, self._repo) + check_error(err) + + return Config.from_c(self, cconfig[0]) + + # + # References + # + 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. Based on the type and value of the target parameter, this method tries to guess whether it is a direct or a symbolic reference. Keyword arguments: - force + force: bool If True references will be overridden, otherwise (the default) an exception is raised. + message: str + Optional message to use for the reflog. + Examples:: - repo.create_reference('refs/heads/foo', repo.head.hex) + repo.create_reference('refs/heads/foo', repo.head.target) repo.create_reference('refs/tags/foo', 'refs/heads/master') repo.create_reference('refs/tags/foo', 'bbb78a9cec580') """ - direct = ( - type(target) is Oid - or ( - all(c in hexdigits for c in target) - and GIT_OID_MINPREFIXLEN <= len(target) <= GIT_OID_HEXSZ)) + direct = isinstance(target, Oid) or ( + all(c in hexdigits for c in target) + and GIT_OID_MINPREFIXLEN <= len(target) <= GIT_OID_HEXSZ + ) + + # 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) - if direct: - return self.create_reference_direct(name, target, force) + 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()) - return self.create_reference_symbolic(name, target, force) + 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: str) -> tuple[Commit, Reference]: + """Convert a reference-like short name "ref-ish" to a valid + (commit, reference) pair. + + If ref-ish points to a commit, the reference element of the result + will be None. + + Examples:: + + repo.resolve_refish('mybranch') + repo.resolve_refish('sometag') + repo.resolve_refish('origin/master') + repo.resolve_refish('bbb78a9') + """ + try: + reference = self.lookup_reference_dwim(refish) + except (KeyError, InvalidSpecError): + reference = None + commit = self.revparse_single(refish) + else: + 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:: + + 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 # - def checkout(self, refname=None, strategy=GIT_CHECKOUT_SAFE_CREATE): + + def checkout_head(self, **kwargs): + """Checkout HEAD + + For arguments, see Repository.checkout(). """ - Checkout the given reference using the given strategy, and update - the HEAD. - The reference may be a reference name or a Reference object. - The default strategy is GIT_CHECKOUT_SAFE_CREATE. + with git_checkout_options(**kwargs) as payload: + err = C.git_checkout_head(self._repo, payload.checkout_options) + payload.check_error(err) - To checkout from the HEAD, just pass 'HEAD':: + def checkout_index(self, index=None, **kwargs): + """Checkout the given index or the repository's index + + For arguments, see Repository.checkout(). + """ + with git_checkout_options(**kwargs) as payload: + err = C.git_checkout_index( + self._repo, + index._index if index else ffi.NULL, + payload.checkout_options, + ) + payload.check_error(err) - >>> checkout('HEAD') + def checkout_tree(self, treeish, **kwargs): + """Checkout the given treeish + + For arguments, see Repository.checkout(). + """ + with git_checkout_options(**kwargs) as payload: + cptr = ffi.new('git_object **') + ffi.buffer(cptr)[:] = treeish._pointer[:] + err = C.git_checkout_tree(self._repo, cptr[0], payload.checkout_options) + payload.check_error(err) + + def checkout( + self, + refname: str | None | Reference = None, + **kwargs, + ) -> None: + """ + Checkout the given reference using the given strategy, and update the + HEAD. + The reference may be a reference name or a Reference object. + The default strategy is SAFE | RECREATE_MISSING. If no reference is given, checkout from the index. + Parameters: + + refname : str or Reference + The reference to checkout. After checkout, the current branch will + be switched to this one. + + strategy : CheckoutStrategy + A ``CheckoutStrategy`` value. The default is ``SAFE | RECREATE_MISSING``. + + directory : str + Alternative checkout path to workdir. + + paths : list[str] + A list of files to checkout from the given reference. + If paths is provided, HEAD will not be set to the reference. + + callbacks : CheckoutCallbacks + Optional. Supply a `callbacks` object to get information about + conflicted files, updated files, etc. as the checkout is being + performed. The callbacks can also abort the checkout prematurely. + + The callbacks should be an object which inherits from + `pyclass:CheckoutCallbacks`. It should implement the callbacks + as overridden methods. + + Examples: + + * To checkout from the HEAD, just pass 'HEAD':: + + >>> checkout('HEAD') + + This is identical to calling checkout_head(). """ + # Case 1: Checkout index if refname is None: - return self.checkout_index(strategy) + return self.checkout_index(**kwargs) # Case 2: Checkout head if refname == 'HEAD': - return self.checkout_head(strategy) + return self.checkout_head(**kwargs) # Case 3: Reference - if type(refname) is Reference: + if isinstance(refname, Reference): reference = refname refname = refname.name else: @@ -126,15 +478,90 @@ def checkout(self, refname=None, strategy=GIT_CHECKOUT_SAFE_CREATE): oid = reference.resolve().target treeish = self[oid] - self.checkout_tree(treeish, strategy) - self.head = refname + self.checkout_tree(treeish, **kwargs) + if 'paths' not in kwargs: + self.set_head(refname) + + # + # Setting HEAD + # + def set_head(self, target: Oid | str) -> None: + """ + Set HEAD to point to the given target. + + Parameters: + + target + The new target for HEAD. Can be a string or Oid (to detach). + """ + + if isinstance(target, Oid): + oid = ffi.new('git_oid *') + ffi.buffer(oid)[:] = target.raw[:] + err = C.git_repository_set_head_detached(self._repo, oid) + check_error(err) + return + + # if it's a string, then it's a reference name + err = C.git_repository_set_head(self._repo, to_bytes(target)) + check_error(err) # # Diff # - def diff(self, a=None, b=None, cached=False, flags=GIT_DIFF_NORMAL, - context_lines=3, interhunk_lines=0): + def __whatever_to_tree_or_blob(self, obj): + if obj is None: + return None + + # If it's a string, then it has to be valid revspec + if isinstance(obj, str) or isinstance(obj, bytes): + obj = self.revparse_single(obj) + elif isinstance(obj, Oid): + obj = self[obj] + + # First we try to get to a blob + try: + obj = obj.peel(Blob) + except Exception: + # And if that failed, try to get a tree, raising a type + # error if that still doesn't work + try: + obj = obj.peel(Tree) + except Exception: + raise TypeError(f'unexpected "{type(obj)}"') + + return obj + + @overload + def diff( + self, + a: None | str | bytes | Commit | Oid | Reference = None, + b: None | str | bytes | Commit | Oid | Reference = None, + cached: bool = False, + flags: DiffOption = DiffOption.NORMAL, + context_lines: int = 3, + interhunk_lines: int = 0, + ) -> Diff: ... + @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 @@ -142,19 +569,34 @@ def diff(self, a=None, b=None, cached=False, flags=GIT_DIFF_NORMAL, Keyword arguments: + a + None, a str (that refers to an Object, see revparse_single()) or a + Reference object. + If None, b must be None, too. In this case the working directory is + compared with the index. Otherwise the referred object is compared to + 'b'. + + b + None, a str (that refers to an Object, see revparse_single()) or a + Reference object. + If None, the working directory is compared to 'a'. (except + 'cached' is True, in which case the index is compared to 'a'). + Otherwise the referred object is compared to 'a' + cached - use staged changes instead of workdir + 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 - a GIT_DIFF_* constant + A combination of enums.DiffOption constants. context_lines - the number of unchanged lines that define the boundary - of a hunk (and to display before and after) + The number of unchanged lines that define the boundary of a hunk + (and to display before and after) interhunk_lines - the maximum number of unchanged lines between hunk - boundaries before the hunks will be merged into a one + The maximum number of unchanged lines between hunk boundaries + before the hunks will be merged into a one Examples:: @@ -177,41 +619,1185 @@ def diff(self, a=None, b=None, cached=False, flags=GIT_DIFF_NORMAL, API (Tree.diff_to_tree()) directly. """ - def treeish_to_tree(obj): - try: - obj = self.revparse_single(obj) - except: - pass + a = self.__whatever_to_tree_or_blob(a) + b = self.__whatever_to_tree_or_blob(b) - if isinstance(obj, Commit): - return obj.tree - elif isinstance(obj, Reference): - oid = obj.resolve().target - return self[oid] - - a = treeish_to_tree(a) or a - b = treeish_to_tree(b) or b - - opt_keys = ['flags', 'context_lines', 'interhunk_lines'] - opt_values = [flags, context_lines, interhunk_lines] + options = { + 'flags': int(flags), + 'context_lines': context_lines, + 'interhunk_lines': interhunk_lines, + } # Case 1: Diff tree to tree if isinstance(a, Tree) and isinstance(b, Tree): - return a.diff_to_tree(b, **dict(zip(opt_keys, opt_values))) + return a.diff_to_tree(b, **options) # type: ignore[arg-type] # Case 2: Index to workdir elif a is None and b is None: - return self.index.diff_to_workdir(*opt_values) + return self.index.diff_to_workdir(**options) # Case 3: Diff tree to index or workdir elif isinstance(a, Tree) and b is None: if cached: - return a.diff_to_index(self.index, *opt_values) + return a.diff_to_index(self.index, **options) # type: ignore[arg-type] else: - return a.diff_to_workdir(*opt_values) + return a.diff_to_workdir(**options) # type: ignore[arg-type] # Case 4: Diff blob to blob if isinstance(a, Blob) and isinstance(b, Blob): - raise NotImplementedError('git_diff_blob_to_blob()') + return a.diff(b, **options) # type: ignore[arg-type] + + raise ValueError('Only blobs and treeish can be diffed') + + def state(self) -> RepositoryState: + """Determines the state of a git repository - ie, whether an operation + (merge, cherry-pick, etc) is in progress. + + Returns a RepositoryState constant. + """ + cstate: int = C.git_repository_state(self._repo) + try: + return RepositoryState(cstate) + except ValueError: + # Some value not in the IntEnum - newer libgit2 version? + return cstate # type: ignore[return-value] + + 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. + """ + C.git_repository_state_cleanup(self._repo) + + # + # blame + # + def blame( + self, + path: str, + flags: BlameFlag = BlameFlag.NORMAL, + 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. + + Parameters: + + path + Path to the file to blame. + + flags + An enums.BlameFlag constant. + + min_match_characters + The number of alphanum chars that must be detected as moving/copying + within a file for it to associate those lines with the parent commit. + + newest_commit + The id of the newest commit to consider. + + oldest_commit + The id of the oldest commit to consider. + + min_line + The first line in the file to blame. + + max_line + The last line in the file to blame. + + Examples:: + + repo.blame('foo.c', flags=enums.BlameFlag.IGNORE_WHITESPACE) + """ + + options = ffi.new('git_blame_options *') + + C.git_blame_options_init(options, C.GIT_BLAME_OPTIONS_VERSION) + if flags: + options.flags = int(flags) + if min_match_characters: + options.min_match_characters = min_match_characters + if newest_commit: + if not isinstance(newest_commit, Oid): + newest_commit = Oid(hex=newest_commit) + ffi.buffer(ffi.addressof(options, 'newest_commit'))[:] = newest_commit.raw + if oldest_commit: + if not isinstance(oldest_commit, Oid): + oldest_commit = Oid(hex=oldest_commit) + ffi.buffer(ffi.addressof(options, 'oldest_commit'))[:] = oldest_commit.raw + if min_line: + options.min_line = min_line + if max_line: + options.max_line = max_line + + cblame = ffi.new('git_blame **') + err = C.git_blame_file(cblame, self._repo, to_bytes(path), options) + check_error(err) + + return Blame._from_c(self, cblame[0]) + + # + # Index + # + @property + def index(self): + """Index representing the repository's index file.""" + cindex = ffi.new('git_index **') + err = C.git_repository_index(cindex, self._repo) + check_error(err, io=True) + + return Index.from_c(self, cindex) + + # + # Merging + # + @staticmethod + def _merge_options( + favor: int | MergeFavor, flags: int | MergeFlag, file_flags: int | MergeFileFlag + ) -> 'GitMergeOptionsC': + """Return a 'git_merge_opts *'""" + + # Check arguments type + if not isinstance(favor, (int, MergeFavor)): + raise TypeError('favor argument must be MergeFavor') + + if not isinstance(flags, (int, MergeFlag)): + raise TypeError('flags argument must be MergeFlag') + + if not isinstance(file_flags, (int, MergeFileFlag)): + raise TypeError('file_flags argument must be MergeFileFlag') + + opts = ffi.new('git_merge_options *') + err = C.git_merge_options_init(opts, C.GIT_MERGE_OPTIONS_VERSION) + check_error(err) + + opts.file_favor = int(favor) + opts.flags = int(flags) + opts.file_flags = int(file_flags) + + return opts + + def merge_file_from_index( + self, + ancestor: 'IndexEntry | None', + ours: 'IndexEntry | None', + theirs: 'IndexEntry | None', + use_deprecated: bool = True, + ) -> 'str | MergeFileResult | None': + """Merge files from index. + + Returns: A string with the content of the file containing + possible conflicts if use_deprecated==True. + If use_deprecated==False then it returns an instance of MergeFileResult. + + ancestor + The index entry which will be used as a common + ancestor. + ours + The index entry to take as "ours" or base. + theirs + The index entry which will be merged into "ours" + use_deprecated + This controls what will be returned. If use_deprecated==True (default), + a string with the contents of the file will be returned. + An instance of MergeFileResult will be returned otherwise. + """ + cmergeresult = ffi.new('git_merge_file_result *') + + cancestor, ancestor_str_ref = ( + ancestor._to_c() if ancestor is not None else (ffi.NULL, ffi.NULL) + ) + cours, ours_str_ref = ours._to_c() if ours is not None else (ffi.NULL, ffi.NULL) + ctheirs, theirs_str_ref = ( + theirs._to_c() if theirs is not None else (ffi.NULL, ffi.NULL) + ) + + err = C.git_merge_file_from_index( + cmergeresult, self._repo, cancestor, cours, ctheirs, ffi.NULL + ) + check_error(err) + + mergeFileResult = MergeFileResult._from_c(cmergeresult) + C.git_merge_file_result_free(cmergeresult) + + if use_deprecated: + warnings.warn( + 'Getting an str from Repository.merge_file_from_index is deprecated. ' + 'The method will later return an instance of MergeFileResult by default, instead. ' + 'Check parameter use_deprecated.', + DeprecationWarning, + ) + return mergeFileResult.contents if mergeFileResult else '' + + return mergeFileResult + + def merge_commits( + self, + ours: 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. + + Returns: an index with the result of the merge. + + Parameters: + + ours + The commit to take as "ours" or base. + + theirs + The commit which will be merged into "ours" + + 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. + + Both "ours" and "theirs" can be any object which peels to a commit or + the id (string or Oid) of an object which peels to a commit. + """ + ours_ptr = ffi.new('git_commit **') + theirs_ptr = ffi.new('git_commit **') + cindex = ffi.new('git_index **') + + if isinstance(ours, (str, Oid)): + 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_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) + + opts = self._merge_options(favor, flags, file_flags) + + ffi.buffer(ours_ptr)[:] = ours._pointer[:] + ffi.buffer(theirs_ptr)[:] = theirs._pointer[:] + + err = C.git_merge_commits(cindex, self._repo, ours_ptr[0], theirs_ptr[0], opts) + check_error(err) + + return Index.from_c(self, cindex) + + def merge_trees( + self, + ancestor: str | Oid | Tree, + ours: str | Oid | Tree, + theirs: str | Oid | Tree, + favor: MergeFavor = MergeFavor.NORMAL, + flags: MergeFlag = MergeFlag.FIND_RENAMES, + file_flags: MergeFileFlag = MergeFileFlag.DEFAULT, + ) -> 'Index': + """ + Merge two trees. + + Returns: an Index that reflects the result of the merge. + + Parameters: + + ancestor + The tree which is the common ancestor between 'ours' and 'theirs'. + + ours + The commit to take as "ours" or base. + + theirs + The commit which will be merged into "ours". + + 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. + """ + ancestor_ptr = ffi.new('git_tree **') + ours_ptr = ffi.new('git_tree **') + theirs_ptr = ffi.new('git_tree **') + cindex = ffi.new('git_index **') + + ancestor = self.__ensure_tree(ancestor) + ours = self.__ensure_tree(ours) + theirs = self.__ensure_tree(theirs) + + opts = self._merge_options(favor, flags, file_flags) + + ffi.buffer(ancestor_ptr)[:] = ancestor._pointer[:] + ffi.buffer(ours_ptr)[:] = ours._pointer[:] + ffi.buffer(theirs_ptr)[:] = theirs._pointer[:] + + err = C.git_merge_trees( + cindex, self._repo, ancestor_ptr[0], ours_ptr[0], theirs_ptr[0], opts + ) + check_error(err) + + return Index.from_c(self, cindex) + + def merge( + self, + source: Reference | Commit | Oid | str, + favor: MergeFavor = MergeFavor.NORMAL, + flags: MergeFlag = MergeFlag.FIND_RENAMES, + file_flags: MergeFileFlag = MergeFileFlag.DEFAULT, + ) -> None: + """ + Merges the given Reference or Commit into HEAD. + + Merges the given commit into HEAD, writing the results into the working directory. + Any changes are staged for commit and any conflicts are written to the index. + Callers should inspect the repository's index after this completes, + resolve any conflicts and prepare a commit. + + Parameters: + + source + The Reference, Commit, or commit Oid to merge into HEAD. + It is preferable to pass in a Reference, because this enriches the + merge with additional information (for example, Repository.message will + specify the name of the branch being merged). + Previous versions of pygit2 allowed passing in a partial commit + hash as a string; this is deprecated. + + favor + An enums.MergeFavor constant specifying how to deal with file-level conflicts. + 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. + """ + + if isinstance(source, Reference): + # Annotated commit from ref + cptr = ffi.new('struct git_reference **') + ffi.buffer(cptr)[:] = source._pointer[:] # type: ignore[attr-defined] + commit_ptr = ffi.new('git_annotated_commit **') + err = C.git_annotated_commit_from_ref(commit_ptr, self._repo, cptr[0]) + check_error(err) + else: + # Annotated commit from commit id + if isinstance(source, str): + # For backwards compatibility, parse a string as a partial commit hash + warnings.warn( + 'Passing str to Repository.merge is deprecated. ' + 'Pass Commit, Oid, or a Reference (such as a Branch) instead.', + DeprecationWarning, + ) + oid = self[source].peel(Commit).id + elif isinstance(source, Commit): + oid = source.id + elif isinstance(source, Oid): + oid = source + else: + raise TypeError('expected Reference, Commit, or Oid') + c_id = ffi.new('git_oid *') + ffi.buffer(c_id)[:] = oid.raw[:] + commit_ptr = ffi.new('git_annotated_commit **') + err = C.git_annotated_commit_lookup(commit_ptr, self._repo, c_id) + check_error(err) + + merge_opts = self._merge_options(favor, flags, file_flags) + + checkout_opts = ffi.new('git_checkout_options *') + C.git_checkout_options_init(checkout_opts, 1) + checkout_opts.checkout_strategy = int( + CheckoutStrategy.SAFE | CheckoutStrategy.RECREATE_MISSING + ) + + err = C.git_merge(self._repo, commit_ptr, 1, merge_opts, checkout_opts) + C.git_annotated_commit_free(commit_ptr[0]) + check_error(err) + + # + # Prepared message (MERGE_MSG) + # + @property + def raw_message(self) -> bytes: + """ + Retrieve git's prepared message (bytes). + See `Repository.message` for more information. + """ + buf = ffi.new('git_buf *', (ffi.NULL, 0)) + try: + err = C.git_repository_message(buf, self._repo) + if err == C.GIT_ENOTFOUND: + return b'' + check_error(err) + return ffi.string(buf.ptr) + finally: + C.git_buf_dispose(buf) + + @property + def message(self) -> str: + """ + Retrieve git's prepared message. + + Operations such as git revert/cherry-pick/merge with the -n option stop + just short of creating a commit with the changes and save their + prepared message in .git/MERGE_MSG so the next git-commit execution can + present it to the user for them to amend if they wish. + + Use this function to get the contents of this file. Don't forget to + call `Repository.remove_message()` after you create the commit. + + Note that the message is also removed by `Repository.state_cleanup()`. + + If there is no such message, an empty string is returned. + """ + return self.raw_message.decode('utf-8') + + def remove_message(self) -> None: + """ + Remove git's prepared message. + """ + err = C.git_repository_message_remove(self._repo) + check_error(err) + + # + # Describe + # + def describe( + self, + committish: str | Reference | Commit | None = None, + max_candidates_tags: int | None = None, + describe_strategy: DescribeStrategy = DescribeStrategy.DEFAULT, + pattern: str | None = None, + only_follow_first_parent: bool | None = None, + show_commit_oid_as_fallback: bool | None = None, + abbreviated_size: 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. + + Returns: The description (str). + + Parameters: + + committish : `str`, :class:`~.Reference`, or :class:`~.Commit` + Commit-ish object or object name to describe, or `None` to describe + the current working tree. + + max_candidates_tags : int + The number of candidate tags to consider. Increasing above 10 will + take slightly longer but may produce a more accurate result. A + value of 0 will cause only exact matches to be output. + + describe_strategy : DescribeStrategy + Can be one of: + + * `DescribeStrategy.DEFAULT` - Only match annotated tags. + * `DescribeStrategy.TAGS` - Match everything under refs/tags/ + (includes lightweight tags). + * `DescribeStrategy.ALL` - Match everything under refs/ (includes + branches). + + pattern : str + Only consider tags matching the given `glob(7)` pattern, excluding + the "refs/tags/" prefix. + + only_follow_first_parent : bool + Follow only the first parent commit upon seeing a merge commit. + + show_commit_oid_as_fallback : bool + Show uniquely abbreviated commit object as fallback. + + abbreviated_size : int + The minimum number of hexadecimal digits to show for abbreviated + object names. A value of 0 will suppress long format, only showing + the closest tag. + + always_use_long_format : bool + Always output the long format (the nearest tag, the number of + commits, and the abbreviated commit name) even when the committish + matches a tag. + + dirty_suffix : str + A string to append if the working tree is dirty. + + Example:: + + repo.describe(pattern='public/*', dirty_suffix='-dirty') + """ + + options = ffi.new('git_describe_options *') + C.git_describe_options_init(options, C.GIT_DESCRIBE_OPTIONS_VERSION) + + if max_candidates_tags is not None: + options.max_candidates_tags = max_candidates_tags + if describe_strategy is not None: + options.describe_strategy = int(describe_strategy) + if pattern: + # 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)) + options.pattern = pattern_char + if only_follow_first_parent is not None: + options.only_follow_first_parent = only_follow_first_parent + if show_commit_oid_as_fallback is not None: + options.show_commit_oid_as_fallback = show_commit_oid_as_fallback + + result = ffi.new('git_describe_result **') + if committish: + committish_rev: Object | Reference | Commit + if isinstance(committish, str): + committish_rev = self.revparse_single(committish) + else: + committish_rev = committish + + commit = committish_rev.peel(Commit) + + cptr = ffi.new('git_object **') + ffi.buffer(cptr)[:] = commit._pointer[:] + + err = C.git_describe_commit(result, cptr[0], options) + else: + err = C.git_describe_workdir(result, self._repo, options) + check_error(err) + + try: + format_options = ffi.new('git_describe_format_options *') + C.git_describe_init_format_options( + format_options, C.GIT_DESCRIBE_FORMAT_OPTIONS_VERSION + ) + + if abbreviated_size is not None: + format_options.abbreviated_size = abbreviated_size + if always_use_long_format is not None: + 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)) + format_options.dirty_suffix = dirty_ptr + + buf = ffi.new('git_buf *', (ffi.NULL, 0)) + + err = C.git_describe_format(buf, result[0], format_options) + check_error(err) + + try: + return ffi.string(buf.ptr).decode('utf-8') + finally: + C.git_buf_dispose(buf) + finally: + C.git_describe_result_free(result[0]) + + # + # Stash + # + def stash( + self, + stasher: Signature, + message: str | None = None, + keep_index: bool = False, + include_untracked: bool = False, + include_ignored: bool = False, + keep_all: bool = False, + paths: list[str] | None = None, + ) -> Oid: + """ + Save changes to the working directory to the stash. + + Returns: The Oid of the stash merge commit (Oid). + + Parameters: + + stasher : Signature + The identity of the person doing the stashing. + + message : str + An optional description of stashed state. + + keep_index : bool + Leave changes already added to the index in the working directory. + + include_untracked : bool + Also stash untracked files. + + include_ignored : bool + Also stash ignored files. + + keep_all : bool + All changes in the index and working directory are left intact. + + paths : list[str] + An optional list of paths that control which files are stashed. + + Example:: + + >>> repo = pygit2.Repository('.') + >>> repo.stash(repo.default_signature(), 'WIP: stashing') + """ + + opts = ffi.new('git_stash_save_options *') + C.git_stash_save_options_init(opts, C.GIT_STASH_SAVE_OPTIONS_VERSION) + + flags = 0 + flags |= keep_index * C.GIT_STASH_KEEP_INDEX + flags |= keep_all * C.GIT_STASH_KEEP_ALL + flags |= include_untracked * C.GIT_STASH_INCLUDE_UNTRACKED + flags |= include_ignored * C.GIT_STASH_INCLUDE_IGNORED + opts.flags = flags + + stasher_cptr = ffi.new('git_signature **') + ffi.buffer(stasher_cptr)[:] = stasher._pointer[:] + opts.stasher = stasher_cptr[0] + + if message: + message_ref = ffi.new('char[]', to_bytes(message)) + opts.message = message_ref + + if paths: + arr = StrArray(paths) + opts.paths = arr.ptr[0] # type: ignore[index] + + coid = ffi.new('git_oid *') + err = C.git_stash_save_with_opts(coid, self._repo, opts) + + check_error(err) + + return Oid(raw=bytes(ffi.buffer(coid)[:])) + + 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. + + Parameters: + + index : int + The position within the stash list of the stash to apply. 0 is the + most recent stash. + + reinstate_index : bool + Try to reinstate stashed changes to the index. + + callbacks : StashApplyCallbacks + Optional. Supply a `callbacks` object to get information about + the progress of the stash application as it is being performed. + + The callbacks should be an object which inherits from + `pyclass:StashApplyCallbacks`. It should implement the callbacks + as overridden methods. + + Note that this class inherits from CheckoutCallbacks, so you can + also get information from the checkout part of the unstashing + process via the callbacks. + + The checkout options may be customized using the same arguments taken by + Repository.checkout(). + + Example:: + + >>> repo = pygit2.Repository('.') + >>> repo.stash(repo.default_signature(), 'WIP: stashing') + >>> repo.stash_apply(strategy=CheckoutStrategy.ALLOW_CONFLICTS) + """ + 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: int = 0) -> None: + """ + Remove a stashed state from the stash list. + + Parameters: + + index : int + The position within the stash list of the stash to remove. 0 is + the most recent stash. + """ + check_error(C.git_stash_drop(self._repo, index)) + + 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( + 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: str | Tree | Object | Oid, + archive: tarfile.TarFile, + timestamp: int | None = None, + prefix: str = '', + ) -> None: + """ + Write treeish into an archive. + + If no timestamp is provided and 'treeish' is a commit, its committer + timestamp will be used. Otherwise the current time will be used. + + All path names in the archive are added to 'prefix', which defaults to + an empty string. + + Parameters: + + treeish + The treeish to write. + + archive + An archive from the 'tarfile' module. + + timestamp + Timestamp to use for the files in the archive. + + prefix + Extra prefix to add to the path names in the archive. + + Example:: + + >>> import tarfile, pygit2 + >>> with tarfile.open('foo.tar', 'w') as archive: + >>> repo = pygit2.Repository('.') + >>> repo.write_archive(repo.head.target, archive) + """ + + # Try to get a tree form whatever we got + if isinstance(treeish, (str, Oid)): + treeish = self[treeish] + + tree = treeish.peel(Tree) + + # if we don't have a timestamp, try to get it from a commit + if not timestamp: + try: + commit = treeish.peel(Commit) + timestamp = commit.committer.time + except Exception: + pass + + # as a last resort, use the current timestamp + if not timestamp: + timestamp = int(time()) + + index = Index() + index.read_tree(tree) + + for entry in index: + content = self[entry.id].read_raw() + info = tarfile.TarInfo(prefix + entry.path) + info.size = len(content) + info.mtime = timestamp + info.uname = info.gname = 'root' # just because git does this + if entry.mode == FileMode.LINK: + info.type = tarfile.SYMTYPE + info.linkname = content.decode('utf-8') + info.mode = 0o777 # symlinks get placeholder + info.size = 0 + archive.addfile(info) + else: + info.mode = entry.mode + archive.addfile(info, BytesIO(content)) + + # + # Ahead-behind, which mostly lives on its own namespace + # + 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. + + Ahead is how many commits are in the ancestry of the `local` commit + which are not in the `upstream` commit. Behind is the opposite. + + Returns: a tuple of two integers with the number of commits ahead and + behind respectively. + + Parameters: + + local + The commit which is considered the local or current state. + + upstream + The commit which is considered the upstream. + """ + + if not isinstance(local, Oid): + local = self.expand_id(local) + + if not isinstance(upstream, Oid): + upstream = self.expand_id(upstream) + + ahead, behind = ffi.new('size_t*'), ffi.new('size_t*') + oid1, oid2 = ffi.new('git_oid *'), ffi.new('git_oid *') + ffi.buffer(oid1)[:] = local.raw[:] + ffi.buffer(oid2)[:] = upstream.raw[:] + err = C.git_graph_ahead_behind(ahead, behind, self._repo, oid1, oid2) + check_error(err) + + return int(ahead[0]), int(behind[0]) + + # + # Git attributes + # + def get_attr( + self, + path: str | bytes | Path, + name: str | bytes, + flags: AttrCheck = AttrCheck.FILE_THEN_INDEX, + commit: Oid | str | None = None, + ) -> bool | None | str: + """ + Retrieve an attribute for a file by path. + + Returns: a boolean, `None` if the value is unspecified, or string with + the value of the attribute. + + Parameters: + + path + The path of the file to look up attributes for, relative to the + workdir root. + + name + The name of the attribute to look up. + + flags + A combination of enums.AttrCheck flags which determine the lookup order. + + commit + Optional id of commit to load attributes from when the + `INCLUDE_COMMIT` flag is specified. + + Examples:: + + >>> print(repo.get_attr('splash.bmp', 'binary')) + True + >>> print(repo.get_attr('splash.bmp', 'unknown-attr')) + None + >>> repo.get_attr('test.h', 'whitespace') + 'tab-in-indent,trailing-space' + """ + + copts = ffi.new('git_attr_options *') + copts.version = C.GIT_ATTR_OPTIONS_VERSION + copts.flags = int(flags) + if commit is not None: + if not isinstance(commit, Oid): + commit = Oid(hex=commit) + ffi.buffer(ffi.addressof(copts, 'attr_commit_id'))[:] = commit.raw + + cvalue = ffi.new('char **') + err = C.git_attr_get_ext( + cvalue, self._repo, copts, to_bytes(path), to_bytes(name) + ) + check_error(err) + + # Now let's see if we can figure out what the value is + attr_kind = C.git_attr_value(cvalue[0]) + if attr_kind == C.GIT_ATTR_VALUE_UNSPECIFIED: + return None + elif attr_kind == C.GIT_ATTR_VALUE_TRUE: + return True + elif attr_kind == C.GIT_ATTR_VALUE_FALSE: + return False + elif attr_kind == C.GIT_ATTR_VALUE_STRING: + return ffi.string(cvalue[0]).decode('utf-8') + + assert False, 'the attribute value from libgit2 is invalid' + + # + # Identity for reference operations + # + @property + def ident(self): + 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')) + + def set_ident(self, name: str, email: str) -> None: + """Set the identity to be used for reference operations. + + Updates to some references also append data to their + reflog. You can use this method to set what identity will be + 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)) + check_error(err) + + def revert(self, commit: Commit) -> None: + """ + Revert the given commit, producing changes in the index and working + directory. + + This operation updates the repository's state and prepared message + (MERGE_MSG). + """ + commit_ptr = ffi.new('git_commit **') + ffi.buffer(commit_ptr)[:] = commit._pointer[:] + err = C.git_revert(self._repo, commit_ptr[0], ffi.NULL) + check_error(err) + + 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. + + Returns: an Index with the result of the revert. + + Parameters: + + revert_commit + The Commit to revert. + + our_commit + The Commit to revert against (eg, HEAD). + + mainline + The parent of the revert Commit, if it is a merge (i.e. 1, 2). + """ + cindex = ffi.new('git_index **') + revert_commit_ptr = ffi.new('git_commit **') + our_commit_ptr = ffi.new('git_commit **') + + ffi.buffer(revert_commit_ptr)[:] = revert_commit._pointer[:] + ffi.buffer(our_commit_ptr)[:] = our_commit._pointer[:] + + opts = ffi.new('git_merge_options *') + err = C.git_merge_options_init(opts, C.GIT_MERGE_OPTIONS_VERSION) + check_error(err) + + err = C.git_revert_commit( + cindex, self._repo, revert_commit_ptr[0], our_commit_ptr[0], mainline, opts + ) + check_error(err) + + return Index.from_c(self, cindex) + + # + # Amend commit + # + def amend_commit( + self, + commit: Commit | Oid | str, + refname: Reference | str | None, + author: Signature | None = None, + committer: Signature | None = None, + message: str | None = None, + tree: Tree | Oid | str | None = None, + encoding: str = 'UTF-8', + ) -> Oid: + """ + Amend an existing commit by replacing only explicitly passed values, + return the rewritten commit's oid. + + This creates a new commit that is exactly the same as the old commit, + except that any explicitly passed values will be updated. The new + commit has the same parents as the old commit. + + You may omit the `author`, `committer`, `message`, `tree`, and + `encoding` parameters, in which case this will use the values + from the original `commit`. + + Parameters: + + commit : Commit, Oid, or str + The commit to amend. + + refname : Reference or str + If not `None`, name of the reference that will be updated to point + to the newly rewritten commit. Use "HEAD" to update the HEAD of the + current branch and make it point to the rewritten commit. + If you want to amend a commit that is not currently the tip of the + branch and then rewrite the following commits to reach a ref, pass + this as `None` and update the rest of the commit chain and ref + separately. + + author : Signature + If not None, replace the old commit's author signature with this + one. + + committer : Signature + If not None, replace the old commit's committer signature with this + one. + + message : str + If not None, replace the old commit's message with this one. + + tree : Tree, Oid, or str + If not None, replace the old commit's tree with this one. + + encoding : str + Optional encoding for `message`. + """ + + # Initialize parameters to pass on to C function git_commit_amend. + # Note: the pointers are all initialized to NULL by default. + coid = ffi.new('git_oid *') + commit_cptr = ffi.new('git_commit **') + refname_cstr: 'ArrayC[char]' | 'ffi.NULL_TYPE' = ffi.NULL + author_cptr = ffi.new('git_signature **') + committer_cptr = ffi.new('git_signature **') + 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_object = self[commit] + commit_commit = commit_object.peel(Commit) + elif isinstance(commit, Commit): + 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') + 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)) + elif type(refname) is str: + refname_cstr = ffi.new('char[]', to_bytes(refname)) + elif refname is not None: + raise TypeError('refname must be a str or Reference') + + # Get author as pointer to git_signature. + if isinstance(author, Signature): + ffi.buffer(author_cptr)[:] = author._pointer[:] + elif author is not None: + raise TypeError('author must be a Signature') + + # Get committer as pointer to git_signature. + if isinstance(committer, Signature): + ffi.buffer(committer_cptr)[:] = committer._pointer[:] + elif committer is not None: + raise TypeError('committer must be a Signature') + + # 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)) + + # Get tree as pointer to git_tree. + if tree is not None: + if isinstance(tree, (str, Oid)): + 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( + coid, + commit_cptr[0], + refname_cstr, + author_cptr[0], + committer_cptr[0], + encoding_cstr, + message_cstr, + tree_cptr[0], + ) + check_error(err) + + 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: str | bytes | None | Path = None, + flags: RepositoryOpenFlag = RepositoryOpenFlag.DEFAULT, + ): + """ + The Repository constructor will commonly be called with one argument, + the path of the repository to open. + + Alternatively, constructing a repository with no arguments will create + a repository with no backends. You can use this path to create + repositories with custom backends. Note that most operations on the + repository are considered invalid and may lead to undefined behavior if + attempted before providing an odb and refdb via set_odb and set_refdb. + + Parameters: + + path : str + The path to open - if not provided, the repository will have no backend. + + flags : enums.RepositoryOpenFlag + An optional combination of enums.RepositoryOpenFlag constants + controlling how to open the repository. + """ + + if path is not None: + if hasattr(path, '__fspath__'): + path = path.__fspath__() + if not isinstance(path, str): + path = path.decode('utf-8') + path_backend = init_file_backend(path, int(flags)) + super().__init__(path_backend) + else: + super().__init__() - raise ValueError("Only blobs and treeish can be diffed") + @classmethod + 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) # type: ignore + repo._common_init() + return repo diff --git a/pygit2/settings.py b/pygit2/settings.py new file mode 100644 index 000000000..91b5d3191 --- /dev/null +++ b/pygit2/settings.py @@ -0,0 +1,348 @@ +# 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. + +""" +Settings mapping. +""" + +from ssl import DefaultVerifyPaths, get_default_verify_paths +from typing import overload + +import pygit2.enums + +from .enums import ConfigLevel, Option +from .errors import GitError +from .options import option + + +class SearchPathList: + def __getitem__(self, key: ConfigLevel) -> str: + return option(Option.GET_SEARCH_PATH, key) + + def __setitem__(self, key: ConfigLevel, value: str) -> None: + option(Option.SET_SEARCH_PATH, key, value) + + +class Settings: + """Library-wide settings interface.""" + + __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) -> None: + """Initialize global pygit2 and libgit2 settings.""" + self._initialize_tls_certificate_locations() + + 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: + self.set_ssl_cert_locations( + self._default_tls_verify_paths.cafile, + self._default_tls_verify_paths.capath, + ) + except GitError as git_err: + valid_msg = "TLS backend doesn't support certificate locations" + if str(git_err) != valid_msg: + raise + self._default_tls_verify_paths = None + self._ssl_cert_file = None + self._ssl_cert_dir = None + + @property + def search_path(self) -> SearchPathList: + """Configuration file search path. + + This behaves like an array whose indices correspond to ConfigLevel values. + The local search path cannot be changed. + """ + return self._search_path + + @property + 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: int) -> None: + option(Option.SET_MWINDOW_SIZE, value) + + @property + def mwindow_mapped_limit(self) -> int: + """ + Get or set the maximum memory that will be mapped in total by the + library + """ + return option(Option.GET_MWINDOW_MAPPED_LIMIT) + + @mwindow_mapped_limit.setter + def mwindow_mapped_limit(self, value: int) -> None: + option(Option.SET_MWINDOW_MAPPED_LIMIT, value) + + @property + def mwindow_file_limit(self) -> int: + """Get or set the maximum number of files to be mapped at any time""" + return option(Option.GET_MWINDOW_FILE_LIMIT) + + @mwindow_file_limit.setter + def mwindow_file_limit(self, value: int) -> None: + option(Option.SET_MWINDOW_FILE_LIMIT, value) + + @property + def cached_memory(self) -> tuple[int, int]: + """ + 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: bool = True) -> None: + """ + Enable or disable caching completely. + + Because caches are repository-specific, disabling the cache + cannot immediately clear all cached objects, but each cache will + be cleared on the next attempt to update anything in it. + """ + return option(Option.ENABLE_CACHING, value) + + 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 + filesystems. + """ + return option(Option.DISABLE_PACK_KEEP_FILE_CHECKS, 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 + from the cache. This is a soft limit, in that the library might + briefly exceed it, but will start aggressively evicting objects + from cache when that happens. The default cache size is 256MB. + """ + return option(Option.SET_CACHE_MAX_SIZE, 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 + zero means that that type of object will not be cached. + Defaults to 0 for enums.ObjectType.BLOB (i.e. won't cache blobs) + and 4k for COMMIT, TREE, and TAG. + """ + return option(Option.SET_CACHE_OBJECT_LIMIT, object_type, value) + + @property + 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: 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) -> None: + """Reset the TLS cert file path.""" + self.ssl_cert_file = self._default_tls_verify_paths.cafile # type: ignore[union-attr] + + @property + 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: 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) -> None: + """Reset the TLS certificate lookup folder.""" + 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. + + - `cert_file` is the location of a file containing several + certificates concatenated together. + - `cert_dir` is the location of a directory holding several + certificates, one per file. + + Either parameter may be `NULL`, but not both. + """ + 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 new file mode 100644 index 000000000..4ff00980d --- /dev/null +++ b/pygit2/submodules.py @@ -0,0 +1,394 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +from __future__ import annotations + +from collections.abc import Iterable, Iterator +from pathlib import Path +from typing import TYPE_CHECKING, Optional + +from ._pygit2 import Oid +from .callbacks import RemoteCallbacks, git_fetch_options +from .enums import SubmoduleIgnore, SubmoduleStatus +from .errors import check_error +from .ffi import C, ffi +from .utils import maybe_string, to_bytes + +# Need BaseRepository for type hints, but don't let it cause a circular dependency +if TYPE_CHECKING: + from pygit2 import Repository + from pygit2._libgit2.ffi import GitSubmoduleC + + from .repository import BaseRepository + + +class Submodule: + _repo: BaseRepository + _subm: 'GitSubmoduleC' + + @classmethod + def _from_c(cls, repo: BaseRepository, cptr: 'GitSubmoduleC') -> 'Submodule': + subm = cls.__new__(cls) + + subm._repo = repo + subm._subm = cptr + + return subm + + def __del__(self) -> None: + C.git_submodule_free(self._subm) + + 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) # type: ignore[attr-defined] + + def init(self, overwrite: bool = False) -> None: + """ + Just like "git submodule init", this copies information about the submodule + into ".git/config". + + Parameters: + + overwrite + By default, existing submodule entries will not be overwritten, + but setting this to True forces them to be updated. + """ + err = C.git_submodule_init(self._subm, int(overwrite)) + check_error(err) + + def update( + 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 + containing repository. If the submodule repository doesn't contain the + target commit (e.g. because fetchRecurseSubmodules isn't set), then the + submodule is fetched using the fetch options supplied in options. + + Parameters: + + init + If the submodule is not initialized, setting this flag to True will + initialize the submodule before updating. Otherwise, this will raise + an error if attempting to update an uninitialized repository. + + callbacks + Optional RemoteCallbacks to clone or fetch the submodule. + + depth + Number of commits to fetch. + The default is 0 (full commit history). + """ + + opts = ffi.new('git_submodule_update_options *') + C.git_submodule_update_options_init( + opts, C.GIT_SUBMODULE_UPDATE_OPTIONS_VERSION + ) + opts.fetch_opts.depth = depth + + with git_fetch_options(callbacks, opts=opts.fetch_opts) as payload: + err = C.git_submodule_update(self._subm, int(init), opts) + payload.check_error(err) + + def reload(self, force: bool = False) -> None: + """ + Reread submodule info from config, index, and HEAD. + + Call this to reread cached submodule information for this submodule if + you have reason to believe that it has changed. + + Parameters: + + force + Force reload even if the data doesn't seem out of date + """ + err = C.git_submodule_reload(self._subm, int(force)) + check_error(err) + + @property + def name(self): + """Name of the submodule.""" + name = C.git_submodule_name(self._subm) + return ffi.string(name).decode('utf-8') + + @property + def path(self): + """Path of the submodule.""" + path = C.git_submodule_path(self._subm) + return ffi.string(path).decode('utf-8') + + @property + def url(self) -> str | None: + """URL of the submodule.""" + url = C.git_submodule_url(self._subm) + return maybe_string(url) + + @url.setter + def url(self, url: str) -> None: + crepo = self._repo._repo + cname = ffi.new('char[]', to_bytes(self.name)) + curl = ffi.new('char[]', to_bytes(url)) + err = C.git_submodule_set_url(crepo, cname, curl) + check_error(err) + + @property + def branch(self): + """Branch that is to be tracked by the submodule.""" + branch = C.git_submodule_branch(self._subm) + return ffi.string(branch).decode('utf-8') + + @property + def head_id(self) -> Oid | None: + """ + The submodule's HEAD commit id (as recorded in the superproject's + current HEAD tree). + Returns None if the superproject's HEAD doesn't contain the submodule. + """ + + head = C.git_submodule_head_id(self._subm) + if head == ffi.NULL: + return None + return Oid(raw=bytes(ffi.buffer(head.id)[:])) + + +class SubmoduleCollection: + """Collection of submodules in a repository.""" + + def __init__(self, repository: BaseRepository): + self._repository = repository + + 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)) + + err = C.git_submodule_lookup(csub, self._repository._repo, cpath) + check_error(err) + return Submodule._from_c(self._repository, csub[0]) + + def __contains__(self, name: str) -> bool: + return self.get(name) is not None + + def __iter__(self) -> Iterator[Submodule]: + for s in self._repository.listall_submodules(): + yield self[s] + + 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. + """ + try: + return self[name] + except KeyError: + return None + + def add( + self, + url: str, + path: str, + link: bool = True, + callbacks: Optional[RemoteCallbacks] = None, + depth: int = 0, + ) -> Submodule: + """ + Add a submodule to the index. + The submodule is automatically cloned. + + Returns: the submodule that was added. + + Parameters: + + url + The URL of the submodule. + + path + The path within the parent repository to add the submodule + + link + Should workdir contain a gitlink to the repo in `.git/modules` vs. repo directly in workdir. + + callbacks + Optional RemoteCallbacks to clone the submodule. + + depth + Number of commits to fetch. + 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)) + gitlink = 1 if link else 0 + + err = C.git_submodule_add_setup( + csub, self._repository._repo, curl, cpath, gitlink + ) + check_error(err) + + submodule_instance = Submodule._from_c(self._repository, csub[0]) + + # Prepare options + opts = ffi.new('git_submodule_update_options *') + C.git_submodule_update_options_init( + opts, C.GIT_SUBMODULE_UPDATE_OPTIONS_VERSION + ) + opts.fetch_opts.depth = depth + + with git_fetch_options(callbacks, opts=opts.fetch_opts) as payload: + crepo = ffi.new('git_repository **') + err = C.git_submodule_clone(crepo, submodule_instance._subm, opts) + payload.check_error(err) + + # Clean up submodule repository + from .repository import Repository + + Repository._from_c(crepo[0], True) + + err = C.git_submodule_add_finalize(submodule_instance._subm) + check_error(err) + return submodule_instance + + 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". + + Parameters: + + submodules + Optional list of submodule paths or names to initialize. + Default argument initializes all submodules. + + overwrite + Flag indicating if initialization should overwrite submodule entries. + """ + if submodules is None: + submodules = self._repository.listall_submodules() + + instances = [self[s] for s in submodules] + + for submodule in instances: + submodule.init(overwrite) + + def update( + self, + submodules: Optional[Iterable[str]] = None, + 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 + containing repository. If the submodule repository doesn't contain the + target commit (e.g. because fetchRecurseSubmodules isn't set), then the + submodule is fetched using the fetch options supplied in options. + + Parameters: + + submodules + Optional list of submodule paths or names. If you omit this parameter + or pass None, all submodules will be updated. + + init + If the submodule is not initialized, setting this flag to True will + initialize the submodule before updating. Otherwise, this will raise + an error if attempting to update an uninitialized repository. + + callbacks + Optional RemoteCallbacks to clone or fetch the submodule. + + depth + Number of commits to fetch. + The default is 0 (full commit history). + """ + if submodules is None: + submodules = self._repository.listall_submodules() + + instances = [self[s] for s in submodules] + + for submodule in instances: + submodule.update(init, callbacks, depth) + + def status( + self, name: str, ignore: SubmoduleIgnore = SubmoduleIgnore.UNSPECIFIED + ) -> SubmoduleStatus: + """ + Get the status of a submodule. + + Returns: A combination of SubmoduleStatus flags. + + Parameters: + + name + Submodule name or path. + + ignore + A SubmoduleIgnore value indicating how deeply to examine the working directory. + """ + cstatus = ffi.new('unsigned int *') + err = C.git_submodule_status( + cstatus, self._repository._repo, to_bytes(name), ignore + ) + check_error(err) + return SubmoduleStatus(cstatus[0]) + + def cache_all(self) -> None: + """ + Load and cache all submodules in the repository. + + Because the `.gitmodules` file is unstructured, loading submodules is an + O(N) operation. Any operation that requires accessing all submodules is O(N^2) + in the number of submodules, if it has to look each one up individually. + This function loads all submodules and caches them so that subsequent + submodule lookups by name are O(1). + """ + err = C.git_repository_submodule_cache_all(self._repository._repo) + check_error(err) + + def cache_clear(self) -> None: + """ + Clear the submodule cache populated by `submodule_cache_all`. + If there is no cache, do nothing. + + The cache incorporates data from the repository's configuration, as well + as the state of the working tree, the index, and HEAD. So any time any + of these has changed, the cache might become invalid. + """ + err = C.git_repository_submodule_cache_clear(self._repository._repo) + check_error(err) diff --git a/pygit2/transaction.py b/pygit2/transaction.py new file mode 100644 index 000000000..358b40a98 --- /dev/null +++ b/pygit2/transaction.py @@ -0,0 +1,199 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +from __future__ import annotations + +import threading +from typing import TYPE_CHECKING + +from .errors import check_error +from .ffi import C, ffi +from .utils import to_bytes + +if TYPE_CHECKING: + from ._pygit2 import Oid, Signature + from .repository import BaseRepository + + +class ReferenceTransaction: + """Context manager for transactional reference updates. + + A transaction allows multiple reference updates to be performed atomically. + All updates are applied when the transaction is committed, or none are applied + if the transaction is rolled back. + + Example: + with repo.transaction() as txn: + txn.lock_ref('refs/heads/master') + txn.set_target('refs/heads/master', new_oid, message='Update master') + # Changes committed automatically on context exit + """ + + def __init__(self, repository: BaseRepository) -> None: + self._repository = repository + self._transaction = ffi.new('git_transaction **') + self._tx = None + self._thread_id = threading.get_ident() + + err = C.git_transaction_new(self._transaction, repository._repo) + check_error(err) + self._tx = self._transaction[0] + + def _check_thread(self) -> None: + """Verify transaction is being used from the same thread that created it.""" + current_thread = threading.get_ident() + if current_thread != self._thread_id: + raise RuntimeError( + f'Transaction created in thread {self._thread_id} ' + f'but used in thread {current_thread}. ' + 'Transactions must be used from the thread that created them.' + ) + + def lock_ref(self, refname: str) -> None: + """Lock a reference in preparation for updating it. + + Args: + refname: Name of the reference to lock (e.g., 'refs/heads/master') + """ + self._check_thread() + if self._tx is None: + raise ValueError('Transaction already closed') + + c_refname = ffi.new('char[]', to_bytes(refname)) + err = C.git_transaction_lock_ref(self._tx, c_refname) + check_error(err) + + def set_target( + self, + refname: str, + target: Oid | str, + signature: Signature | None = None, + message: str | None = None, + ) -> None: + """Set the target of a direct reference. + + The reference must be locked first via lock_ref(). + + Args: + refname: Name of the reference to update + target: Target OID or hex string + signature: Signature for the reflog (None to use repo identity) + message: Message for the reflog + """ + self._check_thread() + if self._tx is None: + raise ValueError('Transaction already closed') + + from ._pygit2 import Oid + + c_refname = ffi.new('char[]', to_bytes(refname)) + + # Convert target to OID + if isinstance(target, str): + target = Oid(hex=target) + + c_oid = ffi.new('git_oid *') + ffi.buffer(c_oid)[:] = target.raw + + c_sig = signature._pointer if signature else ffi.NULL + c_msg = ffi.new('char[]', to_bytes(message)) if message else ffi.NULL + + err = C.git_transaction_set_target(self._tx, c_refname, c_oid, c_sig, c_msg) + check_error(err) + + def set_symbolic_target( + self, + refname: str, + target: str, + signature: Signature | None = None, + message: str | None = None, + ) -> None: + """Set the target of a symbolic reference. + + The reference must be locked first via lock_ref(). + + Args: + refname: Name of the reference to update + target: Target reference name (e.g., 'refs/heads/master') + signature: Signature for the reflog (None to use repo identity) + message: Message for the reflog + """ + self._check_thread() + if self._tx is None: + raise ValueError('Transaction already closed') + + c_refname = ffi.new('char[]', to_bytes(refname)) + c_target = ffi.new('char[]', to_bytes(target)) + c_sig = signature._pointer if signature else ffi.NULL + c_msg = ffi.new('char[]', to_bytes(message)) if message else ffi.NULL + + err = C.git_transaction_set_symbolic_target( + self._tx, c_refname, c_target, c_sig, c_msg + ) + check_error(err) + + def remove(self, refname: str) -> None: + """Remove a reference. + + The reference must be locked first via lock_ref(). + + Args: + refname: Name of the reference to remove + """ + self._check_thread() + if self._tx is None: + raise ValueError('Transaction already closed') + + c_refname = ffi.new('char[]', to_bytes(refname)) + err = C.git_transaction_remove(self._tx, c_refname) + check_error(err) + + def commit(self) -> None: + """Commit the transaction, applying all queued updates.""" + self._check_thread() + if self._tx is None: + raise ValueError('Transaction already closed') + + err = C.git_transaction_commit(self._tx) + check_error(err) + + def __enter__(self) -> ReferenceTransaction: + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + self._check_thread() + # Only commit if no exception occurred + if exc_type is None and self._tx is not None: + self.commit() + + # Always free the transaction + if self._tx is not None: + C.git_transaction_free(self._tx) + self._tx = None + + def __del__(self) -> None: + if self._tx is not None: + C.git_transaction_free(self._tx) + self._tx = None diff --git a/pygit2/utils.py b/pygit2/utils.py new file mode 100644 index 000000000..4a36a06fe --- /dev/null +++ b/pygit2/utils.py @@ -0,0 +1,225 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +import 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 C, ffi + +if TYPE_CHECKING: + from ._libgit2.ffi import ArrayC, GitStrrayC, char, char_pointer + + +def maybe_string(ptr: 'char_pointer | None') -> str | None: + if not ptr: + return None + + return ffi.string(ptr).decode('utf8', errors='surrogateescape') + + +@overload +def to_bytes( + s: str | bytes | os.PathLike[str] | os.PathLike[bytes], + encoding: str = 'utf-8', + errors: str = 'strict', +) -> bytes: ... +@overload +def to_bytes( + s: Union['ffi.NULL_TYPE', None], + encoding: str = 'utf-8', + errors: str = 'strict', +) -> Union['ffi.NULL_TYPE']: ... +def to_bytes( + s: Union[str, bytes, 'ffi.NULL_TYPE', os.PathLike[str], os.PathLike[bytes], None], + encoding: str = 'utf-8', + errors: str = 'strict', +) -> Union[bytes, 'ffi.NULL_TYPE']: + if s == ffi.NULL or s is None: + return ffi.NULL + + if hasattr(s, '__fspath__'): + s = os.fspath(s) + + if isinstance(s, bytes): + return s + + return s.encode(encoding, errors) # type: ignore[union-attr] + + +def to_str(s: str | bytes | os.PathLike[str] | os.PathLike[bytes]) -> str: + if hasattr(s, '__fspath__'): + s = os.fspath(s) + + if type(s) is str: + return s + + if type(s) is bytes: + return s.decode() + + raise TypeError(f'unexpected type "{repr(s)}"') + + +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. + """ + + pp = ffi.new('void **', ptr_cdata) + return bytes(ffi.buffer(pp)[:]) + + +@contextlib.contextmanager +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) -> list[str]: + """ + Return a list of strings from a git_strarray pointer. + + Free the strings contained in the git_strarry, this means it won't be usable after + calling this function. + """ + try: + return [ffi.string(arr.strings[i]).decode('utf-8') for i in range(arr.count)] + finally: + C.git_strarray_dispose(arr) + + +class StrArray: + """A git_strarray wrapper + + Use this in order to get a git_strarray* to pass to libgit2 out of a + list of strings. This has a context manager, which you should use, e.g. + + with StrArray(list_of_strings) as arr: + C.git_function_that_takes_strarray(arr.ptr) + + To make a pre-existing git_strarray point to the provided list of strings, + use the context manager's assign_to() method: + + struct = ffi.new('git_strarray *', [ffi.NULL, 0]) + with StrArray(list_of_strings) as arr: + arr.assign_to(struct) + + The above construct is still subject to FFI scoping rules, i.e. the + contents of 'struct' only remain valid within the StrArray context. + """ + + __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 + return + + if not isinstance(lst, (list, tuple)): + raise TypeError('Value must be a list') + + 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)) + + self.__arr = ffi.new('char *[]', strings) + self.__strings = strings + self.__array = ffi.new('git_strarray *', [self.__arr, len(strings)]) # type: ignore[call-overload] + + def __enter__(self) -> 'StrArray': + return self + + def __exit__( + self, + exc_type: Optional[type[BaseException]], + exc_value: Optional[BaseException], + traceback: Optional[TracebackType], + ) -> None: + pass + + @property + def ptr(self) -> 'GitStrrayC | ffi.NULL_TYPE': + return self.__array + + def assign_to(self, git_strarray: 'GitStrrayC') -> None: + if self.__array == ffi.NULL: + git_strarray.strings = ffi.NULL + git_strarray.count = 0 + else: + git_strarray.strings = self.__arr + git_strarray.count = len(self.__strings) + + +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: SequenceProtocol[T]) -> None: + self.container = container + self.length = len(container) + self.idx = 0 + + def __iter__(self) -> Iterator[T]: + return self + + def __next__(self) -> T: + idx = self.idx + if idx >= self.length: + raise StopIteration + + self.idx += 1 + return self.container[idx] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..e688d1783 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,71 @@ +[build-system] +requires = ["setuptools", "wheel"] + +[tool.cibuildwheel] +enable = ["pypy"] +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"} + +before-all = "sh build.sh" + +[tool.cibuildwheel.linux] +repair-wheel-command = "LD_LIBRARY_PATH=/project/ci/lib64 auditwheel repair -w {dest_dir} {wheel}" + +[[tool.cibuildwheel.overrides]] +select = "*-musllinux*" +repair-wheel-command = "LD_LIBRARY_PATH=/project/ci/lib auditwheel repair -w {dest_dir} {wheel}" + +[tool.cibuildwheel.macos] +archs = ["universal2"] +environment = {LIBGIT2_VERSION="1.9.1", LIBSSH2_VERSION="1.11.1", OPENSSL_VERSION="3.3.3", LIBGIT2="/Users/runner/work/pygit2/pygit2/ci"} +repair-wheel-command = "DYLD_LIBRARY_PATH=/Users/runner/work/pygit2/pygit2/ci/lib delocate-wheel --require-archs {delocate_archs} -w {dest_dir} {wheel}" + +[tool.cibuildwheel.windows] +environment.LIBGIT2_SRC = "build/libgit2_src" +environment.LIBGIT2_VERSION = "1.9.1" +before-all = "powershell -File build.ps1" + +[[tool.cibuildwheel.overrides]] +select="*-win_amd64" +inherit.environment="append" +environment.CMAKE_GENERATOR = "Visual Studio 17 2022" +environment.CMAKE_GENERATOR_PLATFORM = "x64" +environment.CMAKE_INSTALL_PREFIX = "C:/libgit2_install_x86_64" +environment.LIBGIT2 = "C:/libgit2_install_x86_64" + +[[tool.cibuildwheel.overrides]] +select="*-win32" +inherit.environment="append" +environment.CMAKE_GENERATOR = "Visual Studio 17 2022" +environment.CMAKE_GENERATOR_PLATFORM = "Win32" +environment.CMAKE_INSTALL_PREFIX = "C:/libgit2_install_x86" +environment.LIBGIT2 = "C:/libgit2_install_x86" + +[[tool.cibuildwheel.overrides]] +select="*-win_arm64" +inherit.environment="append" +environment.CMAKE_GENERATOR = "Visual Studio 17 2022" +environment.CMAKE_GENERATOR_PLATFORM = "ARM64" +environment.CMAKE_INSTALL_PREFIX = "C:/libgit2_install_arm64" +environment.LIBGIT2 = "C:/libgit2_install_arm64" + +[tool.ruff] +extend-exclude = [ ".cache", ".coverage", "build", "site-packages", "venv*"] +target-version = "py310" # oldest supported Python version + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F", "I", "UP035", "UP007"] + +[tool.ruff.format] +quote-style = "single" + +[tool.codespell] +# Ref: https://github.com/codespell-project/codespell#using-a-config-file +skip = '.git*' +check-hidden = true +# ignore-regex = '' +ignore-words-list = 'devault,claus' diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 000000000..8c3b37ebc --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +addopts = --capture=no -ra --verbose +testpaths = test/ diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 000000000..15e9ecf87 --- /dev/null +++ b/requirements-test.txt @@ -0,0 +1,4 @@ +pytest +pytest-cov +mypy +types-cffi diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..df6e4d8c4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +cffi>=2.0 +setuptools ; python_version >= "3.12" diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 000000000..0175e0692 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,3 @@ +[pycodestyle] +exclude = .eggs,.git,.tox,build,dist,docs,venv* +select = E4,E9,W1,W2,W3,W6 diff --git a/setup.py b/setup.py index 0717839a8..f8b934727 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,4 @@ -# -*- coding: utf-8 -*- -# coding: UTF-8 -# -# Copyright 2010-2013 The pygit2 contributors +# Copyright 2010-2025 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -26,129 +23,48 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. -"""Setup file for pygit2.""" - -from __future__ import print_function +# mypy: disable-error-code="import-not-found, import-untyped" -import codecs -from distutils.core import setup, Extension, Command -from distutils.command.build import build -from distutils.command.sdist import sdist -from distutils import log +# Import setuptools before distutils to avoid user warning import os -import shlex -from subprocess import Popen, PIPE import sys -import unittest +from distutils import log # type: ignore[attr-defined] +from distutils.command.build import build +from distutils.command.sdist import sdist +from pathlib import Path +from subprocess import PIPE, Popen -# Read version from local pygit2/version.py without pulling in -# pygit2/__init__.py -sys.path.insert(0, 'pygit2') -from version import __version__ - -# Python 2 support -# See https://github.com/libgit2/pygit2/pull/180 for a discussion about this. -if sys.version_info[0] == 2: - u = lambda s: unicode(s, 'utf-8') -else: - u = str - - -# Use environment variable LIBGIT2 to set your own libgit2 configuration. -libgit2_path = os.getenv("LIBGIT2") -if libgit2_path is None: - if os.name == 'nt': - program_files = os.getenv("ProgramFiles") - libgit2_path = '%s\libgit2' % program_files - else: - libgit2_path = '/usr/local' - -libgit2_bin = os.path.join(libgit2_path, 'bin') -libgit2_include = os.path.join(libgit2_path, 'include') -libgit2_lib = os.getenv('LIBGIT2_LIB', os.path.join(libgit2_path, 'lib')) -pygit2_exts = [os.path.join('src', name) for name in os.listdir('src') - if name.endswith('.c')] - - -class TestCommand(Command): - """Command for running unittests without install.""" - - user_options = [("args=", None, '''The command args string passed to - unittest framework, such as - --args="-v -f"''')] - - def initialize_options(self): - self.args = '' - pass - - def finalize_options(self): - pass - - def run(self): - self.run_command('build') - bld = self.distribution.get_command_obj('build') - # Add build_lib in to sys.path so that unittest can found DLLs and libs - sys.path = [os.path.abspath(bld.build_lib)] + sys.path - - test_argv0 = [sys.argv[0] + ' test --args='] - # For transfering args to unittest, we have to split args by ourself, - # so that command like: - # - # python setup.py test --args="-v -f" - # - # can be executed, and the parameter '-v -f' can be transfering to - # unittest properly. - test_argv = test_argv0 + shlex.split(self.args) - unittest.main(None, defaultTest='test.test_suite', argv=test_argv) +from setuptools import Extension, setup +# Import stuff from pygit2/_utils.py without loading the whole pygit2 package +sys.path.insert(0, 'pygit2') +from _build import __version__, get_libgit2_paths -class BuildWithDLLs(build): +del sys.path[0] - # On Windows, we install the git2.dll too. - def _get_dlls(self): - # 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 - libgit2_dlls = [] - if compiler_type == 'msvc': - libgit2_dlls.append('git2.dll') - elif compiler_type == 'mingw32': - libgit2_dlls.append('libgit2.dll') - look_dirs = [libgit2_bin] + os.getenv("PATH", "").split(os.pathsep) - target = os.path.abspath(self.build_lib) - for bin in libgit2_dlls: - for look in look_dirs: - f = os.path.join(look, bin) - if os.path.isfile(f): - ret.append((f, target)) - break - else: - log.warn("Could not find required DLL %r to include", bin) - log.debug("(looked in %s)", look_dirs) - return ret - def run(self): - build.run(self) - if os.name == 'nt': - # On Windows we package up the dlls with the plugin. - for s, d in self._get_dlls(): - self.copy_file(s, d) +libgit2_bin, libgit2_kw = get_libgit2_paths() class sdist_files_from_git(sdist): - def get_file_list(self): - popen = Popen(['git', 'ls-files'], stdout=PIPE, stderr=PIPE) + def get_file_list(self) -> None: + popen = Popen( + ['git', 'ls-files'], stdout=PIPE, stderr=PIPE, universal_newlines=True + ) stdoutdata, stderrdata = popen.communicate() if popen.returncode != 0: print(stderrdata) sys.exit() + def exclude(line: str) -> bool: + for prefix in ['.', 'docs/', 'misc/']: + if line.startswith(prefix): + return True + return False + for line in stdoutdata.splitlines(): - # Skip hidden files at the root - if line[0] == '.': - continue - self.filelist.append(line) + if not exclude(line): + self.filelist.append(line) # Ok self.filelist.sort() @@ -156,38 +72,95 @@ def get_file_list(self): self.write_manifest() +classifiers = [ + 'Development Status :: 5 - Production/Stable', + '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 :: Implementation :: PyPy', + 'Programming Language :: Python :: Implementation :: CPython', + 'Topic :: Software Development :: Version Control', + 'Typing :: Typed', +] + +__dir__ = Path(__file__).parent +long_description = (__dir__ / 'README.md').read_text() + cmdclass = { - 'test': TestCommand, - 'sdist': sdist_files_from_git} + 'sdist': sdist_files_from_git, +} -if os.name == 'nt': - # BuildWithDLLs can copy external DLLs into source directory. - cmdclass['build'] = BuildWithDLLs -classifiers = [ - "Development Status :: 3 - Alpha", - "Intended Audience :: Developers", - "Topic :: Software Development :: Version Control"] - - -with codecs.open('README.rst', 'r', 'utf-8') as readme: - long_description = readme.read() - -setup(name='pygit2', - description='Python bindings for libgit2.', - keywords='git', - version=__version__, - url='http://github.com/libgit2/pygit2', - classifiers=classifiers, - license='GPLv2', - maintainer=u('J. David Ibáñez'), - maintainer_email='jdavid.ibp@gmail.com', - long_description=long_description, - packages=['pygit2'], - ext_modules=[ - Extension('_pygit2', pygit2_exts, - include_dirs=[libgit2_include, 'include'], - library_dirs=[libgit2_lib], - libraries=['git2']), - ], - cmdclass=cmdclass) +# On Windows, we install the git2.dll too. +class BuildWithDLLs(build): + 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 # type: ignore[attr-defined] + libgit2_dlls = [] + if compiler_type == 'msvc': + libgit2_dlls.append('git2.dll') + elif compiler_type == 'mingw32': + libgit2_dlls.append('libgit2.dll') + look_dirs = [libgit2_bin] + os.getenv('PATH', '').split(os.pathsep) + + target = Path(self.build_lib).absolute() / 'pygit2' + for dll in libgit2_dlls: + for look in look_dirs: + f = Path(look) / dll + if f.is_file(): + ret.append((f, target)) + break + else: + log.warn(f'Could not find required DLL {dll} to include') + log.debug(f'(looked in {look_dirs})') + return ret + + def run(self) -> None: + build.run(self) + for s, d in self._get_dlls(): + self.copy_file(s, d) + + +# On Windows we package up the dlls with the plugin. +if os.name == 'nt': + cmdclass['build'] = BuildWithDLLs # type: ignore[assignment] + +src = __dir__ / 'src' +pygit2_exts = [str(path) for path in sorted(src.iterdir()) if path.suffix == '.c'] +ext_modules = [Extension('pygit2._pygit2', pygit2_exts, **libgit2_kw)] + +setup( + name='pygit2', + description='Python bindings for libgit2.', + keywords='git', + version=__version__, + classifiers=classifiers, + license='GPLv2 with linking exception', + maintainer='J. David Ibáñez', + maintainer_email='jdavid.ibp@gmail.com', + long_description=long_description, + long_description_content_type='text/markdown', + packages=['pygit2'], + package_data={'pygit2': ['decl/*.h', '*.pyi', 'py.typed']}, + zip_safe=False, + cmdclass=cmdclass, + cffi_modules=['pygit2/_run.py:ffi'], + ext_modules=ext_modules, + # Requirements + python_requires='>=3.10', + setup_requires=['cffi>=2.0'], + install_requires=['cffi>=2.0'], + # URLs + url='https://github.com/libgit2/pygit2', + project_urls={ + 'Documentation': 'https://www.pygit2.org/', + 'Changelog': 'https://github.com/libgit2/pygit2/blob/master/CHANGELOG.md', + 'Funding': 'https://github.com/sponsors/jdavid', + }, +) diff --git a/src/blob.c b/src/blob.c index 465d8d651..93e7dbe47 100644 --- a/src/blob.c +++ b/src/blob.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2013 The pygit2 contributors + * Copyright 2010-2025 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -27,32 +27,423 @@ #define PY_SSIZE_T_CLEAN #include -#include "utils.h" +#include +#include +#include "diff.h" +#include "error.h" #include "object.h" -#include "blob.h" +#include "oid.h" +#include "patch.h" +#include "utils.h" + +extern PyObject *GitError; + +extern PyTypeObject BlobType; + +PyDoc_STRVAR(Blob_diff__doc__, + "diff([blob: Blob, flags: int = GIT_DIFF_NORMAL, old_as_path: str, new_as_path: str]) -> Patch\n" + "\n" + "Directly generate a :py:class:`pygit2.Patch` from the difference\n" + "between two blobs.\n" + "\n" + "Returns: Patch.\n" + "\n" + "Parameters:\n" + "\n" + "blob : Blob\n" + " The :py:class:`~pygit2.Blob` to diff.\n" + "\n" + "flags\n" + " A combination of GIT_DIFF_* constant.\n" + "\n" + "old_as_path : str\n" + " Treat old blob as if it had this filename.\n" + "\n" + "new_as_path : str\n" + " Treat new blob as if it had this filename.\n" + "\n" + "context_lines: int\n" + " Number of unchanged lines that define the boundary of a hunk\n" + " (and to display before and after).\n" + "\n" + "interhunk_lines: int\n" + " Maximum number of unchanged lines between hunk boundaries\n" + " before the hunks will be merged into one.\n"); + +PyObject * +Blob_diff(Blob *self, PyObject *args, PyObject *kwds) +{ + git_diff_options opts = GIT_DIFF_OPTIONS_INIT; + git_patch *patch; + char *old_as_path = NULL, *new_as_path = NULL; + Blob *other = NULL; + int err; + char *keywords[] = {"blob", "flags", "old_as_path", "new_as_path", "context_lines", "interhunk_lines", NULL}; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "|O!IssHH", keywords, + &BlobType, &other, &opts.flags, + &old_as_path, &new_as_path, + &opts.context_lines, &opts.interhunk_lines)) + return NULL; + + if (Object__load((Object*)self) == NULL) { return NULL; } // Lazy load + if (other && Object__load((Object*)other) == NULL) { return NULL; } // Lazy load + + err = git_patch_from_blobs(&patch, self->blob, old_as_path, + other ? other->blob : NULL, new_as_path, + &opts); + if (err < 0) + return Error_set(err); + + return wrap_patch(patch, self, other); +} + + +PyDoc_STRVAR(Blob_diff_to_buffer__doc__, + "diff_to_buffer(buffer: bytes = None, flags: int = GIT_DIFF_NORMAL[, old_as_path: str, buffer_as_path: str]) -> Patch\n" + "\n" + "Directly generate a :py:class:`~pygit2.Patch` from the difference\n" + "between a blob and a buffer.\n" + "\n" + "Returns: Patch.\n" + "\n" + "Parameters:\n" + "\n" + "buffer : bytes\n" + " Raw data for new side of diff.\n" + "\n" + "flags\n" + " A combination of GIT_DIFF_* constants.\n" + "\n" + "old_as_path : str\n" + " Treat old blob as if it had this filename.\n" + "\n" + "buffer_as_path : str\n" + " Treat buffer as if it had this filename.\n"); + +PyObject * +Blob_diff_to_buffer(Blob *self, PyObject *args, PyObject *kwds) +{ + git_diff_options opts = GIT_DIFF_OPTIONS_INIT; + git_patch *patch; + char *old_as_path = NULL, *buffer_as_path = NULL; + const char *buffer = NULL; + Py_ssize_t buffer_len; + int err; + char *keywords[] = {"buffer", "flags", "old_as_path", "buffer_as_path", NULL}; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "|z#Iss", keywords, + &buffer, &buffer_len, &opts.flags, + &old_as_path, &buffer_as_path)) + return NULL; + + if (Object__load((Object*)self) == NULL) { return NULL; } // Lazy load + + err = git_patch_from_blob_and_buffer(&patch, self->blob, old_as_path, + buffer, buffer_len, buffer_as_path, + &opts); + if (err < 0) + return Error_set(err); + + return wrap_patch(patch, self, NULL); +} + + +struct blob_filter_stream { + git_writestream stream; + PyObject *py_queue; + PyObject *py_ready; + PyObject *py_closed; + Py_ssize_t chunk_size; +}; + +static int blob_filter_stream_write( + git_writestream *s, const char *buffer, size_t len) +{ + struct blob_filter_stream *stream = (struct blob_filter_stream *)s; + const char *pos = buffer; + const char *endpos = buffer + len; + Py_ssize_t chunk_size; + PyObject *result; + PyGILState_STATE gil = PyGILState_Ensure(); + int err = 0; + + while (pos < endpos) + { + chunk_size = endpos - pos; + if (stream->chunk_size < chunk_size) + chunk_size = stream->chunk_size; + result = PyObject_CallMethod(stream->py_queue, "put", "y#", pos, chunk_size); + if (result == NULL) + { + PyErr_Clear(); + git_error_set(GIT_ERROR_OS, "failed to put chunk to queue"); + err = GIT_ERROR; + goto done; + } + Py_DECREF(result); + result = PyObject_CallMethod(stream->py_ready, "set", NULL); + if (result == NULL) + { + PyErr_Clear(); + git_error_set(GIT_ERROR_OS, "failed to signal queue ready"); + err = GIT_ERROR; + goto done; + } + pos += chunk_size; + } + +done: + PyGILState_Release(gil); + return err; +} + +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; + int err = 0; + + /* Signal closed and then ready in that order so consumers can block on + * ready.wait() and then check for indicated EOF (via closed.is_set()) */ + result = PyObject_CallMethod(stream->py_closed, "set", NULL); + if (result == NULL) + { + PyErr_Clear(); + git_error_set(GIT_ERROR_OS, "failed to signal writer closed"); + err = GIT_ERROR; + } + result = PyObject_CallMethod(stream->py_ready, "set", NULL); + if (result == NULL) + { + PyErr_Clear(); + git_error_set(GIT_ERROR_OS, "failed to signal queue ready"); + err = GIT_ERROR; + } + + PyGILState_Release(gil); + return err; +} + +static void blob_filter_stream_free(git_writestream *s) +{ +} + +#define STREAM_CHUNK_SIZE (8 * 1024) -PyDoc_STRVAR(Blob_size__doc__, "Size."); + +PyDoc_STRVAR(Blob__write_to_queue__doc__, + "_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" + "otherwise the contents of the blob will be filtered.\n" + "\n" + "In most cases, the higher level `BlobIO` wrapper should be used when\n" + "streaming blob content instead of calling this method directly.\n" + "\n" + "Note that this method will block the current thread until all chunks have\n" + "been written to the queue. The GIL will be released while running\n" + "libgit2 filtering.\n" + "\n" + "Returns: The filtered content.\n" + "\n" + "Parameters:\n" + "\n" + "queue: queue.Queue\n" + " Destination queue.\n" + "\n" + "ready: threading.Event\n" + " Event to signal consumers that the data is available for reading.\n" + " This event is also set upon closing the writer in order to indicate \n" + " EOF.\n" + "\n" + "closed: threading.Event\n" + " Event to signal consumers that the writer is closed.\n" + "\n" + "chunk_size : int\n" + " Maximum size of chunks to be written to `queue`.\n" + "\n" + "as_path : str\n" + " When set, the blob contents will be filtered as if it had this\n" + " filename (used for attribute lookups).\n" + "\n" + "flags : enums.BlobFilter\n" + " A combination of BlobFilter constants (only applicable when `as_path` is set).\n" + "\n" + "commit_id : oid\n" + " Commit to load attributes from when ATTRIBUTES_FROM_COMMIT is\n" + " specified in `flags` (only applicable when `as_path` is set).\n"); + +PyObject * +Blob__write_to_queue(Blob *self, PyObject *args, PyObject *kwds) +{ + PyObject *py_queue = NULL; + PyObject *py_ready = NULL; + PyObject *py_closed = NULL; + Py_ssize_t chunk_size = STREAM_CHUNK_SIZE; + char *as_path = NULL; + PyObject *py_oid = NULL; + int err; + char *keywords[] = {"queue", "ready", "closed", "chunk_size", "as_path", "flags", "commit_id", NULL}; + git_blob_filter_options opts = GIT_BLOB_FILTER_OPTIONS_INIT; + git_filter_options filter_opts = GIT_FILTER_OPTIONS_INIT; + git_filter_list *fl = NULL; + git_blob *blob = NULL; + const git_oid *blob_oid; + struct blob_filter_stream writer; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "OOO|nzIO", keywords, + &py_queue, &py_ready, &py_closed, + &chunk_size, &as_path, &opts.flags, + &py_oid)) + return NULL; + + if (Object__load((Object*)self) == NULL) { return NULL; } // Lazy load + + /* we load our own copy of this blob since libgit2 objects are not + * thread-safe */ + blob_oid = Object__id((Object*)self); + err = git_blob_lookup(&blob, git_blob_owner(self->blob), blob_oid); + if (err < 0) + return Error_set(err); + + if (as_path != NULL && + !((opts.flags & GIT_BLOB_FILTER_CHECK_FOR_BINARY) != 0 && + git_blob_is_binary(blob))) + { + 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); + } + + if ((opts.flags & GIT_BLOB_FILTER_NO_SYSTEM_ATTRIBUTES) != 0) + filter_opts.flags |= GIT_FILTER_NO_SYSTEM_ATTRIBUTES; + if ((opts.flags & GIT_BLOB_FILTER_ATTRIBUTES_FROM_HEAD) != 0) + filter_opts.flags |= GIT_FILTER_ATTRIBUTES_FROM_HEAD; + if ((opts.flags & GIT_BLOB_FILTER_ATTRIBUTES_FROM_COMMIT) != 0) + filter_opts.flags |= GIT_FILTER_ATTRIBUTES_FROM_COMMIT; + git_oid_cpy(&filter_opts.attr_commit_id, &opts.attr_commit_id); + + err = git_filter_list_load_ext(&fl, git_blob_owner(blob), blob, + as_path, GIT_FILTER_TO_WORKTREE, + &filter_opts); + if (err < 0) + { + if (blob != NULL) + git_blob_free(blob); + return Error_set(err); + } + } + + memset(&writer, 0, sizeof(struct blob_filter_stream)); + writer.stream.write = blob_filter_stream_write; + writer.stream.close = blob_filter_stream_close; + writer.stream.free = blob_filter_stream_free; + writer.py_queue = py_queue; + writer.py_ready = py_ready; + writer.py_closed = py_closed; + writer.chunk_size = chunk_size; + Py_INCREF(writer.py_queue); + Py_INCREF(writer.py_ready); + Py_INCREF(writer.py_closed); + + Py_BEGIN_ALLOW_THREADS; + err = git_filter_list_stream_blob(fl, blob, &writer.stream); + Py_END_ALLOW_THREADS; + git_filter_list_free(fl); + if (writer.py_queue != NULL) + Py_DECREF(writer.py_queue); + if (writer.py_ready != NULL) + Py_DECREF(writer.py_ready); + if (writer.py_closed != NULL) + Py_DECREF(writer.py_closed); + if (blob != NULL) + git_blob_free(blob); + if (err < 0) + return Error_set(err); + + Py_RETURN_NONE; +} + +static PyMethodDef Blob_methods[] = { + METHOD(Blob, diff, METH_VARARGS | METH_KEYWORDS), + METHOD(Blob, diff_to_buffer, METH_VARARGS | METH_KEYWORDS), + METHOD(Blob, _write_to_queue, METH_VARARGS | METH_KEYWORDS), + {NULL} +}; + + +PyDoc_STRVAR(Blob_size__doc__, + "Size in bytes.\n" + "\n" + "Example:\n" + "\n" + " >>> print(blob.size)\n" + " 130\n"); PyObject * Blob_size__get__(Blob *self) { + if (Object__load((Object*)self) == NULL) { return NULL; } // Lazy load return PyLong_FromLongLong(git_blob_rawsize(self->blob)); } +PyDoc_STRVAR(Blob_is_binary__doc__, "True if binary data, False if not."); + +PyObject * +Blob_is_binary__get__(Blob *self) +{ + if (Object__load((Object*)self) == NULL) { return NULL; } // Lazy load + + if (git_blob_is_binary(self->blob)) + Py_RETURN_TRUE; + Py_RETURN_FALSE; +} + + PyDoc_STRVAR(Blob_data__doc__, - "The contents of the blob, a bytes string. This is the same as\n" - "Blob.read_raw()"); + "The contents of the blob, a byte string. This is the same as\n" + "Blob.read_raw().\n" + "\n" + "Example, print the contents of the ``.gitignore`` file:\n" + "\n" + " >>> blob = repo['d8022420bf6db02e906175f64f66676df539f2fd']\n" + " >>> print(blob.data)\n" + " MANIFEST\n" + " build\n" + " dist\n"); PyGetSetDef Blob_getseters[] = { GETTER(Blob, size), + GETTER(Blob, is_binary), {"data", (getter)Object_read_raw, NULL, Blob_data__doc__, NULL}, {NULL} }; +static int +Blob_getbuffer(Blob *self, Py_buffer *view, int flags) +{ + if (Object__load((Object*)self) == NULL) { return -1; } // Lazy load + return PyBuffer_FillInfo(view, (PyObject *) self, + (void *) git_blob_rawcontent(self->blob), + git_blob_rawsize(self->blob), 1, flags); +} + +static PyBufferProcs Blob_as_buffer = { + (getbufferproc)Blob_getbuffer, +}; -PyDoc_STRVAR(Blob__doc__, "Blob objects."); +PyDoc_STRVAR(Blob__doc__, "Blob object.\n" + "\n" + "Blobs implement the buffer interface, which means you can get access\n" + "to its data via `memoryview(blob)` without the need to create a copy." +); PyTypeObject BlobType = { PyVarObject_HEAD_INIT(NULL, 0) @@ -64,7 +455,7 @@ PyTypeObject BlobType = { 0, /* tp_getattr */ 0, /* tp_setattr */ 0, /* tp_compare */ - 0, /* tp_repr */ + (reprfunc)Object_repr, /* tp_repr */ 0, /* tp_as_number */ 0, /* tp_as_sequence */ 0, /* tp_as_mapping */ @@ -73,8 +464,8 @@ PyTypeObject BlobType = { 0, /* tp_str */ 0, /* tp_getattro */ 0, /* tp_setattro */ - 0, /* tp_as_buffer */ - Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, /* tp_flags */ + &Blob_as_buffer, /* tp_as_buffer */ + Py_TPFLAGS_DEFAULT, /* tp_flags */ Blob__doc__, /* tp_doc */ 0, /* tp_traverse */ 0, /* tp_clear */ @@ -82,7 +473,7 @@ PyTypeObject BlobType = { 0, /* tp_weaklistoffset */ 0, /* tp_iter */ 0, /* tp_iternext */ - 0, /* tp_methods */ + Blob_methods, /* tp_methods */ 0, /* tp_members */ Blob_getseters, /* tp_getset */ 0, /* tp_base */ diff --git a/src/branch.c b/src/branch.c index 48f5bbaab..60e0e2dc6 100644 --- a/src/branch.c +++ b/src/branch.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2013 The pygit2 contributors + * Copyright 2010-2025 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -61,7 +61,7 @@ Branch_delete(Branch *self, PyObject *args) PyDoc_STRVAR(Branch_is_head__doc__, - "is_head()\n" + "is_head() -> bool\n" "\n" "True if HEAD points at the branch, False otherwise."); @@ -81,15 +81,38 @@ Branch_is_head(Branch *self) return Error_set(err); } +PyDoc_STRVAR(Branch_is_checked_out__doc__, + "is_checked_out() -> bool\n" + "\n" + "True if branch is checked out by any repo connected to the current one, " + " False otherwise."); + +PyObject * +Branch_is_checked_out(Branch *self) +{ + int err; + + CHECK_REFERENCE(self); + + err = git_branch_is_checked_out(self->reference); + if (err == 1) + Py_RETURN_TRUE; + else if (err == 0) + Py_RETURN_FALSE; + else + return Error_set(err); +} + PyDoc_STRVAR(Branch_rename__doc__, - "rename(name, force=False)\n" + "rename(name: str, force: bool = False)\n" "\n" "Move/rename an existing local branch reference. The new branch name will be " "checked for validity.\n" "Returns the new branch."); -PyObject* Branch_rename(Branch *self, PyObject *args) +PyObject * +Branch_rename(Branch *self, PyObject *args) { int err, force = 0; git_reference *c_out; @@ -111,7 +134,8 @@ PyObject* Branch_rename(Branch *self, PyObject *args) PyDoc_STRVAR(Branch_branch_name__doc__, "The name of the local or remote branch."); -PyObject* Branch_branch_name__get__(Branch *self) +PyObject * +Branch_branch_name__get__(Branch *self) { int err; const char *c_name; @@ -125,50 +149,60 @@ PyObject* Branch_branch_name__get__(Branch *self) return Error_set(err); } +PyDoc_STRVAR(Branch_raw_branch_name__doc__, + "The name of the local or remote branch (bytes)."); + +PyObject * +Branch_raw_branch_name__get__(Branch *self) +{ + int err; + const char *c_name; + + CHECK_REFERENCE(self); + + err = git_branch_name(&c_name, self->reference); + if (err == GIT_OK) + return PyBytes_FromString(c_name); + else + return Error_set(err); +} PyDoc_STRVAR(Branch_remote_name__doc__, - "The name of the remote that the remote tracking branch belongs to."); + "Find the remote name of a remote-tracking branch.\n" + "\n" + "This will return the name of the remote whose fetch refspec is matching " + "the given branch. E.g. given a branch 'refs/remotes/test/master', it will " + "extract the 'test' part. If refspecs from multiple remotes match, the " + "function will raise ValueError."); -PyObject* Branch_remote_name__get__(Branch *self) +PyObject * +Branch_remote_name__get__(Branch *self) { int err; + git_buf name = {NULL}; const char *branch_name; - char *c_name = NULL; + PyObject *py_name; CHECK_REFERENCE(self); branch_name = git_reference_name(self->reference); - // get the length of the remote name - err = git_branch_remote_name(NULL, 0, self->repo->repo, branch_name); + err = git_branch_remote_name(&name, self->repo->repo, branch_name); if (err < GIT_OK) return Error_set(err); - // get the actual remote name - c_name = calloc(err, sizeof(char)); - if (c_name == NULL) - return PyErr_NoMemory(); - - err = git_branch_remote_name(c_name, - err * sizeof(char), - self->repo->repo, - branch_name); - if (err < GIT_OK) { - free(c_name); - return Error_set(err); - } - - PyObject *py_name = to_unicode(c_name, NULL, NULL); - free(c_name); + py_name = to_unicode_n(name.ptr, name.size, NULL, NULL); + git_buf_dispose(&name); return py_name; } PyDoc_STRVAR(Branch_upstream__doc__, - "The branch supporting the remote tracking branch or None if this is not a " - "remote tracking branch. Set to None to unset."); + "The branch's upstream branch or None if this branch does not have an upstream set. " + "Set to None to unset the upstream configuration."); -PyObject* Branch_upstream__get__(Branch *self) +PyObject * +Branch_upstream__get__(Branch *self) { int err; git_reference *c_reference; @@ -216,38 +250,26 @@ int Branch_upstream__set__(Branch *self, Reference *py_ref) PyDoc_STRVAR(Branch_upstream_name__doc__, - "The name of the reference supporting the remote tracking branch."); + "The name of the reference set to be the upstream of this one"); -PyObject* Branch_upstream_name__get__(Branch *self) +PyObject * +Branch_upstream_name__get__(Branch *self) { int err; + git_buf name = {NULL}; const char *branch_name; - char *c_name = NULL; + PyObject *py_name; CHECK_REFERENCE(self); branch_name = git_reference_name(self->reference); - // get the length of the upstream name - err = git_branch_upstream_name(NULL, 0, self->repo->repo, branch_name); - if (err < GIT_OK) - return Error_set(err); - - // get the actual upstream name - c_name = calloc(err, sizeof(char)); - if (c_name == NULL) - return PyErr_NoMemory(); - err = git_branch_upstream_name(c_name, - err * sizeof(char), - self->repo->repo, - branch_name); - if (err < GIT_OK) { - free(c_name); + err = git_branch_upstream_name(&name, self->repo->repo, branch_name); + if (err < GIT_OK) return Error_set(err); - } - PyObject *py_name = to_unicode(c_name, NULL, NULL); - free(c_name); + py_name = to_unicode_n(name.ptr, name.size, NULL, NULL); + git_buf_dispose(&name); return py_name; } @@ -256,12 +278,14 @@ PyObject* Branch_upstream_name__get__(Branch *self) PyMethodDef Branch_methods[] = { METHOD(Branch, delete, METH_NOARGS), METHOD(Branch, is_head, METH_NOARGS), + METHOD(Branch, is_checked_out, METH_NOARGS), METHOD(Branch, rename, METH_VARARGS), {NULL} }; PyGetSetDef Branch_getseters[] = { GETTER(Branch, branch_name), + GETTER(Branch, raw_branch_name), GETTER(Branch, remote_name), GETSET(Branch, upstream), GETTER(Branch, upstream_name), diff --git a/src/branch.h b/src/branch.h index ee1185e09..5ee6de7e2 100644 --- a/src/branch.h +++ b/src/branch.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2013 The pygit2 contributors + * Copyright 2010-2025 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -34,7 +34,8 @@ PyObject* Branch_delete(Branch *self, PyObject *args); PyObject* Branch_is_head(Branch *self); -PyObject* Branch_move(Branch *self, PyObject *args); +PyObject* Branch_is_checked_out(Branch *self); +PyObject* Branch_rename(Branch *self, PyObject *args); PyObject* wrap_branch(git_reference *c_reference, Repository *repo); diff --git a/src/commit.c b/src/commit.c index 74b1ecc16..f758bdd87 100644 --- a/src/commit.c +++ b/src/commit.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2013 The pygit2 contributors + * Copyright 2010-2025 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -30,20 +30,21 @@ #include "error.h" #include "utils.h" #include "signature.h" -#include "commit.h" #include "object.h" +#include "oid.h" extern PyTypeObject TreeType; +extern PyObject *GitError; PyDoc_STRVAR(Commit_message_encoding__doc__, "Message encoding."); PyObject * -Commit_message_encoding__get__(Commit *commit) +Commit_message_encoding__get__(Commit *self) { - const char *encoding; + if (Object__load((Object*)self) == NULL) { return NULL; } // Lazy load - encoding = git_commit_message_encoding(commit->commit); + const char *encoding = git_commit_message_encoding(self->commit); if (encoding == NULL) Py_RETURN_NONE; @@ -54,40 +55,116 @@ Commit_message_encoding__get__(Commit *commit) PyDoc_STRVAR(Commit_message__doc__, "The commit message, a text string."); PyObject * -Commit_message__get__(Commit *commit) +Commit_message__get__(Commit *self) { - const char *message, *encoding; + if (Object__load((Object*)self) == NULL) { return NULL; } // Lazy load - message = git_commit_message(commit->commit); - encoding = git_commit_message_encoding(commit->commit); - return to_unicode(message, encoding, "strict"); + const char *message = git_commit_message(self->commit); + const char *encoding = git_commit_message_encoding(self->commit); + return to_unicode(message, encoding, NULL); } +PyDoc_STRVAR(Commit_gpg_signature__doc__, "A tuple with the GPG signature and the signed payload."); -PyDoc_STRVAR(Commit__message__doc__, "Message (bytes)."); +PyObject * +Commit_gpg_signature__get__(Commit *self) +{ + git_buf gpg_signature = { NULL }, signed_data = { NULL }; + PyObject *py_gpg_signature, *py_signed_data; + + const git_oid *oid = Object__id((Object*)self); + int err = git_commit_extract_signature( + &gpg_signature, &signed_data, self->repo->repo, (git_oid*) oid, NULL + ); + + if (err != GIT_OK){ + git_buf_dispose(&gpg_signature); + git_buf_dispose(&signed_data); + + if (err == GIT_ENOTFOUND){ + return Py_BuildValue("OO", Py_None, Py_None); + } + + return Error_set(err); + } + + py_gpg_signature = PyBytes_FromString(gpg_signature.ptr); + py_signed_data = PyBytes_FromString(signed_data.ptr); + git_buf_dispose(&gpg_signature); + git_buf_dispose(&signed_data); + + return Py_BuildValue("NN", py_gpg_signature, py_signed_data); +} + + +PyDoc_STRVAR(Commit_message_trailers__doc__, + "Returns commit message trailers (e.g., Bug: 1234) as a dictionary." +); PyObject * -Commit__message__get__(Commit *commit) +Commit_message_trailers__get__(Commit *self) { - return PyBytes_FromString(git_commit_message(commit->commit)); + git_message_trailer_array gmt_arr; + int i, trailer_count, err; + PyObject *dict; + PyObject *py_val; + const char *message = git_commit_message(self->commit); + const char *encoding = git_commit_message_encoding(self->commit); + + err = git_message_trailers(&gmt_arr, message); + if (err < 0) + return Error_set(err); + + dict = PyDict_New(); + if (dict == NULL) + goto error; + + trailer_count = gmt_arr.count; + for (i=0; i < trailer_count; i++) { + py_val = to_unicode(gmt_arr.trailers[i].value, encoding, NULL); + err = PyDict_SetItemString(dict, gmt_arr.trailers[i].key, py_val); + Py_DECREF(py_val); + if (err < 0) + goto error; + + } + + git_message_trailer_array_free(&gmt_arr); + return dict; + +error: + git_message_trailer_array_free(&gmt_arr); + Py_CLEAR(dict); + return NULL; +} + +PyDoc_STRVAR(Commit_raw_message__doc__, "Message (bytes)."); + +PyObject * +Commit_raw_message__get__(Commit *self) +{ + if (Object__load((Object*)self) == NULL) { return NULL; } // Lazy load + return PyBytes_FromString(git_commit_message(self->commit)); } PyDoc_STRVAR(Commit_commit_time__doc__, "Commit time."); PyObject * -Commit_commit_time__get__(Commit *commit) +Commit_commit_time__get__(Commit *self) { - return PyLong_FromLongLong(git_commit_time(commit->commit)); + if (Object__load((Object*)self) == NULL) { return NULL; } // Lazy load + return PyLong_FromLongLong(git_commit_time(self->commit)); } PyDoc_STRVAR(Commit_commit_time_offset__doc__, "Commit time offset."); PyObject * -Commit_commit_time_offset__get__(Commit *commit) +Commit_commit_time_offset__get__(Commit *self) { - return PyLong_FromLong(git_commit_time_offset(commit->commit)); + if (Object__load((Object*)self) == NULL) { return NULL; } // Lazy load + return PyLong_FromLong(git_commit_time_offset(self->commit)); } @@ -96,11 +173,10 @@ PyDoc_STRVAR(Commit_committer__doc__, "The committer of the commit."); PyObject * Commit_committer__get__(Commit *self) { - const git_signature *signature; - const char *encoding; + if (Object__load((Object*)self) == NULL) { return NULL; } // Lazy load - signature = git_commit_committer(self->commit); - encoding = git_commit_message_encoding(self->commit); + const git_signature *signature = git_commit_committer(self->commit); + const char *encoding = git_commit_message_encoding(self->commit); return build_signature((Object*)self, signature, encoding); } @@ -111,41 +187,44 @@ PyDoc_STRVAR(Commit_author__doc__, "The author of the commit."); PyObject * Commit_author__get__(Commit *self) { - const git_signature *signature; - const char *encoding; + if (Object__load((Object*)self) == NULL) { return NULL; } // Lazy load - signature = git_commit_author(self->commit); - encoding = git_commit_message_encoding(self->commit); + const git_signature *signature = git_commit_author(self->commit); + const char *encoding = git_commit_message_encoding(self->commit); return build_signature((Object*)self, signature, encoding); } - PyDoc_STRVAR(Commit_tree__doc__, "The tree object attached to the commit."); PyObject * -Commit_tree__get__(Commit *commit) +Commit_tree__get__(Commit *self) { git_tree *tree; - Tree *py_tree; - int err; - err = git_commit_tree(&tree, commit->commit); - if (err == GIT_ENOTFOUND) - Py_RETURN_NONE; + if (Object__load((Object*)self) == NULL) { return NULL; } // Lazy load + + int err = git_commit_tree(&tree, self->commit); + if (err == GIT_ENOTFOUND) { + char tree_id[GIT_OID_HEXSZ + 1] = { 0 }; + git_oid_fmt(tree_id, git_commit_tree_id(self->commit)); + return PyErr_Format(GitError, "Unable to read tree %s", tree_id); + } if (err < 0) return Error_set(err); - py_tree = PyObject_New(Tree, &TreeType); - if (py_tree) { - Py_INCREF(commit->repo); - py_tree->repo = commit->repo; - py_tree->tree = (git_tree*)tree; - } - return (PyObject*)py_tree; + return wrap_object((git_object*)tree, self->repo, NULL); } +PyDoc_STRVAR(Commit_tree_id__doc__, "The id of the tree attached to the commit."); + +PyObject * +Commit_tree_id__get__(Commit *self) +{ + if (Object__load((Object*)self) == NULL) { return NULL; } // Lazy load + return git_oid_to_python(git_commit_tree_id(self->commit)); +} PyDoc_STRVAR(Commit_parents__doc__, "The list of parent commits."); @@ -160,6 +239,8 @@ Commit_parents__get__(Commit *self) PyObject *py_parent; PyObject *list; + if (Object__load((Object*)self) == NULL) { return NULL; } // Lazy load + parent_count = git_commit_parentcount(self->commit); list = PyList_New(parent_count); if (!list) @@ -180,7 +261,7 @@ Commit_parents__get__(Commit *self) return Error_set_oid(err, parent_oid, GIT_OID_HEXSZ); } - py_parent = wrap_object((git_object*)parent, py_repo); + py_parent = wrap_object((git_object*)parent, py_repo, NULL); if (py_parent == NULL) { Py_DECREF(list); return NULL; @@ -192,16 +273,44 @@ Commit_parents__get__(Commit *self) return list; } +PyDoc_STRVAR(Commit_parent_ids__doc__, "The list of parent commits' ids."); + +PyObject * +Commit_parent_ids__get__(Commit *self) +{ + unsigned int i, parent_count; + const git_oid *id; + PyObject *list; + + if (Object__load((Object*)self) == NULL) { return NULL; } // Lazy load + + parent_count = git_commit_parentcount(self->commit); + list = PyList_New(parent_count); + if (!list) + return NULL; + + for (i=0; i < parent_count; i++) { + id = git_commit_parent_id(self->commit, i); + PyList_SET_ITEM(list, i, git_oid_to_python(id)); + } + + return list; +} + PyGetSetDef Commit_getseters[] = { GETTER(Commit, message_encoding), GETTER(Commit, message), - GETTER(Commit, _message), + GETTER(Commit, raw_message), GETTER(Commit, commit_time), GETTER(Commit, commit_time_offset), GETTER(Commit, committer), GETTER(Commit, author), + GETTER(Commit, gpg_signature), GETTER(Commit, tree), + GETTER(Commit, tree_id), GETTER(Commit, parents), + GETTER(Commit, parent_ids), + GETTER(Commit, message_trailers), {NULL} }; @@ -218,7 +327,7 @@ PyTypeObject CommitType = { 0, /* tp_getattr */ 0, /* tp_setattr */ 0, /* tp_compare */ - 0, /* tp_repr */ + (reprfunc)Object_repr, /* tp_repr */ 0, /* tp_as_number */ 0, /* tp_as_sequence */ 0, /* tp_as_mapping */ @@ -228,7 +337,7 @@ PyTypeObject CommitType = { 0, /* tp_getattro */ 0, /* tp_setattro */ 0, /* tp_as_buffer */ - Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, /* tp_flags */ + Py_TPFLAGS_DEFAULT, /* tp_flags */ Commit__doc__, /* tp_doc */ 0, /* tp_traverse */ 0, /* tp_clear */ diff --git a/src/config.c b/src/config.c deleted file mode 100644 index dc9b44f53..000000000 --- a/src/config.c +++ /dev/null @@ -1,490 +0,0 @@ -/* - * Copyright 2010-2013 The pygit2 contributors - * - * 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 "error.h" -#include "types.h" -#include "utils.h" -#include "config.h" - -extern PyTypeObject ConfigType; - - -PyObject * -wrap_config(char *c_path) { - int err; - PyObject *py_path; - Config *py_config; - - py_path = Py_BuildValue("(s)", c_path); - py_config = PyObject_New(Config, &ConfigType); - - err = Config_init(py_config, py_path, NULL); - if (err < 0) - return NULL; - - return (PyObject*) py_config; -} - - -int -Config_init(Config *self, PyObject *args, PyObject *kwds) -{ - char *path = NULL; - int err; - - if (kwds) { - PyErr_SetString(PyExc_TypeError, - "Config takes no keyword arguments"); - return -1; - } - - if (!PyArg_ParseTuple(args, "|s", &path)) - return -1; - - if (path == NULL) - err = git_config_new(&self->config); - else - err = git_config_open_ondisk(&self->config, path); - - if (err < 0) { - git_config_free(self->config); - - if (err == GIT_ENOTFOUND) - Error_set_exc(PyExc_IOError); - else - Error_set(err); - - return -1; - } - - return 0; -} - - -void -Config_dealloc(Config *self) -{ - git_config_free(self->config); - PyObject_Del(self); -} - -PyDoc_STRVAR(Config_get_global_config__doc__, - "get_global_config() -> Config\n" - "\n" - "Return an object representing the global configuration file."); - -PyObject * -Config_get_global_config(void) -{ - char path[GIT_PATH_MAX]; - int err; - - err = git_config_find_global(path, GIT_PATH_MAX); - if (err < 0) { - if (err == GIT_ENOTFOUND) { - PyErr_SetString(PyExc_IOError, "Global config file not found."); - return NULL; - } - - return Error_set(err); - } - - return wrap_config(path); -} - - -PyDoc_STRVAR(Config_get_system_config__doc__, - "get_system_config() -> Config\n" - "\n" - "Return an object representing the system configuration file."); - -PyObject * -Config_get_system_config(void) -{ - char path[GIT_PATH_MAX]; - int err; - - err = git_config_find_system(path, GIT_PATH_MAX); - if (err < 0) { - if (err == GIT_ENOTFOUND) { - PyErr_SetString(PyExc_IOError, "System config file not found."); - return NULL; - } - return Error_set(err); - } - - return wrap_config(path); -} - - -int -Config_contains(Config *self, PyObject *py_key) { - int err; - const char *c_value; - char *c_key; - - c_key = py_str_to_c_str(py_key, NULL); - if (c_key == NULL) - return -1; - - err = git_config_get_string(&c_value, self->config, c_key); - free(c_key); - - if (err < 0) { - if (err == GIT_ENOTFOUND) - return 0; - - Error_set(err); - return -1; - } - - return 1; -} - - -PyObject * -Config_getitem(Config *self, PyObject *py_key) -{ - int64_t value_int; - int err, value_bool; - const char *value_str; - char *key; - PyObject* py_value; - - key = py_str_to_c_str(py_key, NULL); - if (key == NULL) - return NULL; - - err = git_config_get_string(&value_str, self->config, key); - if (err < 0) - goto cleanup; - - if (git_config_parse_int64(&value_int, value_str) == 0) - py_value = PyLong_FromLongLong(value_int); - else if(git_config_parse_bool(&value_bool, value_str) == 0) - py_value = PyBool_FromLong(value_bool); - else - py_value = to_unicode(value_str, NULL, NULL); - -cleanup: - free(key); - - if (err < 0) { - if (err == GIT_ENOTFOUND) { - PyErr_SetObject(PyExc_KeyError, py_key); - return NULL; - } - - return Error_set(err); - } - - return py_value; -} - -int -Config_setitem(Config *self, PyObject *py_key, PyObject *py_value) -{ - int err; - char *key, *value; - - key = py_str_to_c_str(py_key, NULL); - if (key == NULL) - return -1; - - if (py_value == NULL) - err = git_config_delete_entry(self->config, key); - else if (PyBool_Check(py_value)) { - err = git_config_set_bool(self->config, key, - (int)PyObject_IsTrue(py_value)); - } else if (PyLong_Check(py_value)) { - err = git_config_set_int64(self->config, key, - (int64_t)PyLong_AsLong(py_value)); - } else { - value = py_str_to_c_str(py_value, NULL); - err = git_config_set_string(self->config, key, value); - free(value); - } - - free(key); - if (err < 0) { - Error_set(err); - return -1; - } - return 0; -} - -int -Config_foreach_callback_wrapper(const git_config_entry *entry, void *c_payload) -{ - PyObject *args = (PyObject *)c_payload; - PyObject *py_callback = NULL; - PyObject *py_payload = NULL; - PyObject *py_result = NULL; - int c_result; - - if (!PyArg_ParseTuple(args, "O|O", &py_callback, &py_payload)) - return -1; - - if (py_payload) - args = Py_BuildValue("ssO", entry->name, entry->value, py_payload); - else - args = Py_BuildValue("ss", entry->name, entry->value); - if (!args) - return -1; - - if (!(py_result = PyObject_CallObject(py_callback, args))) - return -1; - - if ((c_result = PyLong_AsLong(py_result)) == -1) - return -1; - - Py_CLEAR(args); - - return c_result; -} - - -PyDoc_STRVAR(Config_foreach__doc__, - "foreach(callback[, payload]) -> int\n" - "\n" - "Perform an operation on each config variable.\n" - "\n" - "The callback must be of type Callable and receives the normalized name\n" - "and value of each variable in the config backend, and an optional payload\n" - "passed to this method. As soon as one of the callbacks returns an integer\n" - "other than 0, this function returns that value."); - -PyObject * -Config_foreach(Config *self, PyObject *args) -{ - int ret; - PyObject *py_callback; - PyObject *py_payload = NULL; - - if (!PyArg_ParseTuple(args, "O|O", &py_callback, &py_payload)) - return NULL; - - if (!PyCallable_Check(py_callback)) { - PyErr_SetString(PyExc_TypeError, - "Argument 'callback' is not callable"); - return NULL; - } - - ret = git_config_foreach(self->config, Config_foreach_callback_wrapper, - (void *)args); - - return PyLong_FromLong((long)ret); -} - - -PyDoc_STRVAR(Config_add_file__doc__, - "add_file(path, level=0, force=0)\n" - "\n" - "Add a config file instance to an existing config."); - -PyObject * -Config_add_file(Config *self, PyObject *args, PyObject *kwds) -{ - char *keywords[] = {"path", "level", "force", NULL}; - int err; - char *path; - unsigned int level = 0; - int force = 0; - - if (!PyArg_ParseTupleAndKeywords(args, kwds, "s|Ii", keywords, - &path, &level, &force)) - return NULL; - - err = git_config_add_file_ondisk(self->config, path, level, force); - if (err < 0) - return Error_set_str(err, path); - - Py_RETURN_NONE; -} - - -PyDoc_STRVAR(Config_get_multivar__doc__, - "get_multivar(name[, regex]) -> [str, ...]\n" - "\n" - "Get each value of a multivar ''name'' as a list. The optional ''regex''\n" - "parameter is expected to be a regular expression to filter the variables\n" - "we're interested in."); - -int -Config_get_multivar_fn_wrapper(const git_config_entry *value, void *data) -{ - PyObject *item; - - item = to_unicode(value->value, NULL, NULL); - if (item == NULL) - /* FIXME Right now there is no way to forward errors through the - * libgit2 API, open an issue or pull-request to libgit2. - * - * See libgit2/src/config_file.c:443 (config_get_multivar). - * Comment says "early termination by the user is not an error". - * That's wrong. - */ - return -2; - - PyList_Append((PyObject *)data, item); - Py_CLEAR(item); - return 0; -} - -PyObject * -Config_get_multivar(Config *self, PyObject *args) -{ - int err; - PyObject *list; - Py_ssize_t size; - const char *name = NULL; - const char *regex = NULL; - - if (!PyArg_ParseTuple(args, "s|s", &name, ®ex)) - return NULL; - - list = PyList_New(0); - err = git_config_get_multivar(self->config, name, regex, - Config_get_multivar_fn_wrapper, - (void *)list); - - if (err < 0) { - /* XXX The return value of git_config_get_multivar is not reliable, - * see https://github.com/libgit2/libgit2/pull/1712 - * Once libgit2 0.20 is released, we will remove this test. */ - if (err == GIT_ENOTFOUND && PyList_Size(list) != 0) - return list; - - Py_CLEAR(list); - return Error_set(err); - } - - return list; -} - - -PyDoc_STRVAR(Config_set_multivar__doc__, - "set_multivar(name, regex, value)\n" - "\n" - "Set a multivar ''name'' to ''value''. ''regexp'' is a regular expression\n" - "to indicate which values to replace"); - -PyObject * -Config_set_multivar(Config *self, PyObject *args) -{ - int err; - const char *name = NULL; - const char *regex = NULL; - const char *value = NULL; - - if (!PyArg_ParseTuple(args, "sss", &name, ®ex, &value)) - return NULL; - - err = git_config_set_multivar(self->config, name, regex, value); - if (err < 0) { - if (err == GIT_ENOTFOUND) - Error_set(err); - else - PyErr_SetNone(PyExc_TypeError); - return NULL; - } - - Py_RETURN_NONE; -} - -PyMethodDef Config_methods[] = { - METHOD(Config, get_system_config, METH_NOARGS | METH_STATIC), - METHOD(Config, get_global_config, METH_NOARGS | METH_STATIC), - METHOD(Config, foreach, METH_VARARGS), - METHOD(Config, add_file, METH_VARARGS | METH_KEYWORDS), - METHOD(Config, get_multivar, METH_VARARGS), - METHOD(Config, set_multivar, METH_VARARGS), - {NULL} -}; - -PySequenceMethods Config_as_sequence = { - 0, /* sq_length */ - 0, /* sq_concat */ - 0, /* sq_repeat */ - 0, /* sq_item */ - 0, /* sq_slice */ - 0, /* sq_ass_item */ - 0, /* sq_ass_slice */ - (objobjproc)Config_contains, /* sq_contains */ -}; - -PyMappingMethods Config_as_mapping = { - 0, /* mp_length */ - (binaryfunc)Config_getitem, /* mp_subscript */ - (objobjargproc)Config_setitem, /* mp_ass_subscript */ -}; - - -PyDoc_STRVAR(Config__doc__, "Configuration management."); - -PyTypeObject ConfigType = { - PyVarObject_HEAD_INIT(NULL, 0) - "_pygit2.Config", /* tp_name */ - sizeof(Config), /* tp_basicsize */ - 0, /* tp_itemsize */ - (destructor)Config_dealloc, /* tp_dealloc */ - 0, /* tp_print */ - 0, /* tp_getattr */ - 0, /* tp_setattr */ - 0, /* tp_compare */ - 0, /* tp_repr */ - 0, /* tp_as_number */ - &Config_as_sequence, /* tp_as_sequence */ - &Config_as_mapping, /* tp_as_mapping */ - 0, /* tp_hash */ - 0, /* tp_call */ - 0, /* tp_str */ - 0, /* tp_getattro */ - 0, /* tp_setattro */ - 0, /* tp_as_buffer */ - Py_TPFLAGS_DEFAULT, /* tp_flags */ - Config__doc__, /* tp_doc */ - 0, /* tp_traverse */ - 0, /* tp_clear */ - 0, /* tp_richcompare */ - 0, /* tp_weaklistoffset */ - 0, /* tp_iter */ - 0, /* tp_iternext */ - Config_methods, /* tp_methods */ - 0, /* tp_members */ - 0, /* tp_getset */ - 0, /* tp_base */ - 0, /* tp_dict */ - 0, /* tp_descr_get */ - 0, /* tp_descr_set */ - 0, /* tp_dictoffset */ - (initproc)Config_init, /* tp_init */ - 0, /* tp_alloc */ - 0, /* tp_new */ -}; diff --git a/src/config.h b/src/config.h deleted file mode 100644 index b0eba9353..000000000 --- a/src/config.h +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright 2010-2013 The pygit2 contributors - * - * 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_config_h -#define INCLUDE_pygit2_config_h - -#define PY_SSIZE_T_CLEAN -#include -#include - -PyObject* wrap_config(char *c_path); -PyObject* Config_get_global_config(void); -PyObject* Config_get_system_config(void); -PyObject* Config_add_file(Config *self, PyObject *args, PyObject *kwds); -PyObject* Config_getitem(Config *self, PyObject *key); -PyObject* Config_foreach(Config *self, PyObject *args); -PyObject* Config_get_multivar(Config *self, PyObject *args); -PyObject* Config_set_multivar(Config *self, PyObject *args); -int Config_init(Config *self, PyObject *args, PyObject *kwds); -int Config_setitem(Config *self, PyObject *key, PyObject *value); -#endif diff --git a/src/diff.c b/src/diff.c index f46d4bdb7..0bc7c6136 100644 --- a/src/diff.c +++ b/src/diff.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2013 The pygit2 contributors + * Copyright 2010-2025 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -28,141 +28,233 @@ #define PY_SSIZE_T_CLEAN #include #include +#include "diff.h" #include "error.h" +#include "oid.h" +#include "patch.h" #include "types.h" #include "utils.h" -#include "diff.h" extern PyObject *GitError; extern PyTypeObject TreeType; extern PyTypeObject IndexType; extern PyTypeObject DiffType; -extern PyTypeObject HunkType; +extern PyTypeObject DiffDeltaType; +extern PyTypeObject DiffFileType; +extern PyTypeObject DiffHunkType; +extern PyTypeObject DiffLineType; +extern PyTypeObject DiffStatsType; +extern PyTypeObject RepositoryType; -PyTypeObject PatchType; +extern PyObject *DeltaStatusEnum; +extern PyObject *DiffFlagEnum; +extern PyObject *FileModeEnum; -PyObject* -wrap_diff(git_diff_list *diff, Repository *repo) +PyObject * +wrap_diff(git_diff *diff, Repository *repo) { Diff *py_diff; py_diff = PyObject_New(Diff, &DiffType); if (py_diff) { - Py_INCREF(repo); + Py_XINCREF(repo); py_diff->repo = repo; - py_diff->list = diff; + py_diff->diff = diff; } return (PyObject*) py_diff; } -PyObject* -diff_get_patch_byindex(git_diff_list* list, size_t idx) +PyObject * +wrap_diff_file(const git_diff_file *file) +{ + DiffFile *py_file; + + if (!file) + Py_RETURN_NONE; + + py_file = PyObject_New(DiffFile, &DiffFileType); + if (py_file) { + py_file->id = git_oid_to_python(&file->id); + if (file->path) { + py_file->path = strdup(file->path); + py_file->raw_path = PyBytes_FromString(file->path); + } else { + py_file->path = NULL; + py_file->raw_path = NULL; + } + py_file->size = file->size; + py_file->flags = file->flags; + py_file->mode = file->mode; + } + + return (PyObject *) py_file; +} + +PyObject * +wrap_diff_delta(const git_diff_delta *delta) { - const git_diff_delta* delta; - const git_diff_range* range; - git_diff_patch* patch = NULL; - size_t i, j, hunk_amounts, lines_in_hunk, line_len, header_len; - const char* line, *header; - char line_origin; + DiffDelta *py_delta; + + if (!delta) + Py_RETURN_NONE; + + py_delta = PyObject_New(DiffDelta, &DiffDeltaType); + if (py_delta) { + py_delta->status = delta->status; + py_delta->flags = delta->flags; + py_delta->similarity = delta->similarity; + 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); + } + + return (PyObject *) py_delta; +} + +PyObject * +wrap_diff_hunk(Patch *patch, size_t idx) +{ + DiffHunk *py_hunk; + const git_diff_hunk *hunk; + size_t lines_in_hunk; int err; - Hunk *py_hunk = NULL; - Patch *py_patch = NULL; - PyObject *py_line_origin=NULL, *py_line=NULL; - err = git_diff_get_patch(&patch, &delta, list, idx); + err = git_patch_get_hunk(&hunk, &lines_in_hunk, patch->patch, idx); if (err < 0) return Error_set(err); - py_patch = PyObject_New(Patch, &PatchType); - if (py_patch != NULL) { - py_patch->old_file_path = delta->old_file.path; - py_patch->new_file_path = delta->new_file.path; - py_patch->status = git_diff_status_char(delta->status); - py_patch->similarity = delta->similarity; - py_patch->old_oid = git_oid_allocfmt(&delta->old_file.oid); - py_patch->new_oid = git_oid_allocfmt(&delta->new_file.oid); - - - hunk_amounts = git_diff_patch_num_hunks(patch); - py_patch->hunks = PyList_New(hunk_amounts); - for (i=0; i < hunk_amounts; ++i) { - err = git_diff_patch_get_hunk(&range, &header, &header_len, - &lines_in_hunk, patch, i); - - if (err < 0) - goto cleanup; - - py_hunk = PyObject_New(Hunk, &HunkType); - if (py_hunk != NULL) { - py_hunk->old_start = range->old_start; - py_hunk->old_lines = range->old_lines; - py_hunk->new_start = range->new_start; - py_hunk->new_lines = range->new_lines; - - py_hunk->lines = PyList_New(lines_in_hunk); - for (j=0; j < lines_in_hunk; ++j) { - err = git_diff_patch_get_line_in_hunk(&line_origin, - &line, &line_len, NULL, NULL, patch, i, j); - - if (err < 0) - goto cleanup; - - py_line_origin = to_unicode_n(&line_origin, 1, NULL, NULL); - py_line = to_unicode_n(line, line_len, NULL, NULL); - PyList_SetItem(py_hunk->lines, j, - Py_BuildValue("OO", - py_line_origin, - py_line - ) - ); - Py_DECREF(py_line_origin); - Py_DECREF(py_line); - } - - PyList_SetItem((PyObject*) py_patch->hunks, i, - (PyObject*) py_hunk); - } - } + py_hunk = PyObject_New(DiffHunk, &DiffHunkType); + if (py_hunk) { + Py_INCREF(patch); + py_hunk->patch = patch; + py_hunk->hunk = hunk; + py_hunk->idx = idx; + py_hunk->n_lines = lines_in_hunk; + } + + return (PyObject *) py_hunk; +} + +PyObject * +wrap_diff_stats(git_diff *diff) +{ + git_diff_stats *stats; + DiffStats *py_stats; + int err; + + err = git_diff_get_stats(&stats, diff); + if (err < 0) + return Error_set(err); + + py_stats = PyObject_New(DiffStats, &DiffStatsType); + if (!py_stats) { + git_diff_stats_free(stats); + return NULL; } -cleanup: - git_diff_patch_free(patch); + py_stats->stats = stats; + + return (PyObject *) py_stats; +} + +PyObject * +wrap_diff_line(const git_diff_line *line, DiffHunk *hunk) +{ + DiffLine *py_line; + + py_line = PyObject_New(DiffLine, &DiffLineType); + if (py_line) { + Py_INCREF(hunk); + py_line->hunk = hunk; + py_line->line = line; + } - return (err < 0) ? Error_set(err) : (PyObject*) py_patch; + return (PyObject *) py_line; } static void -Patch_dealloc(Patch *self) +DiffFile_dealloc(DiffFile *self) { - Py_CLEAR(self->hunks); - free(self->old_oid); - free(self->new_oid); - // we do not have to free old_file_path and new_file_path, they will - // be freed by git_diff_list_free in Diff_dealloc + Py_CLEAR(self->id); + Py_CLEAR(self->raw_path); + free(self->path); PyObject_Del(self); } -PyMemberDef Patch_members[] = { - MEMBER(Patch, old_file_path, T_STRING, "old file path"), - MEMBER(Patch, new_file_path, T_STRING, "new file path"), - MEMBER(Patch, old_oid, T_STRING, "old oid"), - MEMBER(Patch, new_oid, T_STRING, "new oid"), - MEMBER(Patch, status, T_CHAR, "status"), - MEMBER(Patch, similarity, T_INT, "similarity"), - MEMBER(Patch, hunks, T_OBJECT, "hunks"), +PyDoc_STRVAR(DiffFile_from_c__doc__, "Method exposed for _checkout_notify_cb to hook into"); + +/* Expose wrap_diff_file to python so we can call it in callbacks.py. */ +PyObject * +DiffFile_from_c(DiffFile *dummy, PyObject *py_diff_file_ptr) +{ + const git_diff_file *diff_file; + char *buffer; + Py_ssize_t length; + + /* Here we need to do the opposite conversion from the _pointer getters */ + if (PyBytes_AsStringAndSize(py_diff_file_ptr, &buffer, &length)) + return NULL; + + if (length != sizeof(git_diff_file *)) { + PyErr_SetString(PyExc_TypeError, "passed value is not a pointer"); + return NULL; + } + + /* the "buffer" contains the pointer */ + diff_file = *((const git_diff_file **) buffer); + + return wrap_diff_file(diff_file); +} + +PyDoc_STRVAR(DiffFile_flags__doc__, + "A combination of enums.DiffFlag constants." +); + +PyObject * +DiffFile_flags__get__(DiffFile *self) +{ + return pygit2_enum(DiffFlagEnum, self->flags); +} + +PyDoc_STRVAR(DiffFile_mode__doc__, + "Mode of the entry (an enums.FileMode constant)." +); + +PyObject * +DiffFile_mode__get__(DiffFile *self) +{ + return pygit2_enum(FileModeEnum, self->mode); +} + +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} }; -PyDoc_STRVAR(Patch__doc__, "Diff patch object."); +PyMethodDef DiffFile_methods[] = { + METHOD(DiffFile, from_c, METH_STATIC | METH_O), + {NULL}, +}; + +PyGetSetDef DiffFile_getsetters[] = { + GETTER(DiffFile, flags), + GETTER(DiffFile, mode), + {NULL}, +}; + +PyDoc_STRVAR(DiffFile__doc__, "DiffFile object."); -PyTypeObject PatchType = { +PyTypeObject DiffFileType = { PyVarObject_HEAD_INIT(NULL, 0) - "_pygit2.Patch", /* tp_name */ - sizeof(Patch), /* tp_basicsize */ + "_pygit2.DiffFile", /* tp_name */ + sizeof(DiffFile), /* tp_basicsize */ 0, /* tp_itemsize */ - (destructor)Patch_dealloc, /* tp_dealloc */ + (destructor)DiffFile_dealloc, /* tp_dealloc */ 0, /* tp_print */ 0, /* tp_getattr */ 0, /* tp_setattr */ @@ -177,8 +269,241 @@ PyTypeObject PatchType = { 0, /* tp_getattro */ 0, /* tp_setattro */ 0, /* tp_as_buffer */ - Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, /* tp_flags */ - Patch__doc__, /* tp_doc */ + Py_TPFLAGS_DEFAULT, /* tp_flags */ + DiffFile__doc__, /* tp_doc */ + 0, /* tp_traverse */ + 0, /* tp_clear */ + 0, /* tp_richcompare */ + 0, /* tp_weaklistoffset */ + 0, /* tp_iter */ + 0, /* tp_iternext */ + DiffFile_methods, /* tp_methods */ + DiffFile_members, /* tp_members */ + DiffFile_getsetters, /* tp_getset */ + 0, /* tp_base */ + 0, /* tp_dict */ + 0, /* tp_descr_get */ + 0, /* tp_descr_set */ + 0, /* tp_dictoffset */ + 0, /* tp_init */ + 0, /* tp_alloc */ + 0, /* tp_new */ +}; + + +PyDoc_STRVAR(DiffDelta_status_char__doc__, + "status_char() -> str\n" + "\n" + "Return the single character abbreviation for a delta status code." +); + +PyObject * +DiffDelta_status_char(DiffDelta *self) +{ + char status = git_diff_status_char(self->status); + return Py_BuildValue("C", status); +} + +PyDoc_STRVAR(DiffDelta_is_binary__doc__, + "True if binary data, False if text, None if not (yet) known." +); + +PyObject * +DiffDelta_is_binary__get__(DiffDelta *self) +{ + if (self->flags & GIT_DIFF_FLAG_BINARY) + Py_RETURN_TRUE; + + if (self->flags & GIT_DIFF_FLAG_NOT_BINARY) + Py_RETURN_FALSE; + + // This means the file has not been loaded, so we don't know whether it's + // binary or text + Py_RETURN_NONE; +} + +PyDoc_STRVAR(DiffDelta_status__doc__, + "An enums.DeltaStatus constant." +); + +PyObject * +DiffDelta_status__get__(DiffDelta *self) +{ + return pygit2_enum(DeltaStatusEnum, self->status); +} + +PyDoc_STRVAR(DiffDelta_flags__doc__, + "A combination of enums.DiffFlag constants." +); + +PyObject * +DiffDelta_flags__get__(DiffDelta *self) +{ + return pygit2_enum(DiffFlagEnum, self->flags); +} + +static void +DiffDelta_dealloc(DiffDelta *self) +{ + Py_CLEAR(self->old_file); + Py_CLEAR(self->new_file); + PyObject_Del(self); +} + +static PyMethodDef DiffDelta_methods[] = { + METHOD(DiffDelta, status_char, METH_NOARGS), + {NULL} +}; + +PyMemberDef DiffDelta_members[] = { + MEMBER(DiffDelta, similarity, T_USHORT, "For renamed and copied."), + MEMBER(DiffDelta, nfiles, T_USHORT, "Number of files in the delta."), + MEMBER(DiffDelta, old_file, T_OBJECT, "\"from\" side of the diff."), + MEMBER(DiffDelta, new_file, T_OBJECT, "\"to\" side of the diff."), + {NULL} +}; + +PyGetSetDef DiffDelta_getsetters[] = { + GETTER(DiffDelta, is_binary), + GETTER(DiffDelta, status), + GETTER(DiffDelta, flags), + {NULL} +}; + +PyDoc_STRVAR(DiffDelta__doc__, "DiffDelta object."); + +PyTypeObject DiffDeltaType = { + PyVarObject_HEAD_INIT(NULL, 0) + "_pygit2.DiffDelta", /* tp_name */ + sizeof(DiffDelta), /* tp_basicsize */ + 0, /* tp_itemsize */ + (destructor)DiffDelta_dealloc, /* tp_dealloc */ + 0, /* tp_print */ + 0, /* tp_getattr */ + 0, /* tp_setattr */ + 0, /* tp_compare */ + 0, /* tp_repr */ + 0, /* tp_as_number */ + 0, /* tp_as_sequence */ + 0, /* tp_as_mapping */ + 0, /* tp_hash */ + 0, /* tp_call */ + 0, /* tp_str */ + 0, /* tp_getattro */ + 0, /* tp_setattro */ + 0, /* tp_as_buffer */ + Py_TPFLAGS_DEFAULT, /* tp_flags */ + DiffDelta__doc__, /* tp_doc */ + 0, /* tp_traverse */ + 0, /* tp_clear */ + 0, /* tp_richcompare */ + 0, /* tp_weaklistoffset */ + 0, /* tp_iter */ + 0, /* tp_iternext */ + DiffDelta_methods, /* tp_methods */ + DiffDelta_members, /* tp_members */ + DiffDelta_getsetters, /* tp_getset */ + 0, /* tp_base */ + 0, /* tp_dict */ + 0, /* tp_descr_get */ + 0, /* tp_descr_set */ + 0, /* tp_dictoffset */ + 0, /* tp_init */ + 0, /* tp_alloc */ + 0, /* tp_new */ +}; + +static void +DiffLine_dealloc(DiffLine *self) +{ + Py_CLEAR(self->hunk); + PyObject_Del(self); +} + +PyDoc_STRVAR(DiffLine_origin__doc__, "Type of the diff line"); +PyObject * +DiffLine_origin__get__(DiffLine *self) +{ + return PyUnicode_FromStringAndSize(&(self->line->origin), 1); +} + +PyDoc_STRVAR(DiffLine_old_lineno__doc__, "Line number in old file or -1 for added line"); +PyObject * +DiffLine_old_lineno__get__(DiffLine *self) +{ + return PyLong_FromLong(self->line->old_lineno); +} + +PyDoc_STRVAR(DiffLine_new_lineno__doc__, "Line number in new file or -1 for deleted line"); +PyObject * +DiffLine_new_lineno__get__(DiffLine *self) +{ + return PyLong_FromLong(self->line->new_lineno); +} + +PyDoc_STRVAR(DiffLine_num_lines__doc__, "Number of newline characters in content"); +PyObject * +DiffLine_num_lines__get__(DiffLine *self) +{ + return PyLong_FromLong(self->line->num_lines); +} + +PyDoc_STRVAR(DiffLine_content_offset__doc__, "Offset in the original file to the content"); +PyObject * +DiffLine_content_offset__get__(DiffLine *self) +{ + return PyLong_FromLongLong(self->line->content_offset); +} + +PyDoc_STRVAR(DiffLine_content__doc__, "Content of the diff line"); +PyObject * +DiffLine_content__get__(DiffLine *self) +{ + return to_unicode_n(self->line->content, self->line->content_len, NULL, NULL); +} + +PyDoc_STRVAR(DiffLine_raw_content__doc__, "Content of the diff line (byte string)"); +PyObject * +DiffLine_raw_content__get__(DiffLine *self) +{ + return PyBytes_FromStringAndSize(self->line->content, self->line->content_len); +} + +PyGetSetDef DiffLine_getsetters[] = { + GETTER(DiffLine, origin), + GETTER(DiffLine, old_lineno), + GETTER(DiffLine, new_lineno), + GETTER(DiffLine, num_lines), + GETTER(DiffLine, content_offset), + GETTER(DiffLine, content), + GETTER(DiffLine, raw_content), + {NULL} +}; + +PyDoc_STRVAR(DiffLine__doc__, "DiffLine object."); + +PyTypeObject DiffLineType = { + PyVarObject_HEAD_INIT(NULL, 0) + "_pygit2.DiffLine", /* tp_name */ + sizeof(DiffLine), /* tp_basicsize */ + 0, /* tp_itemsize */ + (destructor)DiffLine_dealloc, /* tp_dealloc */ + 0, /* tp_print */ + 0, /* tp_getattr */ + 0, /* tp_setattr */ + 0, /* tp_compare */ + 0, /* tp_repr */ + 0, /* tp_as_number */ + 0, /* tp_as_sequence */ + 0, /* tp_as_mapping */ + 0, /* tp_hash */ + 0, /* tp_call */ + 0, /* tp_str */ + 0, /* tp_getattro */ + 0, /* tp_setattro */ + 0, /* tp_as_buffer */ + Py_TPFLAGS_DEFAULT, /* tp_flags */ + DiffLine__doc__, /* tp_doc */ 0, /* tp_traverse */ 0, /* tp_clear */ 0, /* tp_richcompare */ @@ -186,8 +511,8 @@ PyTypeObject PatchType = { 0, /* tp_iter */ 0, /* tp_iternext */ 0, /* tp_methods */ - Patch_members, /* tp_members */ - 0, /* tp_getset */ + 0, /* tp_members */ + DiffLine_getsetters, /* tp_getset */ 0, /* tp_base */ 0, /* tp_dict */ 0, /* tp_descr_get */ @@ -198,12 +523,24 @@ PyTypeObject PatchType = { 0, /* tp_new */ }; +PyObject * +diff_get_patch_byindex(git_diff *diff, size_t idx) +{ + git_patch *patch = NULL; + int err; + + err = git_patch_from_diff(&patch, diff, idx); + if (err < 0) + return Error_set(err); + + return (PyObject*) wrap_patch(patch, NULL, NULL); +} PyObject * DiffIter_iternext(DiffIter *self) { if (self->i < self->n) - return diff_get_patch_byindex(self->diff->list, self->i++); + return diff_get_patch_byindex(self->diff->diff, self->i++); PyErr_SetNone(PyExc_StopIteration); return NULL; @@ -239,7 +576,7 @@ PyTypeObject DiffIterType = { 0, /* tp_getattro */ 0, /* tp_setattro */ 0, /* tp_as_buffer */ - Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, /* tp_flags */ + Py_TPFLAGS_DEFAULT, /* tp_flags */ DiffIter__doc__, /* tp_doc */ 0, /* tp_traverse */ 0, /* tp_clear */ @@ -249,76 +586,349 @@ PyTypeObject DiffIterType = { (iternextfunc) DiffIter_iternext, /* tp_iternext */ }; +PyObject * +diff_get_delta_byindex(git_diff *diff, size_t idx) +{ + const git_diff_delta *delta = git_diff_get_delta(diff, idx); + if (delta == NULL) { + PyErr_SetObject(PyExc_IndexError, PyLong_FromSize_t(idx)); + return NULL; + } + + return (PyObject*) wrap_diff_delta(delta); +} + +PyObject * +DeltasIter_iternext(DeltasIter *self) +{ + if (self->i < self->n) + return diff_get_delta_byindex(self->diff->diff, self->i++); + + PyErr_SetNone(PyExc_StopIteration); + return NULL; +} + +void +DeltasIter_dealloc(DeltasIter *self) +{ + Py_CLEAR(self->diff); + PyObject_Del(self); +} + +PyDoc_STRVAR(DeltasIter__doc__, "Deltas iterator object."); + +PyTypeObject DeltasIterType = { + PyVarObject_HEAD_INIT(NULL, 0) + "_pygit2.DeltasIter", /* tp_name */ + sizeof(DeltasIter), /* tp_basicsize */ + 0, /* tp_itemsize */ + (destructor)DeltasIter_dealloc, /* tp_dealloc */ + 0, /* tp_print */ + 0, /* tp_getattr */ + 0, /* tp_setattr */ + 0, /* tp_compare */ + 0, /* tp_repr */ + 0, /* tp_as_number */ + 0, /* tp_as_sequence */ + 0, /* tp_as_mapping */ + 0, /* tp_hash */ + 0, /* tp_call */ + 0, /* tp_str */ + 0, /* tp_getattro */ + 0, /* tp_setattro */ + 0, /* tp_as_buffer */ + Py_TPFLAGS_DEFAULT, /* tp_flags */ + DeltasIter__doc__, /* tp_doc */ + 0, /* tp_traverse */ + 0, /* tp_clear */ + 0, /* tp_richcompare */ + 0, /* tp_weaklistoffset */ + PyObject_SelfIter, /* tp_iter */ + (iternextfunc) DeltasIter_iternext, /* tp_iternext */ +}; + + +Py_ssize_t +Diff_len(Diff *self) +{ + assert(self->diff); + return (Py_ssize_t)git_diff_num_deltas(self->diff); +} + +PyDoc_STRVAR(Diff_patchid__doc__, + "Corresponding patchid."); + +PyObject * +Diff_patchid__get__(Diff *self) +{ + git_oid oid; + int err; + + err = git_diff_patchid(&oid, self->diff, NULL); + if (err < 0) + return Error_set(err); + return git_oid_to_python(&oid); +} -PyDoc_STRVAR(Diff_patch__doc__, "Patch diff string."); + +PyDoc_STRVAR(Diff_deltas__doc__, "Iterate over the diff deltas."); + +PyObject * +Diff_deltas__get__(Diff *self) +{ + DeltasIter *iter; + + iter = PyObject_New(DeltasIter, &DeltasIterType); + if (iter != NULL) { + Py_INCREF(self); + iter->diff = self; + iter->i = 0; + iter->n = git_diff_num_deltas(self->diff); + } + return (PyObject*)iter; +} + +PyDoc_STRVAR(Diff_patch__doc__, + "Patch diff string. Can be None in some cases, such as empty commits."); PyObject * Diff_patch__get__(Diff *self) { - const git_diff_delta* delta; - git_diff_patch* patch; - char **strings = NULL; - char *buffer = NULL; - int err = GIT_ERROR; - size_t i, len, num; - PyObject *py_patch = NULL; + git_buf buf = {NULL}; - num = git_diff_num_deltas(self->list); - MALLOC(strings, num * sizeof(char*), cleanup); + int err = git_diff_to_buf(&buf, self->diff, GIT_DIFF_FORMAT_PATCH); + if (err < 0) + return Error_set(err); - for (i = 0, len = 1; i < num ; ++i) { - err = git_diff_get_patch(&patch, &delta, self->list, i); - if (err < 0) - goto cleanup; + PyObject *py_patch = to_unicode_n(buf.ptr, buf.size, NULL, NULL); + + git_buf_dispose(&buf); + return py_patch; +} + + +static void +DiffHunk_dealloc(DiffHunk *self) +{ + Py_CLEAR(self->patch); + PyObject_Del(self); +} + +PyDoc_STRVAR(DiffHunk_old_start__doc__, "Old start."); - err = git_diff_patch_to_str(&(strings[i]), patch); +PyObject * +DiffHunk_old_start__get__(DiffHunk *self) +{ + return PyLong_FromLong(self->hunk->old_start); +} + +PyDoc_STRVAR(DiffHunk_old_lines__doc__, "Old lines."); + +PyObject * +DiffHunk_old_lines__get__(DiffHunk *self) +{ + return PyLong_FromLong(self->hunk->old_lines); +} + +PyDoc_STRVAR(DiffHunk_new_start__doc__, "New start."); + +PyObject * +DiffHunk_new_start__get__(DiffHunk *self) +{ + return PyLong_FromLong(self->hunk->new_start); +} + +PyDoc_STRVAR(DiffHunk_new_lines__doc__, "New lines."); + +PyObject * +DiffHunk_new_lines__get__(DiffHunk *self) +{ + return PyLong_FromLong(self->hunk->new_lines); +} + +PyDoc_STRVAR(DiffHunk_header__doc__, "Header."); + +PyObject * +DiffHunk_header__get__(DiffHunk *self) +{ + return to_unicode_n((const char *) &self->hunk->header, + self->hunk->header_len, NULL, NULL); +} + +PyDoc_STRVAR(DiffHunk_lines__doc__, "Lines."); + +PyObject * +DiffHunk_lines__get__(DiffHunk *self) +{ + PyObject *py_lines; + PyObject *py_line; + const git_diff_line *line; + size_t i; + int err; + + // TODO Replace by an iterator + py_lines = PyList_New(self->n_lines); + 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) - goto cleanup; + return Error_set(err); - len += strlen(strings[i]); - git_diff_patch_free(patch); - } + py_line = wrap_diff_line(line, self); + if (py_line == NULL) + return NULL; - CALLOC(buffer, (len + 1), sizeof(char), cleanup); - for (i = 0; i < num; ++i) { - strcat(buffer, strings[i]); - free(strings[i]); - } - free(strings); + PyList_SetItem(py_lines, i, py_line); + } + return py_lines; +} + + +PyGetSetDef DiffHunk_getsetters[] = { + GETTER(DiffHunk, old_start), + GETTER(DiffHunk, old_lines), + GETTER(DiffHunk, new_start), + GETTER(DiffHunk, new_lines), + GETTER(DiffHunk, header), + GETTER(DiffHunk, lines), + {NULL} +}; + +PyDoc_STRVAR(DiffHunk__doc__, "DiffHunk object."); + +PyTypeObject DiffHunkType = { + PyVarObject_HEAD_INIT(NULL, 0) + "_pygit2.DiffHunk", /* tp_name */ + sizeof(DiffHunk), /* tp_basicsize */ + 0, /* tp_itemsize */ + (destructor)DiffHunk_dealloc, /* tp_dealloc */ + 0, /* tp_print */ + 0, /* tp_getattr */ + 0, /* tp_setattr */ + 0, /* tp_compare */ + 0, /* tp_repr */ + 0, /* tp_as_number */ + 0, /* tp_as_sequence */ + 0, /* tp_as_mapping */ + 0, /* tp_hash */ + 0, /* tp_call */ + 0, /* tp_str */ + 0, /* tp_getattro */ + 0, /* tp_setattro */ + 0, /* tp_as_buffer */ + Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, /* tp_flags */ + DiffHunk__doc__, /* tp_doc */ + 0, /* tp_traverse */ + 0, /* tp_clear */ + 0, /* tp_richcompare */ + 0, /* tp_weaklistoffset */ + 0, /* tp_iter */ + 0, /* tp_iternext */ + 0, /* tp_methods */ + 0, /* tp_members */ + DiffHunk_getsetters, /* tp_getset */ + 0, /* tp_base */ + 0, /* tp_dict */ + 0, /* tp_descr_get */ + 0, /* tp_descr_set */ + 0, /* tp_dictoffset */ + 0, /* tp_init */ + 0, /* tp_alloc */ + 0, /* tp_new */ +}; - py_patch = to_unicode(buffer, NULL, NULL); - free(buffer); +PyDoc_STRVAR(DiffStats_insertions__doc__, "Total number of insertions"); -cleanup: - return (err < 0) ? Error_set(err) : py_patch; +PyObject * +DiffStats_insertions__get__(DiffStats *self) +{ + return PyLong_FromSize_t(git_diff_stats_insertions(self->stats)); } +PyDoc_STRVAR(DiffStats_deletions__doc__, "Total number of deletions"); + +PyObject * +DiffStats_deletions__get__(DiffStats *self) +{ + return PyLong_FromSize_t(git_diff_stats_deletions(self->stats)); +} + +PyDoc_STRVAR(DiffStats_files_changed__doc__, "Total number of files changed"); + +PyObject * +DiffStats_files_changed__get__(DiffStats *self) +{ + return PyLong_FromSize_t(git_diff_stats_files_changed(self->stats)); +} + +PyDoc_STRVAR(DiffStats_format__doc__, + "format(format: enums.DiffStatsFormat, width: int) -> str\n" + "\n" + "Format the stats as a string.\n" + "\n" + "Returns: str.\n" + "\n" + "Parameters:\n" + "\n" + "format\n" + " The format to use. A combination of DiffStatsFormat constants.\n" + "\n" + "width\n" + " The width of the output. The output will be scaled to fit."); + +PyObject * +DiffStats_format(DiffStats *self, PyObject *args, PyObject *kwds) +{ + int err, format; + git_buf buf = { 0 }; + Py_ssize_t width; + PyObject *str; + char *keywords[] = {"format", "width", NULL}; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "in", keywords, &format, &width)) + return NULL; + + if (width <= 0) { + PyErr_SetString(PyExc_ValueError, "width must be positive"); + return NULL; + } + + err = git_diff_stats_to_buf(&buf, self->stats, format, width); + if (err < 0) + return Error_set(err); + + str = to_unicode_n(buf.ptr, buf.size, NULL, NULL); + git_buf_dispose(&buf); + + return str; +} static void -Hunk_dealloc(Hunk *self) +DiffStats_dealloc(DiffStats *self) { - Py_CLEAR(self->lines); + git_diff_stats_free(self->stats); PyObject_Del(self); } -PyMemberDef Hunk_members[] = { - MEMBER(Hunk, old_start, T_INT, "Old start."), - MEMBER(Hunk, old_lines, T_INT, "Old lines."), - MEMBER(Hunk, new_start, T_INT, "New start."), - MEMBER(Hunk, new_lines, T_INT, "New lines."), - MEMBER(Hunk, lines, T_OBJECT, "Lines."), +PyMethodDef DiffStats_methods[] = { + METHOD(DiffStats, format, METH_VARARGS | METH_KEYWORDS), {NULL} }; +PyGetSetDef DiffStats_getsetters[] = { + GETTER(DiffStats, insertions), + GETTER(DiffStats, deletions), + GETTER(DiffStats, files_changed), + {NULL} +}; -PyDoc_STRVAR(Hunk__doc__, "Hunk object."); +PyDoc_STRVAR(DiffStats__doc__, "DiffStats object."); -PyTypeObject HunkType = { +PyTypeObject DiffStatsType = { PyVarObject_HEAD_INIT(NULL, 0) - "_pygit2.Hunk", /* tp_name */ - sizeof(Hunk), /* tp_basicsize */ + "_pygit2.DiffStats", /* tp_name */ + sizeof(DiffStats), /* tp_basicsize */ 0, /* tp_itemsize */ - (destructor)Hunk_dealloc, /* tp_dealloc */ + (destructor)DiffStats_dealloc, /* tp_dealloc */ 0, /* tp_print */ 0, /* tp_getattr */ 0, /* tp_setattr */ @@ -334,16 +944,16 @@ PyTypeObject HunkType = { 0, /* tp_setattro */ 0, /* tp_as_buffer */ Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, /* tp_flags */ - Hunk__doc__, /* tp_doc */ + DiffStats__doc__, /* tp_doc */ 0, /* tp_traverse */ 0, /* tp_clear */ 0, /* tp_richcompare */ 0, /* tp_weaklistoffset */ 0, /* tp_iter */ 0, /* tp_iternext */ - 0, /* tp_methods */ - Hunk_members, /* tp_members */ - 0, /* tp_getset */ + DiffStats_methods, /* tp_methods */ + 0, /* tp_members */ + DiffStats_getsetters, /* tp_getset */ 0, /* tp_base */ 0, /* tp_dict */ 0, /* tp_descr_get */ @@ -354,9 +964,36 @@ PyTypeObject HunkType = { 0, /* tp_new */ }; +PyDoc_STRVAR(Diff_from_c__doc__, "Method exposed for Index to hook into"); + +PyObject * +Diff_from_c(Diff *dummy, PyObject *args) +{ + PyObject *py_diff, *py_repository; + git_diff *diff; + char *buffer; + Py_ssize_t length; + + if (!PyArg_ParseTuple(args, "OO!", &py_diff, &RepositoryType, &py_repository)) + return NULL; + + /* Here we need to do the opposite conversion from the _pointer getters */ + if (PyBytes_AsStringAndSize(py_diff, &buffer, &length)) + return NULL; + + if (length != sizeof(git_diff *)) { + PyErr_SetString(PyExc_TypeError, "passed value is not a pointer"); + return NULL; + } + + /* the "buffer" contains the pointer */ + diff = *((git_diff **) buffer); + + return wrap_diff(diff, (Repository *) py_repository); +} PyDoc_STRVAR(Diff_merge__doc__, - "merge(diff)\n" + "merge(diff: Diff)\n" "\n" "Merge one diff into another."); @@ -369,10 +1006,7 @@ Diff_merge(Diff *self, PyObject *args) if (!PyArg_ParseTuple(args, "O!", &DiffType, &py_diff)) return NULL; - if (py_diff->repo->repo != self->repo->repo) - return Error_set(GIT_ERROR); - - err = git_diff_merge(self->list, py_diff->list); + err = git_diff_merge(self->diff, py_diff->diff); if (err < 0) return Error_set(err); @@ -381,20 +1015,35 @@ Diff_merge(Diff *self, PyObject *args) PyDoc_STRVAR(Diff_find_similar__doc__, - "find_similar([flags])\n" + "find_similar(flags: enums.DiffFind = enums.DiffFind.FIND_BY_CONFIG, rename_threshold: int = 50, copy_threshold: int = 50, rename_from_rewrite_threshold: int = 50, break_rewrite_threshold: int = 60, rename_limit: int = 1000)\n" + "\n" + "Transform a diff marking file renames, copies, etc.\n" "\n" - "Find renamed files in diff and updates them in-place in the diff itself."); + "This modifies a diff in place, replacing old entries that look like\n" + "renames or copies with new entries reflecting those changes. This also " + "will, if requested, break modified files into add/remove pairs if the " + "amount of change is above a threshold.\n" + "\n" + "flags - Combination of enums.DiffFind.FIND_* and enums.DiffFind.BREAK_* constants." + ); PyObject * -Diff_find_similar(Diff *self, PyObject *args) +Diff_find_similar(Diff *self, PyObject *args, PyObject *kwds) { int err; git_diff_find_options opts = GIT_DIFF_FIND_OPTIONS_INIT; - if (!PyArg_ParseTuple(args, "|i", &opts.flags)) + char *keywords[] = {"flags", "rename_threshold", "copy_threshold", + "rename_from_rewrite_threshold", + "break_rewrite_threshold", "rename_limit", NULL}; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "|iHHHHI", keywords, + &opts.flags, &opts.rename_threshold, &opts.copy_threshold, + &opts.rename_from_rewrite_threshold, &opts.break_rewrite_threshold, + &opts.rename_limit)) return NULL; - err = git_diff_find_similar(self->list, &opts); + err = git_diff_find_similar(self->diff, &opts); if (err < 0) return Error_set(err); @@ -411,7 +1060,7 @@ Diff_iter(Diff *self) Py_INCREF(self); iter->diff = self; iter->i = 0; - iter->n = git_diff_num_deltas(self->list); + iter->n = git_diff_num_deltas(self->diff); } return (PyObject*)iter; } @@ -421,40 +1070,75 @@ Diff_getitem(Diff *self, PyObject *value) { size_t i; - if (PyLong_Check(value) < 0) - return NULL; + if (!PyLong_Check(value)) + return NULL; /* FIXME Raise error */ - i = PyLong_AsUnsignedLong(value); + i = PyLong_AsSize_t(value); + return diff_get_patch_byindex(self->diff, i); +} + +PyDoc_STRVAR(Diff_stats__doc__, "Accumulate diff statistics for all patches."); - return diff_get_patch_byindex(self->list, i); +PyObject * +Diff_stats__get__(Diff *self) +{ + return wrap_diff_stats(self->diff); } +PyDoc_STRVAR(Diff_parse_diff__doc__, + "parse_diff(git_diff: str | bytes) -> Diff\n" + "\n" + "Parses a git unified diff into a diff object without a repository"); + +static PyObject * +Diff_parse_diff(PyObject *self, PyObject *py_str) +{ + /* A wrapper around git_diff_from_buffer */ + git_diff *diff; + + const char *content = pgit_borrow(py_str); + if (content == NULL) + return NULL; + + int err = git_diff_from_buffer(&diff, content, strlen(content)); + if (err < 0) + return Error_set(err); + + return wrap_diff(diff, NULL); +} static void Diff_dealloc(Diff *self) { - git_diff_list_free(self->list); + git_diff_free(self->diff); Py_CLEAR(self->repo); PyObject_Del(self); } -PyGetSetDef Diff_getseters[] = { +PyGetSetDef Diff_getsetters[] = { + GETTER(Diff, deltas), GETTER(Diff, patch), + GETTER(Diff, stats), + GETTER(Diff, patchid), {NULL} }; PyMappingMethods Diff_as_mapping = { - 0, /* mp_length */ + (lenfunc)Diff_len, /* mp_length */ (binaryfunc)Diff_getitem, /* mp_subscript */ 0, /* mp_ass_subscript */ }; static PyMethodDef Diff_methods[] = { METHOD(Diff, merge, METH_VARARGS), - METHOD(Diff, find_similar, METH_VARARGS), + METHOD(Diff, find_similar, METH_VARARGS | METH_KEYWORDS), + METHOD(Diff, from_c, METH_STATIC | METH_VARARGS), + {"parse_diff", (PyCFunction) Diff_parse_diff, + METH_O | METH_STATIC, Diff_parse_diff__doc__}, {NULL} }; +/* TODO Implement Diff.patches, deprecate Diff_iter and Diff_getitem */ PyDoc_STRVAR(Diff__doc__, "Diff objects."); @@ -488,7 +1172,7 @@ PyTypeObject DiffType = { 0, /* tp_iternext */ Diff_methods, /* tp_methods */ 0, /* tp_members */ - Diff_getseters, /* tp_getset */ + Diff_getsetters, /* tp_getset */ 0, /* tp_base */ 0, /* tp_dict */ 0, /* tp_descr_get */ diff --git a/src/diff.h b/src/diff.h index 75e633334..17b64df8a 100644 --- a/src/diff.h +++ b/src/diff.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2013 The pygit2 contributors + * Copyright 2010-2025 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -33,14 +33,10 @@ #include #include "types.h" -#define DIFF_CHECK_TYPES(_x, _y, _type_x, _type_y) \ - PyObject_TypeCheck(_x, _type_x) && \ - PyObject_TypeCheck(_y, _type_y) - - -PyObject* Diff_changes(Diff *self); -PyObject* Diff_patch(Diff *self); - -PyObject* wrap_diff(git_diff_list *diff, Repository *repo); +PyObject* wrap_diff(git_diff *diff, Repository *repo); +PyObject* wrap_diff_delta(const git_diff_delta *delta); +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); #endif diff --git a/src/error.c b/src/error.c index 730c5263b..d264e6196 100644 --- a/src/error.c +++ b/src/error.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2013 The pygit2 contributors + * Copyright 2010-2025 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -27,9 +27,12 @@ #include "error.h" -PyObject *GitError; +extern PyObject *GitError; +extern PyObject *AlreadyExistsError; +extern PyObject *InvalidSpecError; -PyObject * Error_type(int type) +PyObject * +Error_type(int type) { const git_error* error; /* Expected */ @@ -40,7 +43,7 @@ PyObject * Error_type(int type) /* A reference with this name already exists */ case GIT_EEXISTS: - return PyExc_ValueError; + return AlreadyExistsError; /* The given short oid is ambiguous */ case GIT_EAMBIGUOUS: @@ -52,7 +55,7 @@ PyObject * Error_type(int type) /* Invalid input spec */ case GIT_EINVALIDSPEC: - return PyExc_ValueError; + return InvalidSpecError; /* Skip and passthrough the given ODB backend */ case GIT_PASSTHROUGH: @@ -64,7 +67,7 @@ PyObject * Error_type(int type) } /* Critical */ - error = giterr_last(); + error = git_error_last(); if (error != NULL) { switch (error->klass) { case GITERR_NOMEMORY: @@ -79,16 +82,18 @@ PyObject * Error_type(int type) } -PyObject* Error_set(int err) +PyObject * +Error_set(int err) { assert(err < 0); return Error_set_exc(Error_type(err)); } -PyObject* Error_set_exc(PyObject* exception) +PyObject * +Error_set_exc(PyObject* exception) { - const git_error* error = giterr_last(); + const git_error* error = git_error_last(); char* message = (error == NULL) ? "(No error information given)" : error->message; PyErr_SetString(exception, message); @@ -97,23 +102,24 @@ PyObject* Error_set_exc(PyObject* exception) } -PyObject* Error_set_str(int err, const char *str) +PyObject * +Error_set_str(int err, const char *str) { - const git_error* error; if (err == GIT_ENOTFOUND) { /* KeyError expects the arg to be the missing key. */ PyErr_SetString(PyExc_KeyError, str); return NULL; } - error = giterr_last(); + const git_error *error = git_error_last(); if (error == NULL) /* Expected error - no error msg set */ return PyErr_Format(Error_type(err), "%s", str); return PyErr_Format(Error_type(err), "%s: %s", str, error->message); } -PyObject* Error_set_oid(int err, const git_oid *oid, size_t len) +PyObject * +Error_set_oid(int err, const git_oid *oid, size_t len) { char hex[GIT_OID_HEXSZ + 1]; @@ -121,3 +127,35 @@ PyObject* Error_set_oid(int err, const git_oid *oid, size_t len) hex[len] = '\0'; return Error_set_str(err, hex); } + +PyObject * +Error_type_error(const char *format, PyObject *value) +{ + PyErr_Format(PyExc_TypeError, format, Py_TYPE(value)->tp_name); + return NULL; +} + +int +git_error_for_exc(void) +{ + PyObject *err = PyErr_Occurred(); + if (err) { + // FIXME Here we're masking exception, if the Python implementation has + // a genuine Key or Value error. We should have an explicit way for the + // Python callbacks to signal ENOTFOUND (and EAMBIGUOUS?) + + // Not found is an expected condition (the ODB will try with the next + // backend), so we clear the exception. + if (PyErr_GivenExceptionMatches(err, PyExc_KeyError)) { + PyErr_Clear(); + return GIT_ENOTFOUND; + } + + if (PyErr_GivenExceptionMatches(err, PyExc_ValueError)) + return GIT_EAMBIGUOUS; + + return GIT_EUSER; + } + + return 0; +} diff --git a/src/error.h b/src/error.h index f487763bf..f08f3a998 100644 --- a/src/error.h +++ b/src/error.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2013 The pygit2 contributors + * Copyright 2010-2025 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -37,5 +37,7 @@ PyObject* Error_set(int err); PyObject* Error_set_exc(PyObject* exception); PyObject* Error_set_str(int err, const char *str); PyObject* Error_set_oid(int err, const git_oid *oid, size_t len); +PyObject* Error_type_error(const char *format, PyObject *value); +int git_error_for_exc(void); #endif diff --git a/src/filter.c b/src/filter.c new file mode 100644 index 000000000..730dbcf77 --- /dev/null +++ b/src/filter.c @@ -0,0 +1,558 @@ +/* + * 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 +#include +#include "diff.h" +#include "error.h" +#include "object.h" +#include "oid.h" +#include "patch.h" +#include "utils.h" +#include "filter.h" + +extern PyObject *GitError; + +extern PyTypeObject FilterSourceType; +extern PyTypeObject RepositoryType; + +PyDoc_STRVAR(FilterSource_repo__doc__, + "Repository the source data is from\n"); + +PyObject * +FilterSource_repo__get__(FilterSource *self) +{ + git_repository *repo = git_filter_source_repo(self->src); + Repository *py_repo; + + if (repo == NULL) + Py_RETURN_NONE; + + py_repo = PyObject_New(Repository, &RepositoryType); + if (py_repo == NULL) + return NULL; + py_repo->repo = repo; + py_repo->config = NULL; + py_repo->index = NULL; + py_repo->owned = 0; + Py_INCREF(py_repo); + return (PyObject *)py_repo; +} + +PyDoc_STRVAR(FilterSource_path__doc__, + "File path the source data is from.\n"); + +PyObject * +FilterSource_path__get__(FilterSource *self) +{ + return to_unicode_safe(git_filter_source_path(self->src), NULL); +} + +PyDoc_STRVAR(FilterSource_filemode__doc__, + "Mode of the source file. If this is unknown, `filemode` will be 0.\n"); + +PyObject * +FilterSource_filemode__get__(FilterSource *self) +{ + return PyLong_FromUnsignedLong(git_filter_source_filemode(self->src)); +} + +PyDoc_STRVAR(FilterSource_oid__doc__, + "Oid of the source object. If the oid is unknown " + "(often the case with FilterMode.CLEAN) then `oid` will be None.\n"); +PyObject * +FilterSource_oid__get__(FilterSource *self) +{ + const git_oid *oid = git_filter_source_id(self->src); + if (oid == NULL) + Py_RETURN_NONE; + return git_oid_to_python(oid); +} + +PyDoc_STRVAR(FilterSource_mode__doc__, + "Filter mode (either FilterMode.CLEAN or FilterMode.SMUDGE).\n"); + +PyObject * +FilterSource_mode__get__(FilterSource *self) +{ + return PyLong_FromUnsignedLong(git_filter_source_mode(self->src)); +} + +PyDoc_STRVAR(FilterSource_flags__doc__, + "A combination of filter flags (enums.FilterFlag) to be applied to the data.\n"); + +PyObject * +FilterSource_flags__get__(FilterSource *self) +{ + return PyLong_FromUnsignedLong(git_filter_source_flags(self->src)); +} + +PyGetSetDef FilterSource_getseters[] = { + GETTER(FilterSource, repo), + GETTER(FilterSource, path), + GETTER(FilterSource, filemode), + GETTER(FilterSource, oid), + GETTER(FilterSource, mode), + GETTER(FilterSource, flags), + {NULL} +}; + +PyDoc_STRVAR(FilterSource__doc__, + "A filter source represents the file/blob to be processed.\n"); + +PyTypeObject FilterSourceType = { + PyVarObject_HEAD_INIT(NULL, 0) + "_pygit2.FilterSource", /* tp_name */ + sizeof(FilterSource), /* tp_basicsize */ + 0, /* tp_itemsize */ + 0, /* tp_dealloc */ + 0, /* tp_print */ + 0, /* tp_getattr */ + 0, /* tp_setattr */ + 0, /* tp_compare */ + 0, /* tp_repr */ + 0, /* tp_as_number */ + 0, /* tp_as_sequence */ + 0, /* tp_as_mapping */ + 0, /* tp_hash */ + 0, /* tp_call */ + 0, /* tp_str */ + 0, /* tp_getattro */ + 0, /* tp_setattro */ + 0, /* tp_as_buffer */ + Py_TPFLAGS_DEFAULT, /* tp_flags */ + FilterSource__doc__, /* tp_doc */ + 0, /* tp_traverse */ + 0, /* tp_clear */ + 0, /* tp_richcompare */ + 0, /* tp_weaklistoffset */ + 0, /* tp_iter */ + 0, /* tp_iternext */ + 0, /* tp_methods */ + 0, /* tp_members */ + FilterSource_getseters, /* tp_getset */ + 0, /* tp_base */ + 0, /* tp_dict */ + 0, /* tp_descr_get */ + 0, /* tp_descr_set */ + 0, /* tp_dictoffset */ + 0, /* tp_init */ + 0, /* tp_alloc */ + 0, /* tp_new */ +}; + +PyDoc_STRVAR(filter__write_next__doc__, + "Write to the next writestream in a filter list.\n"); + +static PyObject * +filter__write_next(PyObject *self, PyObject *args, PyObject *kwds) +{ + PyObject *py_next; + git_writestream *next; + const char *buf; + Py_ssize_t size; + char *keywords[] = {"next", "data", NULL}; + int err; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "Oy#", keywords, + &py_next, &buf, &size)) + return NULL; + + next = (git_writestream *)PyCapsule_GetPointer(py_next, NULL); + if (next == NULL) + goto done; + + Py_BEGIN_ALLOW_THREADS; + err = next->write(next, buf, size); + Py_END_ALLOW_THREADS; + if (err < 0) + return Error_set(err); + +done: + Py_RETURN_NONE; +} + +static PyMethodDef filter__write_next_method = { + "_write_next", + (PyCFunction)filter__write_next, + METH_VARARGS | METH_KEYWORDS, + filter__write_next__doc__ +}; + +struct pygit2_filter_stream { + git_writestream stream; + git_writestream *next; + PyObject *py_filter; + FilterSource *py_src; + PyObject *py_write_next; +}; + +struct pygit2_filter_payload { + PyObject *py_filter; + FilterSource *src; + struct pygit2_filter_stream *stream; +}; + +static void pygit2_filter_payload_free( + struct pygit2_filter_payload *payload) +{ + if (payload == NULL) + return; + if (payload->py_filter != NULL) + Py_DECREF(payload->py_filter); + if (payload->src != NULL) + Py_DECREF(payload->src); + if (payload->stream != NULL) + free(payload->stream); + free(payload); +} + +static struct pygit2_filter_payload * pygit2_filter_payload_new( + PyObject *py_filter_cls, const git_filter_source *src) +{ + struct pygit2_filter_payload *payload = NULL; + + payload = malloc(sizeof(struct pygit2_filter_payload)); + if (payload == NULL) + return NULL; + memset(payload, 0, sizeof(struct pygit2_filter_payload)); + + payload->py_filter = PyObject_CallFunction(py_filter_cls, NULL); + if (payload->py_filter == NULL) + { + PyErr_Clear(); + goto error; + } + payload->src = PyObject_New(FilterSource, &FilterSourceType); + if (payload->src == NULL) + { + PyErr_Clear(); + goto error; + } + payload->src->src = src; + goto done; + +error: + pygit2_filter_payload_free(payload); + payload = NULL; +done: + return payload; +} + +static int pygit2_filter_stream_write( + git_writestream *s, const char *buffer, size_t len) +{ + struct pygit2_filter_stream *stream = (struct pygit2_filter_stream *)s; + PyObject *result = NULL; + PyGILState_STATE gil = PyGILState_Ensure(); + int err = 0; + + result = PyObject_CallMethod(stream->py_filter, "write", "y#OO", + buffer, len, stream->py_src, + stream->py_write_next); + if (result == NULL) + { + PyErr_Clear(); + git_error_set(GIT_ERROR_OS, "failed to write to filter stream"); + err = GIT_ERROR; + goto done; + } + Py_DECREF(result); + +done: + PyGILState_Release(gil); + return err; +} + +static int pygit2_filter_stream_close(git_writestream *s) +{ + struct pygit2_filter_stream *stream = (struct pygit2_filter_stream *)s; + PyObject *result = NULL; + PyGILState_STATE gil = PyGILState_Ensure(); + int err = 0; + int nexterr; + + result = PyObject_CallMethod(stream->py_filter, "close", "O", + stream->py_write_next); + if (result == NULL) + { + PyErr_Clear(); + git_error_set(GIT_ERROR_OS, "failed to close filter stream"); + err = GIT_ERROR; + goto done; + } + Py_DECREF(result); + +done: + if (stream->py_write_next != NULL) + Py_DECREF(stream->py_write_next); + PyGILState_Release(gil); + if (stream->next != NULL) { + nexterr = stream->next->close(stream->next); + if (err == 0) + err = nexterr; + } + return err; +} + +static void pygit2_filter_stream_free(git_writestream *s) +{ +} + +static int pygit2_filter_stream_init( + struct pygit2_filter_stream *stream, git_writestream *next, PyObject *py_filter, FilterSource *py_src) +{ + int err = 0; + PyObject *py_next = NULL; + PyObject *py_functools = NULL; + PyObject *py_write_next = NULL; + PyObject *py_partial_write = NULL; + PyGILState_STATE gil = PyGILState_Ensure(); + + memset(stream, 0, sizeof(struct pygit2_filter_stream)); + stream->stream.write = pygit2_filter_stream_write; + stream->stream.close = pygit2_filter_stream_close; + stream->stream.free = pygit2_filter_stream_free; + stream->next = next; + stream->py_filter = py_filter; + stream->py_src = py_src; + + py_functools = PyImport_ImportModule("functools"); + if (py_functools == NULL) + { + PyErr_Clear(); + git_error_set(GIT_ERROR_OS, "failed to import module"); + err = GIT_ERROR; + goto error; + } + py_next = PyCapsule_New(stream->next, NULL, NULL); + if (py_next == NULL) + { + PyErr_Clear(); + giterr_set_oom(); + err = GIT_ERROR; + goto error; + } + py_write_next = PyCFunction_New(&filter__write_next_method, NULL); + if (py_write_next == NULL) + { + PyErr_Clear(); + err = GIT_ERROR; + goto error; + } + py_partial_write = PyObject_CallMethod(py_functools, "partial", "OO", + py_write_next, py_next); + if (py_partial_write == NULL) + { + PyErr_Clear(); + err = GIT_ERROR; + goto error; + } + stream->py_write_next = py_partial_write; + goto done; + +error: + if (py_partial_write != NULL) + Py_DECREF(py_partial_write); +done: + if (py_write_next != NULL) + Py_DECREF(py_write_next); + if (py_functools != NULL) + Py_DECREF(py_functools); + if (py_next != NULL) + Py_DECREF(py_next); + PyGILState_Release(gil); + return err; +} + +static PyObject * get_passthrough() +{ + PyObject *py_passthrough; + PyObject *py_errors = PyImport_ImportModule("pygit2.errors"); + if (py_errors == NULL) + return NULL; + py_passthrough = PyObject_GetAttrString(py_errors, "Passthrough"); + Py_DECREF(py_errors); + return py_passthrough; +} + +int pygit2_filter_check( + git_filter *self, void **payload, const git_filter_source *src, const char **attr_values) +{ + pygit2_filter *filter = (pygit2_filter *)self; + struct pygit2_filter_payload *pl = NULL; + PyObject *py_attrs = NULL; + Py_ssize_t nattrs; + Py_ssize_t i; + PyObject *result; + PyObject *py_passthrough = NULL; + PyGILState_STATE gil = PyGILState_Ensure(); + int err = 0; + + py_passthrough = get_passthrough(); + if (py_passthrough == NULL) + { + PyErr_Clear(); + err = GIT_ERROR; + goto error; + } + + pl = pygit2_filter_payload_new(filter->py_filter_cls, src); + if (pl == NULL) + { + giterr_set_oom(); + err = GIT_ERROR; + goto done; + } + + result = PyObject_CallMethod(pl->py_filter, "nattrs", NULL); + if (result == NULL) + { + PyErr_Clear(); + err = GIT_ERROR; + goto error; + } + nattrs = PyLong_AsSsize_t(result); + Py_DECREF(result); + py_attrs = PyList_New(nattrs); + if (py_attrs == NULL) + { + PyErr_Clear(); + err = GIT_ERROR; + goto error; + } + for (i = 0; i < nattrs; ++i) + { + if (attr_values[i] == NULL) + { + if (PyList_SetItem(py_attrs, i, Py_None) < 0) + { + PyErr_Clear(); + err = GIT_ERROR; + goto error; + } + } + else if (PyList_SetItem(py_attrs, i, to_unicode_safe(attr_values[i], NULL)) < 0) + { + PyErr_Clear(); + err = GIT_ERROR; + goto error; + } + } + result = PyObject_CallMethod(pl->py_filter, "check", "OO", pl->src, py_attrs); + if (result == NULL) + { + if (PyErr_ExceptionMatches(py_passthrough)) + { + PyErr_Clear(); + err = GIT_PASSTHROUGH; + } + else + { + PyErr_Clear(); + err = GIT_ERROR; + goto error; + } + } + else + { + Py_DECREF(result); + *payload = pl; + } + goto done; + +error: + if (pl != NULL) + pygit2_filter_payload_free(pl); +done: + if (py_attrs != NULL) + Py_DECREF(py_attrs); + if (py_passthrough != NULL) + Py_DECREF(py_passthrough); + PyGILState_Release(gil); + return err; +} + +int pygit2_filter_stream( + git_writestream **out, git_filter *self, void **payload, const git_filter_source *src, git_writestream *next) +{ + pygit2_filter *filter = (pygit2_filter *)self; + struct pygit2_filter_stream *stream = NULL; + struct pygit2_filter_payload *pl = NULL; + PyGILState_STATE gil = PyGILState_Ensure(); + int err = 0; + + if (*payload == NULL) + { + pl = pygit2_filter_payload_new(filter->py_filter_cls, src); + if (pl == NULL) + { + giterr_set_oom(); + err = GIT_ERROR; + goto done; + } + *payload = pl; + } + else + { + pl = *payload; + } + + stream = malloc(sizeof(struct pygit2_filter_stream)); + if ((err = pygit2_filter_stream_init(stream, next, pl->py_filter, pl->src)) < 0) + goto error; + *out = &stream->stream; + goto done; + +error: + if (stream != NULL) + free(stream); +done: + PyGILState_Release(gil); + return err; +} + +void pygit2_filter_cleanup(git_filter *self, void *payload) +{ + struct pygit2_filter_payload *pl = (struct pygit2_filter_payload *)payload; + + PyGILState_STATE gil = PyGILState_Ensure(); + pygit2_filter_payload_free(pl); + PyGILState_Release(gil); +} + +void pygit2_filter_shutdown(git_filter *self) +{ + pygit2_filter *filter = (pygit2_filter *)self; + PyGILState_STATE gil = PyGILState_Ensure(); + Py_DECREF(filter->py_filter_cls); + free(filter); + PyGILState_Release(gil); +} diff --git a/src/index.h b/src/filter.h similarity index 66% rename from src/index.h rename to src/filter.h index 7c63c1c0e..04bbf7c0b 100644 --- a/src/index.h +++ b/src/filter.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2013 The pygit2 contributors + * Copyright 2010-2025 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -25,23 +25,25 @@ * Boston, MA 02110-1301, USA. */ -#ifndef INCLUDE_pygit2_index_h -#define INCLUDE_pygit2_index_h +#ifndef INCLUDE_pygit2_filter_h +#define INCLUDE_pygit2_filter_h #define PY_SSIZE_T_CLEAN #include #include +#include +#include "types.h" -PyObject* Index_add(Index *self, PyObject *args); -PyObject* Index_clear(Index *self); -PyObject* Index_find(Index *self, PyObject *py_path); -PyObject* Index_read(Index *self); -PyObject* Index_write(Index *self); -PyObject* Index_iter(Index *self); -PyObject* Index_getitem(Index *self, PyObject *value); -PyObject* Index_read_tree(Index *self, PyObject *value); -PyObject* Index_write_tree(Index *self); -Py_ssize_t Index_len(Index *self); -int Index_setitem(Index *self, PyObject *key, PyObject *value); +typedef struct pygit2_filter { + git_filter filter; + PyObject *py_filter_cls; +} pygit2_filter; + +int pygit2_filter_check( + git_filter *self, void **payload, const git_filter_source *src, const char **attr_values); +int pygit2_filter_stream( + git_writestream **out, git_filter *self, void **payload, const git_filter_source *src, git_writestream *next); +void pygit2_filter_cleanup(git_filter *self, void *payload); +void pygit2_filter_shutdown(git_filter *self); #endif diff --git a/src/index.c b/src/index.c deleted file mode 100644 index 3c73f2178..000000000 --- a/src/index.c +++ /dev/null @@ -1,644 +0,0 @@ -/* - * Copyright 2010-2013 The pygit2 contributors - * - * 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 "error.h" -#include "types.h" -#include "utils.h" -#include "oid.h" -#include "diff.h" -#include "index.h" - -extern PyTypeObject IndexType; -extern PyTypeObject TreeType; -extern PyTypeObject DiffType; -extern PyTypeObject IndexIterType; -extern PyTypeObject IndexEntryType; - -int -Index_init(Index *self, PyObject *args, PyObject *kwds) -{ - char *path; - int err; - - if (kwds) { - PyErr_SetString(PyExc_TypeError, "Index takes no keyword arguments"); - return -1; - } - - if (!PyArg_ParseTuple(args, "s", &path)) - return -1; - - err = git_index_open(&self->index, path); - if (err < 0) { - Error_set_str(err, path); - return -1; - } - - return 0; -} - -void -Index_dealloc(Index* self) -{ - PyObject_GC_UnTrack(self); - Py_XDECREF(self->repo); - git_index_free(self->index); - PyObject_GC_Del(self); -} - -int -Index_traverse(Index *self, visitproc visit, void *arg) -{ - Py_VISIT(self->repo); - return 0; -} - - -PyDoc_STRVAR(Index_add__doc__, - "add(path)\n" - "\n" - "Add or update an index entry from a file in disk."); - -PyObject * -Index_add(Index *self, PyObject *args) -{ - int err; - const char *path; - - if (!PyArg_ParseTuple(args, "s", &path)) - return NULL; - - err = git_index_add_bypath(self->index, path); - if (err < 0) - return Error_set_str(err, path); - - Py_RETURN_NONE; -} - - -PyDoc_STRVAR(Index_clear__doc__, - "clear()\n" - "\n" - "Clear the contents (all the entries) of an index object."); - -PyObject * -Index_clear(Index *self) -{ - git_index_clear(self->index); - Py_RETURN_NONE; -} - - -PyDoc_STRVAR(Index_diff_to_workdir__doc__, - "diff_to_workdir([flag, context_lines, interhunk_lines]) -> Diff\n" - "\n" - "Return a :py:class:`~pygit2.Diff` object with the differences between the\n" - "index and the working copy.\n" - "\n" - "Arguments:\n" - "\n" - "flag: a GIT_DIFF_* constant.\n" - "\n" - "context_lines: the number of unchanged lines that define the boundary\n" - " of a hunk (and to display before and after)\n" - "\n" - "interhunk_lines: the maximum number of unchanged lines between hunk\n" - " boundaries before the hunks will be merged into a one.\n"); - -PyObject * -Index_diff_to_workdir(Index *self, PyObject *args) -{ - git_diff_options opts = GIT_DIFF_OPTIONS_INIT; - git_diff_list *diff; - int err; - - if (!PyArg_ParseTuple(args, "|IHH", &opts.flags, &opts.context_lines, - &opts.interhunk_lines)) - return NULL; - - err = git_diff_index_to_workdir( - &diff, - self->repo->repo, - self->index, - &opts); - - if (err < 0) - return Error_set(err); - - return wrap_diff(diff, self->repo); -} - -PyDoc_STRVAR(Index_diff_to_tree__doc__, - "diff_to_tree(tree [, flag, context_lines, interhunk_lines]) -> Diff\n" - "\n" - "Return a :py:class:`~pygit2.Diff` object with the differences between the\n" - "index and the given tree.\n" - "\n" - "Arguments:\n" - "\n" - "tree: the tree to diff.\n" - "\n" - "flag: a GIT_DIFF_* constant.\n" - "\n" - "context_lines: the number of unchanged lines that define the boundary\n" - " of a hunk (and to display before and after)\n" - "\n" - "interhunk_lines: the maximum number of unchanged lines between hunk\n" - " boundaries before the hunks will be merged into a one.\n"); - -PyObject * -Index_diff_to_tree(Index *self, PyObject *args) -{ - Repository *py_repo; - git_diff_options opts = GIT_DIFF_OPTIONS_INIT; - git_diff_list *diff; - int err; - - Tree *py_tree = NULL; - - if (!PyArg_ParseTuple(args, "O!|IHH", &TreeType, &py_tree, &opts.flags, - &opts.context_lines, &opts.interhunk_lines)) - return NULL; - - py_repo = py_tree->repo; - err = git_diff_tree_to_index(&diff, py_repo->repo, py_tree->tree, - self->index, &opts); - if (err < 0) - return Error_set(err); - - return wrap_diff(diff, py_repo); -} - - -PyDoc_STRVAR(Index__find__doc__, - "_find(path) -> integer\n" - "\n" - "Find the first index of any entries which point to given path in the\n" - "index file."); - -PyObject * -Index__find(Index *self, PyObject *py_path) -{ - char *path; - size_t idx; - int err; - - path = PyBytes_AsString(py_path); - if (!path) - return NULL; - - err = git_index_find(&idx, self->index, path); - if (err < 0) - return Error_set_str(err, path); - - return PyLong_FromSize_t(idx); -} - - -PyDoc_STRVAR(Index_read__doc__, - "read()\n" - "\n" - "Update the contents of an existing index object in memory by reading from\n" - "the hard disk."); - -PyObject * -Index_read(Index *self) -{ - int err; - - err = git_index_read(self->index); - if (err < GIT_OK) - return Error_set(err); - - Py_RETURN_NONE; -} - - -PyDoc_STRVAR(Index_write__doc__, - "write()\n" - "\n" - "Write an existing index object from memory back to disk using an atomic\n" - "file lock."); - -PyObject * -Index_write(Index *self) -{ - int err; - - err = git_index_write(self->index); - if (err < GIT_OK) - return Error_set(err); - - Py_RETURN_NONE; -} - -int -Index_contains(Index *self, PyObject *value) -{ - char *path; - int err; - - path = py_path_to_c_str(value); - if (!path) - return -1; - err = git_index_find(NULL, self->index, path); - if (err == GIT_ENOTFOUND) { - free(path); - return 0; - } - if (err < 0) { - Error_set_str(err, path); - free(path); - return -1; - } - free(path); - return 1; -} - -PyObject * -Index_iter(Index *self) -{ - IndexIter *iter; - - iter = PyObject_New(IndexIter, &IndexIterType); - if (iter) { - Py_INCREF(self); - iter->owner = self; - iter->i = 0; - } - return (PyObject*)iter; -} - -Py_ssize_t -Index_len(Index *self) -{ - return (Py_ssize_t)git_index_entrycount(self->index); -} - -PyObject * -wrap_index_entry(const git_index_entry *entry, Index *index) -{ - IndexEntry *py_entry; - - py_entry = PyObject_New(IndexEntry, &IndexEntryType); - if (py_entry) - py_entry->entry = entry; - - return (PyObject*)py_entry; -} - -PyObject * -Index_getitem(Index *self, PyObject *value) -{ - long idx; - char *path; - const git_index_entry *index_entry; - - /* Case 1: integer */ - if (PyLong_Check(value)) { - idx = PyLong_AsLong(value); - if (idx == -1 && PyErr_Occurred()) - return NULL; - if (idx < 0) { - PyErr_SetObject(PyExc_ValueError, value); - return NULL; - } - index_entry = git_index_get_byindex(self->index, (size_t)idx); - /* Case 2: byte or text string */ - } else { - path = py_path_to_c_str(value); - if (!path) - return NULL; - - index_entry = git_index_get_bypath(self->index, path, 0); - free(path); - } - - if (!index_entry) { - PyErr_SetObject(PyExc_KeyError, value); - return NULL; - } - return wrap_index_entry(index_entry, self); -} - - -PyDoc_STRVAR(Index_remove__doc__, - "remove(path)\n" - "\n" - "Removes an entry from index."); - -PyObject * -Index_remove(Index *self, PyObject *args) -{ - int err; - const char *path; - - if (!PyArg_ParseTuple(args, "s", &path)) - return NULL; - - err = git_index_remove(self->index, path, 0); - if (err < 0) { - Error_set(err); - return NULL; - } - - Py_RETURN_NONE; -} - - -PyDoc_STRVAR(Index_read_tree__doc__, - "read_tree(tree)\n" - "\n" - "Update the index file from the tree identified by the given oid."); - -PyObject * -Index_read_tree(Index *self, PyObject *value) -{ - git_oid oid; - git_tree *tree; - int err; - size_t len; - - len = py_oid_to_git_oid(value, &oid); - if (len == 0) - return NULL; - - err = git_tree_lookup_prefix(&tree, self->repo->repo, &oid, len); - if (err < 0) - return Error_set(err); - - err = git_index_read_tree(self->index, tree); - git_tree_free(tree); - if (err < 0) - return Error_set(err); - - Py_RETURN_NONE; -} - - -PyDoc_STRVAR(Index_write_tree__doc__, - "write_tree() -> Oid\n" - "\n" - "Create a tree object from the index file, return its oid."); - -PyObject * -Index_write_tree(Index *self) -{ - git_oid oid; - int err; - - err = git_index_write_tree(&oid, self->index); - if (err < 0) - return Error_set(err); - - return git_oid_to_python(&oid); -} - -PyMethodDef Index_methods[] = { - METHOD(Index, add, METH_VARARGS), - METHOD(Index, remove, METH_VARARGS), - METHOD(Index, clear, METH_NOARGS), - METHOD(Index, diff_to_workdir, METH_VARARGS), - METHOD(Index, diff_to_tree, METH_VARARGS), - METHOD(Index, _find, METH_O), - METHOD(Index, read, METH_NOARGS), - METHOD(Index, write, METH_NOARGS), - METHOD(Index, read_tree, METH_O), - METHOD(Index, write_tree, METH_NOARGS), - {NULL} -}; - -PySequenceMethods Index_as_sequence = { - 0, /* sq_length */ - 0, /* sq_concat */ - 0, /* sq_repeat */ - 0, /* sq_item */ - 0, /* sq_slice */ - 0, /* sq_ass_item */ - 0, /* sq_ass_slice */ - (objobjproc)Index_contains, /* sq_contains */ -}; - -PyMappingMethods Index_as_mapping = { - (lenfunc)Index_len, /* mp_length */ - (binaryfunc)Index_getitem, /* mp_subscript */ - NULL, /* mp_ass_subscript */ -}; - -PyDoc_STRVAR(Index__doc__, "Index file."); - -PyTypeObject IndexType = { - PyVarObject_HEAD_INIT(NULL, 0) - "_pygit2.Index", /* tp_name */ - sizeof(Index), /* tp_basicsize */ - 0, /* tp_itemsize */ - (destructor)Index_dealloc, /* tp_dealloc */ - 0, /* tp_print */ - 0, /* tp_getattr */ - 0, /* tp_setattr */ - 0, /* tp_compare */ - 0, /* tp_repr */ - 0, /* tp_as_number */ - &Index_as_sequence, /* tp_as_sequence */ - &Index_as_mapping, /* tp_as_mapping */ - 0, /* tp_hash */ - 0, /* tp_call */ - 0, /* tp_str */ - 0, /* tp_getattro */ - 0, /* tp_setattro */ - 0, /* tp_as_buffer */ - Py_TPFLAGS_DEFAULT | - Py_TPFLAGS_BASETYPE | - Py_TPFLAGS_HAVE_GC, /* tp_flags */ - Index__doc__, /* tp_doc */ - (traverseproc)Index_traverse, /* tp_traverse */ - 0, /* tp_clear */ - 0, /* tp_richcompare */ - 0, /* tp_weaklistoffset */ - (getiterfunc)Index_iter, /* tp_iter */ - 0, /* tp_iternext */ - Index_methods, /* tp_methods */ - 0, /* tp_members */ - 0, /* tp_getset */ - 0, /* tp_base */ - 0, /* tp_dict */ - 0, /* tp_descr_get */ - 0, /* tp_descr_set */ - 0, /* tp_dictoffset */ - (initproc)Index_init, /* tp_init */ - 0, /* tp_alloc */ - 0, /* tp_new */ -}; - - -void -IndexIter_dealloc(IndexIter *self) -{ - Py_CLEAR(self->owner); - PyObject_Del(self); -} - -PyObject * -IndexIter_iternext(IndexIter *self) -{ - const git_index_entry *index_entry; - - index_entry = git_index_get_byindex(self->owner->index, self->i); - if (!index_entry) - return NULL; - - self->i += 1; - return wrap_index_entry(index_entry, self->owner); -} - - -PyDoc_STRVAR(IndexIter__doc__, "Index iterator."); - -PyTypeObject IndexIterType = { - PyVarObject_HEAD_INIT(NULL, 0) - "_pygit2.IndexIter", /* tp_name */ - sizeof(IndexIter), /* tp_basicsize */ - 0, /* tp_itemsize */ - (destructor)IndexIter_dealloc , /* tp_dealloc */ - 0, /* tp_print */ - 0, /* tp_getattr */ - 0, /* tp_setattr */ - 0, /* tp_compare */ - 0, /* tp_repr */ - 0, /* tp_as_number */ - 0, /* tp_as_sequence */ - 0, /* tp_as_mapping */ - 0, /* tp_hash */ - 0, /* tp_call */ - 0, /* tp_str */ - 0, /* tp_getattro */ - 0, /* tp_setattro */ - 0, /* tp_as_buffer */ - Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, /* tp_flags */ - IndexIter__doc__, /* tp_doc */ - 0, /* tp_traverse */ - 0, /* tp_clear */ - 0, /* tp_richcompare */ - 0, /* tp_weaklistoffset */ - PyObject_SelfIter, /* tp_iter */ - (iternextfunc)IndexIter_iternext, /* tp_iternext */ -}; - -void -IndexEntry_dealloc(IndexEntry *self) -{ - PyObject_Del(self); -} - - -PyDoc_STRVAR(IndexEntry_mode__doc__, "Mode."); - -PyObject * -IndexEntry_mode__get__(IndexEntry *self) -{ - return PyLong_FromLong(self->entry->mode); -} - - -PyDoc_STRVAR(IndexEntry_path__doc__, "Path."); - -PyObject * -IndexEntry_path__get__(IndexEntry *self) -{ - return to_path(self->entry->path); -} - - -PyDoc_STRVAR(IndexEntry_oid__doc__, "Object id."); - -PyObject * -IndexEntry_oid__get__(IndexEntry *self) -{ - return git_oid_to_python(&self->entry->oid); -} - - -PyDoc_STRVAR(IndexEntry_hex__doc__, "Hex id."); - -PyObject * -IndexEntry_hex__get__(IndexEntry *self) -{ - return git_oid_to_py_str(&self->entry->oid); -} - -PyGetSetDef IndexEntry_getseters[] = { - GETTER(IndexEntry, mode), - GETTER(IndexEntry, path), - GETTER(IndexEntry, oid), - GETTER(IndexEntry, hex), - {NULL}, -}; - -PyDoc_STRVAR(IndexEntry__doc__, "Index entry."); - -PyTypeObject IndexEntryType = { - PyVarObject_HEAD_INIT(NULL, 0) - "_pygit2.IndexEntry", /* tp_name */ - sizeof(IndexEntry), /* tp_basicsize */ - 0, /* tp_itemsize */ - (destructor)IndexEntry_dealloc, /* tp_dealloc */ - 0, /* tp_print */ - 0, /* tp_getattr */ - 0, /* tp_setattr */ - 0, /* tp_compare */ - 0, /* tp_repr */ - 0, /* tp_as_number */ - 0, /* tp_as_sequence */ - 0, /* tp_as_mapping */ - 0, /* tp_hash */ - 0, /* tp_call */ - 0, /* tp_str */ - 0, /* tp_getattro */ - 0, /* tp_setattro */ - 0, /* tp_as_buffer */ - Py_TPFLAGS_DEFAULT, /* tp_flags */ - IndexEntry__doc__, /* tp_doc */ - 0, /* tp_traverse */ - 0, /* tp_clear */ - 0, /* tp_richcompare */ - 0, /* tp_weaklistoffset */ - 0, /* tp_iter */ - 0, /* tp_iternext */ - 0, /* tp_methods */ - 0, /* tp_members */ - IndexEntry_getseters, /* tp_getset */ - 0, /* tp_base */ - 0, /* tp_dict */ - 0, /* tp_descr_get */ - 0, /* tp_descr_set */ - 0, /* tp_dictoffset */ - 0, /* tp_init */ - 0, /* tp_alloc */ - 0, /* tp_new */ -}; diff --git a/src/mailmap.c b/src/mailmap.c new file mode 100644 index 000000000..4bfc60faf --- /dev/null +++ b/src/mailmap.c @@ -0,0 +1,258 @@ +/* + * 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 "utils.h" +#include "types.h" +#include "mailmap.h" +#include "signature.h" + +extern PyTypeObject SignatureType; +extern PyTypeObject RepositoryType; + +int +Mailmap_init(Mailmap *self, PyObject *args, PyObject *kwargs) +{ + char *keywords[] = {NULL}; + git_mailmap *mm; + int error; + + /* Our init method does not handle parameters */ + if (!PyArg_ParseTupleAndKeywords(args, kwargs, "", keywords)) + return -1; + + error = git_mailmap_new(&mm); + if (error < 0) { + Error_set(error); + return -1; + } + + self->mailmap = mm; + return 0; +} + +PyDoc_STRVAR(Mailmap_from_repository__doc__, + "from_repository(repository: Repository) -> Mailmap\n" + "\n" + "Create a new mailmap instance from a repository, loading mailmap files based on the repository's configuration.\n" + "\n" + "Mailmaps are loaded in the following order:\n" + " 1. '.mailmap' in the root of the repository's working directory, if present.\n" + " 2. The blob object identified by the 'mailmap.blob' config entry, if set.\n" + " [NOTE: 'mailmap.blob' defaults to 'HEAD:.mailmap' in bare repositories]\n" + " 3. The path in the 'mailmap.file' config entry, if set."); +PyObject * +Mailmap_from_repository(Mailmap *dummy, PyObject *args) +{ + Repository *repo = NULL; + git_mailmap *mm = NULL; + int error; + + if (!PyArg_ParseTuple(args, "O!", &RepositoryType, &repo)) + return NULL; + + error = git_mailmap_from_repository(&mm, repo->repo); + if (error < 0) + return Error_set(error); + + return wrap_mailmap(mm); +} + +PyDoc_STRVAR(Mailmap_from_buffer__doc__, + "from_buffer(buffer: str) -> Mailmap\n" + "\n" + "Parse a passed-in buffer and construct a mailmap object."); +PyObject * +Mailmap_from_buffer(Mailmap *dummy, PyObject *args) +{ + char *buffer = NULL; + Py_ssize_t size = 0; + git_mailmap *mm = NULL; + int error; + + if (!PyArg_ParseTuple(args, "s#", &buffer, &size)) + return NULL; + + error = git_mailmap_from_buffer(&mm, buffer, size); + if (error < 0) + return Error_set(error); + + return wrap_mailmap(mm); +} + +PyDoc_STRVAR(Mailmap_add_entry__doc__, + "add_entry(real_name: str = None, real_email: str = None, replace_name: str = None, replace_email: str)\n" + "\n" + "Add a new entry to the mailmap, overriding existing entries."); +PyObject * +Mailmap_add_entry(Mailmap *self, PyObject *args, PyObject *kwargs) +{ + char *keywords[] = {"real_name", "real_email", "replace_name", "replace_email", NULL}; + char *real_name = NULL, *real_email = NULL; + char *replace_name = NULL, *replace_email = NULL; + int error; + + if (!PyArg_ParseTupleAndKeywords(args, kwargs, "|zzzs", keywords, + &real_name, &real_email, + &replace_name, &replace_email)) + return NULL; + + /* replace_email cannot be null */ + if (!replace_email) { + PyErr_BadArgument(); + return NULL; + } + + error = git_mailmap_add_entry(self->mailmap, real_name, real_email, + replace_name, replace_email); + if (error < 0) + return Error_set(error); + + Py_RETURN_NONE; +} + +PyDoc_STRVAR(Mailmap_resolve__doc__, + "resolve(name: str, email: str) -> tuple[str, str]\n" + "\n" + "Resolve name & email to a real name and email."); +PyObject * +Mailmap_resolve(Mailmap *self, PyObject *args) +{ + const char *name = NULL, *email = NULL; + const char *real_name = NULL, *real_email = NULL; + int error; + + if (!PyArg_ParseTuple(args, "ss", &name, &email)) + return NULL; + + error = git_mailmap_resolve(&real_name, &real_email, self->mailmap, name, email); + if (error < 0) + return Error_set(error); + + return Py_BuildValue("ss", real_name, real_email); +} + +PyDoc_STRVAR(Mailmap_resolve_signature__doc__, + "resolve_signature(sig: Signature) -> Signature\n" + "\n" + "Resolve signature to real name and email."); +PyObject * +Mailmap_resolve_signature(Mailmap *self, PyObject *args) +{ + Signature *sig = NULL; + git_signature *resolved = NULL; + int error; + + if (!PyArg_ParseTuple(args, "O!", &SignatureType, &sig)) + return NULL; + + error = git_mailmap_resolve_signature(&resolved, self->mailmap, sig->signature); + if (error < 0) + return Error_set(error); + + return build_signature(sig->obj, resolved, sig->encoding); +} + +static void +Mailmap_dealloc(Mailmap *self) +{ + git_mailmap_free(self->mailmap); + PyObject_Del(self); +} + + +PyMethodDef Mailmap_methods[] = { + METHOD(Mailmap, add_entry, METH_VARARGS | METH_KEYWORDS), + METHOD(Mailmap, resolve, METH_VARARGS), + METHOD(Mailmap, resolve_signature, METH_VARARGS), + METHOD(Mailmap, from_repository, METH_VARARGS | METH_STATIC), + METHOD(Mailmap, from_buffer, METH_VARARGS | METH_STATIC), + {NULL} +}; + + +PyDoc_STRVAR(Mailmap__doc__, "Mailmap object."); + +PyTypeObject MailmapType = { + PyVarObject_HEAD_INIT(NULL, 0) + "_pygit2.Mailmap", /* tp_name */ + sizeof(Mailmap), /* tp_basicsize */ + 0, /* tp_itemsize */ + (destructor)Mailmap_dealloc, /* tp_dealloc */ + 0, /* tp_print */ + 0, /* tp_getattr */ + 0, /* tp_setattr */ + 0, /* tp_compare */ + 0, /* tp_repr */ + 0, /* tp_as_number */ + 0, /* tp_as_sequence */ + 0, /* tp_as_mapping */ + 0, /* tp_hash */ + 0, /* tp_call */ + 0, /* tp_str */ + 0, /* tp_getattro */ + 0, /* tp_setattro */ + 0, /* tp_as_buffer */ + Py_TPFLAGS_DEFAULT, /* tp_flags */ + Mailmap__doc__, /* tp_doc */ + 0, /* tp_traverse */ + 0, /* tp_clear */ + 0, /* tp_richcompare */ + 0, /* tp_weaklistoffset */ + 0, /* tp_iter */ + 0, /* tp_iternext */ + Mailmap_methods, /* tp_methods */ + 0, /* tp_members */ + 0, /* tp_getset */ + 0, /* tp_base */ + 0, /* tp_dict */ + 0, /* tp_descr_get */ + 0, /* tp_descr_set */ + 0, /* tp_dictoffset */ + (initproc)Mailmap_init, /* tp_init */ + 0, /* tp_alloc */ + 0, /* tp_new */ +}; + +PyObject * +wrap_mailmap(git_mailmap* mm) +{ + Mailmap* py_mm = NULL; + + py_mm = PyObject_New(Mailmap, &MailmapType); + if (py_mm == NULL) { + PyErr_NoMemory(); + return NULL; + } + + py_mm->mailmap = mm; + + return (PyObject*) py_mm; +} diff --git a/src/remote.h b/src/mailmap.h similarity index 82% rename from src/remote.h rename to src/mailmap.h index 7f6e131de..0f61d96b6 100644 --- a/src/remote.h +++ b/src/mailmap.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2013 The pygit2 contributors + * Copyright 2010-2025 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -25,15 +25,14 @@ * Boston, MA 02110-1301, USA. */ -#ifndef INCLUDE_pygit2_remote_h -#define INCLUDE_pygit2_remote_h +#ifndef INCLUDE_pygit2_mailmap_h +#define INCLUDE_pygit2_mailmap_h #define PY_SSIZE_T_CLEAN #include #include -#include +#include "types.h" -PyObject* Remote_init(Remote *self, PyObject *args, PyObject *kwds); -PyObject* Remote_fetch(Remote *self, PyObject *args); +PyObject* wrap_mailmap(git_mailmap *c_object); #endif diff --git a/src/note.c b/src/note.c index c7ef0f2ca..cb25e39c0 100644 --- a/src/note.c +++ b/src/note.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2013 The pygit2 contributors + * Copyright 2010-2025 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -37,15 +37,17 @@ extern PyTypeObject SignatureType; PyDoc_STRVAR(Note_remove__doc__, + "remove(author: Signature, committer: Signature, ref: str = \"refs/notes/commits\")\n" + "\n" "Removes a note for an annotated object"); -PyObject* +PyObject * Note_remove(Note *self, PyObject* args) { char *ref = "refs/notes/commits"; int err = GIT_ERROR; - git_oid annotated_id; Signature *py_author, *py_committer; + Oid *id; if (!PyArg_ParseTuple(args, "O!O!|s", &SignatureType, &py_author, @@ -53,12 +55,9 @@ Note_remove(Note *self, PyObject* args) &ref)) return NULL; - err = git_oid_fromstr(&annotated_id, self->annotated_id); - if (err < 0) - return Error_set(err); - + id = (Oid *) self->annotated_id; err = git_note_remove(self->repo->repo, ref, py_author->signature, - py_committer->signature, &annotated_id); + py_committer->signature, &id->oid); if (err < 0) return Error_set(err); @@ -66,23 +65,25 @@ Note_remove(Note *self, PyObject* args) } -PyDoc_STRVAR(Note_oid__doc__, - "Gets the id of the blob containing the note message\n"); - -PyObject * -Note_oid__get__(Note *self) -{ - return git_oid_to_python(git_note_oid(self->note)); -} - - PyDoc_STRVAR(Note_message__doc__, "Gets message of the note\n"); PyObject * Note_message__get__(Note *self) { - return to_unicode(git_note_message(self->note), NULL, NULL); + int err; + + // Lazy load + if (self->note == NULL) { + err = git_note_read(&self->note, + self->repo->repo, + self->ref, + &((Oid *)self->annotated_id)->oid); + if (err < 0) + return Error_set(err); + } + + return to_unicode(git_note_message(self->note), NULL, NULL); } @@ -90,8 +91,10 @@ static void Note_dealloc(Note *self) { Py_CLEAR(self->repo); - free(self->annotated_id); - git_note_free(self->note); + Py_CLEAR(self->annotated_id); + Py_CLEAR(self->id); + if (self->note != NULL) + git_note_free(self->note); PyObject_Del(self); } @@ -102,13 +105,13 @@ PyMethodDef Note_methods[] = { }; PyMemberDef Note_members[] = { - MEMBER(Note, annotated_id, T_STRING, "id of the annotated object."), + MEMBER(Note, id, T_OBJECT, "id of the note object."), + MEMBER(Note, annotated_id, T_OBJECT, "id of the annotated object."), {NULL} }; PyGetSetDef Note_getseters[] = { GETTER(Note, message), - GETTER(Note, oid), {NULL} }; @@ -134,7 +137,7 @@ PyTypeObject NoteType = { 0, /* tp_getattro */ 0, /* tp_setattro */ 0, /* tp_as_buffer */ - Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, /* tp_flags */ + Py_TPFLAGS_DEFAULT, /* tp_flags */ Note__doc__, /* tp_doc */ 0, /* tp_traverse */ 0, /* tp_clear */ @@ -166,7 +169,7 @@ NoteIter_iternext(NoteIter *self) if (err < 0) return Error_set(err); - return (PyObject*) wrap_note(self->repo, &annotated_id, self->ref); + return (PyObject*) wrap_note(self->repo, ¬e_id, &annotated_id, self->ref); } void @@ -200,19 +203,19 @@ PyTypeObject NoteIterType = { 0, /* tp_getattro */ 0, /* tp_setattro */ 0, /* tp_as_buffer */ - Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, /* tp_flags */ + Py_TPFLAGS_DEFAULT, /* tp_flags */ NoteIter__doc__, /* tp_doc */ 0, /* tp_traverse */ 0, /* tp_clear */ 0, /* tp_richcompare */ 0, /* tp_weaklistoffset */ PyObject_SelfIter, /* tp_iter */ - (iternextfunc) NoteIter_iternext, /* tp_iternext */ + (iternextfunc) NoteIter_iternext, /* tp_iternext */ }; -PyObject* -wrap_note(Repository* repo, git_oid* annotated_id, const char* ref) +PyObject * +wrap_note(Repository* repo, git_oid* note_id, git_oid* annotated_id, const char* ref) { Note* py_note = NULL; int err = GIT_ERROR; @@ -223,13 +226,24 @@ wrap_note(Repository* repo, git_oid* annotated_id, const char* ref) return NULL; } - err = git_note_read(&py_note->note, repo->repo, ref, annotated_id); - if (err < 0) - return Error_set(err); - - py_note->repo = repo; Py_INCREF(repo); - py_note->annotated_id = git_oid_allocfmt(annotated_id); + py_note->repo = repo; + py_note->ref = ref; + py_note->annotated_id = git_oid_to_python(annotated_id); + py_note->id = NULL; + py_note->note = NULL; + + /* If the note has been provided, defer the git_note_read() call */ + if (note_id != NULL) { + py_note->id = git_oid_to_python(note_id); + } else { + err = git_note_read(&py_note->note, repo->repo, ref, annotated_id); + if (err < 0) { + Py_DECREF(py_note); + return Error_set(err); + } + py_note->id = git_oid_to_python(git_note_id(py_note->note)); + } return (PyObject*) py_note; } diff --git a/src/note.h b/src/note.h index a4ded369a..2e87b8942 100644 --- a/src/note.h +++ b/src/note.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2013 The pygit2 contributors + * Copyright 2010-2025 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -32,6 +32,7 @@ #include #include -PyObject* wrap_note(Repository* repo, git_oid* annotated_id, const char* ref); +PyObject* wrap_note(Repository* repo, git_oid* note_id, + git_oid* annotated_id, const char* ref); #endif diff --git a/src/object.c b/src/object.c index 6e018e564..15127c7f8 100644 --- a/src/object.c +++ b/src/object.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2013 The pygit2 contributors + * Copyright 2010-2025 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -27,9 +27,11 @@ #define PY_SSIZE_T_CLEAN #include +#include #include "error.h" #include "types.h" #include "utils.h" +#include "odb.h" #include "oid.h" #include "repository.h" #include "object.h" @@ -38,74 +40,157 @@ extern PyTypeObject TreeType; extern PyTypeObject CommitType; extern PyTypeObject BlobType; extern PyTypeObject TagType; +extern PyObject *FileModeEnum; +PyTypeObject ObjectType; void Object_dealloc(Object* self) { Py_CLEAR(self->repo); git_object_free(self->obj); - PyObject_Del(self); + git_tree_entry_free((git_tree_entry*)self->entry); + Py_TYPE(self)->tp_free(self); } -PyDoc_STRVAR(Object_oid__doc__, - "The object id, an instance of the Oid type."); +git_object* +Object__load(Object *self) +{ + if (self->obj == NULL) { + int err = git_tree_entry_to_object(&self->obj, self->repo->repo, self->entry); + if (err < 0) { + Error_set(err); + return NULL; + } + } -PyObject * -Object_oid__get__(Object *self) + return self->obj; +} + +const git_oid* +Object__id(Object *self) { - const git_oid *oid; + return (self->obj) ? git_object_id(self->obj) : git_tree_entry_id(self->entry); +} - oid = git_object_id(self->obj); - assert(oid); - return git_oid_to_python(oid); +git_object_t +Object__type(Object *self) +{ + return (self->obj) ? git_object_type(self->obj) : git_tree_entry_type(self->entry); } -PyDoc_STRVAR(Object_hex__doc__, - "Hexadecimal representation of the object id. This is a shortcut for\n" - "Object.oid.hex"); +PyDoc_STRVAR(Object_id__doc__, + "The object id, an instance of the Oid type."); PyObject * -Object_hex__get__(Object *self) +Object_id__get__(Object *self) { - const git_oid *oid; + return git_oid_to_python(Object__id(self)); +} + + +PyDoc_STRVAR(Object_short_id__doc__, + "An unambiguous short (abbreviated) hex Oid string for the object."); - oid = git_object_id(self->obj); - assert(oid); +PyObject * +Object_short_id__get__(Object *self) +{ + if (Object__load(self) == NULL) { return NULL; } // Lazy load - return git_oid_to_py_str(oid); + git_buf short_id = { NULL, 0, 0 }; + int err = git_object_short_id(&short_id, self->obj); + if (err != GIT_OK) + return Error_set(err); + + PyObject *py_short_id = to_unicode_n(short_id.ptr, short_id.size, NULL, "strict"); + git_buf_dispose(&short_id); + return py_short_id; } PyDoc_STRVAR(Object_type__doc__, - "One of the GIT_OBJ_COMMIT, GIT_OBJ_TREE, GIT_OBJ_BLOB or GIT_OBJ_TAG\n" - "constants."); + "One of the enums.ObjectType.COMMIT, TREE, BLOB or TAG constants."); PyObject * Object_type__get__(Object *self) { - return PyLong_FromLong(git_object_type(self->obj)); + return PyLong_FromLong(Object__type(self)); +} + +PyDoc_STRVAR(Object_type_str__doc__, + "One of the 'commit', 'tree', 'blob' or 'tag' strings."); + +PyObject * +Object_type_str__get__(Object *self) +{ + return PyUnicode_DecodeFSDefault(git_object_type2string(Object__type(self))); +} + +PyDoc_STRVAR(Object__pointer__doc__, "Get the object's pointer. For internal use only."); +PyObject * +Object__pointer__get__(Object *self) +{ + /* Bytes means a raw buffer */ + if (Object__load(self) == NULL) { return NULL; } // Lazy load + return PyBytes_FromStringAndSize((char *) &self->obj, sizeof(git_object *)); +} + +PyDoc_STRVAR(Object_name__doc__, + "Name (or None if the object was not reached through a tree)"); +PyObject * +Object_name__get__(Object *self) +{ + if (self->entry == NULL) + Py_RETURN_NONE; + + return PyUnicode_DecodeFSDefault(git_tree_entry_name(self->entry)); +} + +PyDoc_STRVAR(Object_raw_name__doc__, "Name (bytes)."); + +PyObject * +Object_raw_name__get__(Object *self) +{ + if (self->entry == NULL) + Py_RETURN_NONE; + + return PyBytes_FromString(git_tree_entry_name(self->entry)); +} + +PyDoc_STRVAR(Object_filemode__doc__, + "An enums.FileMode constant (or None if the object was not reached through a tree)"); +PyObject * +Object_filemode__get__(Object *self) +{ + if (self->entry == NULL) + Py_RETURN_NONE; + + return pygit2_enum(FileModeEnum, git_tree_entry_filemode(self->entry)); } PyDoc_STRVAR(Object_read_raw__doc__, - "read_raw()\n" + "read_raw() -> bytes\n" "\n" "Returns the byte string with the raw contents of the object."); PyObject * Object_read_raw(Object *self) { - const git_oid *oid; - git_odb_object *obj; + int err; + git_odb *odb; PyObject *aux; - oid = git_object_id(self->obj); + err = git_repository_odb(&odb, self->repo->repo); + if (err < 0) + return Error_set(err); - obj = Repository_read_raw(self->repo->repo, oid, GIT_OID_HEXSZ); + const git_oid *oid = Object__id(self); + git_odb_object *obj = Odb_read_raw(odb, oid, GIT_OID_HEXSZ); + git_odb_free(odb); if (obj == NULL) return NULL; @@ -117,15 +202,109 @@ Object_read_raw(Object *self) return aux; } +PyDoc_STRVAR(Object_peel__doc__, + "peel(target_type) -> Object\n" + "\n" + "Peel the current object and returns the first object of the given type.\n" + "\n" + "If you pass None as the target type, then the object will be peeled\n" + "until the type changes. A tag will be peeled until the referenced object\n" + "is no longer a tag, and a commit will be peeled to a tree. Any other\n" + "object type will raise InvalidSpecError.\n"); + +PyObject * +Object_peel(Object *self, PyObject *py_type) +{ + int err; + git_otype otype; + git_object *peeled; + + if (Object__load(self) == NULL) { return NULL; } // Lazy load + + otype = py_object_to_otype(py_type); + if (otype == GIT_OBJECT_INVALID) + return NULL; + + err = git_object_peel(&peeled, self->obj, otype); + if (err < 0) + return Error_set(err); + + return wrap_object(peeled, self->repo, NULL); +} + +Py_hash_t +Object_hash(Object *self) +{ + const git_oid *oid = Object__id(self); + PyObject *py_oid = git_oid_to_py_str(oid); + Py_hash_t ret = PyObject_Hash(py_oid); + Py_DECREF(py_oid); + return ret; +} + +PyObject * +Object_repr(Object *self) +{ + char hex[GIT_OID_HEXSZ + 1]; + + git_oid_fmt(hex, Object__id(self)); + hex[GIT_OID_HEXSZ] = '\0'; + + return PyUnicode_FromFormat("", + git_object_type2string(Object__type(self)), + hex + ); +} + +PyObject * +Object_richcompare(PyObject *o1, PyObject *o2, int op) +{ + PyObject *res; + + if (!PyObject_TypeCheck(o2, &ObjectType)) { + Py_INCREF(Py_NotImplemented); + return Py_NotImplemented; + } + + int equal = git_oid_equal(Object__id((Object *)o1), Object__id((Object *)o2)); + switch (op) { + case Py_NE: + res = (equal) ? Py_False : Py_True; + break; + case Py_EQ: + res = (equal) ? Py_True : Py_False; + break; + case Py_LT: + case Py_LE: + case Py_GT: + case Py_GE: + Py_INCREF(Py_NotImplemented); + return Py_NotImplemented; + default: + PyErr_Format(PyExc_RuntimeError, "Unexpected '%d' op", op); + return NULL; + } + + Py_INCREF(res); + return res; +} + PyGetSetDef Object_getseters[] = { - GETTER(Object, oid), - GETTER(Object, hex), + GETTER(Object, id), + GETTER(Object, short_id), GETTER(Object, type), + GETTER(Object, type_str), + GETTER(Object, _pointer), + // These come from git_tree_entry + GETTER(Object, name), + GETTER(Object, raw_name), + GETTER(Object, filemode), {NULL} }; PyMethodDef Object_methods[] = { METHOD(Object, read_raw, METH_NOARGS), + METHOD(Object, peel, METH_O), {NULL} }; @@ -142,11 +321,11 @@ PyTypeObject ObjectType = { 0, /* tp_getattr */ 0, /* tp_setattr */ 0, /* tp_compare */ - 0, /* tp_repr */ + (reprfunc)Object_repr, /* tp_repr */ 0, /* tp_as_number */ 0, /* tp_as_sequence */ 0, /* tp_as_mapping */ - 0, /* tp_hash */ + (hashfunc)Object_hash, /* tp_hash */ 0, /* tp_call */ 0, /* tp_str */ 0, /* tp_getattro */ @@ -156,7 +335,7 @@ PyTypeObject ObjectType = { Object__doc__, /* tp_doc */ 0, /* tp_traverse */ 0, /* tp_clear */ - 0, /* tp_richcompare */ + (richcmpfunc)Object_richcompare, /* tp_richcompare */ 0, /* tp_weaklistoffset */ 0, /* tp_iter */ 0, /* tp_iternext */ @@ -174,21 +353,23 @@ PyTypeObject ObjectType = { }; PyObject * -wrap_object(git_object *c_object, Repository *repo) +wrap_object(git_object *c_object, Repository *repo, const git_tree_entry *entry) { Object *py_obj = NULL; - switch (git_object_type(c_object)) { - case GIT_OBJ_COMMIT: + git_object_t obj_type = (c_object) ? git_object_type(c_object) : git_tree_entry_type(entry); + + switch (obj_type) { + case GIT_OBJECT_COMMIT: py_obj = PyObject_New(Object, &CommitType); break; - case GIT_OBJ_TREE: + case GIT_OBJECT_TREE: py_obj = PyObject_New(Object, &TreeType); break; - case GIT_OBJ_BLOB: + case GIT_OBJECT_BLOB: py_obj = PyObject_New(Object, &BlobType); break; - case GIT_OBJ_TAG: + case GIT_OBJECT_TAG: py_obj = PyObject_New(Object, &TagType); break; default: @@ -201,6 +382,7 @@ wrap_object(git_object *c_object, Repository *repo) py_obj->repo = repo; Py_INCREF(repo); } + py_obj->entry = entry; } return (PyObject *)py_obj; } diff --git a/src/object.h b/src/object.h index 7ab68a4a9..9fc41526e 100644 --- a/src/object.h +++ b/src/object.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2013 The pygit2 contributors + * Copyright 2010-2025 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -33,10 +33,11 @@ #include #include "types.h" -PyObject* Object_get_oid(Object *self); -PyObject* Object_get_hex(Object *self); -PyObject* Object_get_type(Object *self); + +git_object* Object__load(Object *self); +const git_oid* Object__id(Object *self); PyObject* Object_read_raw(Object *self); -PyObject* wrap_object(git_object *c_object, Repository *repo); +PyObject* Object_repr(Object *self); +PyObject* wrap_object(git_object *c_object, Repository *repo, const git_tree_entry *entry); #endif diff --git a/src/odb.c b/src/odb.c new file mode 100644 index 000000000..97181aa72 --- /dev/null +++ b/src/odb.c @@ -0,0 +1,437 @@ +/* + * 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 "error.h" +#include "object.h" +#include "odb_backend.h" +#include "oid.h" +#include "types.h" +#include "utils.h" +#include + +extern PyTypeObject OdbBackendType; + +static git_otype +int_to_loose_object_type(int type_id) +{ + switch((git_otype)type_id) { + case GIT_OBJECT_COMMIT: + case GIT_OBJECT_TREE: + case GIT_OBJECT_BLOB: + case GIT_OBJECT_TAG: + return (git_otype)type_id; + default: + return GIT_OBJECT_INVALID; + } +} + +int +Odb_init(Odb *self, PyObject *args, PyObject *kwds) +{ + if (kwds && PyDict_Size(kwds) > 0) { + PyErr_SetString(PyExc_TypeError, "Odb takes no keyword arguments"); + return -1; + } + + PyObject *py_path = NULL; + if (!PyArg_ParseTuple(args, "|O", &py_path)) + return -1; + + int err; + if (py_path) { + PyObject *tvalue; + char *path = pgit_borrow_fsdefault(py_path, &tvalue); + if (path == NULL) + return -1; + err = git_odb_open(&self->odb, path); + Py_DECREF(tvalue); + } + else { + err = git_odb_new(&self->odb); + } + + if (err) { + Error_set(err); + return -1; + } + + return 0; +} + +void +Odb_dealloc(Odb *self) +{ + git_odb_free(self->odb); + + Py_TYPE(self)->tp_free((PyObject *) self); +} + +static int +Odb_build_as_iter(const git_oid *oid, void *accum) +{ + int err; + PyObject *py_oid = git_oid_to_python(oid); + if (py_oid == NULL) + return GIT_EUSER; + + err = PyList_Append((PyObject*)accum, py_oid); + Py_DECREF(py_oid); + if (err < 0) + return GIT_EUSER; + + return 0; +} + +PyObject * +Odb_as_iter(Odb *self) +{ + int err; + PyObject *accum = PyList_New(0); + PyObject *ret = NULL; + + err = git_odb_foreach(self->odb, Odb_build_as_iter, (void*)accum); + if (err == GIT_EUSER) + goto exit; + if (err < 0) { + ret = Error_set(err); + goto exit; + } + + ret = PyObject_GetIter(accum); + +exit: + Py_DECREF(accum); + return ret; +} + + +PyDoc_STRVAR(Odb_add_disk_alternate__doc__, + "add_disk_alternate(path: str)\n" + "\n" + "Adds a path on disk as an alternate backend for objects.\n" + "Alternate backends are checked for objects only *after* the main backends\n" + "are checked. Writing is disabled on alternate backends.\n"); + +PyObject * +Odb_add_disk_alternate(Odb *self, PyObject *py_path) +{ + PyObject *tvalue; + char *path = pgit_borrow_fsdefault(py_path, &tvalue); + if (path == NULL) + return NULL; + + int err = git_odb_add_disk_alternate(self->odb, path); + Py_DECREF(tvalue); + if (err) + return Error_set(err); + + Py_RETURN_NONE; +} + +git_odb_object * +Odb_read_raw(git_odb *odb, const git_oid *oid, size_t len) +{ + git_odb_object *obj; + int err; + + err = git_odb_read_prefix(&obj, odb, oid, (unsigned int)len); + if (err < 0 && err != GIT_EUSER) { + Error_set_oid(err, oid, len); + return NULL; + } + + return obj; +} + +PyDoc_STRVAR(Odb_read__doc__, + "read(oid) -> type, data, size\n" + "\n" + "Read raw object data from the object db."); + +PyObject * +Odb_read(Odb *self, PyObject *py_hex) +{ + git_oid oid; + git_odb_object *obj; + size_t len; + PyObject* tuple; + + len = py_oid_to_git_oid(py_hex, &oid); + if (len == 0) + return NULL; + + obj = Odb_read_raw(self->odb, &oid, len); + if (obj == NULL) + return NULL; + + tuple = Py_BuildValue( + "(ny#)", + git_odb_object_type(obj), + git_odb_object_data(obj), + git_odb_object_size(obj)); + + git_odb_object_free(obj); + return tuple; +} + +PyDoc_STRVAR(Odb_write__doc__, + "write(type: int, data: bytes) -> Oid\n" + "\n" + "Write raw object data into the object db. First arg is the object\n" + "type, the second one a buffer with data. Return the Oid of the created\n" + "object."); + +PyObject * +Odb_write(Odb *self, PyObject *args) +{ + int err; + git_oid oid; + git_odb_stream* stream; + int type_id; + const char* buffer; + Py_ssize_t buflen; + git_otype type; + + if (!PyArg_ParseTuple(args, "Is#", &type_id, &buffer, &buflen)) + return NULL; + + type = int_to_loose_object_type(type_id); + if (type == GIT_OBJECT_INVALID) + return PyErr_Format(PyExc_ValueError, "%d", type_id); + + err = git_odb_open_wstream(&stream, self->odb, buflen, type); + if (err < 0) + return Error_set(err); + + err = git_odb_stream_write(stream, buffer, buflen); + if (err) { + git_odb_stream_free(stream); + return Error_set(err); + } + + err = git_odb_stream_finalize_write(&oid, stream); + git_odb_stream_free(stream); + if (err) + return Error_set(err); + + return git_oid_to_python(&oid); +} + +PyDoc_STRVAR(Odb_exists__doc__, + "exists(oid: Oid) -> bool\n" + "\n" + "Returns true if the given oid can be found in this odb."); + +PyObject * +Odb_exists(Odb *self, PyObject *py_hex) +{ + git_oid oid; + size_t len; + int result; + + len = py_oid_to_git_oid(py_hex, &oid); + if (len == 0) + return NULL; + + result = git_odb_exists(self->odb, &oid); + if (result < 0) + return Error_set(result); + else if (result == 0) + Py_RETURN_FALSE; + else + Py_RETURN_TRUE; +} + + +PyDoc_STRVAR(Odb_add_backend__doc__, + "add_backend(backend: OdbBackend, priority: int)\n" + "\n" + "Adds an OdbBackend to the list of backends for this object database.\n"); + +PyObject * +Odb_add_backend(Odb *self, PyObject *args) +{ + int err, priority; + OdbBackend *backend; + + if (!PyArg_ParseTuple(args, "OI", &backend, &priority)) + return NULL; + + if (!PyObject_IsInstance((PyObject *)backend, (PyObject *)&OdbBackendType)) { + PyErr_SetString(PyExc_TypeError, "add_backend expects an instance of pygit2.OdbBackend"); + return NULL; + } + + err = git_odb_add_backend(self->odb, backend->odb_backend, priority); + if (err != 0) + return Error_set(err); + + Py_INCREF(backend); + + Py_RETURN_NONE; +} + + +PyMethodDef Odb_methods[] = { + METHOD(Odb, add_disk_alternate, METH_O), + METHOD(Odb, read, METH_O), + METHOD(Odb, write, METH_VARARGS), + METHOD(Odb, exists, METH_O), + METHOD(Odb, add_backend, METH_VARARGS), + {NULL} +}; + + +PyDoc_STRVAR(Odb_backends__doc__, + "Return an iterable of backends for this object database."); + +PyObject * +Odb_backends__get__(Odb *self) +{ + int err; + git_odb_backend *backend; + PyObject *ret = NULL; + PyObject *py_backend; + + PyObject *accum = PyList_New(0); + if (accum == NULL) + return NULL; + + size_t nbackends = git_odb_num_backends(self->odb); + for (size_t i = 0; i < nbackends; ++i) { + err = git_odb_get_backend(&backend, self->odb, i); + if (err != 0) { + ret = Error_set(err); + goto exit; + } + + // XXX This won't return the correct class for custom backends (add a + // test and fix) + py_backend = wrap_odb_backend(backend); + if (py_backend == NULL) + goto exit; + + err = PyList_Append(accum, py_backend); + if (err != 0) + goto exit; + } + + ret = PyObject_GetIter(accum); + +exit: + Py_DECREF(accum); + return ret; +} + + +PyGetSetDef Odb_getseters[] = { + GETTER(Odb, backends), + {NULL} +}; + + +int +Odb_contains(Odb *self, PyObject *py_name) +{ + git_oid oid; + size_t len; + + len = py_oid_to_git_oid(py_name, &oid); + if (len == 0) { + PyErr_SetString(PyExc_TypeError, "name must be an oid"); + return -1; + } + + return git_odb_exists(self->odb, &oid); +} + +PySequenceMethods Odb_as_sequence = { + 0, /* sq_length */ + 0, /* sq_concat */ + 0, /* sq_repeat */ + 0, /* sq_item */ + 0, /* sq_slice */ + 0, /* sq_ass_item */ + 0, /* sq_ass_slice */ + (objobjproc)Odb_contains, /* sq_contains */ +}; + +PyDoc_STRVAR(Odb__doc__, "Object database."); + +PyTypeObject OdbType = { + PyVarObject_HEAD_INIT(NULL, 0) + "_pygit2.Odb", /* tp_name */ + sizeof(Odb), /* tp_basicsize */ + 0, /* tp_itemsize */ + (destructor)Odb_dealloc, /* tp_dealloc */ + 0, /* tp_print */ + 0, /* tp_getattr */ + 0, /* tp_setattr */ + 0, /* tp_compare */ + 0, /* tp_repr */ + 0, /* tp_as_number */ + &Odb_as_sequence, /* tp_as_sequence */ + 0, /* tp_as_mapping */ + 0, /* tp_hash */ + 0, /* tp_call */ + 0, /* tp_str */ + 0, /* tp_getattro */ + 0, /* tp_setattro */ + 0, /* tp_as_buffer */ + Py_TPFLAGS_DEFAULT, /* tp_flags */ + Odb__doc__, /* tp_doc */ + 0, /* tp_traverse */ + 0, /* tp_clear */ + 0, /* tp_richcompare */ + 0, /* tp_weaklistoffset */ + (getiterfunc)Odb_as_iter, /* tp_iter */ + 0, /* tp_iternext */ + Odb_methods, /* tp_methods */ + 0, /* tp_members */ + Odb_getseters, /* tp_getset */ + 0, /* tp_base */ + 0, /* tp_dict */ + 0, /* tp_descr_get */ + 0, /* tp_descr_set */ + 0, /* tp_dictoffset */ + (initproc)Odb_init, /* tp_init */ + 0, /* tp_alloc */ + 0, /* tp_new */ +}; + +PyObject * +wrap_odb(git_odb *c_odb) +{ + Odb *py_odb = PyObject_New(Odb, &OdbType); + + if (py_odb) + py_odb->odb = c_odb; + + return (PyObject *)py_odb; +} diff --git a/src/tag.h b/src/odb.h similarity index 81% rename from src/tag.h rename to src/odb.h index 04d3a1092..7a69a46c0 100644 --- a/src/tag.h +++ b/src/odb.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2013 The pygit2 contributors + * Copyright 2010-2025 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -25,18 +25,18 @@ * Boston, MA 02110-1301, USA. */ -#ifndef INCLUDE_pygit2_tag_h -#define INCLUDE_pygit2_tag_h +#ifndef INCLUDE_pygit2_odb_h +#define INCLUDE_pygit2_odb_h #define PY_SSIZE_T_CLEAN #include #include #include "types.h" -PyObject* Tag_get_target(Tag *self); -PyObject* Tag_get_name(Tag *self); -PyObject* Tag_get_tagger(Tag *self); -PyObject* Tag_get_message(Tag *self); -PyObject* Tag_get_raw_message(Tag *self); +PyObject *wrap_odb(git_odb *c_odb); + +git_odb_object *Odb_read_raw(git_odb *odb, const git_oid *oid, size_t len); + +PyObject *Odb_read(Odb *self, PyObject *py_hex); #endif diff --git a/src/odb_backend.c b/src/odb_backend.c new file mode 100644 index 000000000..a189aec53 --- /dev/null +++ b/src/odb_backend.c @@ -0,0 +1,747 @@ +/* + * 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. + * + * + * TODO This still needs much work to make it usable, and maintanable! + * - Create OdbBackendCustomType that inherits from OdbBackendType. + * OdbBackendType should not be subclassed, instead subclass + * OdbBackendCustomType to develop custom backends in Python. + * Implement this new type in src/odb_backend_custom.c + */ + +#define PY_SSIZE_T_CLEAN +#include +#include "error.h" +#include "object.h" +#include "oid.h" +#include "types.h" +#include "utils.h" +#include +#include +#include + +/* + * pgit_odb_backend_t is a container for the state associated with a custom + * implementation of git_odb_backend. The git_odb_backend field's function + * pointers are assigned to the pgit_odb_backend_* functions, which handle + * translating between the libgit2 ABI and the Python ABI. + * It holds a pointer to the subclass, which must implement + * the callbacks in Python. + */ +typedef struct { + git_odb_backend backend; + PyObject *py_backend; +} pgit_odb_backend; + +static int +pgit_odb_backend_read(void **ptr, size_t *sz, git_object_t *type, + git_odb_backend *_be, const git_oid *oid) +{ + pgit_odb_backend *be = (pgit_odb_backend *)_be; + + PyObject *py_oid = git_oid_to_python(oid); + if (py_oid == NULL) + return GIT_EUSER; + + PyObject *result = PyObject_CallMethod(be->py_backend, "read_cb", "N", py_oid); + if (result == NULL) + return git_error_for_exc(); + + const char *bytes; + Py_ssize_t type_value; + if (!PyArg_ParseTuple(result, "ny#", &type_value, &bytes, sz) || !bytes) { + Py_DECREF(result); + return GIT_EUSER; + } + *type = (git_object_t)type_value; + + *ptr = git_odb_backend_data_alloc(_be, *sz); + if (!*ptr) { + Py_DECREF(result); + return GIT_EUSER; + } + + memcpy(*ptr, bytes, *sz); + Py_DECREF(result); + return 0; +} + +static int +pgit_odb_backend_read_prefix(git_oid *oid_out, void **ptr, size_t *sz, git_object_t *type, + git_odb_backend *_be, const git_oid *short_id, size_t len) +{ + // short_id to hex + char short_id_hex[GIT_OID_HEXSZ]; + git_oid_nfmt(short_id_hex, len, short_id); + + // Call callback + pgit_odb_backend *be = (pgit_odb_backend *)_be; + PyObject *result = PyObject_CallMethod(be->py_backend, "read_prefix_cb", "s#", short_id_hex, len); + if (result == NULL) + return git_error_for_exc(); + + // Parse output from callback + PyObject *py_oid_out; + Py_ssize_t type_value; + const char *bytes; + if (!PyArg_ParseTuple(result, "ny#O", &type_value, &bytes, sz, &py_oid_out) || !bytes) { + Py_DECREF(result); + return GIT_EUSER; + } + *type = (git_object_t)type_value; + + *ptr = git_odb_backend_data_alloc(_be, *sz); + if (!*ptr) { + Py_DECREF(result); + return GIT_EUSER; + } + + memcpy(*ptr, bytes, *sz); + py_oid_to_git_oid(py_oid_out, oid_out); + Py_DECREF(result); + return 0; +} + +static int +pgit_odb_backend_read_header(size_t *len, git_object_t *type, + git_odb_backend *_be, const git_oid *oid) +{ + pgit_odb_backend *be = (pgit_odb_backend *)_be; + + PyObject *py_oid = git_oid_to_python(oid); + if (py_oid == NULL) + return GIT_EUSER; + + PyObject *result = PyObject_CallMethod(be->py_backend, "read_header_cb", "N", py_oid); + if (result == NULL) + return git_error_for_exc(); + + Py_ssize_t type_value; + if (!PyArg_ParseTuple(result, "nn", &type_value, len)) { + Py_DECREF(result); + return GIT_EUSER; + } + *type = (git_object_t)type_value; + + Py_DECREF(result); + return 0; +} + +static int +pgit_odb_backend_write(git_odb_backend *_be, const git_oid *oid, + const void *data, size_t sz, git_object_t typ) +{ + pgit_odb_backend *be = (pgit_odb_backend *)_be; + + PyObject *py_oid = git_oid_to_python(oid); + if (py_oid == NULL) + return GIT_EUSER; + + PyObject *result = PyObject_CallMethod(be->py_backend, "write_cb", "Ny#n", py_oid, data, sz, typ); + if (result == NULL) + return git_error_for_exc(); + + Py_DECREF(result); + return 0; +} + +static int +pgit_odb_backend_exists(git_odb_backend *_be, const git_oid *oid) +{ + pgit_odb_backend *be = (pgit_odb_backend *)_be; + + PyObject *py_oid = git_oid_to_python(oid); + if (py_oid == NULL) + return GIT_EUSER; + + PyObject *result = PyObject_CallMethod(be->py_backend, "exists_cb", "N", py_oid); + if (result == NULL) + return git_error_for_exc(); + + int r = PyObject_IsTrue(result); + Py_DECREF(result); + return r; +} + +static int +pgit_odb_backend_exists_prefix(git_oid *out, git_odb_backend *_be, + const git_oid *short_id, size_t len) +{ + // short_id to hex + char short_id_hex[GIT_OID_HEXSZ]; + git_oid_nfmt(short_id_hex, len, short_id); + + // Call callback + pgit_odb_backend *be = (pgit_odb_backend *)_be; + PyObject *py_oid = PyObject_CallMethod(be->py_backend, "exists_prefix_cb", "s#", short_id_hex, len); + if (py_oid == NULL) + return git_error_for_exc(); + + py_oid_to_git_oid(py_oid, out); + Py_DECREF(py_oid); + return 0; +} + +static int +pgit_odb_backend_refresh(git_odb_backend *_be) +{ + pgit_odb_backend *be = (pgit_odb_backend *)_be; + PyObject_CallMethod(be->py_backend, "refresh_cb", NULL); + return git_error_for_exc(); +} + +static int +pgit_odb_backend_foreach(git_odb_backend *_be, + git_odb_foreach_cb cb, void *payload) +{ + PyObject *item; + git_oid oid; + pgit_odb_backend *be = (pgit_odb_backend *)_be; + PyObject *iterator = PyObject_GetIter((PyObject *)be->py_backend); + assert(iterator); + + while ((item = PyIter_Next(iterator))) { + py_oid_to_git_oid(item, &oid); + cb(&oid, payload); + Py_DECREF(item); + } + + return git_error_for_exc(); +} + +static void +pgit_odb_backend_free(git_odb_backend *backend) +{ + pgit_odb_backend *custom_backend = (pgit_odb_backend *)backend; + Py_DECREF(custom_backend->py_backend); +} + +int +OdbBackend_init(OdbBackend *self, PyObject *args, PyObject *kwds) +{ + // Check input arguments + if (args && PyTuple_Size(args) > 0) { + PyErr_SetString(PyExc_TypeError, "OdbBackend takes no arguments"); + return -1; + } + + if (kwds && PyDict_Size(kwds) > 0) { + PyErr_SetString(PyExc_TypeError, "OdbBackend takes no keyword arguments"); + return -1; + } + + // Create the C backend + pgit_odb_backend *custom_backend = calloc(1, sizeof(pgit_odb_backend)); + custom_backend->backend.version = GIT_ODB_BACKEND_VERSION; + + // Fill the member methods + custom_backend->backend.free = pgit_odb_backend_free; + custom_backend->backend.read = pgit_odb_backend_read; + custom_backend->backend.read_prefix = pgit_odb_backend_read_prefix; + custom_backend->backend.read_header = pgit_odb_backend_read_header; + custom_backend->backend.write = pgit_odb_backend_write; + custom_backend->backend.exists = pgit_odb_backend_exists; + custom_backend->backend.exists_prefix = pgit_odb_backend_exists_prefix; + custom_backend->backend.refresh = pgit_odb_backend_refresh; +// custom_backend->backend.writepack = pgit_odb_backend_writepack; +// 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)) + custom_backend->backend.foreach = pgit_odb_backend_foreach; + + // Cross reference (don't incref because it's something internal) + custom_backend->py_backend = (PyObject *)self; + self->odb_backend = (git_odb_backend *)custom_backend; + + return 0; +} + +void +OdbBackend_dealloc(OdbBackend *self) +{ + if (self->odb_backend && self->odb_backend->read == pgit_odb_backend_read) { + pgit_odb_backend *custom_backend = (pgit_odb_backend *)self->odb_backend; + free(custom_backend); + } + + Py_TYPE(self)->tp_free((PyObject *) self); +} + +static int +OdbBackend_build_as_iter(const git_oid *oid, void *accum) +{ + int err; + + PyObject *py_oid = git_oid_to_python(oid); + if (py_oid == NULL) + return GIT_EUSER; + + err = PyList_Append((PyObject*)accum, py_oid); + Py_DECREF(py_oid); + + if (err < 0) + return GIT_EUSER; + + return 0; +} + +PyObject * +OdbBackend_as_iter(OdbBackend *self) +{ + PyObject *accum = PyList_New(0); + PyObject *iter = NULL; + + int err = self->odb_backend->foreach(self->odb_backend, OdbBackend_build_as_iter, (void*)accum); + if (err == GIT_EUSER) + goto exit; + + if (err < 0) { + Error_set(err); + goto exit; + } + + iter = PyObject_GetIter(accum); + +exit: + Py_DECREF(accum); + return iter; +} + +PyDoc_STRVAR(OdbBackend_read__doc__, + "read(oid) -> (type, data)\n" + "\n" + "Read raw object data from this odb backend.\n"); + +PyObject * +OdbBackend_read(OdbBackend *self, PyObject *py_hex) +{ + int err; + git_oid oid; + git_object_t type; + size_t len, size; + void *data; + + if (self->odb_backend->read == NULL) + Py_RETURN_NOTIMPLEMENTED; + + len = py_oid_to_git_oid(py_hex, &oid); + if (len == 0) + return NULL; + + err = self->odb_backend->read(&data, &size, &type, self->odb_backend, &oid); + if (err != 0) { + Error_set_oid(err, &oid, len); + return NULL; + } + + PyObject *tuple = Py_BuildValue("(ny#)", type, data, size); + + git_odb_backend_data_free(self->odb_backend, data); + + return tuple; +} + +PyDoc_STRVAR(OdbBackend_read_prefix__doc__, + "read_prefix(oid: Oid) -> tuple[int, bytes, Oid]\n" + "\n" + "Read raw object data from this odb backend based on an oid prefix.\n" + "The returned tuple contains (type, data, oid)."); + +PyObject * +OdbBackend_read_prefix(OdbBackend *self, PyObject *py_hex) +{ + int err; + git_oid oid, oid_out; + git_object_t type; + size_t len, size; + void *data; + + if (self->odb_backend->read_prefix == NULL) + Py_RETURN_NOTIMPLEMENTED; + + len = py_oid_to_git_oid(py_hex, &oid); + if (len == 0) + return NULL; + + 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; + } + + PyObject *py_oid_out = git_oid_to_python(&oid_out); + if (py_oid_out == NULL) + return Error_set_exc(PyExc_MemoryError); + + PyObject *tuple = Py_BuildValue("(ny#N)", type, data, size, py_oid_out); + + git_odb_backend_data_free(self->odb_backend, data); + + return tuple; +} + +PyDoc_STRVAR(OdbBackend_read_header__doc__, + "read_header(oid) -> (type, len)\n" + "\n" + "Read raw object header from this odb backend."); + +PyObject * +OdbBackend_read_header(OdbBackend *self, PyObject *py_hex) +{ + int err; + size_t len; + git_object_t type; + git_oid oid; + + if (self->odb_backend->read_header == NULL) + Py_RETURN_NOTIMPLEMENTED; + + len = py_oid_to_git_oid(py_hex, &oid); + if (len == 0) + return NULL; + + err = self->odb_backend->read_header(&len, &type, self->odb_backend, &oid); + if (err != 0) { + Error_set_oid(err, &oid, len); + return NULL; + } + + return Py_BuildValue("(ni)", type, len); +} + +PyDoc_STRVAR(OdbBackend_exists__doc__, + "exists(oid: Oid) -> bool\n" + "\n" + "Returns true if the given oid can be found in this odb."); + +PyObject * +OdbBackend_exists(OdbBackend *self, PyObject *py_hex) +{ + int result; + size_t len; + git_oid oid; + + if (self->odb_backend->exists == NULL) + Py_RETURN_NOTIMPLEMENTED; + + len = py_oid_to_git_oid(py_hex, &oid); + if (len == 0) + return NULL; + + result = self->odb_backend->exists(self->odb_backend, &oid); + if (result < 0) + return Error_set(result); + else if (result == 0) + Py_RETURN_FALSE; + else + Py_RETURN_TRUE; +} + +PyDoc_STRVAR(OdbBackend_exists_prefix__doc__, + "exists_prefix(partial_id: Oid) -> Oid\n" + "\n" + "Given a partial oid, returns the full oid. Raises KeyError if not found,\n" + "or ValueError if ambiguous."); + +PyObject * +OdbBackend_exists_prefix(OdbBackend *self, PyObject *py_hex) +{ + int result; + size_t len; + git_oid oid; + + if (self->odb_backend->exists_prefix == NULL) + Py_RETURN_NOTIMPLEMENTED; + + len = py_oid_to_git_oid(py_hex, &oid); + if (len == 0) + return NULL; + + git_oid out; + result = self->odb_backend->exists_prefix(&out, self->odb_backend, &oid, len); + + if (result < 0) + return Error_set(result); + + return git_oid_to_python(&out); +} + +PyDoc_STRVAR(OdbBackend_refresh__doc__, + "refresh()\n" + "\n" + "If the backend supports a refreshing mechanism, this function will invoke\n" + "it. However, the backend implementation should try to stay up-to-date as\n" + "much as possible by itself as libgit2 will not automatically invoke this\n" + "function. For instance, a potential strategy for the backend\n" + "implementation to utilize this could be internally calling the refresh\n" + "function on failed lookups."); + +PyObject * +OdbBackend_refresh(OdbBackend *self) +{ + if (self->odb_backend->refresh == NULL) + Py_RETURN_NOTIMPLEMENTED; + + self->odb_backend->refresh(self->odb_backend); + Py_RETURN_NONE; +} + +/* + * TODO: + * - write + * - writepack + * - writestream + * - readstream + * - freshen + */ +PyMethodDef OdbBackend_methods[] = { + METHOD(OdbBackend, read, METH_O), + METHOD(OdbBackend, read_prefix, METH_O), + METHOD(OdbBackend, read_header, METH_O), + METHOD(OdbBackend, exists, METH_O), + METHOD(OdbBackend, exists_prefix, METH_O), + METHOD(OdbBackend, refresh, METH_NOARGS), + {NULL} +}; + +PyDoc_STRVAR(OdbBackend__doc__, "Object database backend."); + +PyTypeObject OdbBackendType = { + PyVarObject_HEAD_INIT(NULL, 0) + "_pygit2.OdbBackend", /* tp_name */ + sizeof(OdbBackend), /* tp_basicsize */ + 0, /* tp_itemsize */ + (destructor)OdbBackend_dealloc, /* tp_dealloc */ + 0, /* tp_print */ + 0, /* tp_getattr */ + 0, /* tp_setattr */ + 0, /* tp_compare */ + 0, /* tp_repr */ + 0, /* tp_as_number */ + 0, /* tp_as_sequence */ + 0, /* tp_as_mapping */ + 0, /* tp_hash */ + 0, /* tp_call */ + 0, /* tp_str */ + 0, /* tp_getattro */ + 0, /* tp_setattro */ + 0, /* tp_as_buffer */ + Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, /* tp_flags */ + OdbBackend__doc__, /* tp_doc */ + 0, /* tp_traverse */ + 0, /* tp_clear */ + 0, /* tp_richcompare */ + 0, /* tp_weaklistoffset */ + (getiterfunc)OdbBackend_as_iter, /* tp_iter */ + 0, /* tp_iternext */ + OdbBackend_methods, /* tp_methods */ + 0, /* tp_members */ + 0, /* tp_getset */ + 0, /* tp_base */ + 0, /* tp_dict */ + 0, /* tp_descr_get */ + 0, /* tp_descr_set */ + 0, /* tp_dictoffset */ + (initproc)OdbBackend_init, /* tp_init */ + 0, /* tp_alloc */ + 0, /* tp_new */ +}; + +PyObject * +wrap_odb_backend(git_odb_backend *odb_backend) +{ + OdbBackend *py_backend = PyObject_New(OdbBackend, &OdbBackendType); + if (py_backend) + py_backend->odb_backend = odb_backend; + + return (PyObject *)py_backend; +} + +PyDoc_STRVAR(OdbBackendPack__doc__, "Object database backend for packfiles."); + +int +OdbBackendPack_init(OdbBackendPack *self, PyObject *args, PyObject *kwds) +{ + if (kwds && PyDict_Size(kwds) > 0) { + PyErr_SetString(PyExc_TypeError, "OdbBackendPack takes no keyword arguments"); + return -1; + } + + PyObject *py_path; + if (!PyArg_ParseTuple(args, "O", &py_path)) + return -1; + + PyObject *tvalue; + char *path = pgit_borrow_fsdefault(py_path, &tvalue); + if (path == NULL) + return -1; + + int err = git_odb_backend_pack(&self->super.odb_backend, path); + Py_DECREF(tvalue); + if (err) { + Error_set(err); + return -1; + } + + return 0; +} + +PyTypeObject OdbBackendPackType = { + PyVarObject_HEAD_INIT(NULL, 0) + "_pygit2.OdbBackendPack", /* tp_name */ + sizeof(OdbBackendPack), /* tp_basicsize */ + 0, /* tp_itemsize */ + 0, /* tp_dealloc */ + 0, /* tp_print */ + 0, /* tp_getattr */ + 0, /* tp_setattr */ + 0, /* tp_compare */ + 0, /* tp_repr */ + 0, /* tp_as_number */ + 0, /* tp_as_sequence */ + 0, /* tp_as_mapping */ + 0, /* tp_hash */ + 0, /* tp_call */ + 0, /* tp_str */ + 0, /* tp_getattro */ + 0, /* tp_setattro */ + 0, /* tp_as_buffer */ + Py_TPFLAGS_DEFAULT, /* tp_flags */ + OdbBackendPack__doc__, /* tp_doc */ + 0, /* tp_traverse */ + 0, /* tp_clear */ + 0, /* tp_richcompare */ + 0, /* tp_weaklistoffset */ + 0, /* tp_iter */ + 0, /* tp_iternext */ + 0, /* tp_methods */ + 0, /* tp_members */ + 0, /* tp_getset */ + &OdbBackendType, /* tp_base */ + 0, /* tp_dict */ + 0, /* tp_descr_get */ + 0, /* tp_descr_set */ + 0, /* tp_dictoffset */ + (initproc)OdbBackendPack_init, /* tp_init */ + 0, /* tp_alloc */ + 0, /* tp_new */ +}; + +PyDoc_STRVAR(OdbBackendLoose__doc__, + "OdbBackendLoose(objects_dir, compression_level," + " do_fsync, dir_mode=0, file_mode=0)\n" + "\n" + "Object database backend for loose objects.\n" + "\n" + "Parameters:\n" + "\n" + "objects_dir\n" + " path to top-level object dir on disk\n" + "\n" + "compression_level\n" + " zlib compression level to use\n" + "\n" + "do_fsync\n" + " true to fsync() after writing\n" + "\n" + "dir_mode\n" + " mode for new directories, or 0 for default\n" + "\n" + "file_mode\n" + " mode for new files, or 0 for default"); + +int +OdbBackendLoose_init(OdbBackendLoose *self, PyObject *args, PyObject *kwds) +{ + if (kwds && PyDict_Size(kwds) > 0) { + PyErr_SetString(PyExc_TypeError, "OdbBackendLoose takes no keyword arguments"); + return -1; + } + + PyObject *py_path; + int compression_level, do_fsync; + unsigned int dir_mode = 0, file_mode = 0; + if (!PyArg_ParseTuple(args, "Oip|II", &py_path, &compression_level, + &do_fsync, &dir_mode, &file_mode)) + return -1; + + PyObject *tvalue; + char *path = pgit_borrow_fsdefault(py_path, &tvalue); + if (path == NULL) + return -1; + + int err = git_odb_backend_loose(&self->super.odb_backend, path, compression_level, + do_fsync, dir_mode, file_mode); + Py_DECREF(tvalue); + if (err) { + Error_set(err); + return -1; + } + + return 0; +} + +PyTypeObject OdbBackendLooseType = { + PyVarObject_HEAD_INIT(NULL, 0) + "_pygit2.OdbBackendLoose", /* tp_name */ + sizeof(OdbBackendLoose), /* tp_basicsize */ + 0, /* tp_itemsize */ + 0, /* tp_dealloc */ + 0, /* tp_print */ + 0, /* tp_getattr */ + 0, /* tp_setattr */ + 0, /* tp_compare */ + 0, /* tp_repr */ + 0, /* tp_as_number */ + 0, /* tp_as_sequence */ + 0, /* tp_as_mapping */ + 0, /* tp_hash */ + 0, /* tp_call */ + 0, /* tp_str */ + 0, /* tp_getattro */ + 0, /* tp_setattro */ + 0, /* tp_as_buffer */ + Py_TPFLAGS_DEFAULT, /* tp_flags */ + OdbBackendLoose__doc__, /* tp_doc */ + 0, /* tp_traverse */ + 0, /* tp_clear */ + 0, /* tp_richcompare */ + 0, /* tp_weaklistoffset */ + 0, /* tp_iter */ + 0, /* tp_iternext */ + 0, /* tp_methods */ + 0, /* tp_members */ + 0, /* tp_getset */ + &OdbBackendType, /* tp_base */ + 0, /* tp_dict */ + 0, /* tp_descr_get */ + 0, /* tp_descr_set */ + 0, /* tp_dictoffset */ + (initproc)OdbBackendLoose_init, /* tp_init */ + 0, /* tp_alloc */ + 0, /* tp_new */ +}; diff --git a/src/commit.h b/src/odb_backend.h similarity index 73% rename from src/commit.h rename to src/odb_backend.h index b94e30370..15ec46c72 100644 --- a/src/commit.h +++ b/src/odb_backend.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2013 The pygit2 contributors + * Copyright 2010-2025 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -25,19 +25,14 @@ * Boston, MA 02110-1301, USA. */ -#ifndef INCLUDE_pygit2_commit_h -#define INCLUDE_pygit2_commit_h +#ifndef INCLUDE_pygit2_odb_backend_h +#define INCLUDE_pygit2_odb_backend_h #define PY_SSIZE_T_CLEAN #include #include +#include "types.h" -PyObject* Commit_get_message_encoding(Commit *commit); -PyObject* Commit_get_message(Commit *commit); -PyObject* Commit_get_raw_message(Commit *commit); -PyObject* Commit_get_commit_time(Commit *commit); -PyObject* Commit_get_commit_time_offset(Commit *commit); -PyObject* Commit_get_committer(Commit *self); -PyObject* Commit_get_author(Commit *self); +PyObject *wrap_odb_backend(git_odb_backend *c_odb_backend); #endif diff --git a/src/oid.c b/src/oid.c index d1db630ef..ffce36a25 100644 --- a/src/oid.c +++ b/src/oid.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2013 The pygit2 contributors + * Copyright 2010-2025 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -34,6 +34,8 @@ PyTypeObject OidType; +static const git_oid oid_zero = GIT_OID_SHA1_ZERO; + PyObject * git_oid_to_python(const git_oid *oid) @@ -41,35 +43,21 @@ git_oid_to_python(const git_oid *oid) Oid *py_oid; py_oid = PyObject_New(Oid, &OidType); + if (py_oid == NULL) + return NULL; + git_oid_cpy(&(py_oid->oid), oid); return (PyObject*)py_oid; } size_t -py_hex_to_git_oid (PyObject *py_oid, git_oid *oid) +py_hex_to_git_oid(PyObject *py_oid, git_oid *oid) { PyObject *py_hex; int err; char *hex; Py_ssize_t len; -#if PY_MAJOR_VERSION == 2 - /* Bytes (only supported in Python 2) */ - if (PyBytes_Check(py_oid)) { - err = PyBytes_AsStringAndSize(py_oid, &hex, &len); - if (err) - return 0; - - err = git_oid_fromstrn(oid, hex, len); - if (err < 0) { - PyErr_SetObject(Error_type(err), py_oid); - return 0; - } - - return (size_t)len; - } -#endif - /* Unicode */ if (PyUnicode_Check(py_oid)) { py_hex = PyUnicode_AsASCIIString(py_oid); @@ -116,7 +104,7 @@ py_oid_to_git_oid_expand(git_repository *repo, PyObject *py_str, git_oid *oid) int err; size_t len; git_odb *odb = NULL; - git_odb_object *obj = NULL; + git_oid tmp; len = py_oid_to_git_oid(py_str, oid); if (len == 0) @@ -130,17 +118,16 @@ py_oid_to_git_oid_expand(git_repository *repo, PyObject *py_str, git_oid *oid) if (err < 0) goto error; - err = git_odb_read_prefix(&obj, odb, oid, len); + err = git_odb_exists_prefix(&tmp, odb, oid, len); if (err < 0) goto error; - git_oid_cpy(oid, git_odb_object_id(obj)); - git_odb_object_free(obj); + git_oid_cpy(oid, &tmp); + git_odb_free(odb); return 0; error: - git_odb_object_free(obj); git_odb_free(odb); Error_set(err); return -1; @@ -152,12 +139,7 @@ git_oid_to_py_str(const git_oid *oid) char hex[GIT_OID_HEXSZ]; git_oid_fmt(hex, oid); - - #if PY_MAJOR_VERSION == 2 - return PyBytes_FromStringAndSize(hex, GIT_OID_HEXSZ); - #else return to_unicode_n(hex, GIT_OID_HEXSZ, "utf-8", "strict"); - #endif } @@ -210,25 +192,37 @@ Oid_init(Oid *self, PyObject *args, PyObject *kw) Py_hash_t Oid_hash(PyObject *oid) { - /* TODO Randomize (use _Py_HashSecret) to avoid collission DoS attacks? */ - return *(Py_hash_t*) ((Oid*)oid)->oid.id; + PyObject *py_oid = git_oid_to_py_str(&((Oid *)oid)->oid); + Py_hash_t ret = PyObject_Hash(py_oid); + Py_DECREF(py_oid); + return ret; } PyObject * -Oid_richcompare(PyObject *o1, PyObject *o2, int op) +Oid_richcompare(PyObject *self, PyObject *other, int op) { - PyObject *res; + git_oid *oid = &((Oid*)self)->oid; int cmp; - /* Comparing to something else than an Oid is not supported. */ - if (!PyObject_TypeCheck(o2, &OidType)) { + // Can compare an oid against another oid or a unicode string + if (PyObject_TypeCheck(other, &OidType)) { + cmp = git_oid_cmp(oid, &((Oid*)other)->oid); + } + else if (PyObject_TypeCheck(other, &PyUnicode_Type)) { + const char * str = PyUnicode_AsUTF8(other); + if (str == NULL) { + return NULL; + } + cmp = git_oid_strcmp(oid, str); + } + else { Py_INCREF(Py_NotImplemented); return Py_NotImplemented; } - /* Ok go. */ - cmp = git_oid_cmp(&((Oid*)o1)->oid, &((Oid*)o2)->oid); + // Return boolean + PyObject *res; switch (op) { case Py_LT: res = (cmp <= 0) ? Py_True: Py_False; @@ -257,30 +251,72 @@ Oid_richcompare(PyObject *o1, PyObject *o2, int op) return res; } - -PyDoc_STRVAR(Oid_raw__doc__, "Raw oid, a 20 bytes string."); - PyObject * -Oid_raw__get__(Oid *self) +Oid__str__(Oid *self) { - return PyBytes_FromStringAndSize((const char*)self->oid.id, GIT_OID_RAWSZ); + return git_oid_to_py_str(&self->oid); } +int +Oid__bool(PyObject *self) +{ + git_oid *oid = &((Oid*)self)->oid; + return !git_oid_equal(oid, &oid_zero); +} -PyDoc_STRVAR(Oid_hex__doc__, "Hex oid, a 40 chars long string (type str)."); +PyDoc_STRVAR(Oid_raw__doc__, "Raw oid, a 20 bytes string."); PyObject * -Oid_hex__get__(Oid *self) +Oid_raw__get__(Oid *self) { - return git_oid_to_py_str(&self->oid); + return PyBytes_FromStringAndSize((const char*)self->oid.id, GIT_OID_RAWSZ); } + PyGetSetDef Oid_getseters[] = { GETTER(Oid, raw), - GETTER(Oid, hex), {NULL}, }; +PyNumberMethods Oid_as_number = { + 0, /* nb_add */ + 0, /* nb_subtract */ + 0, /* nb_multiply */ + 0, /* nb_remainder */ + 0, /* nb_divmod */ + 0, /* nb_power */ + 0, /* nb_negative */ + 0, /* nb_positive */ + 0, /* nb_absolute */ + Oid__bool, /* nb_bool */ + 0, /* nb_invert */ + 0, /* nb_lshift */ + 0, /* nb_rshift */ + 0, /* nb_and */ + 0, /* nb_xor */ + 0, /* nb_or */ + 0, /* nb_int */ + 0, /* nb_reserved */ + 0, /* nb_float */ + 0, /* nb_inplace_add */ + 0, /* nb_inplace_subtract */ + 0, /* nb_inplace_multiply */ + 0, /* nb_inplace_remainder */ + 0, /* nb_inplace_power */ + 0, /* nb_inplace_lshift */ + 0, /* nb_inplace_rshift */ + 0, /* nb_inplace_and */ + 0, /* nb_inplace_xor */ + 0, /* nb_inplace_or */ + 0, /* nb_floor_divide */ + 0, /* nb_true_divide */ + 0, /* nb_inplace_floor_divide */ + 0, /* nb_inplace_true_divide */ + 0, /* nb_index */ + 0, /* nb_matrix_multiply */ + 0, /* nb_inplace_matrix_multiply */ +}; + PyDoc_STRVAR(Oid__doc__, "Object id."); PyTypeObject OidType = { @@ -293,13 +329,13 @@ PyTypeObject OidType = { 0, /* tp_getattr */ 0, /* tp_setattr */ 0, /* tp_compare */ - 0, /* tp_repr */ - 0, /* tp_as_number */ + (reprfunc)Oid__str__, /* tp_repr */ + &Oid_as_number, /* tp_as_number */ 0, /* tp_as_sequence */ 0, /* tp_as_mapping */ (hashfunc)Oid_hash, /* tp_hash */ 0, /* tp_call */ - 0, /* tp_str */ + (reprfunc)Oid__str__, /* tp_str */ 0, /* tp_getattro */ 0, /* tp_setattro */ 0, /* tp_as_buffer */ diff --git a/src/oid.h b/src/oid.h index 871f26a47..99ed3b868 100644 --- a/src/oid.h +++ b/src/oid.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2013 The pygit2 contributors + * Copyright 2010-2025 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/patch.c b/src/patch.c new file mode 100644 index 000000000..256e7e0fe --- /dev/null +++ b/src/patch.c @@ -0,0 +1,294 @@ +/* + * 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 "diff.h" +#include "error.h" +#include "object.h" +#include "oid.h" +#include "types.h" +#include "utils.h" + +extern PyTypeObject DiffHunkType; +extern PyTypeObject BlobType; +PyTypeObject PatchType; + + +PyObject * +wrap_patch(git_patch *patch, Blob *oldblob, Blob *newblob) +{ + Patch *py_patch; + + assert(patch); + + py_patch = PyObject_New(Patch, &PatchType); + if (py_patch) { + py_patch->patch = patch; + + Py_XINCREF(oldblob); + py_patch->oldblob = oldblob; + + Py_XINCREF(newblob); + py_patch->newblob = newblob; + } + + return (PyObject*) py_patch; +} + +static void +Patch_dealloc(Patch *self) +{ + Py_CLEAR(self->oldblob); + Py_CLEAR(self->newblob); + git_patch_free(self->patch); + PyObject_Del(self); +} + +PyDoc_STRVAR(Patch_delta__doc__, "Get the delta associated with a patch."); + +PyObject * +Patch_delta__get__(Patch *self) +{ + assert(self->patch); + return wrap_diff_delta(git_patch_get_delta(self->patch)); +} + +PyDoc_STRVAR(Patch_line_stats__doc__, + "Get line counts of each type in a patch (context, additions, deletions)."); + +PyObject * +Patch_line_stats__get__(Patch *self) +{ + size_t context, additions, deletions; + int err; + + assert(self->patch); + err = git_patch_line_stats(&context, &additions, &deletions, self->patch); + if (err < 0) + return Error_set(err); + + return Py_BuildValue("III", context, additions, deletions); +} + +PyDoc_STRVAR(Patch_create_from__doc__, + "Create a patch from blobs, buffers, or a blob and a buffer"); + +static PyObject * +Patch_create_from(PyObject *self, PyObject *args, PyObject *kwds) +{ + /* A generic wrapper around + * git_patch_from_blob_and_buffer + * git_patch_from_buffers + * git_patch_from_blobs + */ + git_diff_options opts = GIT_DIFF_OPTIONS_INIT; + git_patch *patch; + char *old_as_path = NULL, *new_as_path = NULL; + PyObject *oldobj = NULL, *newobj = NULL; + Blob *oldblob = NULL, *newblob = NULL; + const char *oldbuf = NULL, *newbuf = NULL; + Py_ssize_t oldbuflen, newbuflen; + int err; + + char *keywords[] = {"old", "new", "old_as_path", "new_as_path", + "flag", "context_lines", "interhunk_lines", + NULL}; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "OO|zzIHH", keywords, + &oldobj, &newobj, &old_as_path, &new_as_path, + &opts.flags, &opts.context_lines, + &opts.interhunk_lines)) + return NULL; + + if (oldobj != Py_None && PyObject_TypeCheck(oldobj, &BlobType)) + { + /* The old object exists and is a blob */ + oldblob = (Blob*)oldobj; + if (Object__load((Object*)oldblob) == NULL) { return NULL; } // Lazy load + + if (newobj != Py_None && PyObject_TypeCheck(newobj, &BlobType)) + { + /* The new object exists and is a blob */ + newblob = (Blob*)newobj; + if (Object__load((Object*)newblob) == NULL) { return NULL; } // Lazy load + + err = git_patch_from_blobs(&patch, oldblob->blob, old_as_path, + newblob->blob, new_as_path, &opts); + } + else { + /* The new object does not exist or is a buffer */ + if (!PyArg_Parse(newobj, "z#", &newbuf, &newbuflen)) + return NULL; + + err = git_patch_from_blob_and_buffer(&patch, oldblob->blob, old_as_path, + newbuf, newbuflen, new_as_path, + &opts); + } + } + else + { + /* The old object does exist and is a buffer */ + if (!PyArg_Parse(oldobj, "z#", &oldbuf, &oldbuflen)) + return NULL; + + if (!PyArg_Parse(newobj, "z#", &newbuf, &newbuflen)) + return NULL; + + err = git_patch_from_buffers(&patch, oldbuf, oldbuflen, old_as_path, + newbuf, newbuflen, new_as_path, &opts); + } + + if (err < 0) + return Error_set(err); + + return wrap_patch(patch, oldblob, newblob); +} + +PyDoc_STRVAR(Patch_data__doc__, "The raw bytes of the patch's contents."); + +PyObject * +Patch_data__get__(Patch *self) +{ + git_buf buf = {NULL}; + int err; + PyObject *bytes; + + assert(self->patch); + err = git_patch_to_buf(&buf, self->patch); + if (err < 0) + return Error_set(err); + + bytes = PyBytes_FromStringAndSize(buf.ptr, buf.size); + git_buf_dispose(&buf); + return bytes; +} + +PyDoc_STRVAR(Patch_text__doc__, + "Patch diff string. Can be None in some cases, such as empty commits.\n" + "Note that this decodes the content to Unicode assuming UTF-8 encoding. " + "For non-UTF-8 content that can lead be a lossy, non-reversible process. " + "To access the raw, un-decoded patch, use `patch.data`."); + +PyObject * +Patch_text__get__(Patch *self) +{ + git_buf buf = {NULL}; + int err; + PyObject *text; + + assert(self->patch); + err = git_patch_to_buf(&buf, self->patch); + if (err < 0) + return Error_set(err); + + text = to_unicode_n(buf.ptr, buf.size, NULL, NULL); + git_buf_dispose(&buf); + return text; +} + +PyDoc_STRVAR(Patch_hunks__doc__, "hunks"); + +PyObject * +Patch_hunks__get__(Patch *self) +{ + size_t i, hunk_amounts; + PyObject *py_hunks; + PyObject *py_hunk; + + hunk_amounts = git_patch_num_hunks(self->patch); + py_hunks = PyList_New(hunk_amounts); + for (i = 0; i < hunk_amounts; i++) { + py_hunk = wrap_diff_hunk(self, i); + if (py_hunk == NULL) + return NULL; + + PyList_SET_ITEM((PyObject*) py_hunks, i, py_hunk); + } + + return py_hunks; +} + + +PyMethodDef Patch_methods[] = { + {"create_from", (PyCFunction) Patch_create_from, + METH_KEYWORDS | METH_VARARGS | METH_STATIC, Patch_create_from__doc__}, + {NULL} +}; + +PyGetSetDef Patch_getsetters[] = { + GETTER(Patch, delta), + GETTER(Patch, line_stats), + GETTER(Patch, data), + GETTER(Patch, text), + GETTER(Patch, hunks), + {NULL} +}; + +PyDoc_STRVAR(Patch__doc__, "Diff patch object."); + +PyTypeObject PatchType = { + PyVarObject_HEAD_INIT(NULL, 0) + "_pygit2.Patch", /* tp_name */ + sizeof(Patch), /* tp_basicsize */ + 0, /* tp_itemsize */ + (destructor)Patch_dealloc, /* tp_dealloc */ + 0, /* tp_print */ + 0, /* tp_getattr */ + 0, /* tp_setattr */ + 0, /* tp_compare */ + 0, /* tp_repr */ + 0, /* tp_as_number */ + 0, /* tp_as_sequence */ + 0, /* tp_as_mapping */ + 0, /* tp_hash */ + 0, /* tp_call */ + 0, /* tp_str */ + 0, /* tp_getattro */ + 0, /* tp_setattro */ + 0, /* tp_as_buffer */ + Py_TPFLAGS_DEFAULT, /* tp_flags */ + Patch__doc__, /* tp_doc */ + 0, /* tp_traverse */ + 0, /* tp_clear */ + 0, /* tp_richcompare */ + 0, /* tp_weaklistoffset */ + 0, /* tp_iter */ + 0, /* tp_iternext */ + Patch_methods, /* tp_methods */ + 0, /* tp_members */ + Patch_getsetters, /* tp_getset */ + 0, /* tp_base */ + 0, /* tp_dict */ + 0, /* tp_descr_get */ + 0, /* tp_descr_set */ + 0, /* tp_dictoffset */ + 0, /* tp_init */ + 0, /* tp_alloc */ + 0, /* tp_new */ +}; diff --git a/src/patch.h b/src/patch.h new file mode 100644 index 000000000..3c0ad759f --- /dev/null +++ b/src/patch.h @@ -0,0 +1,37 @@ +/* + * 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_patch_h +#define INCLUDE_pygit2_patch_h + +#define PY_SSIZE_T_CLEAN +#include +#include + +PyObject* wrap_patch(git_patch *patch, Blob *oldblob, Blob *newblob); + +#endif diff --git a/src/pygit2.c b/src/pygit2.c index a4729df63..c2bac5e5e 100644 --- a/src/pygit2.c +++ b/src/pygit2.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2013 The pygit2 contributors + * Copyright 2010-2025 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -27,166 +27,119 @@ #define PY_SSIZE_T_CLEAN #include -#include + #include +#include #include "error.h" #include "types.h" #include "utils.h" #include "repository.h" #include "oid.h" +#include "filter.h" + +PyObject *GitError; +PyObject *AlreadyExistsError; +PyObject *InvalidSpecError; -extern PyObject *GitError; +PyObject *DeltaStatusEnum; +PyObject *DiffFlagEnum; +PyObject *FileModeEnum; +PyObject *FileStatusEnum; +PyObject *MergeAnalysisEnum; +PyObject *MergePreferenceEnum; +PyObject *ReferenceTypeEnum; extern PyTypeObject RepositoryType; +extern PyTypeObject OdbType; +extern PyTypeObject OdbBackendType; +extern PyTypeObject OdbBackendPackType; +extern PyTypeObject OdbBackendLooseType; extern PyTypeObject OidType; extern PyTypeObject ObjectType; extern PyTypeObject CommitType; extern PyTypeObject DiffType; +extern PyTypeObject DeltasIterType; extern PyTypeObject DiffIterType; +extern PyTypeObject DiffDeltaType; +extern PyTypeObject DiffFileType; +extern PyTypeObject DiffHunkType; +extern PyTypeObject DiffLineType; +extern PyTypeObject DiffStatsType; extern PyTypeObject PatchType; -extern PyTypeObject HunkType; extern PyTypeObject TreeType; extern PyTypeObject TreeBuilderType; -extern PyTypeObject TreeEntryType; extern PyTypeObject TreeIterType; extern PyTypeObject BlobType; extern PyTypeObject TagType; -extern PyTypeObject IndexType; -extern PyTypeObject IndexEntryType; -extern PyTypeObject IndexIterType; extern PyTypeObject WalkerType; -extern PyTypeObject ConfigType; +extern PyTypeObject RefdbType; +extern PyTypeObject RefdbBackendType; +extern PyTypeObject RefdbFsBackendType; extern PyTypeObject ReferenceType; +extern PyTypeObject RevSpecType; extern PyTypeObject RefLogIterType; extern PyTypeObject RefLogEntryType; extern PyTypeObject BranchType; extern PyTypeObject SignatureType; extern PyTypeObject RemoteType; +extern PyTypeObject RefspecType; extern PyTypeObject NoteType; extern PyTypeObject NoteIterType; - - - -PyDoc_STRVAR(init_repository__doc__, - "init_repository(path, bare)\n" - "\n" - "Creates a new Git repository in the given path.\n" - "\n" - "Arguments:\n" - "\n" - "path\n" - " Path where to create the repository.\n" - "\n" - "bare\n" - " Whether the repository will be bare or not.\n"); - -PyObject * -init_repository(PyObject *self, PyObject *args) { - git_repository *repo; - const char *path; - unsigned int bare; - int err; - - if (!PyArg_ParseTuple(args, "sI", &path, &bare)) - return NULL; - - err = git_repository_init(&repo, path, bare); - if (err < 0) - return Error_set_str(err, path); - - git_repository_free(repo); - Py_RETURN_NONE; -}; - -PyDoc_STRVAR(clone_repository__doc__, - "clone_repository(url, path, bare, remote_name, push_url," - "fetch_spec, push_spec, checkout_branch)\n" - "\n" - "Clones a Git repository in the given url to the given path " - "with the specified options.\n" - "\n" - "Arguments:\n" - "\n" - "url\n" - " Git repository remote url.\n" - "path\n" - " Path where to create the repository.\n" - "bare\n" - " If 'bare' is not 0, then a bare git repository will be created.\n" - "remote_name\n" - " The name given to the 'origin' remote. The default is 'origin'.\n" - "push_url\n" - " URL to be used for pushing.\n" - "fetch_spec\n" - " The fetch specification to be used for fetching. None results in " - "the same behavior as GIT_REMOTE_DEFAULT_FETCH.\n" - "push_spec\n" - " The fetch specification to be used for pushing. None means use the " - "same spec as for 'fetch_spec'\n" - "checkout_branch\n" - " The name of the branch to checkout. None means use the remote's " - "HEAD.\n"); - - -PyObject * -clone_repository(PyObject *self, PyObject *args) { - git_repository *repo; - const char *url; - const char *path; - unsigned int bare; - const char *remote_name, *push_url, *fetch_spec; - const char *push_spec, *checkout_branch; - int err; - git_clone_options opts = GIT_CLONE_OPTIONS_INIT; - - if (!PyArg_ParseTuple(args, "zzIzzzzz", - &url, &path, &bare, &remote_name, &push_url, - &fetch_spec, &push_spec, &checkout_branch)) - return NULL; - - opts.bare = bare; - opts.remote_name = remote_name; - opts.pushurl = push_url; - opts.fetch_spec = fetch_spec; - opts.push_spec = push_spec; - opts.checkout_branch = checkout_branch; - - err = git_clone(&repo, url, path, &opts); - if (err < 0) - return Error_set(err); - - git_repository_free(repo); - Py_RETURN_NONE; -}; +extern PyTypeObject WorktreeType; +extern PyTypeObject MailmapType; +extern PyTypeObject StashType; +extern PyTypeObject RefsIteratorType; +extern PyTypeObject FilterSourceType; PyDoc_STRVAR(discover_repository__doc__, - "discover_repository(path[, across_fs[, ceiling_dirs]]) -> str\n" + "discover_repository(path: str, across_fs: bool = False[, ceiling_dirs: str]) -> str\n" "\n" - "Look for a git repository and return its path."); + "Look for a git repository and return its path. If not found returns None."); PyObject * discover_repository(PyObject *self, PyObject *args) { - const char *path; + git_buf repo_path = {NULL}; + const char *path = NULL; + PyBytesObject *py_path = NULL; int across_fs = 0; + PyBytesObject *py_ceiling_dirs = NULL; const char *ceiling_dirs = NULL; - char repo_path[MAXPATHLEN]; + PyObject *py_repo_path = NULL; int err; + PyObject *result = NULL; - if (!PyArg_ParseTuple(args, "s|Is", &path, &across_fs, &ceiling_dirs)) + if (!PyArg_ParseTuple(args, "O&|IO&", PyUnicode_FSConverter, &py_path, &across_fs, + PyUnicode_FSConverter, &py_ceiling_dirs)) return NULL; - err = git_repository_discover(repo_path, sizeof(repo_path), - path, across_fs, ceiling_dirs); - if (err < 0) - return Error_set_str(err, path); + if (py_path != NULL) + path = PyBytes_AS_STRING(py_path); + if (py_ceiling_dirs != NULL) + ceiling_dirs = PyBytes_AS_STRING(py_ceiling_dirs); + + memset(&repo_path, 0, sizeof(git_buf)); + err = git_repository_discover(&repo_path, path, across_fs, ceiling_dirs); + + if (err == GIT_OK) { + py_repo_path = PyUnicode_DecodeFSDefault(repo_path.ptr); + git_buf_dispose(&repo_path); + result = py_repo_path; + } else if (err == GIT_ENOTFOUND) { + result = Py_None; + } else { + result = Error_set_str(err, path); + } + + Py_XDECREF(py_ceiling_dirs); + Py_XDECREF(py_path); - return to_path(repo_path); + return result; }; PyDoc_STRVAR(hashfile__doc__, - "hashfile(path) -> Oid\n" + "hashfile(path: str) -> Oid\n" "\n" "Returns the oid of a new blob from a file path without actually writing\n" "to the odb."); @@ -194,13 +147,18 @@ PyObject * hashfile(PyObject *self, PyObject *args) { git_oid oid; - const char* path; + PyBytesObject *py_path = NULL; + const char* path = NULL; int err; - if (!PyArg_ParseTuple(args, "s", &path)) + if (!PyArg_ParseTuple(args, "O&", PyUnicode_FSConverter, &py_path)) return NULL; - err = git_odb_hashfile(&oid, path, GIT_OBJ_BLOB); + if (py_path != NULL) + path = PyBytes_AS_STRING(py_path); + + err = git_odb_hashfile(&oid, path, GIT_OBJECT_BLOB); + Py_XDECREF(py_path); if (err < 0) return Error_set(err); @@ -208,7 +166,7 @@ hashfile(PyObject *self, PyObject *args) } PyDoc_STRVAR(hash__doc__, - "hash(data) -> Oid\n" + "hash(data: bytes) -> Oid\n" "\n" "Returns the oid of a new blob from a string without actually writing to\n" "the odb."); @@ -223,7 +181,7 @@ hash(PyObject *self, PyObject *args) if (!PyArg_ParseTuple(args, "s#", &data, &size)) return NULL; - err = git_odb_hash(&oid, data, size, GIT_OBJ_BLOB); + err = git_odb_hash(&oid, data, size, GIT_OBJECT_BLOB); if (err < 0) { return Error_set(err); } @@ -232,20 +190,278 @@ hash(PyObject *self, PyObject *args) } +PyDoc_STRVAR(init_file_backend__doc__, + "init_file_backend(path: str, flags: int = 0) -> object\n" + "\n" + "Open repo backend given path."); +PyObject * +init_file_backend(PyObject *self, PyObject *args) +{ + PyBytesObject *py_path = NULL; + const char* path = NULL; + unsigned int flags = 0; + int err = GIT_OK; + git_repository *repository = NULL; + PyObject *result = NULL; + + if (!PyArg_ParseTuple(args, "O&|I", PyUnicode_FSConverter, &py_path, &flags)) + return NULL; + if (py_path != NULL) + path = PyBytes_AS_STRING(py_path); + + err = git_repository_open_ext(&repository, path, flags, NULL); + + if (err == GIT_OK) { + result = PyCapsule_New(repository, "backend", NULL); + } else { + result = NULL; + Error_set_str(err, path); + + if (repository) { + git_repository_free(repository); + } + + if (err == GIT_ENOTFOUND) { + PyErr_Format(GitError, "Repository not found at %s", path); + } + } + + Py_XDECREF(py_path); + return result; +} + + +PyDoc_STRVAR(reference_is_valid_name__doc__, + "reference_is_valid_name(refname: str) -> bool\n" + "\n" + "Check if the passed string is a valid reference name."); +PyObject * +reference_is_valid_name(PyObject *self, PyObject *py_refname) +{ + const char *refname = pgit_borrow(py_refname); + if (refname == NULL) + return NULL; + + int result = git_reference_is_valid_name(refname); + return PyBool_FromLong(result); +} + + +PyDoc_STRVAR(tree_entry_cmp__doc__, + "tree_entry_cmp(a: Object, b: Object) -> int\n" + "\n" + "Rich comparison for objects, only available when the objects have been\n" + "obtained through a tree. The sort criteria is the one Git uses to sort\n" + "tree entries in a tree object. This function wraps git_tree_entry_cmp.\n" + "\n" + "Returns < 0 if a is before b, > 0 if a is after b, and 0 if a and b are\n" + "the same."); + +PyObject * +tree_entry_cmp(PyObject *self, PyObject *args) +{ + Object *a, *b; + int cmp; + + if (!PyArg_ParseTuple(args, "O!O!", &ObjectType, &a, &ObjectType, &b)) + return NULL; + + if (a->entry == NULL || b->entry == NULL) { + PyErr_SetString(PyExc_ValueError, "objects lack entry information"); + return NULL; + } + + cmp = git_tree_entry_cmp(a->entry, b->entry); + return PyLong_FromLong(cmp); +} + +PyDoc_STRVAR(filter_register__doc__, + "filter_register(name: str, filter_cls: Type[Filter], [priority: int = C.GIT_FILTER_DRIVER_PRIORITY]) -> None\n" + "\n" + "Register a filter under the given name.\n" + "\n" + "Filters will be run in order of `priority` on smudge (to workdir) and in\n" + "reverse order of priority on clean (to odb).\n" + "\n" + "Two filters are preregistered with libgit2:\n" + " - GIT_FILTER_CRLF with priority 0\n" + " - GIT_FILTER_IDENT with priority 100\n" + "\n" + "`priority` defaults to GIT_FILTER_DRIVER_PRIORITY which imitates a core\n" + "Git filter driver that will be run last on checkout (smudge) and first \n" + "on check-in (clean).\n" + "\n" + "Note that the filter registry is not thread safe. Any registering or\n" + "deregistering of filters should be done outside of any possible usage\n" + "of the filters.\n"); + +PyObject * +filter_register(PyObject *self, PyObject *args, PyObject *kwds) +{ + const char *name; + Py_ssize_t size; + PyObject *py_filter_cls; + 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) + return NULL; + + filter = malloc(sizeof(pygit2_filter)); + if (filter == NULL) + { + return PyExc_MemoryError; + } + memset(filter, 0, sizeof(pygit2_filter)); + git_filter_init(&filter->filter, GIT_FILTER_VERSION); + + filter->filter.attributes = PyUnicode_AsUTF8(py_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; + + if (!PyArg_ParseTuple(args, "s#", &name, &size)) + return NULL; + if ((err = git_filter_unregister(name)) < 0) + return Error_set(err); + + Py_RETURN_NONE; +} + + +static void +forget_enums(void) +{ + Py_CLEAR(DeltaStatusEnum); + Py_CLEAR(DiffFlagEnum); + Py_CLEAR(FileModeEnum); + Py_CLEAR(FileStatusEnum); + Py_CLEAR(MergeAnalysisEnum); + Py_CLEAR(MergePreferenceEnum); + Py_CLEAR(ReferenceTypeEnum); +} + +PyDoc_STRVAR(_cache_enums__doc__, + "_cache_enums()\n" + "\n" + "For internal use only. Do not call this from user code.\n" + "\n" + "Let the _pygit2 C module cache references to Python enums\n" + "defined in pygit2.enums.\n"); + +PyObject * +_cache_enums(PyObject *self, PyObject *args) +{ + (void) args; + + /* In case this is somehow being called several times, let go of old references */ + forget_enums(); + + PyObject *enums = PyImport_ImportModule("pygit2.enums"); + if (enums == NULL) { + return NULL; + } + +#define CACHE_PYGIT2_ENUM(name) do { \ + name##Enum = PyObject_GetAttrString(enums, #name); \ + if (name##Enum == NULL) { goto fail; } \ +} while (0) + + CACHE_PYGIT2_ENUM(DeltaStatus); + CACHE_PYGIT2_ENUM(DiffFlag); + CACHE_PYGIT2_ENUM(FileMode); + CACHE_PYGIT2_ENUM(FileStatus); + CACHE_PYGIT2_ENUM(MergeAnalysis); + CACHE_PYGIT2_ENUM(MergePreference); + CACHE_PYGIT2_ENUM(ReferenceType); + +#undef CACHE_PYGIT2_ENUM + + Py_RETURN_NONE; + +fail: + Py_DECREF(enums); + forget_enums(); + return NULL; +} + +void +free_module(void *self) +{ + forget_enums(); +} + + PyMethodDef module_methods[] = { - {"init_repository", init_repository, METH_VARARGS, init_repository__doc__}, - {"clone_repository", clone_repository, METH_VARARGS, - clone_repository__doc__}, - {"discover_repository", discover_repository, METH_VARARGS, - discover_repository__doc__}, - {"hashfile", hashfile, METH_VARARGS, hashfile__doc__}, + {"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__}, + {"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} }; -PyObject* -moduleinit(PyObject* m) +struct PyModuleDef moduledef = { + PyModuleDef_HEAD_INIT, + "_pygit2", /* m_name */ + "Python bindings for libgit2.", /* m_doc */ + -1, /* m_size */ + module_methods, /* m_methods */ + NULL, /* m_reload */ + NULL, /* m_traverse */ + NULL, /* m_clear */ + free_module, /* m_free */ +}; + +PyMODINIT_FUNC +PyInit__pygit2(void) { + PyObject *m = PyModule_Create(&moduledef); if (m == NULL) return NULL; @@ -255,15 +471,27 @@ moduleinit(PyObject* m) ADD_CONSTANT_INT(m, LIBGIT2_VER_REVISION) ADD_CONSTANT_STR(m, LIBGIT2_VERSION) - /* Errors */ - GitError = PyErr_NewException("_pygit2.GitError", NULL, NULL); - Py_INCREF(GitError); - PyModule_AddObject(m, "GitError", GitError); + + /* Exceptions */ + ADD_EXC(m, GitError, NULL); + ADD_EXC(m, AlreadyExistsError, PyExc_ValueError); + ADD_EXC(m, InvalidSpecError, PyExc_ValueError); /* Repository */ INIT_TYPE(RepositoryType, NULL, PyType_GenericNew) ADD_TYPE(m, Repository) + /* Odb */ + INIT_TYPE(OdbType, NULL, PyType_GenericNew) + ADD_TYPE(m, Odb) + + INIT_TYPE(OdbBackendType, NULL, PyType_GenericNew) + ADD_TYPE(m, OdbBackend) + INIT_TYPE(OdbBackendPackType, &OdbBackendType, PyType_GenericNew) + ADD_TYPE(m, OdbBackendPack) + INIT_TYPE(OdbBackendLooseType, &OdbBackendType, PyType_GenericNew) + ADD_TYPE(m, OdbBackendLoose) + /* Oid */ INIT_TYPE(OidType, NULL, PyType_GenericNew) ADD_TYPE(m, Oid) @@ -279,26 +507,28 @@ moduleinit(PyObject* m) INIT_TYPE(CommitType, &ObjectType, NULL) INIT_TYPE(SignatureType, NULL, PyType_GenericNew) INIT_TYPE(TreeType, &ObjectType, NULL) - INIT_TYPE(TreeEntryType, NULL, NULL) INIT_TYPE(TreeIterType, NULL, NULL) - INIT_TYPE(TreeBuilderType, NULL, PyType_GenericNew) + INIT_TYPE(TreeBuilderType, NULL, NULL) INIT_TYPE(BlobType, &ObjectType, NULL) INIT_TYPE(TagType, &ObjectType, NULL) + INIT_TYPE(RefsIteratorType, NULL, NULL) ADD_TYPE(m, Object) ADD_TYPE(m, Commit) ADD_TYPE(m, Signature) ADD_TYPE(m, Tree) - ADD_TYPE(m, TreeEntry) ADD_TYPE(m, TreeBuilder) ADD_TYPE(m, Blob) ADD_TYPE(m, Tag) - ADD_CONSTANT_INT(m, GIT_OBJ_ANY) - ADD_CONSTANT_INT(m, GIT_OBJ_COMMIT) - ADD_CONSTANT_INT(m, GIT_OBJ_TREE) - ADD_CONSTANT_INT(m, GIT_OBJ_BLOB) - ADD_CONSTANT_INT(m, GIT_OBJ_TAG) + ADD_CONSTANT_INT(m, GIT_OBJECT_ANY) + ADD_CONSTANT_INT(m, GIT_OBJECT_INVALID) + ADD_CONSTANT_INT(m, GIT_OBJECT_COMMIT) + ADD_CONSTANT_INT(m, GIT_OBJECT_TREE) + ADD_CONSTANT_INT(m, GIT_OBJECT_BLOB) + ADD_CONSTANT_INT(m, GIT_OBJECT_TAG) + ADD_CONSTANT_INT(m, GIT_OBJECT_OFS_DELTA) + ADD_CONSTANT_INT(m, GIT_OBJECT_REF_DELTA) /* Valid modes for index and tree entries. */ - ADD_CONSTANT_INT(m, GIT_FILEMODE_NEW) + ADD_CONSTANT_INT(m, GIT_FILEMODE_UNREADABLE) ADD_CONSTANT_INT(m, GIT_FILEMODE_TREE) ADD_CONSTANT_INT(m, GIT_FILEMODE_BLOB) ADD_CONSTANT_INT(m, GIT_FILEMODE_BLOB_EXECUTABLE) @@ -308,12 +538,29 @@ moduleinit(PyObject* m) /* * Log */ - INIT_TYPE(WalkerType, NULL, PyType_GenericNew) + INIT_TYPE(WalkerType, NULL, NULL) + ADD_TYPE(m, Walker); ADD_CONSTANT_INT(m, GIT_SORT_NONE) ADD_CONSTANT_INT(m, GIT_SORT_TOPOLOGICAL) ADD_CONSTANT_INT(m, GIT_SORT_TIME) ADD_CONSTANT_INT(m, GIT_SORT_REVERSE) + /* + * Reset + */ + ADD_CONSTANT_INT(m, GIT_RESET_SOFT) + ADD_CONSTANT_INT(m, GIT_RESET_MIXED) + ADD_CONSTANT_INT(m, GIT_RESET_HARD) + + /* Refdb */ + INIT_TYPE(RefdbType, NULL, PyType_GenericNew) + ADD_TYPE(m, Refdb) + + INIT_TYPE(RefdbBackendType, NULL, PyType_GenericNew) + ADD_TYPE(m, RefdbBackend) + INIT_TYPE(RefdbFsBackendType, &RefdbBackendType, PyType_GenericNew) + ADD_TYPE(m, RefdbFsBackend) + /* * References */ @@ -325,133 +572,278 @@ moduleinit(PyObject* m) ADD_TYPE(m, Reference) ADD_TYPE(m, RefLogEntry) ADD_TYPE(m, Note) - ADD_CONSTANT_INT(m, GIT_REF_INVALID) - ADD_CONSTANT_INT(m, GIT_REF_OID) - ADD_CONSTANT_INT(m, GIT_REF_SYMBOLIC) - ADD_CONSTANT_INT(m, GIT_REF_LISTALL) + ADD_CONSTANT_INT(m, GIT_REFERENCES_ALL) + ADD_CONSTANT_INT(m, GIT_REFERENCES_BRANCHES) + ADD_CONSTANT_INT(m, GIT_REFERENCES_TAGS) + + /* + * RevSpec + */ + INIT_TYPE(RevSpecType, NULL, NULL) + ADD_TYPE(m, RevSpec) + ADD_CONSTANT_INT(m, GIT_REVSPEC_SINGLE) + ADD_CONSTANT_INT(m, GIT_REVSPEC_RANGE) + ADD_CONSTANT_INT(m, GIT_REVSPEC_MERGE_BASE) + + /* + * Worktree + */ + INIT_TYPE(WorktreeType, NULL, NULL) + ADD_TYPE(m, Worktree) /* * Branches */ - INIT_TYPE(BranchType, &ReferenceType, PyType_GenericNew); + INIT_TYPE(BranchType, &ReferenceType, NULL); ADD_TYPE(m, Branch) ADD_CONSTANT_INT(m, GIT_BRANCH_LOCAL) ADD_CONSTANT_INT(m, GIT_BRANCH_REMOTE) + ADD_CONSTANT_INT(m, GIT_BRANCH_ALL) /* * Index & Working copy */ - INIT_TYPE(IndexType, NULL, PyType_GenericNew) - INIT_TYPE(IndexEntryType, NULL, NULL) - INIT_TYPE(IndexIterType, NULL, NULL) - ADD_TYPE(m, Index) - ADD_TYPE(m, IndexEntry) /* Status */ ADD_CONSTANT_INT(m, GIT_STATUS_CURRENT) ADD_CONSTANT_INT(m, GIT_STATUS_INDEX_NEW) ADD_CONSTANT_INT(m, GIT_STATUS_INDEX_MODIFIED) ADD_CONSTANT_INT(m, GIT_STATUS_INDEX_DELETED) + ADD_CONSTANT_INT(m, GIT_STATUS_INDEX_RENAMED) + ADD_CONSTANT_INT(m, GIT_STATUS_INDEX_TYPECHANGE) ADD_CONSTANT_INT(m, GIT_STATUS_WT_NEW) ADD_CONSTANT_INT(m, GIT_STATUS_WT_MODIFIED) ADD_CONSTANT_INT(m, GIT_STATUS_WT_DELETED) + ADD_CONSTANT_INT(m, GIT_STATUS_WT_TYPECHANGE) + ADD_CONSTANT_INT(m, GIT_STATUS_WT_RENAMED) + ADD_CONSTANT_INT(m, GIT_STATUS_WT_UNREADABLE) ADD_CONSTANT_INT(m, GIT_STATUS_IGNORED) /* Flags for ignored files */ + ADD_CONSTANT_INT(m, GIT_STATUS_CONFLICTED) /* Different checkout strategies */ ADD_CONSTANT_INT(m, GIT_CHECKOUT_NONE) ADD_CONSTANT_INT(m, GIT_CHECKOUT_SAFE) - ADD_CONSTANT_INT(m, GIT_CHECKOUT_SAFE_CREATE) ADD_CONSTANT_INT(m, GIT_CHECKOUT_FORCE) + ADD_CONSTANT_INT(m, GIT_CHECKOUT_RECREATE_MISSING) ADD_CONSTANT_INT(m, GIT_CHECKOUT_ALLOW_CONFLICTS) ADD_CONSTANT_INT(m, GIT_CHECKOUT_REMOVE_UNTRACKED) ADD_CONSTANT_INT(m, GIT_CHECKOUT_REMOVE_IGNORED) ADD_CONSTANT_INT(m, GIT_CHECKOUT_UPDATE_ONLY) ADD_CONSTANT_INT(m, GIT_CHECKOUT_DONT_UPDATE_INDEX) ADD_CONSTANT_INT(m, GIT_CHECKOUT_NO_REFRESH) + ADD_CONSTANT_INT(m, GIT_CHECKOUT_SKIP_UNMERGED) + ADD_CONSTANT_INT(m, GIT_CHECKOUT_USE_OURS) + ADD_CONSTANT_INT(m, GIT_CHECKOUT_USE_THEIRS) ADD_CONSTANT_INT(m, GIT_CHECKOUT_DISABLE_PATHSPEC_MATCH) + ADD_CONSTANT_INT(m, GIT_CHECKOUT_SKIP_LOCKED_DIRECTORIES) + ADD_CONSTANT_INT(m, GIT_CHECKOUT_DONT_OVERWRITE_IGNORED) + ADD_CONSTANT_INT(m, GIT_CHECKOUT_CONFLICT_STYLE_MERGE) + ADD_CONSTANT_INT(m, GIT_CHECKOUT_CONFLICT_STYLE_DIFF3) + ADD_CONSTANT_INT(m, GIT_CHECKOUT_DONT_REMOVE_EXISTING) + ADD_CONSTANT_INT(m, GIT_CHECKOUT_DONT_WRITE_INDEX) + ADD_CONSTANT_INT(m, GIT_CHECKOUT_DRY_RUN) + ADD_CONSTANT_INT(m, GIT_CHECKOUT_CONFLICT_STYLE_ZDIFF3) /* * Diff */ INIT_TYPE(DiffType, NULL, NULL) + INIT_TYPE(DeltasIterType, NULL, NULL) INIT_TYPE(DiffIterType, NULL, NULL) + INIT_TYPE(DiffDeltaType, NULL, NULL) + INIT_TYPE(DiffFileType, NULL, NULL) + INIT_TYPE(DiffHunkType, NULL, NULL) + INIT_TYPE(DiffLineType, NULL, NULL) + INIT_TYPE(DiffStatsType, NULL, NULL) INIT_TYPE(PatchType, NULL, NULL) - INIT_TYPE(HunkType, NULL, NULL) ADD_TYPE(m, Diff) + ADD_TYPE(m, DiffDelta) + ADD_TYPE(m, DiffFile) + ADD_TYPE(m, DiffHunk) + ADD_TYPE(m, DiffLine) + ADD_TYPE(m, DiffStats) ADD_TYPE(m, Patch) - ADD_TYPE(m, Hunk) + + /* (git_diff_options in libgit2) */ ADD_CONSTANT_INT(m, GIT_DIFF_NORMAL) ADD_CONSTANT_INT(m, GIT_DIFF_REVERSE) - ADD_CONSTANT_INT(m, GIT_DIFF_FORCE_TEXT) - ADD_CONSTANT_INT(m, GIT_DIFF_IGNORE_WHITESPACE) - ADD_CONSTANT_INT(m, GIT_DIFF_IGNORE_WHITESPACE_CHANGE) - ADD_CONSTANT_INT(m, GIT_DIFF_IGNORE_WHITESPACE_EOL) - ADD_CONSTANT_INT(m, GIT_DIFF_IGNORE_SUBMODULES) - ADD_CONSTANT_INT(m, GIT_DIFF_PATIENCE) ADD_CONSTANT_INT(m, GIT_DIFF_INCLUDE_IGNORED) + ADD_CONSTANT_INT(m, GIT_DIFF_RECURSE_IGNORED_DIRS) ADD_CONSTANT_INT(m, GIT_DIFF_INCLUDE_UNTRACKED) - ADD_CONSTANT_INT(m, GIT_DIFF_INCLUDE_UNMODIFIED) - ADD_CONSTANT_INT(m, GIT_DIFF_RECURSE_UNTRACKED_DIRS) ADD_CONSTANT_INT(m, GIT_DIFF_RECURSE_UNTRACKED_DIRS) - ADD_CONSTANT_INT(m, GIT_DIFF_DISABLE_PATHSPEC_MATCH) - ADD_CONSTANT_INT(m, GIT_DIFF_DELTAS_ARE_ICASE) - ADD_CONSTANT_INT(m, GIT_DIFF_INCLUDE_UNTRACKED_CONTENT) - ADD_CONSTANT_INT(m, GIT_DIFF_SKIP_BINARY_CHECK) + ADD_CONSTANT_INT(m, GIT_DIFF_INCLUDE_UNMODIFIED) ADD_CONSTANT_INT(m, GIT_DIFF_INCLUDE_TYPECHANGE) ADD_CONSTANT_INT(m, GIT_DIFF_INCLUDE_TYPECHANGE_TREES) - ADD_CONSTANT_INT(m, GIT_DIFF_RECURSE_IGNORED_DIRS) - /* Flags for diff find similar */ - /* --find-renames */ - ADD_CONSTANT_INT(m, GIT_DIFF_FIND_RENAMES) - /* --break-rewrites=N */ - ADD_CONSTANT_INT(m, GIT_DIFF_FIND_RENAMES_FROM_REWRITES) - /* --find-copies */ - ADD_CONSTANT_INT(m, GIT_DIFF_FIND_COPIES) - /* --find-copies-harder */ - ADD_CONSTANT_INT(m, GIT_DIFF_FIND_COPIES_FROM_UNMODIFIED) - /* --break-rewrites=/M */ + ADD_CONSTANT_INT(m, GIT_DIFF_IGNORE_FILEMODE) + ADD_CONSTANT_INT(m, GIT_DIFF_IGNORE_SUBMODULES) + ADD_CONSTANT_INT(m, GIT_DIFF_IGNORE_CASE) + ADD_CONSTANT_INT(m, GIT_DIFF_INCLUDE_CASECHANGE) + ADD_CONSTANT_INT(m, GIT_DIFF_DISABLE_PATHSPEC_MATCH) + ADD_CONSTANT_INT(m, GIT_DIFF_SKIP_BINARY_CHECK) + ADD_CONSTANT_INT(m, GIT_DIFF_ENABLE_FAST_UNTRACKED_DIRS) + ADD_CONSTANT_INT(m, GIT_DIFF_UPDATE_INDEX) + ADD_CONSTANT_INT(m, GIT_DIFF_INCLUDE_UNREADABLE) + ADD_CONSTANT_INT(m, GIT_DIFF_INCLUDE_UNREADABLE_AS_UNTRACKED) + ADD_CONSTANT_INT(m, GIT_DIFF_INDENT_HEURISTIC) + ADD_CONSTANT_INT(m, GIT_DIFF_IGNORE_BLANK_LINES) + ADD_CONSTANT_INT(m, GIT_DIFF_FORCE_TEXT) + ADD_CONSTANT_INT(m, GIT_DIFF_FORCE_BINARY) + ADD_CONSTANT_INT(m, GIT_DIFF_IGNORE_WHITESPACE) + ADD_CONSTANT_INT(m, GIT_DIFF_IGNORE_WHITESPACE_CHANGE) + ADD_CONSTANT_INT(m, GIT_DIFF_IGNORE_WHITESPACE_EOL) + ADD_CONSTANT_INT(m, GIT_DIFF_SHOW_UNTRACKED_CONTENT) + ADD_CONSTANT_INT(m, GIT_DIFF_SHOW_UNMODIFIED) + ADD_CONSTANT_INT(m, GIT_DIFF_PATIENCE) + ADD_CONSTANT_INT(m, GIT_DIFF_MINIMAL) + ADD_CONSTANT_INT(m, GIT_DIFF_SHOW_BINARY) + + /* Formatting options for diff stats (git_diff_stats_format_t in libgit2) */ + ADD_CONSTANT_INT(m, GIT_DIFF_STATS_NONE) + ADD_CONSTANT_INT(m, GIT_DIFF_STATS_FULL) + ADD_CONSTANT_INT(m, GIT_DIFF_STATS_SHORT) + ADD_CONSTANT_INT(m, GIT_DIFF_STATS_NUMBER) + ADD_CONSTANT_INT(m, GIT_DIFF_STATS_INCLUDE_SUMMARY) + + /* Flags for Diff.find_similar (git_diff_find_t in libgit2) */ + ADD_CONSTANT_INT(m, GIT_DIFF_FIND_BY_CONFIG) /** Obey diff.renames */ + ADD_CONSTANT_INT(m, GIT_DIFF_FIND_RENAMES) /* --find-renames */ + ADD_CONSTANT_INT(m, GIT_DIFF_FIND_RENAMES_FROM_REWRITES) /* --break-rewrites=N */ + ADD_CONSTANT_INT(m, GIT_DIFF_FIND_COPIES) /* --find-copies */ + ADD_CONSTANT_INT(m, GIT_DIFF_FIND_COPIES_FROM_UNMODIFIED) /* --find-copies-harder */ + ADD_CONSTANT_INT(m, GIT_DIFF_FIND_REWRITES) /* --break-rewrites=/M */ + ADD_CONSTANT_INT(m, GIT_DIFF_BREAK_REWRITES) ADD_CONSTANT_INT(m, GIT_DIFF_FIND_AND_BREAK_REWRITES) + ADD_CONSTANT_INT(m, GIT_DIFF_FIND_FOR_UNTRACKED) + ADD_CONSTANT_INT(m, GIT_DIFF_FIND_ALL) /* Turn on all finding features. */ + ADD_CONSTANT_INT(m, GIT_DIFF_FIND_IGNORE_LEADING_WHITESPACE) + ADD_CONSTANT_INT(m, GIT_DIFF_FIND_IGNORE_WHITESPACE) + ADD_CONSTANT_INT(m, GIT_DIFF_FIND_DONT_IGNORE_WHITESPACE) + ADD_CONSTANT_INT(m, GIT_DIFF_FIND_EXACT_MATCH_ONLY) + ADD_CONSTANT_INT(m, GIT_DIFF_BREAK_REWRITES_FOR_RENAMES_ONLY) + ADD_CONSTANT_INT(m, GIT_DIFF_FIND_REMOVE_UNMODIFIED) + + /* DiffDelta and DiffFile flags (git_diff_flag_t in libgit2) */ + ADD_CONSTANT_INT(m, GIT_DIFF_FLAG_BINARY) + ADD_CONSTANT_INT(m, GIT_DIFF_FLAG_NOT_BINARY) + ADD_CONSTANT_INT(m, GIT_DIFF_FLAG_VALID_ID) + ADD_CONSTANT_INT(m, GIT_DIFF_FLAG_EXISTS) + ADD_CONSTANT_INT(m, GIT_DIFF_FLAG_VALID_SIZE) + + /* DiffDelta.status (git_delta_t in libgit2) */ + ADD_CONSTANT_INT(m, GIT_DELTA_UNMODIFIED) + ADD_CONSTANT_INT(m, GIT_DELTA_ADDED) + ADD_CONSTANT_INT(m, GIT_DELTA_DELETED) + ADD_CONSTANT_INT(m, GIT_DELTA_MODIFIED) + ADD_CONSTANT_INT(m, GIT_DELTA_RENAMED) + ADD_CONSTANT_INT(m, GIT_DELTA_COPIED) + ADD_CONSTANT_INT(m, GIT_DELTA_IGNORED) + ADD_CONSTANT_INT(m, GIT_DELTA_UNTRACKED) + ADD_CONSTANT_INT(m, GIT_DELTA_TYPECHANGE) + ADD_CONSTANT_INT(m, GIT_DELTA_UNREADABLE) + ADD_CONSTANT_INT(m, GIT_DELTA_CONFLICTED) /* Config */ - INIT_TYPE(ConfigType, NULL, PyType_GenericNew) - ADD_TYPE(m, Config) - - /* Remotes */ - INIT_TYPE(RemoteType, NULL, NULL) - ADD_TYPE(m, Remote) + ADD_CONSTANT_INT(m, GIT_CONFIG_LEVEL_PROGRAMDATA); + ADD_CONSTANT_INT(m, GIT_CONFIG_LEVEL_SYSTEM); + ADD_CONSTANT_INT(m, GIT_CONFIG_LEVEL_XDG); + ADD_CONSTANT_INT(m, GIT_CONFIG_LEVEL_GLOBAL); + ADD_CONSTANT_INT(m, GIT_CONFIG_LEVEL_LOCAL); + ADD_CONSTANT_INT(m, GIT_CONFIG_LEVEL_WORKTREE); + ADD_CONSTANT_INT(m, GIT_CONFIG_LEVEL_APP); + ADD_CONSTANT_INT(m, GIT_CONFIG_HIGHEST_LEVEL); + + /* Blame */ + ADD_CONSTANT_INT(m, GIT_BLAME_NORMAL) + ADD_CONSTANT_INT(m, GIT_BLAME_TRACK_COPIES_SAME_FILE) + ADD_CONSTANT_INT(m, GIT_BLAME_TRACK_COPIES_SAME_COMMIT_MOVES) + ADD_CONSTANT_INT(m, GIT_BLAME_TRACK_COPIES_SAME_COMMIT_COPIES) + ADD_CONSTANT_INT(m, GIT_BLAME_TRACK_COPIES_ANY_COMMIT_COPIES) + ADD_CONSTANT_INT(m, GIT_BLAME_FIRST_PARENT) + ADD_CONSTANT_INT(m, GIT_BLAME_USE_MAILMAP) + ADD_CONSTANT_INT(m, GIT_BLAME_IGNORE_WHITESPACE) + + /* Merge */ + ADD_CONSTANT_INT(m, GIT_MERGE_ANALYSIS_NONE) + ADD_CONSTANT_INT(m, GIT_MERGE_ANALYSIS_NORMAL) + ADD_CONSTANT_INT(m, GIT_MERGE_ANALYSIS_UP_TO_DATE) + ADD_CONSTANT_INT(m, GIT_MERGE_ANALYSIS_FASTFORWARD) + ADD_CONSTANT_INT(m, GIT_MERGE_ANALYSIS_UNBORN) + ADD_CONSTANT_INT(m, GIT_MERGE_PREFERENCE_NONE) + ADD_CONSTANT_INT(m, GIT_MERGE_PREFERENCE_NO_FASTFORWARD) + ADD_CONSTANT_INT(m, GIT_MERGE_PREFERENCE_FASTFORWARD_ONLY) + + /* Describe */ + ADD_CONSTANT_INT(m, GIT_DESCRIBE_DEFAULT); + ADD_CONSTANT_INT(m, GIT_DESCRIBE_TAGS); + ADD_CONSTANT_INT(m, GIT_DESCRIBE_ALL); + + /* Stash */ + ADD_CONSTANT_INT(m, GIT_STASH_DEFAULT); + ADD_CONSTANT_INT(m, GIT_STASH_KEEP_INDEX); + ADD_CONSTANT_INT(m, GIT_STASH_INCLUDE_UNTRACKED); + ADD_CONSTANT_INT(m, GIT_STASH_INCLUDE_IGNORED); + ADD_CONSTANT_INT(m, GIT_STASH_KEEP_ALL); + ADD_CONSTANT_INT(m, GIT_STASH_APPLY_DEFAULT); + ADD_CONSTANT_INT(m, GIT_STASH_APPLY_REINSTATE_INDEX); + + /* Apply location */ + ADD_CONSTANT_INT(m, GIT_APPLY_LOCATION_WORKDIR); + ADD_CONSTANT_INT(m, GIT_APPLY_LOCATION_INDEX); + ADD_CONSTANT_INT(m, GIT_APPLY_LOCATION_BOTH); + + /* Submodule */ + ADD_CONSTANT_INT(m, GIT_SUBMODULE_IGNORE_UNSPECIFIED); + ADD_CONSTANT_INT(m, GIT_SUBMODULE_IGNORE_NONE); + ADD_CONSTANT_INT(m, GIT_SUBMODULE_IGNORE_UNTRACKED); + ADD_CONSTANT_INT(m, GIT_SUBMODULE_IGNORE_DIRTY); + ADD_CONSTANT_INT(m, GIT_SUBMODULE_IGNORE_ALL); + + ADD_CONSTANT_INT(m, GIT_SUBMODULE_STATUS_IN_HEAD); + ADD_CONSTANT_INT(m, GIT_SUBMODULE_STATUS_IN_INDEX); + ADD_CONSTANT_INT(m, GIT_SUBMODULE_STATUS_IN_CONFIG); + ADD_CONSTANT_INT(m, GIT_SUBMODULE_STATUS_IN_WD); + ADD_CONSTANT_INT(m, GIT_SUBMODULE_STATUS_INDEX_ADDED); + ADD_CONSTANT_INT(m, GIT_SUBMODULE_STATUS_INDEX_DELETED); + ADD_CONSTANT_INT(m, GIT_SUBMODULE_STATUS_INDEX_MODIFIED); + ADD_CONSTANT_INT(m, GIT_SUBMODULE_STATUS_WD_UNINITIALIZED); + ADD_CONSTANT_INT(m, GIT_SUBMODULE_STATUS_WD_ADDED); + ADD_CONSTANT_INT(m, GIT_SUBMODULE_STATUS_WD_DELETED); + ADD_CONSTANT_INT(m, GIT_SUBMODULE_STATUS_WD_MODIFIED); + ADD_CONSTANT_INT(m, GIT_SUBMODULE_STATUS_WD_INDEX_MODIFIED); + ADD_CONSTANT_INT(m, GIT_SUBMODULE_STATUS_WD_WD_MODIFIED); + ADD_CONSTANT_INT(m, GIT_SUBMODULE_STATUS_WD_UNTRACKED); + + /* Mailmap */ + INIT_TYPE(MailmapType, NULL, PyType_GenericNew) + ADD_TYPE(m, Mailmap) + + /* Stash */ + INIT_TYPE(StashType, NULL, NULL) + ADD_TYPE(m, Stash) + + /* Blob filter */ + ADD_CONSTANT_INT(m, GIT_BLOB_FILTER_CHECK_FOR_BINARY); + ADD_CONSTANT_INT(m, GIT_BLOB_FILTER_NO_SYSTEM_ATTRIBUTES); + ADD_CONSTANT_INT(m, GIT_BLOB_FILTER_ATTRIBUTES_FROM_HEAD); + ADD_CONSTANT_INT(m, GIT_BLOB_FILTER_ATTRIBUTES_FROM_COMMIT); + ADD_CONSTANT_INT(m, GIT_FILTER_DRIVER_PRIORITY); + ADD_CONSTANT_INT(m, GIT_FILTER_TO_WORKTREE); + ADD_CONSTANT_INT(m, GIT_FILTER_SMUDGE); + ADD_CONSTANT_INT(m, GIT_FILTER_TO_ODB); + ADD_CONSTANT_INT(m, GIT_FILTER_CLEAN); + ADD_CONSTANT_INT(m, GIT_FILTER_DEFAULT); + ADD_CONSTANT_INT(m, GIT_FILTER_ALLOW_UNSAFE); + ADD_CONSTANT_INT(m, GIT_FILTER_NO_SYSTEM_ATTRIBUTES); + ADD_CONSTANT_INT(m, GIT_FILTER_ATTRIBUTES_FROM_HEAD); + ADD_CONSTANT_INT(m, GIT_FILTER_ATTRIBUTES_FROM_COMMIT); + + INIT_TYPE(FilterSourceType, NULL, NULL); + ADD_TYPE(m, FilterSource); /* Global initialization of libgit2 */ - git_threads_init(); + git_libgit2_init(); return m; -} - -#if PY_MAJOR_VERSION < 3 - PyMODINIT_FUNC - init_pygit2(void) - { - PyObject* m; - m = Py_InitModule3("_pygit2", module_methods, - "Python bindings for libgit2."); - moduleinit(m); - } -#else - struct PyModuleDef moduledef = { - PyModuleDef_HEAD_INIT, - "_pygit2", /* m_name */ - "Python bindings for libgit2.", /* m_doc */ - -1, /* m_size */ - module_methods, /* m_methods */ - NULL, /* m_reload */ - NULL, /* m_traverse */ - NULL, /* m_clear */ - NULL, /* m_free */ - }; - - PyMODINIT_FUNC - PyInit__pygit2(void) - { - PyObject* m; - m = PyModule_Create(&moduledef); - return moduleinit(m); - } -#endif +fail: + Py_DECREF(m); + return NULL; +} diff --git a/src/refdb.c b/src/refdb.c new file mode 100644 index 000000000..5558d47ac --- /dev/null +++ b/src/refdb.c @@ -0,0 +1,190 @@ +/* + * 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 "error.h" +#include "refdb.h" +#include "types.h" +#include "utils.h" +#include +#include + +extern PyTypeObject RepositoryType; +extern PyTypeObject RefdbType; + +void +Refdb_dealloc(Refdb *self) +{ + git_refdb_free(self->refdb); + + Py_TYPE(self)->tp_free((PyObject *) self); +} + +PyDoc_STRVAR(Refdb_compress__doc__, + "compress()\n" + "\n" + "Suggests that the given refdb compress or optimize its references.\n" + "This mechanism is implementation specific. For on-disk reference\n" + "databases, for example, this may pack all loose references."); + +PyObject * +Refdb_compress(Refdb *self) +{ + int err = git_refdb_compress(self->refdb); + if (err != 0) + return Error_set(err); + Py_RETURN_NONE; +} + +PyDoc_STRVAR(Refdb_set_backend__doc__, + "set_backend(backend: RefdbBackend)\n" + "\n" + "Sets a custom RefdbBackend for this Refdb."); + +PyObject * +Refdb_set_backend(Refdb *self, RefdbBackend *backend) +{ + int err; + err = git_refdb_set_backend(self->refdb, backend->refdb_backend); + if (err != 0) + return Error_set(err); + Py_RETURN_NONE; +} + +PyDoc_STRVAR(Refdb_new__doc__, "Refdb.new(repo: Repository) -> Refdb\n" + "Creates a new refdb with no backend."); + +PyObject * +Refdb_new(PyObject *self, Repository *repo) +{ + if (!PyObject_IsInstance((PyObject *)repo, (PyObject *)&RepositoryType)) { + PyErr_SetString(PyExc_TypeError, + "Refdb.new expects an object of type " + "pygit2.Repository"); + return NULL; + } + + git_refdb *refdb; + int err = git_refdb_new(&refdb, repo->repo); + if (err) { + Error_set(err); + return NULL; + } + + return wrap_refdb(refdb); +} + +PyDoc_STRVAR(Refdb_open__doc__, + "open(repo: Repository) -> Refdb\n" + "\n" + "Create a new reference database and automatically add\n" + "the default backends, assuming the repository dir as the folder."); + +PyObject * +Refdb_open(PyObject *self, Repository *repo) +{ + if (!PyObject_IsInstance((PyObject *)repo, (PyObject *)&RepositoryType)) { + PyErr_SetString(PyExc_TypeError, + "Refdb.open expects an object of type " + "pygit2.Repository"); + return NULL; + } + + git_refdb *refdb; + int err = git_refdb_open(&refdb, repo->repo); + if (err) { + Error_set(err); + return NULL; + } + + return wrap_refdb(refdb); +} + +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__}, + {NULL} +}; + +PyDoc_STRVAR(Refdb__doc__, "Reference database."); + +PyTypeObject RefdbType = { + PyVarObject_HEAD_INIT(NULL, 0) + "_pygit2.Refdb", /* tp_name */ + sizeof(Refdb), /* tp_basicsize */ + 0, /* tp_itemsize */ + (destructor)Refdb_dealloc, /* tp_dealloc */ + 0, /* tp_print */ + 0, /* tp_getattr */ + 0, /* tp_setattr */ + 0, /* tp_compare */ + 0, /* tp_repr */ + 0, /* tp_as_number */ + 0, /* tp_as_sequence */ + 0, /* tp_as_mapping */ + 0, /* tp_hash */ + 0, /* tp_call */ + 0, /* tp_str */ + 0, /* tp_getattro */ + 0, /* tp_setattro */ + 0, /* tp_as_buffer */ + Py_TPFLAGS_DEFAULT, /* tp_flags */ + Refdb__doc__, /* tp_doc */ + 0, /* tp_traverse */ + 0, /* tp_clear */ + 0, /* tp_richcompare */ + 0, /* tp_weaklistoffset */ + 0, /* tp_iter */ + 0, /* tp_iternext */ + Refdb_methods, /* tp_methods */ + 0, /* tp_members */ + 0, /* tp_getset */ + 0, /* tp_base */ + 0, /* tp_dict */ + 0, /* tp_descr_get */ + 0, /* tp_descr_set */ + 0, /* tp_dictoffset */ + 0, /* tp_init */ + 0, /* tp_alloc */ + 0, /* tp_new */ +}; + +PyObject * +wrap_refdb(git_refdb *c_refdb) +{ + Refdb *py_refdb = PyObject_New(Refdb, &RefdbType); + + if (py_refdb) + py_refdb->refdb = c_refdb; + + return (PyObject *)py_refdb; +} diff --git a/src/blob.h b/src/refdb.h similarity index 89% rename from src/blob.h rename to src/refdb.h index 5de1dee00..984423542 100644 --- a/src/blob.h +++ b/src/refdb.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2013 The pygit2 contributors + * Copyright 2010-2025 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -25,14 +25,14 @@ * Boston, MA 02110-1301, USA. */ -#ifndef INCLUDE_pygit2_blob_h -#define INCLUDE_pygit2_blob_h +#ifndef INCLUDE_pygit2_refdb_h +#define INCLUDE_pygit2_refdb_h #define PY_SSIZE_T_CLEAN #include #include #include "types.h" -PyObject* Blob_get_size(Blob *self); +PyObject *wrap_refdb(git_refdb *c_refdb); #endif diff --git a/src/refdb_backend.c b/src/refdb_backend.c new file mode 100644 index 000000000..6f6b0da08 --- /dev/null +++ b/src/refdb_backend.c @@ -0,0 +1,907 @@ +/* + * 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 "error.h" +#include "types.h" +#include "oid.h" +#include "reference.h" +#include "signature.h" +#include "utils.h" +#include "wildmatch.h" +#include +#include + +extern PyTypeObject ReferenceType; +extern PyTypeObject RepositoryType; +extern PyTypeObject SignatureType; + +struct pygit2_refdb_backend +{ + git_refdb_backend backend; + PyObject *RefdbBackend; + PyObject *exists, + *lookup, + *iterator, + *write, + *rename, + *delete, + *compress, + *has_log, + *ensure_log, + *reflog_read, + *reflog_write, + *reflog_rename, + *reflog_delete, + *lock, + *unlock; +}; + +struct pygit2_refdb_iterator { + struct git_reference_iterator base; + PyObject *iterator; + char *glob; +}; + +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; + } + const char *name = git_reference_name(ref->reference); + if (wildmatch(iter->glob, name, 0) != WM_NOMATCH) { + return ref; + } + } + return NULL; +} + +static int +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) { + *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; +} + +static int +pygit2_refdb_iterator_next_name(const char **ref_name, git_reference_iterator *_iter) +{ + struct pygit2_refdb_iterator *iter = (struct pygit2_refdb_iterator *)_iter; + Reference *ref = iterator_get_next(iter); + if (ref == NULL) { + *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; + } + *ref_name = git_reference_name(ref->reference); + return 0; +} + +static void +pygit2_refdb_iterator_free(git_reference_iterator *_iter) +{ + struct pygit2_refdb_iterator *iter = (struct pygit2_refdb_iterator *)_iter; + Py_DECREF(iter->iterator); + free(iter->glob); +} + +static int +pygit2_refdb_backend_iterator(git_reference_iterator **iter, + struct git_refdb_backend *_be, + const char *glob) +{ + struct pygit2_refdb_backend *be = (struct pygit2_refdb_backend *)_be; + PyObject *iterator = PyObject_GetIter((PyObject *)be->RefdbBackend); + assert(iterator); + + struct pygit2_refdb_iterator *pyiter = + calloc(1, sizeof(struct pygit2_refdb_iterator)); + *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; +} + +static int +pygit2_refdb_backend_exists(int *exists, + git_refdb_backend *_be, const char *ref_name) +{ + int err; + PyObject *args, *result; + struct pygit2_refdb_backend *be = (struct pygit2_refdb_backend *)_be; + + if ((args = Py_BuildValue("(s)", ref_name)) == NULL) + return GIT_EUSER; + result = PyObject_CallObject(be->exists, args); + Py_DECREF(args); + + if ((err = git_error_for_exc()) != 0) + goto out; + + *exists = PyObject_IsTrue(result); + +out: + Py_DECREF(result); + return 0; +} + +static int +pygit2_refdb_backend_lookup(git_reference **out, + git_refdb_backend *_be, const char *ref_name) +{ + int err; + PyObject *args; + Reference *result; + struct pygit2_refdb_backend *be = (struct pygit2_refdb_backend *)_be; + + if ((args = Py_BuildValue("(s)", ref_name)) == NULL) + return GIT_EUSER; + result = (Reference *)PyObject_CallObject(be->lookup, args); + Py_DECREF(args); + + if ((err = git_error_for_exc()) != 0) + goto out; + + if (!PyObject_IsInstance((PyObject *)result, (PyObject *)&ReferenceType)) { + PyErr_SetString(PyExc_TypeError, "Expected object of type pygit2.Reference"); + err = GIT_EUSER; + goto out; + } + + *out = result->reference; +out: + return err; +} + +static int +pygit2_refdb_backend_write(git_refdb_backend *_be, + const git_reference *_ref, int force, + const git_signature *_who, const char *message, + const git_oid *_old, const char *old_target) +{ + int err; + PyObject *args = NULL, *ref = NULL, *who = NULL, *old = NULL; + struct pygit2_refdb_backend *be = (struct pygit2_refdb_backend *)_be; + + // XXX: Drops const + if ((ref = wrap_reference((git_reference *)_ref, NULL)) == NULL) + goto euser; + if ((who = build_signature(NULL, _who, "utf-8")) == NULL) + goto euser; + if ((old = git_oid_to_python(_old)) == NULL) + goto euser; + if ((args = Py_BuildValue("(NNNsNs)", ref, + force ? Py_True : Py_False, + who, message, old, old_target)) == NULL) + goto euser; + + PyObject_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 +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) + return GIT_EUSER; + if ((args = Py_BuildValue("(ssNNs)", old_name, new_name, + force ? Py_True : Py_False, who, message)) == NULL) { + Py_DECREF(who); + return GIT_EUSER; + } + Reference *ref = (Reference *)PyObject_CallObject(be->rename, args); + Py_DECREF(who); + Py_DECREF(args); + + if ((err = git_error_for_exc()) != 0) + return err; + + if (!PyObject_IsInstance((PyObject *)ref, (PyObject *)&ReferenceType)) { + PyErr_SetString(PyExc_TypeError, "Expected object of type pygit2.Reference"); + return GIT_EUSER; + } + + git_reference_dup(out, ref->reference); + Py_DECREF(ref); + return 0; +} + +static int +pygit2_refdb_backend_del(git_refdb_backend *_be, + const char *ref_name, const git_oid *_old, const char *old_target) +{ + PyObject *args, *old; + struct pygit2_refdb_backend *be = (struct pygit2_refdb_backend *)_be; + old = git_oid_to_python(_old); + + if ((args = Py_BuildValue("(sOs)", ref_name, old, old_target)) == NULL) { + Py_DECREF(old); + return GIT_EUSER; + } + PyObject_CallObject(be->rename, args); + Py_DECREF(old); + Py_DECREF(args); + return git_error_for_exc(); +} + +static int +pygit2_refdb_backend_compress(git_refdb_backend *_be) +{ + struct pygit2_refdb_backend *be = (struct pygit2_refdb_backend *)_be; + PyObject_CallObject(be->rename, NULL); + return git_error_for_exc(); +} + +static int +pygit2_refdb_backend_has_log(git_refdb_backend *_be, const char *refname) +{ + int err; + PyObject *args, *result; + struct pygit2_refdb_backend *be = (struct pygit2_refdb_backend *)_be; + + if ((args = Py_BuildValue("(s)", refname)) == NULL) { + return GIT_EUSER; + } + result = PyObject_CallObject(be->has_log, args); + Py_DECREF(args); + + if ((err = git_error_for_exc()) != 0) { + return err; + } + + if (PyObject_IsTrue(result)) { + Py_DECREF(result); + return 1; + } + + Py_DECREF(result); + return 0; +} + +static int +pygit2_refdb_backend_ensure_log(git_refdb_backend *_be, const char *refname) +{ + int err; + PyObject *args, *result; + struct pygit2_refdb_backend *be = (struct pygit2_refdb_backend *)_be; + + if ((args = Py_BuildValue("(s)", refname)) == NULL) { + return GIT_EUSER; + } + result = PyObject_CallObject(be->ensure_log, args); + Py_DECREF(args); + + if ((err = git_error_for_exc()) != 0) { + return err; + } + + if (PyObject_IsTrue(result)) { + Py_DECREF(result); + return 1; + } + + Py_DECREF(result); + return 0; +} + +static int +pygit2_refdb_backend_reflog_read(git_reflog **out, + git_refdb_backend *backend, const char *name) +{ + /* TODO: Implement first-class pygit2 reflog support + * These stubs are here because libgit2 requires refdb_backend to implement + * them. libgit2 doesn't actually use them as of 0.99; it assumes the refdb + * backend will update the reflogs itself. */ + return GIT_EUSER; +} + +static int +pygit2_refdb_backend_reflog_write(git_refdb_backend *backend, git_reflog *reflog) +{ + /* TODO: Implement first-class pygit2 reflog support */ + return GIT_EUSER; +} + +static int +pygit2_refdb_backend_reflog_rename(git_refdb_backend *_backend, + const char *old_name, const char *new_name) +{ + /* TODO: Implement first-class pygit2 reflog support */ + return GIT_EUSER; +} + +static int +pygit2_refdb_backend_reflog_delete(git_refdb_backend *backend, const char *name) +{ + /* TODO: Implement first-class pygit2 reflog support */ + return GIT_EUSER; +} + +static void +pygit2_refdb_backend_free(git_refdb_backend *_be) +{ + struct pygit2_refdb_backend *be = (struct pygit2_refdb_backend *)_be; + Py_DECREF(be->RefdbBackend); +} + +int +RefdbBackend_init(RefdbBackend *self, PyObject *args, PyObject *kwds) +{ + if (args && PyTuple_Size(args) > 0) { + 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"); + return -1; + } + + struct pygit2_refdb_backend *be = calloc(1, sizeof(struct pygit2_refdb_backend)); + git_refdb_init_backend(&be->backend, GIT_REFDB_BACKEND_VERSION); + be->RefdbBackend = (PyObject *)self; + + if (PyIter_Check((PyObject *)self)) { + be->backend.iterator = pygit2_refdb_backend_iterator; + } + + if (PyObject_HasAttrString((PyObject *)self, "exists")) { + be->exists = PyObject_GetAttrString((PyObject *)self, "exists"); + be->backend.exists = pygit2_refdb_backend_exists; + } + + if (PyObject_HasAttrString((PyObject *)self, "lookup")) { + be->lookup = PyObject_GetAttrString((PyObject *)self, "lookup"); + be->backend.lookup = pygit2_refdb_backend_lookup; + } + + if (PyObject_HasAttrString((PyObject *)self, "write")) { + be->write = PyObject_GetAttrString((PyObject *)self, "write"); + be->backend.write = pygit2_refdb_backend_write; + } + + if (PyObject_HasAttrString((PyObject *)self, "rename")) { + be->rename = PyObject_GetAttrString((PyObject *)self, "rename"); + be->backend.rename = pygit2_refdb_backend_rename; + } + + if (PyObject_HasAttrString((PyObject *)self, "delete")) { + be->delete = PyObject_GetAttrString((PyObject *)self, "delete"); + be->backend.del = pygit2_refdb_backend_del; + } + + if (PyObject_HasAttrString((PyObject *)self, "compress")) { + be->compress = PyObject_GetAttrString((PyObject *)self, "compress"); + be->backend.compress = pygit2_refdb_backend_compress; + } + + if (PyObject_HasAttrString((PyObject *)self, "has_log")) { + be->has_log = PyObject_GetAttrString((PyObject *)self, "has_log"); + be->backend.has_log = pygit2_refdb_backend_has_log; + } + + if (PyObject_HasAttrString((PyObject *)self, "ensure_log")) { + be->ensure_log = PyObject_GetAttrString((PyObject *)self, "ensure_log"); + be->backend.ensure_log = pygit2_refdb_backend_ensure_log; + } + + /* TODO: First-class reflog support */ + be->backend.reflog_read = pygit2_refdb_backend_reflog_read; + be->backend.reflog_write = pygit2_refdb_backend_reflog_write; + be->backend.reflog_rename = pygit2_refdb_backend_reflog_rename; + be->backend.reflog_delete = pygit2_refdb_backend_reflog_delete; + + /* TODO: transactions + if (PyObject_HasAttrString((PyObject *)self, "lock")) { + be->lock = PyObject_GetAttrString((PyObject *)self, "lock"); + be->backend.lock = pygit2_refdb_backend_lock; + } + + if (PyObject_HasAttrString((PyObject *)self, "unlock")) { + be->unlock = PyObject_GetAttrString((PyObject *)self, "unlock"); + be->backend.unlock = pygit2_refdb_backend_unlock; + } + */ + + Py_INCREF((PyObject *)self); + be->backend.free = pygit2_refdb_backend_free; + + self->refdb_backend = (git_refdb_backend *)be; + return 0; +} + +void +RefdbBackend_dealloc(RefdbBackend *self) +{ + if (self->refdb_backend && self->refdb_backend->free == pygit2_refdb_backend_free) { + struct pygit2_refdb_backend *be = (struct pygit2_refdb_backend *)self->refdb_backend; + Py_CLEAR(be->exists); + Py_CLEAR(be->lookup); + Py_CLEAR(be->iterator); + Py_CLEAR(be->write); + Py_CLEAR(be->rename); + Py_CLEAR(be->delete); + Py_CLEAR(be->compress); + Py_CLEAR(be->has_log); + Py_CLEAR(be->ensure_log); + Py_CLEAR(be->reflog_read); + Py_CLEAR(be->reflog_write); + Py_CLEAR(be->reflog_rename); + Py_CLEAR(be->reflog_delete); + Py_CLEAR(be->lock); + Py_CLEAR(be->unlock); + free(be); + } + Py_TYPE(self)->tp_free((PyObject *) self); +} + +PyDoc_STRVAR(RefdbBackend_exists__doc__, + "exists(refname: str) -> bool\n" + "\n" + "Returns True if a ref by this name exists, or False otherwise."); + +PyObject * +RefdbBackend_exists(RefdbBackend *self, PyObject *py_str) +{ + int err, exists; + const char *ref_name; + if (self->refdb_backend->exists == NULL) { + Py_INCREF(Py_NotImplemented); + return Py_NotImplemented; + } + + if (!PyUnicode_Check(py_str)) { + PyErr_SetString(PyExc_TypeError, + "RefdbBackend.exists takes a string argument"); + return NULL; + } + ref_name = PyUnicode_AsUTF8(py_str); + + err = self->refdb_backend->exists(&exists, self->refdb_backend, ref_name); + if (err != 0) + return Error_set(err); + + if (exists) + Py_RETURN_TRUE; + else + Py_RETURN_FALSE; +} + +PyDoc_STRVAR(RefdbBackend_lookup__doc__, + "lookup(refname: str) -> Reference\n" + "\n" + "Looks up a reference and returns it, or None if not found."); + +PyObject * +RefdbBackend_lookup(RefdbBackend *self, PyObject *py_str) +{ + int err; + git_reference *ref; + const char *ref_name; + if (self->refdb_backend->lookup == NULL) { + Py_INCREF(Py_NotImplemented); + return Py_NotImplemented; + } + + if (!PyUnicode_Check(py_str)) { + PyErr_SetString(PyExc_TypeError, + "RefdbBackend.lookup takes a string argument"); + return NULL; + } + ref_name = PyUnicode_AsUTF8(py_str); + + err = self->refdb_backend->lookup(&ref, self->refdb_backend, ref_name); + + if (err == GIT_ENOTFOUND) { + Py_RETURN_NONE; + } else if (err != 0) { + return Error_set(err); + } + + return wrap_reference(ref, NULL); +} + +PyDoc_STRVAR(RefdbBackend_write__doc__, + "write(ref: Reference, force: bool, who: Signature, message: str, old: Oid, old_target: str)\n" + "\n" + "Writes a new reference to the reference database."); +// TODO: Better docs? libgit2 is scant on documentation for this, too. + +PyObject * +RefdbBackend_write(RefdbBackend *self, PyObject *args) +{ + int err; + Reference *ref; + int force; + Signature *who; + const git_signature *sig = NULL; + char *message, *old_target; + PyObject *py_old; + git_oid _old, *old = NULL; + if (self->refdb_backend->write == NULL) { + Py_INCREF(Py_NotImplemented); + return Py_NotImplemented; + } + + if (!PyArg_ParseTuple(args, "O!pOzOz", &ReferenceType, &ref, + &force, &who, &message, &py_old, &old_target)) + return NULL; + + if ((PyObject *)py_old != Py_None) { + py_oid_to_git_oid(py_old, &_old); + old = &_old; + } + + if ((PyObject *)who != Py_None) { + if (!PyObject_IsInstance((PyObject *)who, (PyObject *)&SignatureType)) { + PyErr_SetString(PyExc_TypeError, + "Signature must be type pygit2.Signature"); + return NULL; + } + sig = who->signature; + } + + err = self->refdb_backend->write(self->refdb_backend, + ref->reference, force, sig, message, old, old_target); + if (err != 0) + return Error_set(err); + + Py_RETURN_NONE; +} + +PyDoc_STRVAR(RefdbBackend_rename__doc__, + "rename(old_name: str, new_name: str, force: bool, who: Signature, message: str) -> Reference\n" + "\n" + "Renames a reference."); + +PyObject * +RefdbBackend_rename(RefdbBackend *self, PyObject *args) +{ + int err; + int force; + Signature *who; + char *old_name, *new_name, *message; + git_reference *out; + + if (self->refdb_backend->rename == NULL) { + Py_INCREF(Py_NotImplemented); + return Py_NotImplemented; + } + + if (!PyArg_ParseTuple(args, "sspO!s", &old_name, &new_name, + &force, &SignatureType, &who, &message)) + return NULL; + + err = self->refdb_backend->rename(&out, self->refdb_backend, + old_name, new_name, force, who->signature, message); + if (err != 0) + return Error_set(err); + + return (PyObject *)wrap_reference(out, NULL); +} + +PyDoc_STRVAR(RefdbBackend_delete__doc__, + "delete(ref_name: str, old_id: Oid, old_target: str)\n" + "\n" + "Deletes a reference."); + +PyObject * +RefdbBackend_delete(RefdbBackend *self, PyObject *args) +{ + int err; + PyObject *py_old_id; + git_oid old_id; + char *ref_name, *old_target; + + if (self->refdb_backend->del == NULL) { + Py_INCREF(Py_NotImplemented); + return Py_NotImplemented; + } + + if (!PyArg_ParseTuple(args, "sOz", &ref_name, &py_old_id, &old_target)) + return NULL; + + if (py_old_id != Py_None) { + py_oid_to_git_oid(py_old_id, &old_id); + err = self->refdb_backend->del(self->refdb_backend, + ref_name, &old_id, old_target); + } else { + err = self->refdb_backend->del(self->refdb_backend, + ref_name, NULL, old_target); + } + + if (err != 0) { + return Error_set(err); + } + + Py_RETURN_NONE; +} + +PyDoc_STRVAR(RefdbBackend_compress__doc__, + "compress()\n" + "\n" + "Suggests that the implementation compress or optimize its references.\n" + "This behavior is implementation-specific."); + +PyObject * +RefdbBackend_compress(RefdbBackend *self) +{ + int err; + if (self->refdb_backend->compress == NULL) { + Py_INCREF(Py_NotImplemented); + return Py_NotImplemented; + } + + err = self->refdb_backend->compress(self->refdb_backend); + if (err != 0) + return Error_set(err); + + Py_RETURN_NONE; +} + +PyDoc_STRVAR(RefdbBackend_has_log__doc__, + "has_log(ref_name: str) -> bool\n" + "\n" + "Returns True if a ref log is available for this reference.\n" + "It may be empty even if it exists."); + +PyObject * +RefdbBackend_has_log(RefdbBackend *self, PyObject *_ref_name) +{ + int err; + const char *ref_name; + if (self->refdb_backend->has_log == NULL) { + Py_INCREF(Py_NotImplemented); + return Py_NotImplemented; + } + + if (!PyUnicode_Check(_ref_name)) { + PyErr_SetString(PyExc_TypeError, + "RefdbBackend.has_log takes a string argument"); + return NULL; + } + ref_name = PyUnicode_AsUTF8(_ref_name); + + err = self->refdb_backend->has_log(self->refdb_backend, ref_name); + if (err < 0) { + return Error_set(err); + } + + if (err == 1) { + Py_RETURN_TRUE; + } else { + Py_RETURN_FALSE; + } +} + +PyDoc_STRVAR(RefdbBackend_ensure_log__doc__, + "ensure_log(ref_name: str) -> bool\n" + "\n" + "Ensure that a particular reference will have a reflog which will be\n" + "appended to on writes."); + +PyObject * +RefdbBackend_ensure_log(RefdbBackend *self, PyObject *_ref_name) +{ + int err; + const char *ref_name; + if (self->refdb_backend->ensure_log == NULL) { + Py_INCREF(Py_NotImplemented); + return Py_NotImplemented; + } + + if (!PyUnicode_Check(_ref_name)) { + PyErr_SetString(PyExc_TypeError, + "RefdbBackend.ensure_log takes a string argument"); + return NULL; + } + ref_name = PyUnicode_AsUTF8(_ref_name); + + err = self->refdb_backend->ensure_log(self->refdb_backend, ref_name); + if (err < 0) { + return Error_set(err); + } + + if (err == 0) { + Py_RETURN_TRUE; + } else { + Py_RETURN_FALSE; + } +} + +PyMethodDef RefdbBackend_methods[] = { + METHOD(RefdbBackend, exists, METH_O), + METHOD(RefdbBackend, lookup, METH_O), + METHOD(RefdbBackend, write, METH_VARARGS), + METHOD(RefdbBackend, rename, METH_VARARGS), + METHOD(RefdbBackend, delete, METH_VARARGS), + METHOD(RefdbBackend, compress, METH_NOARGS), + METHOD(RefdbBackend, has_log, METH_O), + METHOD(RefdbBackend, ensure_log, METH_O), + {NULL} +}; + +PyDoc_STRVAR(RefdbBackend__doc__, "Reference database backend."); + +PyTypeObject RefdbBackendType = { + PyVarObject_HEAD_INIT(NULL, 0) + "_pygit2.RefdbBackend", /* tp_name */ + sizeof(RefdbBackend), /* tp_basicsize */ + 0, /* tp_itemsize */ + (destructor)RefdbBackend_dealloc, /* tp_dealloc */ + 0, /* tp_print */ + 0, /* tp_getattr */ + 0, /* tp_setattr */ + 0, /* tp_compare */ + 0, /* tp_repr */ + 0, /* tp_as_number */ + 0, /* tp_as_sequence */ + 0, /* tp_as_mapping */ + 0, /* tp_hash */ + 0, /* tp_call */ + 0, /* tp_str */ + 0, /* tp_getattro */ + 0, /* tp_setattro */ + 0, /* tp_as_buffer */ + Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, /* tp_flags */ + RefdbBackend__doc__, /* tp_doc */ + 0, /* tp_traverse */ + 0, /* tp_clear */ + 0, /* tp_richcompare */ + 0, /* tp_weaklistoffset */ + 0 /* TODO: Wrap git_reference_iterator */, /* tp_iter */ + 0, /* tp_iternext */ + RefdbBackend_methods, /* tp_methods */ + 0, /* tp_members */ + 0, /* tp_getset */ + 0, /* tp_base */ + 0, /* tp_dict */ + 0, /* tp_descr_get */ + 0, /* tp_descr_set */ + 0, /* tp_dictoffset */ + (initproc)RefdbBackend_init, /* tp_init */ + 0, /* tp_alloc */ + 0, /* tp_new */ +}; + +PyObject * +wrap_refdb_backend(git_refdb_backend *c_refdb_backend) +{ + RefdbBackend *pygit2_refdb_backend = PyObject_New(RefdbBackend, &RefdbBackendType); + + if (pygit2_refdb_backend) + pygit2_refdb_backend->refdb_backend = c_refdb_backend; + + return (PyObject *)pygit2_refdb_backend; +} + +PyDoc_STRVAR(RefdbFsBackend__doc__, + "RefdbFsBackend(repo: Repository)\n" + "\n" + "Reference database filesystem backend. The path to the repository\n" + "is used as the basis of the reference database."); + +int +RefdbFsBackend_init(RefdbFsBackend *self, PyObject *args, PyObject *kwds) +{ + if (kwds && PyDict_Size(kwds) > 0) { + PyErr_SetString(PyExc_TypeError, "RefdbFsBackend takes no keyword arguments"); + return -1; + } + + Repository *repo = NULL; + if (!PyArg_ParseTuple(args, "O!", &RepositoryType, &repo)) + return -1; + + int err = git_refdb_backend_fs(&self->super.refdb_backend, repo->repo); + if (err) { + Error_set(err); + return -1; + } + + return 0; +} + +PyTypeObject RefdbFsBackendType = { + PyVarObject_HEAD_INIT(NULL, 0) + "_pygit2.RefdbFsBackend", /* tp_name */ + sizeof(RefdbFsBackend), /* tp_basicsize */ + 0, /* tp_itemsize */ + 0, /* tp_dealloc */ + 0, /* tp_print */ + 0, /* tp_getattr */ + 0, /* tp_setattr */ + 0, /* tp_compare */ + 0, /* tp_repr */ + 0, /* tp_as_number */ + 0, /* tp_as_sequence */ + 0, /* tp_as_mapping */ + 0, /* tp_hash */ + 0, /* tp_call */ + 0, /* tp_str */ + 0, /* tp_getattro */ + 0, /* tp_setattro */ + 0, /* tp_as_buffer */ + Py_TPFLAGS_DEFAULT, /* tp_flags */ + RefdbFsBackend__doc__, /* tp_doc */ + 0, /* tp_traverse */ + 0, /* tp_clear */ + 0, /* tp_richcompare */ + 0, /* tp_weaklistoffset */ + 0, /* tp_iter */ + 0, /* tp_iternext */ + 0, /* tp_methods */ + 0, /* tp_members */ + 0, /* tp_getset */ + &RefdbBackendType, /* tp_base */ + 0, /* tp_dict */ + 0, /* tp_descr_get */ + 0, /* tp_descr_set */ + 0, /* tp_dictoffset */ + (initproc)RefdbFsBackend_init, /* tp_init */ + 0, /* tp_alloc */ + 0, /* tp_new */ +}; diff --git a/src/refdb_backend.h b/src/refdb_backend.h new file mode 100644 index 000000000..c6a5ff366 --- /dev/null +++ b/src/refdb_backend.h @@ -0,0 +1,38 @@ +/* + * 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_refdb_backend_h +#define INCLUDE_pygit2_refdb_backend_h + +#define PY_SSIZE_T_CLEAN +#include +#include +#include "types.h" + +PyObject *wrap_refdb_backend(git_refdb_backend *c_refdb_backend); + +#endif diff --git a/src/reference.c b/src/reference.c index 4d11cb5c5..b535cbe48 100644 --- a/src/reference.c +++ b/src/reference.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2013 The pygit2 contributors + * Copyright 2010-2025 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -36,11 +36,16 @@ #include "oid.h" #include "signature.h" #include "reference.h" +#include extern PyObject *GitError; extern PyTypeObject RefLogEntryType; +extern PyTypeObject RepositoryType; +extern PyTypeObject SignatureType; +extern PyObject *ReferenceTypeEnum; +PyTypeObject ReferenceType; void RefLogIter_dealloc(RefLogIter *self) { @@ -48,20 +53,28 @@ void RefLogIter_dealloc(RefLogIter *self) PyObject_Del(self); } -PyObject* RefLogIter_iternext(RefLogIter *self) +PyObject * +RefLogIter_iternext(RefLogIter *self) { const git_reflog_entry *entry; + const char * entry_message; RefLogEntry *py_entry; + int err; if (self->i < self->size) { entry = git_reflog_entry_byindex(self->reflog, self->i); py_entry = PyObject_New(RefLogEntry, &RefLogEntryType); - - py_entry->oid_old = git_oid_allocfmt(git_reflog_entry_id_old(entry)); - py_entry->oid_new = git_oid_allocfmt(git_reflog_entry_id_new(entry)); - py_entry->message = strdup(git_reflog_entry_message(entry)); - py_entry->signature = git_signature_dup( - git_reflog_entry_committer(entry)); + if (py_entry == NULL) + return NULL; + + py_entry->oid_old = git_oid_to_python(git_reflog_entry_id_old(entry)); + py_entry->oid_new = git_oid_to_python(git_reflog_entry_id_new(entry)); + entry_message = git_reflog_entry_message(entry); + py_entry->message = (entry_message != NULL) ? strdup(entry_message) : NULL; + err = git_signature_dup(&py_entry->signature, + git_reflog_entry_committer(entry)); + if (err < 0) + return Error_set(err); ++(self->i); @@ -95,7 +108,7 @@ PyTypeObject RefLogIterType = { 0, /* tp_getattro */ 0, /* tp_setattro */ 0, /* tp_as_buffer */ - Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, /* tp_flags */ + Py_TPFLAGS_DEFAULT, /* tp_flags */ RefLogIterType__doc__, /* tp_doc */ 0, /* tp_traverse */ 0, /* tp_clear */ @@ -105,6 +118,62 @@ PyTypeObject RefLogIterType = { (iternextfunc)RefLogIter_iternext, /* tp_iternext */ }; +PyDoc_STRVAR(Reference__doc__, + "Reference(name: str, target: str): create a symbolic reference\n" + "\n" + "Reference(name: str, oid: Oid, peel: Oid): create a direct reference\n" + "\n" + "'peel' is the first non-tag object's OID, or None.\n" + "\n" + "The purpose of this constructor is for use in custom refdb backends.\n" + "References created with this function are unlikely to work as\n" + "expected in other contexts.\n"); + +static int +Reference_init_symbolic(Reference *self, PyObject *args, PyObject *kwds) +{ + const char *name, *target; + if (!PyArg_ParseTuple(args, "ss", &name, &target)) { + return -1; + } + self->reference = git_reference__alloc_symbolic(name, target); + return 0; +} + +int +Reference_init(Reference *self, PyObject *args, PyObject *kwds) +{ + const char *name; + git_oid oid, peel; + PyObject *py_oid, *py_peel; + + if (kwds && PyDict_Size(kwds) > 0) { + PyErr_SetString(PyExc_TypeError, "Reference takes no keyword arguments"); + return -1; + } + + Py_ssize_t nargs = PyTuple_Size(args); + if (nargs == 2) { + return Reference_init_symbolic(self, args, kwds); + } else if (nargs != 3) { + PyErr_SetString(PyExc_TypeError, + "Invalid arguments to Reference constructor"); + return -1; + } + + if (!PyArg_ParseTuple(args, "sOO", &name, &py_oid, &py_peel)) { + return -1; + } + + py_oid_to_git_oid(py_oid, &oid); + if (py_peel != Py_None) { + py_oid_to_git_oid(py_peel, &peel); + } + + self->reference = git_reference__alloc(name, &oid, &peel); + return 0; +} + void Reference_dealloc(Reference *self) { @@ -139,32 +208,32 @@ Reference_delete(Reference *self, PyObject *args) PyDoc_STRVAR(Reference_rename__doc__, - "rename(new_name)\n" + "rename(new_name: str)\n" "\n" "Rename the reference."); PyObject * Reference_rename(Reference *self, PyObject *py_name) { - char *c_name; - int err; - git_reference *new_reference; - CHECK_REFERENCE(self); - /* Get the C name */ - c_name = py_path_to_c_str(py_name); + // Get the C name + PyObject *tvalue; + char *c_name = pgit_borrow_fsdefault(py_name, &tvalue); if (c_name == NULL) return NULL; - /* Rename */ - err = git_reference_rename(&new_reference, self->reference, c_name, 0); - git_reference_free(self->reference); - free(c_name); - if (err < 0) + // Rename + git_reference *new_reference; + int err = git_reference_rename(&new_reference, self->reference, c_name, 0, NULL); + Py_DECREF(tvalue); + if (err) return Error_set(err); + // Update reference + git_reference_free(self->reference); self->reference = new_reference; + Py_RETURN_NONE; } @@ -197,73 +266,125 @@ Reference_resolve(Reference *self, PyObject *args) } +static PyObject * +Reference_target_impl(Reference *self, const char ** c_name) +{ + CHECK_REFERENCE(self); + + /* Case 1: Direct */ + if (GIT_REF_OID == git_reference_type(self->reference)) + return git_oid_to_python(git_reference_target(self->reference)); + + /* Case 2: Symbolic */ + *c_name = git_reference_symbolic_target(self->reference); + if (*c_name == NULL) + PyErr_SetString(PyExc_ValueError, "no target available"); + + return NULL; +} + PyDoc_STRVAR(Reference_target__doc__, "The reference target: If direct the value will be an Oid object, if it\n" "is symbolic it will be an string with the full name of the target\n" - "reference."); + "reference.\n"); PyObject * Reference_target__get__(Reference *self) { - const char * c_name; + const char * c_name = NULL; + PyObject * ret; + + ret = Reference_target_impl(self, &c_name); + if (ret != NULL) + return ret; + if (c_name != NULL) + return PyUnicode_DecodeFSDefault(c_name); + return NULL; +} - CHECK_REFERENCE(self); - /* Case 1: Direct */ - if (GIT_REF_OID == git_reference_type(self->reference)) - return git_oid_to_python(git_reference_target(self->reference)); +PyDoc_STRVAR(Reference_raw_target__doc__, + "The raw reference target: If direct the value will be an Oid object, if it\n" + "is symbolic it will be bytes with the full name of the target\n" + "reference.\n"); - /* Case 2: Symbolic */ - c_name = git_reference_symbolic_target(self->reference); - if (c_name == NULL) { - PyErr_SetString(PyExc_ValueError, "no target available"); - return NULL; - } - return to_path(c_name); +PyObject * +Reference_raw_target__get__(Reference *self) +{ + const char * c_name = NULL; + PyObject * ret; + + ret = Reference_target_impl(self, &c_name); + if (ret != NULL) + return ret; + if (c_name != NULL) + return PyBytes_FromString(c_name); + return NULL; } -int -Reference_target__set__(Reference *self, PyObject *py_target) +PyDoc_STRVAR(Reference_set_target__doc__, + "set_target(target[, message: str])\n" + "\n" + "Set the target of this reference. Creates a new entry in the reflog.\n" + "\n" + "Parameters:\n" + "\n" + "target\n" + " The new target for this reference\n" + "\n" + "message\n" + " Message to use for the reflog.\n"); + +PyObject * +Reference_set_target(Reference *self, PyObject *args, PyObject *kwds) { git_oid oid; - char *c_name; int err; git_reference *new_ref; + PyObject *py_target = NULL; + const char *message = NULL; + char *keywords[] = {"target", "message", NULL}; - CHECK_REFERENCE_INT(self); + CHECK_REFERENCE(self); + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|s", keywords, + &py_target, &message)) + return NULL; /* Case 1: Direct */ if (GIT_REF_OID == git_reference_type(self->reference)) { err = py_oid_to_git_oid_expand(self->repo->repo, py_target, &oid); if (err < 0) - return err; + goto error; - err = git_reference_set_target(&new_ref, self->reference, &oid); + err = git_reference_set_target(&new_ref, self->reference, &oid, message); if (err < 0) goto error; git_reference_free(self->reference); self->reference = new_ref; - return 0; + Py_RETURN_NONE; } /* Case 2: Symbolic */ - c_name = py_path_to_c_str(py_target); + PyObject *tvalue; + char *c_name = pgit_borrow_fsdefault(py_target, &tvalue); if (c_name == NULL) - return -1; + return NULL; - err = git_reference_symbolic_set_target(&new_ref, self->reference, c_name); - free(c_name); + err = git_reference_symbolic_set_target(&new_ref, self->reference, c_name, message); + Py_DECREF(tvalue); if (err < 0) goto error; git_reference_free(self->reference); self->reference = new_ref; - return 0; + + Py_RETURN_NONE; error: Error_set(err); - return -1; + return NULL; } @@ -273,23 +394,60 @@ PyObject * Reference_name__get__(Reference *self) { CHECK_REFERENCE(self); - return to_path(git_reference_name(self->reference)); + return PyUnicode_DecodeFSDefault(git_reference_name(self->reference)); +} + +PyDoc_STRVAR(Reference_raw_name__doc__, "The full name of the reference (Bytes)."); + +PyObject * +Reference_raw_name__get__(Reference *self) +{ + CHECK_REFERENCE(self); + return PyBytes_FromString(git_reference_name(self->reference)); +} + +PyDoc_STRVAR(Reference_shorthand__doc__, + "The shorthand \"human-readable\" name of the reference."); + +PyObject * +Reference_shorthand__get__(Reference *self) +{ + CHECK_REFERENCE(self); + return PyUnicode_DecodeFSDefault(git_reference_shorthand(self->reference)); } +PyDoc_STRVAR(Reference_raw_shorthand__doc__, + "The shorthand \"human-readable\" name of the reference (Bytes)."); + +PyObject * +Reference_raw_shorthand__get__(Reference *self) +{ + CHECK_REFERENCE(self); + return PyBytes_FromString(git_reference_shorthand(self->reference)); +} PyDoc_STRVAR(Reference_type__doc__, - "Type, either GIT_REF_OID or GIT_REF_SYMBOLIC."); + "An enums.ReferenceType constant (either OID or SYMBOLIC)."); PyObject * Reference_type__get__(Reference *self) { - git_ref_t c_type; + git_reference_t c_type; CHECK_REFERENCE(self); c_type = git_reference_type(self->reference); - return PyLong_FromLong(c_type); + + return pygit2_enum(ReferenceTypeEnum, c_type); } +PyDoc_STRVAR(Reference__pointer__doc__, "Get the reference's pointer. For internal use only."); + +PyObject * +Reference__pointer__get__(Reference *self) +{ + /* Bytes means a raw buffer */ + return PyBytes_FromStringAndSize((char *) &self->reference, sizeof(git_reference *)); +} PyDoc_STRVAR(Reference_log__doc__, "log() -> RefLogIter\n" @@ -299,40 +457,119 @@ PyDoc_STRVAR(Reference_log__doc__, PyObject * Reference_log(Reference *self) { + int err; RefLogIter *iter; + git_repository *repo; CHECK_REFERENCE(self); + repo = git_reference_owner(self->reference); iter = PyObject_New(RefLogIter, &RefLogIterType); if (iter != NULL) { - git_reflog_read(&iter->reflog, self->reference); + err = git_reflog_read(&iter->reflog, repo, git_reference_name(self->reference)); + if (err < 0) + return Error_set(err); + iter->size = git_reflog_entrycount(iter->reflog); iter->i = 0; } return (PyObject*)iter; } - -PyDoc_STRVAR(Reference_get_object__doc__, - "get_object() -> object\n" +PyDoc_STRVAR(Reference_peel__doc__, + "peel(type=None) -> Object\n" + "\n" + "Retrieve an object of the given type by recursive peeling.\n" "\n" - "Retrieves the object the current reference is pointing to."); + "If no type is provided, the first non-tag object will be returned."); PyObject * -Reference_get_object(Reference *self) +Reference_peel(Reference *self, PyObject *args) { int err; - git_object* obj; + git_otype otype; + git_object *obj; + PyObject *py_type = Py_None; CHECK_REFERENCE(self); - err = git_reference_peel(&obj, self->reference, GIT_OBJ_ANY); + if (!PyArg_ParseTuple(args, "|O", &py_type)) + return NULL; + + otype = py_object_to_otype(py_type); + if (otype == GIT_OBJECT_INVALID) + return NULL; + + err = git_reference_peel(&obj, self->reference, otype); if (err < 0) return Error_set(err); - return wrap_object(obj, self->repo); + return wrap_object(obj, self->repo, NULL); } +PyObject * +Reference_richcompare(PyObject *o1, PyObject *o2, int op) +{ + PyObject *res; + Reference *obj1; + Reference *obj2; + const char *name1; + const char *name2; + + if (!PyObject_TypeCheck(o2, &ReferenceType)) { + Py_INCREF(Py_NotImplemented); + return Py_NotImplemented; + } + + switch (op) { + case Py_NE: + obj1 = (Reference *) o1; + obj2 = (Reference *) o2; + + CHECK_REFERENCE(obj1); + CHECK_REFERENCE(obj2); + + name1 = git_reference_name(obj1->reference); + name2 = git_reference_name(obj2->reference); + + if (strcmp(name1, name2) != 0) { + res = Py_True; + break; + } + + res = Py_False; + break; + case Py_EQ: + obj1 = (Reference *) o1; + obj2 = (Reference *) o2; + + CHECK_REFERENCE(obj1); + CHECK_REFERENCE(obj2); + + name1 = git_reference_name(obj1->reference); + name2 = git_reference_name(obj2->reference); + + if (strcmp(name1, name2) != 0) { + res = Py_False; + break; + } + + res = Py_True; + break; + case Py_LT: + case Py_LE: + case Py_GT: + case Py_GE: + Py_INCREF(Py_NotImplemented); + return Py_NotImplemented; + default: + PyErr_Format(PyExc_RuntimeError, "Unexpected '%d' op", op); + return NULL; + } + + Py_INCREF(res); + return res; +} PyDoc_STRVAR(RefLogEntry_committer__doc__, "Committer."); @@ -358,16 +595,16 @@ RefLogEntry_init(RefLogEntry *self, PyObject *args, PyObject *kwds) static void RefLogEntry_dealloc(RefLogEntry *self) { - free(self->oid_old); - free(self->oid_new); + Py_CLEAR(self->oid_old); + Py_CLEAR(self->oid_new); free(self->message); git_signature_free(self->signature); PyObject_Del(self); } PyMemberDef RefLogEntry_members[] = { - MEMBER(RefLogEntry, oid_new, T_STRING, "New oid."), - MEMBER(RefLogEntry, oid_old, T_STRING, "Old oid."), + MEMBER(RefLogEntry, oid_new, T_OBJECT, "New oid."), + MEMBER(RefLogEntry, oid_old, T_OBJECT, "Old oid."), MEMBER(RefLogEntry, message, T_STRING, "Message."), {NULL} }; @@ -426,20 +663,23 @@ PyMethodDef Reference_methods[] = { METHOD(Reference, rename, METH_O), METHOD(Reference, resolve, METH_NOARGS), METHOD(Reference, log, METH_NOARGS), - METHOD(Reference, get_object, METH_NOARGS), + METHOD(Reference, set_target, METH_VARARGS | METH_KEYWORDS), + METHOD(Reference, peel, METH_VARARGS), {NULL} }; PyGetSetDef Reference_getseters[] = { GETTER(Reference, name), - GETSET(Reference, target), + GETTER(Reference, raw_name), + GETTER(Reference, shorthand), + GETTER(Reference, raw_shorthand), + GETTER(Reference, target), + GETTER(Reference, raw_target), GETTER(Reference, type), + GETTER(Reference, _pointer), {NULL} }; - -PyDoc_STRVAR(Reference__doc__, "Reference."); - PyTypeObject ReferenceType = { PyVarObject_HEAD_INIT(NULL, 0) "_pygit2.Reference", /* tp_name */ @@ -464,7 +704,7 @@ PyTypeObject ReferenceType = { Reference__doc__, /* tp_doc */ 0, /* tp_traverse */ 0, /* tp_clear */ - 0, /* tp_richcompare */ + Reference_richcompare, /* tp_richcompare */ 0, /* tp_weaklistoffset */ 0, /* tp_iter */ 0, /* tp_iternext */ @@ -476,7 +716,7 @@ PyTypeObject ReferenceType = { 0, /* tp_descr_get */ 0, /* tp_descr_set */ 0, /* tp_dictoffset */ - 0, /* tp_init */ + (initproc)Reference_init, /* tp_init */ 0, /* tp_alloc */ 0, /* tp_new */ }; @@ -490,8 +730,8 @@ wrap_reference(git_reference * c_reference, Repository *repo) py_reference = PyObject_New(Reference, &ReferenceType); if (py_reference) { py_reference->reference = c_reference; + py_reference->repo = repo; if (repo) { - py_reference->repo = repo; Py_INCREF(repo); } } diff --git a/src/reference.h b/src/reference.h index 5cbd13453..909cbf30a 100644 --- a/src/reference.h +++ b/src/reference.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2013 The pygit2 contributors + * Copyright 2010-2025 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -34,13 +34,7 @@ PyObject* Reference_delete(Reference *self, PyObject *args); PyObject* Reference_rename(Reference *self, PyObject *py_name); -PyObject* Reference_reload(Reference *self); PyObject* Reference_resolve(Reference *self, PyObject *args); -PyObject* Reference_get_target(Reference *self); -PyObject* Reference_get_name(Reference *self); -PyObject* Reference_get_oid(Reference *self); -PyObject* Reference_get_hex(Reference *self); -PyObject* Reference_get_type(Reference *self); PyObject* wrap_reference(git_reference *c_reference, Repository *repo); #endif diff --git a/src/remote.c b/src/remote.c deleted file mode 100644 index 9faf11435..000000000 --- a/src/remote.c +++ /dev/null @@ -1,276 +0,0 @@ -/* - * Copyright 2010-2013 The pygit2 contributors - * - * 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 "utils.h" -#include "types.h" -#include "remote.h" - -extern PyObject *GitError; -extern PyTypeObject RepositoryType; - -PyObject * -Remote_init(Remote *self, PyObject *args, PyObject *kwds) -{ - Repository* py_repo = NULL; - char *name = NULL; - int err; - - if (!PyArg_ParseTuple(args, "O!s", &RepositoryType, &py_repo, &name)) - return NULL; - - self->repo = py_repo; - Py_INCREF(self->repo); - err = git_remote_load(&self->remote, py_repo->repo, name); - - if (err < 0) - return Error_set(err); - - return (PyObject*) self; -} - - -static void -Remote_dealloc(Remote *self) -{ - Py_CLEAR(self->repo); - git_remote_free(self->remote); - PyObject_Del(self); -} - - -PyDoc_STRVAR(Remote_name__doc__, "Name of the remote refspec"); - -PyObject * -Remote_name__get__(Remote *self) -{ - return to_unicode(git_remote_name(self->remote), NULL, NULL); -} - -int -Remote_name__set__(Remote *self, PyObject* py_name) -{ - int err; - char* name; - - name = py_str_to_c_str(py_name, NULL); - if (name != NULL) { - err = git_remote_rename(self->remote, name, NULL, NULL); - free(name); - - if (err == GIT_OK) - return 0; - - Error_set(err); - } - - return -1; -} - - -PyDoc_STRVAR(Remote_url__doc__, "Url of the remote"); - -PyObject * -Remote_url__get__(Remote *self) -{ - return to_unicode(git_remote_url(self->remote), NULL, NULL); -} - - -int -Remote_url__set__(Remote *self, PyObject* py_url) -{ - int err; - char* url = NULL; - - url = py_str_to_c_str(py_url, NULL); - if (url != NULL) { - err = git_remote_set_url(self->remote, url); - free(url); - - if (err == GIT_OK) - return 0; - - Error_set(err); - } - - return -1; -} - - -PyDoc_STRVAR(Remote_refspec_count__doc__, "Number of refspecs."); - -PyObject * -Remote_refspec_count__get__(Remote *self) -{ - size_t count; - - count = git_remote_refspec_count(self->remote); - return PyLong_FromSize_t(count); -} - - -PyDoc_STRVAR(Remote_get_refspec__doc__, - "get_refspec(n) -> (str, str)\n" - "\n" - "Return the refspec at the given position."); - -PyObject * -Remote_get_refspec(Remote *self, PyObject *value) -{ - size_t n; - const git_refspec *refspec; - - n = PyLong_AsSize_t(value); - if (PyErr_Occurred()) - return NULL; - - refspec = git_remote_get_refspec(self->remote, n); - if (refspec == NULL) { - PyErr_SetObject(PyExc_IndexError, value); - return NULL; - } - - return Py_BuildValue("(ss)", git_refspec_src(refspec), - git_refspec_dst(refspec)); -} - - -PyDoc_STRVAR(Remote_fetch__doc__, - "fetch() -> {'indexed_objects': int, 'received_objects' : int," - " 'received_bytesa' : int}\n" - "\n" - "Negotiate what objects should be downloaded and download the\n" - "packfile with those objects"); - -PyObject * -Remote_fetch(Remote *self, PyObject *args) -{ - PyObject* py_stats = NULL; - const git_transfer_progress *stats; - int err; - - err = git_remote_connect(self->remote, GIT_DIRECTION_FETCH); - if (err == GIT_OK) { - err = git_remote_download(self->remote, NULL, NULL); - if (err == GIT_OK) { - stats = git_remote_stats(self->remote); - py_stats = Py_BuildValue("{s:I,s:I,s:n}", - "indexed_objects", stats->indexed_objects, - "received_objects", stats->received_objects, - "received_bytes", stats->received_bytes); - - err = git_remote_update_tips(self->remote); - } - git_remote_disconnect(self->remote); - } - - if (err < 0) - return Error_set(err); - - return (PyObject*) py_stats; -} - - -PyDoc_STRVAR(Remote_save__doc__, - "save()\n\n" - "Save a remote to its repository configuration."); - -PyObject * -Remote_save(Remote *self, PyObject *args) -{ - int err; - - err = git_remote_save(self->remote); - if (err == GIT_OK) { - Py_RETURN_NONE; - } - else { - return Error_set(err); - } -} - - -PyMethodDef Remote_methods[] = { - METHOD(Remote, fetch, METH_NOARGS), - METHOD(Remote, save, METH_NOARGS), - METHOD(Remote, get_refspec, METH_O), - {NULL} -}; - -PyGetSetDef Remote_getseters[] = { - GETSET(Remote, name), - GETSET(Remote, url), - GETTER(Remote, refspec_count), - {NULL} -}; - -PyDoc_STRVAR(Remote__doc__, "Remote object."); - -PyTypeObject RemoteType = { - PyVarObject_HEAD_INIT(NULL, 0) - "_pygit2.Remote", /* tp_name */ - sizeof(Remote), /* tp_basicsize */ - 0, /* tp_itemsize */ - (destructor)Remote_dealloc, /* tp_dealloc */ - 0, /* tp_print */ - 0, /* tp_getattr */ - 0, /* tp_setattr */ - 0, /* tp_compare */ - 0, /* tp_repr */ - 0, /* tp_as_number */ - 0, /* tp_as_sequence */ - 0, /* tp_as_mapping */ - 0, /* tp_hash */ - 0, /* tp_call */ - 0, /* tp_str */ - 0, /* tp_getattro */ - 0, /* tp_setattro */ - 0, /* tp_as_buffer */ - Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, /* tp_flags */ - Remote__doc__, /* tp_doc */ - 0, /* tp_traverse */ - 0, /* tp_clear */ - 0, /* tp_richcompare */ - 0, /* tp_weaklistoffset */ - 0, /* tp_iter */ - 0, /* tp_iternext */ - Remote_methods, /* tp_methods */ - 0, /* tp_members */ - Remote_getseters, /* tp_getset */ - 0, /* tp_base */ - 0, /* tp_dict */ - 0, /* tp_descr_get */ - 0, /* tp_descr_set */ - 0, /* tp_dictoffset */ - (initproc)Remote_init, /* tp_init */ - 0, /* tp_alloc */ - 0, /* tp_new */ -}; diff --git a/src/repository.c b/src/repository.c index 935c1665f..4be614bf7 100644 --- a/src/repository.c +++ b/src/repository.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2013 The pygit2 contributors + * Copyright 2010-2025 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -25,19 +25,26 @@ * Boston, MA 02110-1301, USA. */ +#include #define PY_SSIZE_T_CLEAN #include #include "error.h" #include "types.h" #include "reference.h" +#include "revspec.h" #include "utils.h" +#include "odb.h" #include "object.h" #include "oid.h" #include "note.h" +#include "refdb.h" #include "repository.h" -#include "remote.h" +#include "diff.h" #include "branch.h" +#include "signature.h" +#include "worktree.h" #include +#include extern PyObject *GitError; @@ -45,63 +52,132 @@ extern PyTypeObject IndexType; extern PyTypeObject WalkerType; extern PyTypeObject SignatureType; extern PyTypeObject ObjectType; +extern PyTypeObject OidType; extern PyTypeObject CommitType; extern PyTypeObject TreeType; extern PyTypeObject TreeBuilderType; extern PyTypeObject ConfigType; extern PyTypeObject DiffType; -extern PyTypeObject RemoteType; extern PyTypeObject ReferenceType; +extern PyTypeObject RevSpecType; extern PyTypeObject NoteType; extern PyTypeObject NoteIterType; +extern PyTypeObject StashType; +extern PyTypeObject RefsIteratorType; -git_otype -int_to_loose_object_type(int type_id) +extern PyObject *FileStatusEnum; +extern PyObject *MergeAnalysisEnum; +extern PyObject *MergePreferenceEnum; + +/* forward-declaration for Repository._from_c() */ +PyTypeObject RepositoryType; + +PyObject * +wrap_repository(git_repository *c_repo) { - switch((git_otype)type_id) { - case GIT_OBJ_COMMIT: return GIT_OBJ_COMMIT; - case GIT_OBJ_TREE: return GIT_OBJ_TREE; - case GIT_OBJ_BLOB: return GIT_OBJ_BLOB; - case GIT_OBJ_TAG: return GIT_OBJ_TAG; - default: return GIT_OBJ_BAD; + Repository *py_repo = PyObject_GC_New(Repository, &RepositoryType); + + if (py_repo) { + py_repo->repo = c_repo; + py_repo->config = NULL; + py_repo->index = NULL; + py_repo->owned = 1; } + + return (PyObject *)py_repo; } int Repository_init(Repository *self, PyObject *args, PyObject *kwds) { - char *path; - int err; + PyObject *backend = NULL; - if (kwds) { + if (kwds && PyDict_Size(kwds) > 0) { PyErr_SetString(PyExc_TypeError, "Repository takes no keyword arguments"); return -1; } - if (!PyArg_ParseTuple(args, "s", &path)) + if (!PyArg_ParseTuple(args, "|O", &backend)) { return -1; + } - err = git_repository_open(&self->repo, path); - if (err < 0) { - Error_set_str(err, path); - return -1; + if (backend == NULL) { + /* Create repository without odb/refdb */ + int err = git_repository_new(&self->repo); + if (err != 0) { + Error_set(err); + return -1; + } + self->owned = 1; + self->config = NULL; + self->index = NULL; + return 0; } + self->repo = PyCapsule_GetPointer(backend, "backend"); + if (self->repo == NULL) { + PyErr_SetString(PyExc_TypeError, + "Repository unable to unpack backend."); + return -1; + } + self->owned = 1; self->config = NULL; self->index = NULL; return 0; } +PyDoc_STRVAR(Repository__from_c__doc__, "Init a Repository from a pointer. For internal use only."); +PyObject * +Repository__from_c(Repository *py_repo, PyObject *args) +{ + PyObject *py_pointer, *py_free; + char *buffer; + Py_ssize_t len; + int err; + + py_repo->repo = NULL; + py_repo->config = NULL; + py_repo->index = NULL; + + if (!PyArg_ParseTuple(args, "OO!", &py_pointer, &PyBool_Type, &py_free)) + return NULL; + + err = PyBytes_AsStringAndSize(py_pointer, &buffer, &len); + if (err < 0) + return NULL; + + if (len != sizeof(git_repository *)) { + PyErr_SetString(PyExc_TypeError, "invalid pointer length"); + return NULL; + } + + py_repo->repo = *((git_repository **) buffer); + py_repo->owned = py_free == Py_True; + + Py_RETURN_NONE; +} + +PyDoc_STRVAR(Repository__disown__doc__, "Mark the object as not-owned by us. For internal use only."); +PyObject * +Repository__disown(Repository *py_repo) +{ + py_repo->owned = 0; + Py_RETURN_NONE; +} + void Repository_dealloc(Repository *self) { PyObject_GC_UnTrack(self); Py_CLEAR(self->index); Py_CLEAR(self->config); - git_repository_free(self->repo); - PyObject_GC_Del(self); + + if (self->owned) + git_repository_free(self->repo); + + Py_TYPE(self)->tp_free(self); } int @@ -118,38 +194,6 @@ Repository_clear(Repository *self) return 0; } -static int -Repository_build_as_iter(const git_oid *oid, void *accum) -{ - int err; - PyObject *py_oid = git_oid_to_python(oid); - - err = PyList_Append((PyObject*)accum, py_oid); - Py_DECREF(py_oid); - return err; -} - -PyObject * -Repository_as_iter(Repository *self) -{ - git_odb *odb; - int err; - PyObject *accum = PyList_New(0); - - err = git_repository_odb(&odb, self->repo); - if (err < 0) - return Error_set(err); - - err = git_odb_foreach(odb, Repository_build_as_iter, (void*)accum); - git_odb_free(odb); - if (err == GIT_EUSER) - return NULL; - if (err < 0) - return Error_set(err); - - return PyObject_GetIter(accum); -} - PyDoc_STRVAR(Repository_head__doc__, "Current head reference of the repository."); @@ -173,27 +217,6 @@ Repository_head__get__(Repository *self) return wrap_reference(head, self); } -int -Repository_head__set__(Repository *self, PyObject *py_refname) -{ - int err; - char *refname; - - refname = py_str_to_c_str(py_refname, NULL); - if (refname == NULL) - return -1; - - err = git_repository_set_head(self->repo, refname); - free(refname); - if (err < 0) { - Error_set_str(err, refname); - return -1; - } - - return 0; -} - - PyDoc_STRVAR(Repository_head_is_detached__doc__, "A repository's HEAD is detached when it points directly to a commit\n" "instead of a branch."); @@ -208,14 +231,14 @@ Repository_head_is_detached__get__(Repository *self) } -PyDoc_STRVAR(Repository_head_is_orphaned__doc__, - "An orphan branch is one named from HEAD but which doesn't exist in the\n" +PyDoc_STRVAR(Repository_head_is_unborn__doc__, + "An unborn branch is one named from HEAD but which doesn't exist in the\n" "refs namespace, because it doesn't have any commit to point to."); PyObject * -Repository_head_is_orphaned__get__(Repository *self) +Repository_head_is_unborn__get__(Repository *self) { - if (git_repository_head_orphan(self->repo) > 0) + if (git_repository_head_unborn(self->repo) > 0) Py_RETURN_TRUE; Py_RETURN_FALSE; @@ -248,8 +271,21 @@ Repository_is_bare__get__(Repository *self) } +PyDoc_STRVAR(Repository_is_shallow__doc__, + "Check if a repository is a shallow repository."); + +PyObject * +Repository_is_shallow__get__(Repository *self) +{ + if (git_repository_is_shallow(self->repo) > 0) + Py_RETURN_TRUE; + + Py_RETURN_FALSE; +} + + PyDoc_STRVAR(Repository_git_object_lookup_prefix__doc__, - "git_object_lookup_prefix(oid) -> Object\n" + "git_object_lookup_prefix(oid: Oid) -> Object\n" "\n" "Returns the Git object with the given oid."); @@ -265,9 +301,9 @@ Repository_git_object_lookup_prefix(Repository *self, PyObject *key) if (len == 0) return NULL; - err = git_object_lookup_prefix(&obj, self->repo, &oid, len, GIT_OBJ_ANY); + err = git_object_lookup_prefix(&obj, self->repo, &oid, len, GIT_OBJECT_ANY); if (err == 0) - return wrap_object(obj, self); + return wrap_object(obj, self, NULL); if (err == GIT_ENOTFOUND) Py_RETURN_NONE; @@ -277,19 +313,22 @@ Repository_git_object_lookup_prefix(Repository *self, PyObject *key) PyDoc_STRVAR(Repository_lookup_branch__doc__, - "lookup_branch(branch_name, [branch_type]) -> Object\n" + "lookup_branch(branch_name: str, branch_type: BranchType = BranchType.LOCAL) -> Branch\n" "\n" - "Returns the Git reference for the given branch name (local or remote)."); + "Returns the Git reference for the given branch name (local or remote).\n" + "If branch_type is BranchType.REMOTE, you must include the remote name\n" + "in the branch name (eg 'origin/master')."); PyObject * Repository_lookup_branch(Repository *self, PyObject *args) { git_reference *c_reference; const char *c_name; + Py_ssize_t c_name_len; git_branch_t branch_type = GIT_BRANCH_LOCAL; int err; - if (!PyArg_ParseTuple(args, "s|I", &c_name, &branch_type)) + if (!PyArg_ParseTuple(args, "s#|I", &c_name, &c_name_len, &branch_type)) return NULL; err = git_branch_lookup(&c_reference, self->repo, c_name, branch_type); @@ -303,171 +342,113 @@ Repository_lookup_branch(Repository *self, PyObject *args) } -PyDoc_STRVAR(Repository_revparse_single__doc__, - "revparse_single(revision) -> Object\n" +PyDoc_STRVAR(Repository_path_is_ignored__doc__, + "path_is_ignored(path: str) -> bool\n" "\n" - "Find an object, as specified by a revision string. See\n" - "`man gitrevisions`, or the documentation for `git rev-parse` for\n" - "information on the syntax accepted."); + "Check if a path is ignored in the repository."); PyObject * -Repository_revparse_single(Repository *self, PyObject *py_spec) -{ - git_object *c_obj; - char *c_spec; - int err; - - /* 1- Get the C revision spec */ - c_spec = py_str_to_c_str(py_spec, NULL); - if (c_spec == NULL) - return NULL; - - /* 2- Lookup */ - err = git_revparse_single(&c_obj, self->repo, c_spec); - - if (err < 0) { - PyObject *err_obj = Error_set_str(err, c_spec); - free(c_spec); - return err_obj; - } - free(c_spec); - - return wrap_object(c_obj, self); -} - -git_odb_object * -Repository_read_raw(git_repository *repo, const git_oid *oid, size_t len) +Repository_path_is_ignored(Repository *self, PyObject *args) { - git_odb *odb; - git_odb_object *obj; - int err; + int ignored; + char *path; - err = git_repository_odb(&odb, repo); - if (err < 0) { - Error_set(err); + if (!PyArg_ParseTuple(args, "s", &path)) return NULL; - } - err = git_odb_read_prefix(&obj, odb, oid, (unsigned int)len); - git_odb_free(odb); - if (err < 0) { - Error_set_oid(err, oid, len); - return NULL; - } + git_ignore_path_is_ignored(&ignored, self->repo, path); + if (ignored == 1) + Py_RETURN_TRUE; - return obj; + Py_RETURN_FALSE; } -PyDoc_STRVAR(Repository_read__doc__, - "read(oid) -> type, data, size\n" +PyDoc_STRVAR(Repository_revparse_single__doc__, + "revparse_single(revision: str) -> Object\n" "\n" - "Read raw object data from the repository."); + "Find an object, as specified by a revision string. See\n" + "`man gitrevisions`, or the documentation for `git rev-parse` for\n" + "information on the syntax accepted."); PyObject * -Repository_read(Repository *self, PyObject *py_hex) +Repository_revparse_single(Repository *self, PyObject *py_spec) { - git_oid oid; - git_odb_object *obj; - size_t len; - PyObject* tuple; - - len = py_oid_to_git_oid(py_hex, &oid); - if (len == 0) - return NULL; - - obj = Repository_read_raw(self->repo, &oid, len); - if (obj == NULL) + /* Get the C revision spec */ + const char *c_spec = pgit_borrow(py_spec); + if (c_spec == NULL) return NULL; - tuple = Py_BuildValue( - #if PY_MAJOR_VERSION == 2 - "(ns#)", - #else - "(ny#)", - #endif - git_odb_object_type(obj), - git_odb_object_data(obj), - git_odb_object_size(obj)); + /* Lookup */ + git_object *c_obj; + int err = git_revparse_single(&c_obj, self->repo, c_spec); + if (err) + return Error_set_str(err, c_spec); - git_odb_object_free(obj); - return tuple; + return wrap_object(c_obj, self, NULL); } -PyDoc_STRVAR(Repository_write__doc__, - "write(type, data) -> Oid\n" - "\n" - "Write raw object data into the repository. First arg is the object\n" - "type, the second one a buffer with data. Return the Oid of the created\n" - "object."); +PyDoc_STRVAR(Repository_revparse_ext__doc__, + "revparse_ext(revision: str) -> tuple[Object, Reference]\n" + "\n" + "Find a single object and intermediate reference, as specified by a revision\n" + "string. See `man gitrevisions`, or the documentation for `git rev-parse`\n" + "for information on the syntax accepted.\n" + "\n" + "In some cases (@{<-n>} or @{upstream}), the expression may\n" + "point to an intermediate reference, which is returned in the second element\n" + "of the result tuple."); PyObject * -Repository_write(Repository *self, PyObject *args) +Repository_revparse_ext(Repository *self, PyObject *py_spec) { - int err; - git_oid oid; - git_odb *odb; - git_odb_stream* stream; - int type_id; - const char* buffer; - Py_ssize_t buflen; - git_otype type; - - if (!PyArg_ParseTuple(args, "Is#", &type_id, &buffer, &buflen)) + /* Get the C revision spec */ + const char *c_spec = pgit_borrow(py_spec); + if (c_spec == NULL) return NULL; - type = int_to_loose_object_type(type_id); - if (type == GIT_OBJ_BAD) - return PyErr_Format(PyExc_ValueError, "%d", type_id); - - err = git_repository_odb(&odb, self->repo); - if (err < 0) - return Error_set(err); - - err = git_odb_open_wstream(&stream, odb, buflen, type); - git_odb_free(odb); - if (err < 0) - return Error_set(err); + /* Lookup */ + git_object *c_obj = NULL; + git_reference *c_ref = NULL; + int err = git_revparse_ext(&c_obj, &c_ref, self->repo, c_spec); + if (err) + return Error_set_str(err, c_spec); - stream->write(stream, buffer, buflen); - err = stream->finalize_write(&oid, stream); - stream->free(stream); - return git_oid_to_python(&oid); + PyObject *py_obj = wrap_object(c_obj, self, NULL); + PyObject *py_ref = NULL; + if (c_ref != NULL) { + py_ref = wrap_reference(c_ref, self); + } else { + py_ref = Py_None; + Py_INCREF(Py_None); + } + return Py_BuildValue("NN", py_obj, py_ref); } -PyDoc_STRVAR(Repository_index__doc__, "Index file."); +PyDoc_STRVAR(Repository_revparse__doc__, + "revparse(revspec: str) -> RevSpec\n" + "\n" + "Parse a revision string for from, to, and intent. See `man gitrevisions`,\n" + "or the documentation for `git rev-parse` for information on the syntax\n" + "accepted."); PyObject * -Repository_index__get__(Repository *self, void *closure) +Repository_revparse(Repository *self, PyObject *py_spec) { - int err; - git_index *index; - Index *py_index; - - assert(self->repo); - - if (self->index == NULL) { - err = git_repository_index(&index, self->repo); - if (err < 0) - return Error_set(err); - - py_index = PyObject_GC_New(Index, &IndexType); - if (!py_index) { - git_index_free(index); - return NULL; - } + /* Get the C revision spec */ + const char *c_spec = pgit_borrow(py_spec); + if (c_spec == NULL) + return NULL; - Py_INCREF(self); - py_index->repo = self; - py_index->index = index; - PyObject_GC_Track(py_index); - self->index = (PyObject*)py_index; + /* Lookup */ + git_revspec revspec; + int err = git_revparse(&revspec, self->repo, c_spec); + if (err) { + return Error_set_str(err, c_spec); } - - Py_INCREF(self->index); - return self->index; + return wrap_revspec(&revspec, self); } @@ -477,7 +458,15 @@ PyDoc_STRVAR(Repository_path__doc__, PyObject * Repository_path__get__(Repository *self, void *closure) { - return to_path(git_repository_path(self->repo)); + const char *c_path; + if (self->repo == NULL) + Py_RETURN_NONE; + + c_path = git_repository_path(self->repo); + if (c_path == NULL) + Py_RETURN_NONE; + + return PyUnicode_DecodeFSDefault(c_path); } @@ -494,53 +483,64 @@ Repository_workdir__get__(Repository *self, void *closure) if (c_path == NULL) Py_RETURN_NONE; - return to_path(c_path); + return PyUnicode_DecodeFSDefault(c_path); } +int +Repository_workdir__set__(Repository *self, PyObject *py_workdir) +{ + const char *workdir = pgit_borrow(py_workdir); + if (workdir == NULL) + return -1; + + int err = git_repository_set_workdir(self->repo, workdir, 0 /* update_gitlink */); + if (err) { + Error_set_str(err, workdir); + return -1; + } + + return 0; +} -PyDoc_STRVAR(Repository_config__doc__, - "Get the configuration file for this repository.\n" +PyDoc_STRVAR(Repository_descendant_of__doc__, + "descendant_of(oid1: Oid, oid2: Oid) -> bool\n" "\n" - "If a configuration file has not been set, the default config set for the\n" - "repository will be returned, including global and system configurations\n" - "(if they are available)."); + "Determine if the first commit is a descendant of the second commit.\n" + "Note that a commit is not considered a descendant of itself."); PyObject * -Repository_config__get__(Repository *self) +Repository_descendant_of(Repository *self, PyObject *args) { + PyObject *value1; + PyObject *value2; + git_oid oid1; + git_oid oid2; int err; - git_config *config; - Config *py_config; - assert(self->repo); + if (!PyArg_ParseTuple(args, "OO", &value1, &value2)) + return NULL; - if (self->config == NULL) { - err = git_repository_config(&config, self->repo); - if (err < 0) - return Error_set(err); + err = py_oid_to_git_oid_expand(self->repo, value1, &oid1); + if (err < 0) + return NULL; - py_config = PyObject_New(Config, &ConfigType); - if (py_config == NULL) { - git_config_free(config); - return NULL; - } + err = py_oid_to_git_oid_expand(self->repo, value2, &oid2); + if (err < 0) + return NULL; - py_config->config = config; - self->config = (PyObject*)py_config; - // We need 2 refs here. - // One is returned, one is kept internally. - Py_INCREF(self->config); - } else { - Py_INCREF(self->config); - } + // err < 0 => error, see source code of `git_graph_descendant_of` + err = git_graph_descendant_of(self->repo, &oid1, &oid2); + if (err < 0) + return Error_set(err); - return self->config; + return PyBool_FromLong(err); } PyDoc_STRVAR(Repository_merge_base__doc__, - "merge_base(oid, oid) -> Oid\n" + "merge_base(oid1: Oid, oid2: Oid) -> Oid\n" "\n" - "Find as good common ancestors as possible for a merge."); + "Find as good common ancestors as possible for a merge.\n" + "Returns None if there is no merge base between the commits"); PyObject * Repository_merge_base(Repository *self, PyObject *args) @@ -564,100 +564,301 @@ Repository_merge_base(Repository *self, PyObject *args) return NULL; err = git_merge_base(&oid, self->repo, &oid1, &oid2); + + if (err == GIT_ENOTFOUND) + Py_RETURN_NONE; + if (err < 0) return Error_set(err); return git_oid_to_python(&oid); } -PyDoc_STRVAR(Repository_walk__doc__, - "walk(oid, sort_mode) -> iterator\n" - "\n" - "Generator that traverses the history starting from the given commit.\n" - "The following types of sorting could be used to control traversing\n" - "direction:\n" - "\n" - "* GIT_SORT_NONE. This is the default sorting for new walkers\n" - " Sort the repository contents in no particular ordering\n" - "* GIT_SORT_TOPOLOGICAL. Sort the repository contents in topological order\n" - " (parents before children); this sorting mode can be combined with\n" - " time sorting.\n" - "* GIT_SORT_TIME. Sort the repository contents by commit time\n" - "* GIT_SORT_REVERSE. Iterate through the repository contents in reverse\n" - " order; this sorting mode can be combined with any of the above.\n" - "\n" - "Example:\n" - "\n" - " >>> from pygit2 import Repository\n" - " >>> from pygit2 import GIT_SORT_TOPOLOGICAL, GIT_SORT_REVERSE\n" - " >>> repo = Repository('.git')\n" - " >>> for commit in repo.walk(repo.head.oid, GIT_SORT_TOPOLOGICAL):\n" - " ... print commit.message\n" - " >>> for commit in repo.walk(repo.head.oid, GIT_SORT_TOPOLOGICAL | GIT_SORT_REVERSE):\n" - " ... print commit.message\n" - " >>>\n"); +typedef int (*git_merge_base_xxx_t)(git_oid *out, git_repository *repo, size_t length, const git_oid input_array[]); -PyObject * -Repository_walk(Repository *self, PyObject *args) +static PyObject * +merge_base_xxx(Repository *self, PyObject *args, git_merge_base_xxx_t git_merge_base_xxx) { - PyObject *value; - unsigned int sort; - int err; + PyObject *py_result = NULL; + PyObject *py_commit_oid; + PyObject *py_commit_oids; git_oid oid; - git_revwalk *walk; - Walker *py_walker; + int commit_oid_count; + git_oid *commit_oids = NULL; + int i = 0; + int err; - if (!PyArg_ParseTuple(args, "OI", &value, &sort)) + if (!PyArg_ParseTuple(args, "O!", &PyList_Type, &py_commit_oids)) return NULL; - err = git_revwalk_new(&walk, self->repo); - if (err < 0) - return Error_set(err); + commit_oid_count = (int)PyList_Size(py_commit_oids); + commit_oids = malloc(commit_oid_count * sizeof(git_oid)); + if (commit_oids == NULL) { + PyErr_SetNone(PyExc_MemoryError); + goto out; + } - /* Sort */ - git_revwalk_sorting(walk, sort); + for (; i < commit_oid_count; i++) { + py_commit_oid = PyList_GET_ITEM(py_commit_oids, i); + err = py_oid_to_git_oid_expand(self->repo, py_commit_oid, &commit_oids[i]); + if (err < 0) + goto out; + } - /* Push */ - if (value != Py_None) { - err = py_oid_to_git_oid_expand(self->repo, value, &oid); - if (err < 0) { - git_revwalk_free(walk); - return NULL; - } + err = (*git_merge_base_xxx)(&oid, self->repo, commit_oid_count, (const git_oid*)commit_oids); - err = git_revwalk_push(walk, &oid); - if (err < 0) { - git_revwalk_free(walk); - return Error_set(err); - } + if (err == GIT_ENOTFOUND) { + Py_INCREF(Py_None); + py_result = Py_None; + goto out; } - py_walker = PyObject_New(Walker, &WalkerType); - if (!py_walker) { - git_revwalk_free(walk); - return NULL; + if (err < 0) { + py_result = Error_set(err); + goto out; } - Py_INCREF(self); - py_walker->repo = self; - py_walker->walk = walk; - return (PyObject*)py_walker; + py_result = git_oid_to_python(&oid); + +out: + free(commit_oids); + return py_result; } -PyDoc_STRVAR(Repository_create_blob__doc__, - "create_blob(data) -> Oid\n" - "\n" - "Create a new blob from a bytes string. The blob is added to the Git\n" - "object database. Returns the oid of the blob."); +PyDoc_STRVAR(Repository_merge_base_many__doc__, + "merge_base_many(oids: list[Oid]) -> Oid\n" + "\n" + "Find as good common ancestors as possible for an n-way merge.\n" + "Returns None if there is no merge base between the commits"); PyObject * -Repository_create_blob(Repository *self, PyObject *args) +Repository_merge_base_many(Repository *self, PyObject *args) { - git_oid oid; - const char *raw; - Py_ssize_t size; - int err; + return merge_base_xxx(self, args, &git_merge_base_many); +} + +PyDoc_STRVAR(Repository_merge_base_octopus__doc__, + "merge_base_octopus(oids: list[Oid]) -> Oid\n" + "\n" + "Find as good common ancestors as possible for an n-way octopus merge.\n" + "Returns None if there is no merge base between the commits"); + +PyObject * +Repository_merge_base_octopus(Repository *self, PyObject *args) +{ + return merge_base_xxx(self, args, &git_merge_base_octopus); +} + +PyDoc_STRVAR(Repository_merge_analysis__doc__, + "merge_analysis(their_head: Oid, our_ref: str = \"HEAD\") -> tuple[MergeAnalysis, MergePreference]\n" + "\n" + "Analyzes the given branch and determines the opportunities for\n" + "merging it into a reference (defaults to HEAD).\n" + "\n" + "Parameters:\n" + "\n" + "our_ref\n" + " The reference name (String) to perform the analysis from\n" + "\n" + "their_head\n" + " Head (commit Oid) to merge into\n" + "\n" + "The first returned value is a mixture of the MergeAnalysis.NONE, NORMAL,\n" + "UP_TO_DATE, FASTFORWARD and UNBORN flags.\n" + "The second value is the user's preference from 'merge.ff'"); + +PyObject * +Repository_merge_analysis(Repository *self, PyObject *args) +{ + char *our_ref_name = "HEAD"; + PyObject *py_their_head; + PyObject *py_result = NULL; + git_oid head_id; + git_reference *our_ref; + git_annotated_commit *commit; + git_merge_analysis_t analysis; + git_merge_preference_t preference; + int err = 0; + + if (!PyArg_ParseTuple(args, "O|z", + &py_their_head, + &our_ref_name)) + return NULL; + + err = git_reference_lookup(&our_ref, self->repo, our_ref_name); + if (err < 0) { + PyObject *py_err = Error_set_str(err, our_ref_name); + return py_err; + } + + err = py_oid_to_git_oid_expand(self->repo, py_their_head, &head_id); + if (err < 0) + goto out; + + err = git_annotated_commit_lookup(&commit, self->repo, &head_id); + if (err < 0) { + py_result = Error_set(err); + goto out; + } + + err = git_merge_analysis_for_ref(&analysis, &preference, self->repo, our_ref, (const git_annotated_commit **) &commit, 1); + git_annotated_commit_free(commit); + if (err < 0) { + py_result = Error_set(err); + goto out; + } + + // Convert analysis to MergeAnalysis enum + PyObject *analysis_enum = pygit2_enum(MergeAnalysisEnum, analysis); + if (!analysis_enum) { + py_result = NULL; + goto out; + } + + // Convert preference to MergePreference enum + PyObject *preference_enum = pygit2_enum(MergePreferenceEnum, preference); + if (!preference_enum) { + py_result = NULL; + Py_DECREF(analysis_enum); + goto out; + } + + py_result = Py_BuildValue("(OO)", analysis_enum, preference_enum); + +out: + git_reference_free(our_ref); + return py_result; +} + +PyDoc_STRVAR(Repository_cherrypick__doc__, + "cherrypick(id: Oid)\n" + "\n" + "Cherry-pick the given oid, producing changes in the index and working directory.\n" + "\n" + "Merges the given commit into HEAD as a cherrypick, writing the results into the\n" + "working directory. Any changes are staged for commit and any conflicts\n" + "are written to the index. Callers should inspect the repository's\n" + "index after this completes, resolve any conflicts and prepare a\n" + "commit."); + +PyObject * +Repository_cherrypick(Repository *self, PyObject *py_oid) +{ + git_commit *commit; + git_oid oid; + int err; + size_t len; + git_cherrypick_options cherrypick_opts = GIT_CHERRYPICK_OPTIONS_INIT; + + len = py_oid_to_git_oid(py_oid, &oid); + if (len == 0) + return NULL; + + err = git_commit_lookup(&commit, self->repo, &oid); + if (err < 0) + return Error_set(err); + + cherrypick_opts.checkout_opts.checkout_strategy = GIT_CHECKOUT_SAFE; + err = git_cherrypick(self->repo, + commit, + (const git_cherrypick_options *)&cherrypick_opts); + + git_commit_free(commit); + if (err < 0) + return Error_set(err); + + Py_RETURN_NONE; +} + +PyDoc_STRVAR(Repository_walk__doc__, + "walk(oid: Oid | None, sort_mode: enums.SortMode = enums.SortMode.NONE) -> Walker\n" + "\n" + "Start traversing the history from the given commit.\n" + "The following SortMode values can be used to control the walk:\n" + "\n" + "* NONE. Sort the output with the same default method from\n" + " `git`: reverse chronological order. This is the default sorting for\n" + " new walkers.\n" + "* TOPOLOGICAL. Sort the repository contents in topological order\n" + " (no parents before all of its children are shown); this sorting mode\n" + " can be combined with time sorting to produce `git`'s `--date-order``.\n" + "* TIME. Sort the repository contents by commit time; this sorting\n" + " mode can be combined with topological sorting.\n" + "* REVERSE. Iterate through the repository contents in reverse\n" + " order; this sorting mode can be combined with any of the above.\n" + "\n" + "Example:\n" + "\n" + " >>> from pygit2 import Repository\n" + " >>> from pygit2.enums import SortMode\n" + " >>> repo = Repository('.git')\n" + " >>> for commit in repo.walk(repo.head.target, SortMode.TOPOLOGICAL):\n" + " ... print(commit.message)\n" + " >>> for commit in repo.walk(repo.head.target, SortMode.TOPOLOGICAL | SortMode.REVERSE):\n" + " ... print(commit.message)\n" + " >>>\n"); + +PyObject * +Repository_walk(Repository *self, PyObject *args) +{ + PyObject *value; + unsigned int sort = GIT_SORT_NONE; + int err; + git_oid oid; + git_revwalk *walk; + Walker *py_walker; + + if (!PyArg_ParseTuple(args, "O|I", &value, &sort)) + return NULL; + + err = git_revwalk_new(&walk, self->repo); + if (err < 0) + return Error_set(err); + + /* Sort */ + git_revwalk_sorting(walk, sort); + + /* Push */ + if (value != Py_None) { + err = py_oid_to_git_oid_expand(self->repo, value, &oid); + if (err < 0) + goto error; + + err = git_revwalk_push(walk, &oid); + if (err < 0) { + Error_set(err); + goto error; + } + } + + py_walker = PyObject_New(Walker, &WalkerType); + if (py_walker) { + Py_INCREF(self); + py_walker->repo = self; + py_walker->walk = walk; + return (PyObject*)py_walker; + } + +error: + git_revwalk_free(walk); + return NULL; +} + + +PyDoc_STRVAR(Repository_create_blob__doc__, + "create_blob(data: bytes) -> Oid\n" + "\n" + "Create a new blob from a bytes string. The blob is added to the Git\n" + "object database. Returns the oid of the blob."); + +PyObject * +Repository_create_blob(Repository *self, PyObject *args) +{ + git_oid oid; + const char *raw; + Py_ssize_t size; + int err; if (!PyArg_ParseTuple(args, "s#", &raw, &size)) return NULL; @@ -671,23 +872,24 @@ Repository_create_blob(Repository *self, PyObject *args) PyDoc_STRVAR(Repository_create_blob_fromworkdir__doc__, - "create_blob_fromworkdir(path) -> Oid\n" + "create_blob_fromworkdir(path: str) -> Oid\n" "\n" "Create a new blob from a file within the working directory. The given\n" "path must be relative to the working directory, if it is not an error\n" "is raised."); PyObject * -Repository_create_blob_fromworkdir(Repository *self, PyObject *args) +Repository_create_blob_fromworkdir(Repository *self, PyObject *value) { - git_oid oid; - const char* path; - int err; - - if (!PyArg_ParseTuple(args, "s", &path)) + PyObject *tvalue; + char *path = pgit_borrow_fsdefault(value, &tvalue); + if (path == NULL) return NULL; - err = git_blob_create_fromworkdir(&oid, self->repo, path); + git_oid oid; + int err = git_blob_create_fromworkdir(&oid, self->repo, path); + Py_DECREF(tvalue); + if (err < 0) return Error_set(err); @@ -696,21 +898,102 @@ Repository_create_blob_fromworkdir(Repository *self, PyObject *args) PyDoc_STRVAR(Repository_create_blob_fromdisk__doc__, - "create_blob_fromdisk(path) -> Oid\n" + "create_blob_fromdisk(path: str) -> Oid\n" "\n" "Create a new blob from a file anywhere (no working directory check)."); PyObject * -Repository_create_blob_fromdisk(Repository *self, PyObject *args) +Repository_create_blob_fromdisk(Repository *self, PyObject *value) { + PyObject *tvalue; + char *path = pgit_borrow_fsdefault(value, &tvalue); + if (path == NULL) + return NULL; + git_oid oid; - const char* path; - int err; + int err = git_blob_create_fromdisk(&oid, self->repo, path); + Py_DECREF(tvalue); - if (!PyArg_ParseTuple(args, "s", &path)) + if (err < 0) + return Error_set(err); + + return git_oid_to_python(&oid); +} + + +#define BUFSIZE 4096 + +PyDoc_STRVAR(Repository_create_blob_fromiobase__doc__, + "create_blob_fromiobase(io.IOBase) -> Oid\n" + "\n" + "Create a new blob from an IOBase object."); + +PyObject * +Repository_create_blob_fromiobase(Repository *self, PyObject *py_file) +{ + git_writestream *stream; + git_oid oid; + PyObject *py_is_readable; + int is_readable; + int err; + + py_is_readable = PyObject_CallMethod(py_file, "readable", NULL); + if (!py_is_readable) { + if (PyErr_ExceptionMatches(PyExc_AttributeError)) + PyErr_SetObject(PyExc_TypeError, py_file); + return NULL; + } + + is_readable = PyObject_IsTrue(py_is_readable); + Py_DECREF(py_is_readable); + + if (!is_readable) { + Py_DECREF(py_file); + PyErr_SetString(PyExc_TypeError, "expected readable IO type"); return NULL; + } + + err = git_blob_create_fromstream(&stream, self->repo, NULL); + if (err < 0) + return Error_set(err); + + for (;;) { + PyObject *py_bytes; + char *bytes; + Py_ssize_t size; + + py_bytes = PyObject_CallMethod(py_file, "read", "i", 4096); + if (!py_bytes) + return NULL; + + if (py_bytes == Py_None) { + Py_DECREF(py_bytes); + goto cleanup; + } + + if (PyBytes_AsStringAndSize(py_bytes, &bytes, &size)) { + Py_DECREF(py_bytes); + return NULL; + } + + if (size == 0) { + Py_DECREF(py_bytes); + break; + } - err = git_blob_create_fromdisk(&oid, self->repo, path); + err = stream->write(stream, bytes, size); + Py_DECREF(py_bytes); + if (err < 0) + goto cleanup; + } + +cleanup: + if (err < 0) { + stream->free(stream); + return Error_set(err); + } + + err = git_blob_create_fromstream_commit(&oid, stream); if (err < 0) return Error_set(err); @@ -719,7 +1002,7 @@ Repository_create_blob_fromdisk(Repository *self, PyObject *args) PyDoc_STRVAR(Repository_create_commit__doc__, - "create_commit(reference, author, committer, message, tree, parents[, encoding]) -> Oid\n" + "create_commit(reference_name: str, author: Signature, committer: Signature, message: bytes | str, tree: Oid, parents: list[Oid][, encoding: str]) -> Oid\n" "\n" "Create a new commit object, return its oid."); @@ -729,15 +1012,13 @@ Repository_create_commit(Repository *self, PyObject *args) Signature *py_author, *py_committer; PyObject *py_oid, *py_message, *py_parents, *py_parent; PyObject *py_result = NULL; - char *message = NULL; char *update_ref = NULL; char *encoding = NULL; git_oid oid; git_tree *tree = NULL; int parent_count; git_commit **parents = NULL; - int err = 0, i = 0; - size_t len; + int i = 0; if (!PyArg_ParseTuple(args, "zO!O!OOO!|s", &update_ref, @@ -749,15 +1030,16 @@ Repository_create_commit(Repository *self, PyObject *args) &encoding)) return NULL; - len = py_oid_to_git_oid(py_oid, &oid); + size_t len = py_oid_to_git_oid(py_oid, &oid); if (len == 0) - goto out; + return NULL; - message = py_str_to_c_str(py_message, encoding); + PyObject *tmessage; + const char *message = pgit_borrow_encoding(py_message, encoding, NULL, &tmessage); if (message == NULL) - goto out; + return NULL; - err = git_tree_lookup_prefix(&tree, self->repo, &oid, len); + int err = git_tree_lookup_prefix(&tree, self->repo, &oid, len); if (err < 0) { Error_set(err); goto out; @@ -784,7 +1066,7 @@ Repository_create_commit(Repository *self, PyObject *args) err = git_commit_create(&oid, self->repo, update_ref, py_author->signature, py_committer->signature, encoding, message, tree, parent_count, - (const git_commit**)parents); + (const git_commit **)parents); if (err < 0) { Error_set(err); goto out; @@ -793,7 +1075,7 @@ Repository_create_commit(Repository *self, PyObject *args) py_result = git_oid_to_python(&oid); out: - free(message); + Py_DECREF(tmessage); git_tree_free(tree); while (i > 0) { i--; @@ -803,9 +1085,118 @@ Repository_create_commit(Repository *self, PyObject *args) return py_result; } +PyDoc_STRVAR(Repository_create_commit_string__doc__, + "create_commit_string(author: Signature, committer: Signature, message: bytes | str, tree: Oid, parents: list[Oid][, encoding: str]) -> str\n" + "\n" + "Create a new commit but return it as a string."); + +PyObject * +Repository_create_commit_string(Repository *self, PyObject *args) +{ + Signature *py_author, *py_committer; + PyObject *py_oid, *py_message, *py_parents, *py_parent; + PyObject *str = NULL; + char *encoding = NULL; + git_oid oid; + git_tree *tree = NULL; + int parent_count; + git_commit **parents = NULL; + git_buf buf = { 0 }; + int i = 0; + + if (!PyArg_ParseTuple(args, "O!O!OOO!|s", + &SignatureType, &py_author, + &SignatureType, &py_committer, + &py_message, + &py_oid, + &PyList_Type, &py_parents, + &encoding)) + return NULL; + + size_t len = py_oid_to_git_oid(py_oid, &oid); + if (len == 0) + return NULL; + + PyObject *tmessage; + const char *message = pgit_borrow_encoding(py_message, encoding, NULL, &tmessage); + if (message == NULL) + return NULL; + + int err = git_tree_lookup_prefix(&tree, self->repo, &oid, len); + if (err < 0) { + Error_set(err); + goto out; + } + + parent_count = (int)PyList_Size(py_parents); + parents = malloc(parent_count * sizeof(git_commit*)); + if (parents == NULL) { + PyErr_SetNone(PyExc_MemoryError); + goto out; + } + for (; i < parent_count; i++) { + py_parent = PyList_GET_ITEM(py_parents, i); + len = py_oid_to_git_oid(py_parent, &oid); + if (len == 0) + goto out; + err = git_commit_lookup_prefix(&parents[i], self->repo, &oid, len); + if (err < 0) { + Error_set(err); + goto out; + } + } + + err = git_commit_create_buffer(&buf, self->repo, + py_author->signature, py_committer->signature, + encoding, message, tree, parent_count, + (const git_commit **)parents); + if (err < 0) { + Error_set(err); + goto out; + } + + str = to_unicode_n(buf.ptr, buf.size, NULL, NULL); + git_buf_dispose(&buf); + +out: + Py_DECREF(tmessage); + git_tree_free(tree); + while (i > 0) { + i--; + git_commit_free(parents[i]); + } + free(parents); + return str; +} + +PyDoc_STRVAR(Repository_create_commit_with_signature__doc__, + "create_commit_with_signature(content: str, signature: str[, field_name: str]) -> Oid\n" + "\n" + "Create a new signed commit object, return its oid."); + +PyObject * +Repository_create_commit_with_signature(Repository *self, PyObject *args) +{ + git_oid oid; + char *content, *signature; + char *signature_field = NULL; + + if (!PyArg_ParseTuple(args, "ss|s", &content, &signature, &signature_field)) + return NULL; + + int err = git_commit_create_with_signature(&oid, self->repo, content, + signature, signature_field); + + if (err < 0) { + Error_set(err); + return NULL; + } + + return git_oid_to_python(&oid); +} PyDoc_STRVAR(Repository_create_tag__doc__, - "create_tag(name, oid, type, tagger, message) -> 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."); @@ -844,11 +1235,13 @@ Repository_create_tag(Repository *self, PyObject *args) PyDoc_STRVAR(Repository_create_branch__doc__, - "create_branch(name, commit, force=False) -> bytes\n" + "create_branch(name: str, commit: Commit, force: bool = False) -> Branch\n" "\n" "Create a new branch \"name\" which points to a commit.\n" "\n" - "Arguments:\n" + "Returns: Branch\n" + "\n" + "Parameters:\n" "\n" "force\n" " If True branches will be overridden, otherwise (the default) an\n" @@ -856,9 +1249,10 @@ PyDoc_STRVAR(Repository_create_branch__doc__, "\n" "Examples::\n" "\n" - " repo.create_branch('foo', repo.head.hex, force=False)"); + " repo.create_branch('foo', repo.head.peel(), force=False)"); -PyObject* Repository_create_branch(Repository *self, PyObject *args) +PyObject * +Repository_create_branch(Repository *self, PyObject *args) { Commit *py_commit; git_reference *c_reference; @@ -876,13 +1270,18 @@ PyObject* Repository_create_branch(Repository *self, PyObject *args) } -PyDoc_STRVAR(Repository_listall_references__doc__, - "listall_references() -> (str, ...)\n" - "\n" - "Return a tuple with all the references in the repository."); +static PyObject * +to_path_f(const char * x) { + return PyUnicode_DecodeFSDefault(x); +} -PyObject * -Repository_listall_references(Repository *self, PyObject *args) +PyDoc_STRVAR(Repository_raw_listall_references__doc__, + "raw_listall_references() -> list[bytes]\n" + "\n" + "Return a list with all the references in the repository."); + +static PyObject * +Repository_raw_listall_references(Repository *self, PyObject *args) { git_strarray c_result; PyObject *py_result, *py_string; @@ -895,141 +1294,308 @@ Repository_listall_references(Repository *self, PyObject *args) return Error_set(err); /* Create a new PyTuple */ - py_result = PyTuple_New(c_result.count); + py_result = PyList_New(c_result.count); if (py_result == NULL) goto out; /* Fill it */ for (index=0; index < c_result.count; index++) { - py_string = to_path((c_result.strings)[index]); + py_string = PyBytes_FromString(c_result.strings[index]); if (py_string == NULL) { Py_CLEAR(py_result); goto out; } - PyTuple_SET_ITEM(py_result, index, py_string); + PyList_SET_ITEM(py_result, index, py_string); } out: - git_strarray_free(&c_result); + git_strarray_dispose(&c_result); return py_result; } -PyDoc_STRVAR(Repository_listall_branches__doc__, - "listall_branches([flags]) -> (str, ...)\n" - "\n" - "Return a tuple with all the branches in the repository."); +PyObject * +wrap_references_iterator(git_reference_iterator *iter) { + RefsIterator *py_refs_iter = PyObject_New(RefsIterator , &ReferenceType); + if (py_refs_iter) + py_refs_iter->iterator = iter; -struct branch_foreach_s { - PyObject *tuple; - Py_ssize_t pos; -}; + return (PyObject *)py_refs_iter; +} -int -branch_foreach_cb(const char *branch_name, git_branch_t branch_type, void *payload) +void +References_iterator_dealloc(RefsIterator *iter) +{ + git_reference_iterator_free(iter->iterator); + Py_TYPE(iter)->tp_free((PyObject *)iter); +} + +PyDoc_STRVAR(Repository_references_iterator_init__doc__, + "references_iterator_init() -> git_reference_iterator\n" + "\n" + "Creates and returns an iterator for references."); + +PyObject * +Repository_references_iterator_init(Repository *self, PyObject *args) { - /* This is the callback that will be called in git_branch_foreach. It - * will be called for every branch. - * payload is a struct branch_foreach_s. - */ int err; - struct branch_foreach_s *payload_s = (struct branch_foreach_s *)payload; - - if (PyTuple_Size(payload_s->tuple) <= payload_s->pos) - { - err = _PyTuple_Resize(&(payload_s->tuple), payload_s->pos * 2); - if (err) { - Py_CLEAR(payload_s->tuple); - return GIT_ERROR; - } - } + git_reference_iterator *iter; + RefsIterator *refs_iter; - PyObject *py_branch_name = to_path(branch_name); - if (py_branch_name == NULL) { - Py_CLEAR(payload_s->tuple); - return GIT_ERROR; + refs_iter = PyObject_New(RefsIterator, &RefsIteratorType); + if (refs_iter == NULL) { + return NULL; } - PyTuple_SET_ITEM(payload_s->tuple, payload_s->pos++, py_branch_name); + if ((err = git_reference_iterator_new(&iter, self->repo)) < 0) + return Error_set(err); - return GIT_OK; + refs_iter->iterator = iter; + return (PyObject*)refs_iter; } +PyDoc_STRVAR(Repository_references_iterator_next__doc__, + "references_iterator_next(iter: Iterator[Reference], references_return_type: ReferenceFilter = ReferenceFilter.ALL) -> Reference\n" + "\n" + "Returns next reference object for repository. Optionally, can filter \n" + "based on value of references_return_type.\n" + "Acceptable values of references_return_type:\n" + "ReferenceFilter.ALL -> returns all refs, this is the default\n" + "ReferenceFilter.BRANCHES -> returns all branches\n" + "ReferenceFilter.TAGS -> returns all tags\n" + "all other values -> will return None"); PyObject * -Repository_listall_branches(Repository *self, PyObject *args) +Repository_references_iterator_next(Repository *self, PyObject *args) +{ + git_reference *ref; + git_reference_iterator *git_iter; + PyObject *iter; + int references_return_type = GIT_REFERENCES_ALL; + + if (!PyArg_ParseTuple(args, "O|i", &iter, &references_return_type)) + return NULL; + git_iter = ((RefsIterator *) iter)->iterator; + + int err; + while (0 == (err = git_reference_next(&ref, git_iter))) { + switch(references_return_type) { + case GIT_REFERENCES_ALL: + return wrap_reference(ref, self); + case GIT_REFERENCES_BRANCHES: + if (git_reference_is_branch(ref)) { + return wrap_reference(ref, self); + } + break; + case GIT_REFERENCES_TAGS: + if (git_reference_is_tag(ref)) { + return wrap_reference(ref, self); + } + break; + } + } + if (err == GIT_ITEROVER) { + Py_RETURN_NONE; + } + return Error_set(err); +} + +static PyObject * +Repository_listall_branches_impl(Repository *self, PyObject *args, PyObject *(*item_trans)(const char *)) { - unsigned int list_flags = GIT_BRANCH_LOCAL; + git_branch_t list_flags = GIT_BRANCH_LOCAL; + git_branch_iterator *iter; + git_reference *ref = NULL; int err; + git_branch_t type; + PyObject *list; /* 1- Get list_flags */ if (!PyArg_ParseTuple(args, "|I", &list_flags)) return NULL; - /* 2- Get the C result */ - struct branch_foreach_s payload; - payload.tuple = PyTuple_New(4); - if (payload.tuple == NULL) + list = PyList_New(0); + if (list == NULL) return NULL; - payload.pos = 0; - err = git_branch_foreach(self->repo, list_flags, branch_foreach_cb, &payload); - if (err != GIT_OK) + if ((err = git_branch_iterator_new(&iter, self->repo, list_flags)) < 0) return Error_set(err); - /* 3- Trim the tuple */ - err = _PyTuple_Resize(&payload.tuple, payload.pos); - if (err) + while ((err = git_branch_next(&ref, &type, iter)) == 0) { + PyObject *py_branch_name = item_trans(git_reference_shorthand(ref)); + git_reference_free(ref); + + if (py_branch_name == NULL) + goto error; + + err = PyList_Append(list, py_branch_name); + Py_DECREF(py_branch_name); + + if (err < 0) + goto error; + } + + git_branch_iterator_free(iter); + if (err == GIT_ITEROVER) + err = 0; + + if (err < 0) { + Py_CLEAR(list); return Error_set(err); + } + + return list; + +error: + git_branch_iterator_free(iter); + Py_CLEAR(list); + return NULL; +} + +PyDoc_STRVAR(Repository_listall_branches__doc__, + "listall_branches(flag: BranchType = BranchType.LOCAL) -> list[str]\n" + "\n" + "Return a list with all the branches in the repository.\n" + "\n" + "The *flag* may be:\n" + "\n" + "- BranchType.LOCAL - return all local branches (set by default)\n" + "- BranchType.REMOTE - return all remote-tracking branches\n" + "- BranchType.ALL - return local branches and remote-tracking branches"); - return payload.tuple; +PyObject * +Repository_listall_branches(Repository *self, PyObject *args) +{ + return Repository_listall_branches_impl(self, args, to_path_f); +} + +PyDoc_STRVAR(Repository_raw_listall_branches__doc__, + "raw_listall_branches(flag: BranchType = BranchType.LOCAL) -> list[bytes]\n" + "\n" + "Return a list with all the branches in the repository.\n" + "\n" + "The *flag* may be:\n" + "\n" + "- BranchType.LOCAL - return all local branches (set by default)\n" + "- BranchType.REMOTE - return all remote-tracking branches\n" + "- BranchType.ALL - return local branches and remote-tracking branches"); + +PyObject * +Repository_raw_listall_branches(Repository *self, PyObject *args) +{ + return Repository_listall_branches_impl(self, args, PyBytes_FromString); +} + +PyDoc_STRVAR(Repository_listall_submodules__doc__, + "listall_submodules() -> list[str]\n" + "\n" + "Return a list with all submodule paths in the repository.\n"); + +static int foreach_path_cb(git_submodule *submodule, const char *name, void *payload) +{ + PyObject *list = (PyObject *)payload; + PyObject *path = to_unicode(git_submodule_path(submodule), NULL, NULL); + + int err = PyList_Append(list, path); + Py_DECREF(path); + return err; +} + +PyObject * +Repository_listall_submodules(Repository *self, PyObject *args) +{ + PyObject *list = PyList_New(0); + if (list == NULL) + return NULL; + + int err = git_submodule_foreach(self->repo, foreach_path_cb, list); + if (err) { + Py_DECREF(list); + if (PyErr_Occurred()) + return NULL; + + return Error_set(err); + } + + return list; } PyDoc_STRVAR(Repository_lookup_reference__doc__, - "lookup_reference(name) -> Reference\n" + "lookup_reference(name: str) -> Reference\n" "\n" "Lookup a reference by its name in a repository."); PyObject * Repository_lookup_reference(Repository *self, PyObject *py_name) { + /* 1- Get the C name */ + PyObject *tvalue; + char *c_name = pgit_borrow_fsdefault(py_name, &tvalue); + if (c_name == NULL) + return NULL; + + /* 2- Lookup */ git_reference *c_reference; - char *c_name; - int err; + int err = git_reference_lookup(&c_reference, self->repo, c_name); + if (err) { + PyObject *err_obj = Error_set_str(err, c_name); + Py_DECREF(tvalue); + return err_obj; + } + Py_DECREF(tvalue); + + /* 3- Make an instance of Reference and return it */ + return wrap_reference(c_reference, self); +} +PyDoc_STRVAR(Repository_lookup_reference_dwim__doc__, + "lookup_reference_dwim(name: str) -> Reference\n" + "\n" + "Lookup a reference by doing-what-i-mean'ing its short name."); + +PyObject * +Repository_lookup_reference_dwim(Repository *self, PyObject *py_name) +{ /* 1- Get the C name */ - c_name = py_path_to_c_str(py_name); + PyObject *tvalue; + char *c_name = pgit_borrow_fsdefault(py_name, &tvalue); if (c_name == NULL) return NULL; /* 2- Lookup */ - err = git_reference_lookup(&c_reference, self->repo, c_name); - if (err < 0) { + git_reference *c_reference; + int err = git_reference_dwim(&c_reference, self->repo, c_name); + if (err) { PyObject *err_obj = Error_set_str(err, c_name); - free(c_name); + Py_DECREF(tvalue); return err_obj; } - free(c_name); + Py_DECREF(tvalue); /* 3- Make an instance of Reference and return it */ return wrap_reference(c_reference, self); } PyDoc_STRVAR(Repository_create_reference_direct__doc__, - "git_reference_create(name, target, force) -> Reference\n" + "create_reference_direct(name: str, target: Oid, force: bool, message=None) -> Reference\n" "\n" "Create a new reference \"name\" which points to an object.\n" "\n" - "Arguments:\n" + "Returns: Reference\n" + "\n" + "Parameters:\n" "\n" "force\n" " If True references will be overridden, otherwise (the default) an\n" " exception is raised.\n" "\n" + "message\n" + " Optional message to use for the reflog.\n" + "\n" "Examples::\n" "\n" - " repo.git_reference_create('refs/heads/foo', repo.head.hex, False)"); + " repo.create_reference_direct('refs/heads/foo', repo.head.target, False)"); PyObject * Repository_create_reference_direct(Repository *self, PyObject *args, @@ -1040,15 +1606,18 @@ Repository_create_reference_direct(Repository *self, PyObject *args, char *c_name; git_oid oid; int err, force; + const char *message = NULL; + char *keywords[] = {"name", "target", "force", "message", NULL}; - if (!PyArg_ParseTuple(args, "sOi", &c_name, &py_obj, &force)) + if (!PyArg_ParseTupleAndKeywords(args, kw, "sOi|z", keywords, + &c_name, &py_obj, &force, &message)) return NULL; err = py_oid_to_git_oid_expand(self->repo, py_obj, &oid); if (err < 0) return NULL; - err = git_reference_create(&c_reference, self->repo, c_name, &oid, force); + err = git_reference_create(&c_reference, self->repo, c_name, &oid, force, message); if (err < 0) return Error_set(err); @@ -1056,19 +1625,24 @@ Repository_create_reference_direct(Repository *self, PyObject *args, } PyDoc_STRVAR(Repository_create_reference_symbolic__doc__, - "git_reference_symbolic_create(name, source, force) -> Reference\n" + "create_reference_symbolic(name: str, target: str, force: bool, message: str = None) -> Reference\n" "\n" "Create a new reference \"name\" which points to another reference.\n" "\n" - "Arguments:\n" + "Returns: Reference\n" + "\n" + "Parameters:\n" "\n" "force\n" " If True references will be overridden, otherwise (the default) an\n" " exception is raised.\n" "\n" + "message\n" + " Optional message to use for the reflog.\n" + "\n" "Examples::\n" "\n" - " repo.git_reference_symbolic_create('refs/tags/foo', 'refs/heads/master', False)"); + " repo.create_reference_symbolic('refs/tags/foo', 'refs/heads/master', False)"); PyObject * Repository_create_reference_symbolic(Repository *self, PyObject *args, @@ -1077,401 +1651,834 @@ Repository_create_reference_symbolic(Repository *self, PyObject *args, git_reference *c_reference; char *c_name, *c_target; int err, force; + const char *message = NULL; + char *keywords[] = {"name", "target", "force", "message", NULL}; + + if (!PyArg_ParseTupleAndKeywords(args, kw, "ssi|z", keywords, + &c_name, &c_target, &force, &message)) + return NULL; + + err = git_reference_symbolic_create(&c_reference, self->repo, c_name, + c_target, force, message); + if (err < 0) + return Error_set(err); + + return wrap_reference(c_reference, self); +} + +PyDoc_STRVAR(Repository_compress_references__doc__, + "compress_references()\n" + "\n" + "Suggest that the repository compress or optimize its references.\n" + "This mechanism is implementation-specific. For on-disk reference\n" + "databases, for example, this may pack all loose references."); + +PyObject * +Repository_compress_references(Repository *self) +{ + git_refdb *refdb; + int err; + + err = git_repository_refdb(&refdb, self->repo); + if (err < 0) + return Error_set(err); + + err = git_refdb_compress(refdb); + + git_refdb_free(refdb); + if (err < 0) + return Error_set(err); + Py_RETURN_NONE; +} - if (!PyArg_ParseTuple(args, "ssi", &c_name, &c_target, &force)) +PyDoc_STRVAR(Repository_status__doc__, + "status(untracked_files: str = \"all\", ignored: bool = False) -> dict[str, enums.FileStatus]\n" + "\n" + "Reads the status of the repository and returns a dictionary with file\n" + "paths as keys and FileStatus flags as values.\n" + "\n" + "Parameters:\n" + "\n" + "untracked_files\n" + " How to handle untracked files, defaults to \"all\":\n" + "\n" + " - \"no\": do not return untracked files\n" + " - \"normal\": include untracked files/directories but do not recurse subdirectories\n" + " - \"all\": include all files in untracked directories\n" + "\n" + " Using `untracked_files=\"no\"` or \"normal\"can be faster than \"all\" when the worktree\n" + " contains many untracked files/directories.\n" + "\n" + "ignored\n" + " Whether to show ignored files with untracked files. Ignored when untracked_files == \"no\"\n" + " Defaults to False.\n"); + +PyObject * +Repository_status(Repository *self, PyObject *args, PyObject *kw) +{ + int err; + size_t len, i; + git_status_list *list; + + char *untracked_files = "all"; + static char *kwlist[] = {"untracked_files", "ignored", NULL}; + + PyObject* ignored = Py_False; + + if (!PyArg_ParseTupleAndKeywords(args, kw, "|sO", kwlist, &untracked_files, &ignored)) return NULL; - err = git_reference_symbolic_create(&c_reference, self->repo, c_name, - c_target, force); + git_status_options opts = GIT_STATUS_OPTIONS_INIT; + opts.flags = GIT_STATUS_OPT_DEFAULTS; + + if (!strcmp(untracked_files, "no")) { + opts.flags &= ~(GIT_STATUS_OPT_INCLUDE_UNTRACKED | GIT_STATUS_OPT_RECURSE_UNTRACKED_DIRS); + } else if (!strcmp(untracked_files, "normal")){ + opts.flags &= ~GIT_STATUS_OPT_RECURSE_UNTRACKED_DIRS; + } else if (strcmp(untracked_files, "all") ){ + return PyErr_Format( + PyExc_ValueError, + "untracked_files must be one of \"all\", \"normal\" or \"one\""); + }; + + if (!PyBool_Check(ignored)) { + return PyErr_Format(PyExc_TypeError, "ignored must be True or False"); + } + if (!PyObject_IsTrue(ignored)) { + opts.flags &= ~GIT_STATUS_OPT_INCLUDE_IGNORED; + } + + err = git_status_list_new(&list, self->repo, &opts); + if (err < 0) + return Error_set(err); + + PyObject *dict = PyDict_New(); + if (dict == NULL) + goto error; + + len = git_status_list_entrycount(list); + for (i = 0; i < len; i++) { + const git_status_entry *entry; + const char *path; + PyObject *status; + + entry = git_status_byindex(list, i); + if (entry == NULL) + goto error; + + /* We need to choose one of the strings */ + if (entry->head_to_index) + path = entry->head_to_index->old_file.path; + else + path = entry->index_to_workdir->old_file.path; + + /* Get corresponding entry in enums.FileStatus for status int */ + status = pygit2_enum(FileStatusEnum, entry->status); + if (status == NULL) + goto error; + + err = PyDict_SetItemString(dict, path, status); + Py_CLEAR(status); + + if (err < 0) + goto error; + } + + git_status_list_free(list); + return dict; + +error: + git_status_list_free(list); + Py_CLEAR(dict); + return NULL; +} + + +PyDoc_STRVAR(Repository_status_file__doc__, + "status_file(path: str) -> enums.FileStatus\n" + "\n" + "Returns the status of the given file path."); + +PyObject * +Repository_status_file(Repository *self, PyObject *value) +{ + PyObject *tvalue; + char *path = pgit_borrow_fsdefault(value, &tvalue); + if (!path) + return NULL; + + unsigned int status; + int err = git_status_file(&status, self->repo, path); + if (err) { + PyObject *err_obj = Error_set_str(err, path); + Py_DECREF(tvalue); + return err_obj; + } + Py_DECREF(tvalue); + + return pygit2_enum(FileStatusEnum, (int) status); +} + + +PyDoc_STRVAR(Repository_TreeBuilder__doc__, + "TreeBuilder([tree]) -> TreeBuilder\n" + "\n" + "Create a TreeBuilder object for this repository."); + +PyObject * +Repository_TreeBuilder(Repository *self, PyObject *args) +{ + TreeBuilder *builder; + git_treebuilder *bld; + PyObject *py_src = NULL; + git_oid oid; + git_tree *tree = NULL; + git_tree *must_free = NULL; + int err; + + if (!PyArg_ParseTuple(args, "|O", &py_src)) + return NULL; + + if (py_src) { + if (PyObject_TypeCheck(py_src, &TreeType)) { + Tree *py_tree = (Tree *)py_src; + if (py_tree->repo->repo != self->repo) { + /* return Error_set(GIT_EINVALIDARGS); */ + return Error_set(GIT_ERROR); + } + if (Object__load((Object*)py_tree) == NULL) { return NULL; } // Lazy load + tree = py_tree->tree; + } else { + err = py_oid_to_git_oid_expand(self->repo, py_src, &oid); + if (err < 0) + return NULL; + + err = git_tree_lookup(&tree, self->repo, &oid); + if (err < 0) + return Error_set(err); + must_free = tree; + } + } + + err = git_treebuilder_new(&bld, self->repo, tree); + if (must_free != NULL) + git_tree_free(must_free); + + if (err < 0) + return Error_set(err); + + builder = PyObject_New(TreeBuilder, &TreeBuilderType); + if (builder) { + builder->repo = self; + builder->bld = bld; + Py_INCREF(self); + } + + return (PyObject*)builder; +} + +PyDoc_STRVAR(Repository_default_signature__doc__, "Return the signature according to the repository's configuration"); + +PyObject * +Repository_default_signature__get__(Repository *self) +{ + git_signature *sig; + int err; + + if ((err = git_signature_default(&sig, self->repo)) < 0) + return Error_set(err); + + return build_signature(NULL, sig, "utf-8"); +} + +PyDoc_STRVAR(Repository_odb__doc__, "Return the object database for this repository"); + +PyObject * +Repository_odb__get__(Repository *self) +{ + git_odb *odb; + int err; + + err = git_repository_odb(&odb, self->repo); + if (err < 0) + return Error_set(err); + + return wrap_odb(odb); +} + +PyDoc_STRVAR(Repository_refdb__doc__, "Return the reference database for this repository"); + +PyObject * +Repository_refdb__get__(Repository *self) +{ + git_refdb *refdb; + int err; + + err = git_repository_refdb(&refdb, self->repo); + if (err < 0) + return Error_set(err); + + return wrap_refdb(refdb); +} + +PyDoc_STRVAR(Repository__pointer__doc__, "Get the repo's pointer. For internal use only."); +PyObject * +Repository__pointer__get__(Repository *self) +{ + /* Bytes means a raw buffer */ + return PyBytes_FromStringAndSize((char *) &self->repo, sizeof(git_repository *)); +} + +PyDoc_STRVAR(Repository_notes__doc__, ""); + +PyObject * +Repository_notes(Repository *self, PyObject *args) +{ + char *ref = "refs/notes/commits"; + if (!PyArg_ParseTuple(args, "|s", &ref)) + return NULL; + + NoteIter *iter = PyObject_New(NoteIter, &NoteIterType); + if (iter == NULL) + return NULL; + + Py_INCREF(self); + iter->repo = self; + iter->ref = ref; + iter->iter = NULL; + + int err = git_note_iterator_new(&iter->iter, self->repo, iter->ref); + if (err != GIT_OK) { + Py_DECREF(iter); + return Error_set(err); + } + + return (PyObject*)iter; +} + + +PyDoc_STRVAR(Repository_create_note__doc__, + "create_note(message: str, author: Signature, committer: Signature, annotated_id: str, ref: str = \"refs/notes/commits\", force: bool = False) -> Oid\n" + "\n" + "Create a new note for an object, return its SHA-ID." + "If no ref is given 'refs/notes/commits' will be used."); + +PyObject * +Repository_create_note(Repository *self, PyObject* args) +{ + git_oid note_id, annotated_id; + char *annotated = NULL, *message = NULL, *ref = "refs/notes/commits"; + int err = GIT_ERROR; + unsigned int force = 0; + Signature *py_author, *py_committer; + + if (!PyArg_ParseTuple(args, "sO!O!s|si", + &message, + &SignatureType, &py_author, + &SignatureType, &py_committer, + &annotated, &ref, &force)) + return NULL; + + err = git_oid_fromstr(&annotated_id, annotated); + if (err < 0) + return Error_set(err); + + err = git_note_create(¬e_id, self->repo, ref, py_author->signature, + py_committer->signature, + &annotated_id, message, force); if (err < 0) return Error_set(err); - return wrap_reference(c_reference, self); + return git_oid_to_python(¬e_id); } -PyDoc_STRVAR(Repository_status__doc__, - "status() -> {str: int}\n" +PyDoc_STRVAR(Repository_lookup_note__doc__, + "lookup_note(annotated_id: str, ref: str = \"refs/notes/commits\") -> Note\n" "\n" - "Reads the status of the repository and returns a dictionary with file\n" - "paths as keys and status flags as values. See pygit2.GIT_STATUS_*."); + "Lookup a note for an annotated object in a repository."); -int -read_status_cb(const char *path, unsigned int status_flags, void *payload) +PyObject * +Repository_lookup_note(Repository *self, PyObject* args) { - /* This is the callback that will be called in git_status_foreach. It - * will be called for every path.*/ - PyObject *flags; + git_oid annotated_id; + char* annotated = NULL, *ref = "refs/notes/commits"; int err; - flags = PyLong_FromLong((long) status_flags); - err = PyDict_SetItemString(payload, path, flags); - Py_CLEAR(flags); + if (!PyArg_ParseTuple(args, "s|s", &annotated, &ref)) + return NULL; + err = git_oid_fromstr(&annotated_id, annotated); if (err < 0) - return GIT_ERROR; + return Error_set(err); - return GIT_OK; + return (PyObject*) wrap_note(self, NULL, &annotated_id, ref); } +PyDoc_STRVAR(Repository_reset__doc__, + "reset(oid: Oid, reset_mode: enums.ResetMode)\n" + "\n" + "Resets the current head.\n" + "\n" + "Parameters:\n" + "\n" + "oid\n" + " The oid of the commit to reset to.\n" + "\n" + "reset_mode\n" + " * SOFT: resets head to point to oid, but does not modify\n" + " working copy, and leaves the changes in the index.\n" + " * MIXED: resets head to point to oid, but does not modify\n" + " working copy. It empties the index too.\n" + " * HARD: resets head to point to oid, and resets too the\n" + " working copy and the content of the index.\n"); + PyObject * -Repository_status(Repository *self, PyObject *args) +Repository_reset(Repository *self, PyObject* args) { - PyObject *payload_dict; + PyObject *py_oid; + git_oid oid; + git_object *target = NULL; + int err, reset_type; + size_t len; - payload_dict = PyDict_New(); - git_status_foreach(self->repo, read_status_cb, payload_dict); + if (!PyArg_ParseTuple(args, "Oi", + &py_oid, + &reset_type + )) + return NULL; - return payload_dict; -} + len = py_oid_to_git_oid(py_oid, &oid); + if (len == 0) + return NULL; + err = git_object_lookup_prefix(&target, self->repo, &oid, len, + GIT_OBJECT_ANY); + err = err < 0 ? err : git_reset(self->repo, target, reset_type, NULL); + git_object_free(target); + if (err < 0) + return Error_set_oid(err, &oid, len); + Py_RETURN_NONE; +} -PyDoc_STRVAR(Repository_status_file__doc__, - "status_file(path) -> int\n" +PyDoc_STRVAR(Repository_free__doc__, + "free()\n" "\n" - "Returns the status of the given file path."); + "Releases handles to the Git database without deallocating the repository.\n"); PyObject * -Repository_status_file(Repository *self, PyObject *value) +Repository_free(Repository *self) { - char *path; - unsigned int status; - int err; - - path = py_path_to_c_str(value); - if (!path) - return NULL; + if (self->owned) + git_repository__cleanup(self->repo); - err = git_status_file(&status, self->repo, path); - if (err < 0) { - PyObject *err_obj = Error_set_str(err, path); - free(path); - return err_obj; - } - return PyLong_FromLong(status); + Py_RETURN_NONE; } - -PyDoc_STRVAR(Repository_TreeBuilder__doc__, - "TreeBuilder([tree]) -> TreeBuilder\n" - "\n" - "Create a TreeBuilder object for this repository."); +PyDoc_STRVAR(Repository_expand_id__doc__, + "expand_id(hex: str) -> Oid\n" + "\n" + "Expand a string into a full Oid according to the objects in this repository.\n"); PyObject * -Repository_TreeBuilder(Repository *self, PyObject *args) +Repository_expand_id(Repository *self, PyObject *py_hex) { - TreeBuilder *builder; - git_treebuilder *bld; - PyObject *py_src = NULL; git_oid oid; - git_tree *tree = NULL; - git_tree *must_free = NULL; int err; - if (!PyArg_ParseTuple(args, "|O", &py_src)) + err = py_oid_to_git_oid_expand(self->repo, py_hex, &oid); + if (err < 0) return NULL; - if (py_src) { - if (PyObject_TypeCheck(py_src, &TreeType)) { - Tree *py_tree = (Tree *)py_src; - if (py_tree->repo->repo != self->repo) { - /* return Error_set(GIT_EINVALIDARGS); */ - return Error_set(GIT_ERROR); - } - tree = py_tree->tree; - } else { - err = py_oid_to_git_oid_expand(self->repo, py_src, &oid); - if (err < 0) - return NULL; + return git_oid_to_python(&oid); +} - err = git_tree_lookup(&tree, self->repo, &oid); - if (err < 0) - return Error_set(err); - must_free = tree; - } - } +PyDoc_STRVAR(Repository_add_worktree__doc__, + "add_worktree(name: str, path: str | bytes[, ref: Reference]) -> Worktree\n" + "\n" + "Create a new worktree for this repository. If ref is specified, no new \ + branch will be created and the provided ref will be checked out instead."); +PyObject * +Repository_add_worktree(Repository *self, PyObject *args) +{ + char *c_name; + PyBytesObject *py_path = NULL; + char *c_path = NULL; + Reference *py_reference = NULL; + git_worktree *wt; + git_worktree_add_options add_opts = GIT_WORKTREE_ADD_OPTIONS_INIT; - err = git_treebuilder_create(&bld, tree); - if (must_free != NULL) - git_tree_free(must_free); + int err; - if (err < 0) - return Error_set(err); + if (!PyArg_ParseTuple(args, "sO&|O!", &c_name, PyUnicode_FSConverter, &py_path, &ReferenceType, &py_reference)) + return NULL; - builder = PyObject_New(TreeBuilder, &TreeBuilderType); - if (builder) { - builder->repo = self; - builder->bld = bld; - Py_INCREF(self); - } + if (py_path != NULL) + c_path = PyBytes_AS_STRING(py_path); - return (PyObject*)builder; -} + if(py_reference != NULL) + add_opts.ref = py_reference->reference; + err = git_worktree_add(&wt, self->repo, c_name, c_path, &add_opts); + Py_XDECREF(py_path); + if (err < 0) + return Error_set(err); -PyDoc_STRVAR(Repository_create_remote__doc__, - "create_remote(name, url) -> Remote\n" - "\n" - "Creates a new remote."); + return wrap_worktree(self, wt); +} +PyDoc_STRVAR(Repository_lookup_worktree__doc__, + "lookup_worktree(name: str) -> Worktree\n" + "\n" + "Lookup a worktree from its name."); PyObject * -Repository_create_remote(Repository *self, PyObject *args) +Repository_lookup_worktree(Repository *self, PyObject *args) { - Remote *py_remote; - git_remote *remote; - char *name = NULL, *url = NULL; + char *c_name; + git_worktree *wt; int err; - if (!PyArg_ParseTuple(args, "ss", &name, &url)) + if (!PyArg_ParseTuple(args, "s", &c_name)) return NULL; - err = git_remote_create(&remote, self->repo, name, url); + err = git_worktree_lookup(&wt, self->repo, c_name); if (err < 0) return Error_set(err); - py_remote = PyObject_New(Remote, &RemoteType); - Py_INCREF(self); - py_remote->repo = self; - py_remote->remote = remote; - - return (PyObject*) py_remote; + return wrap_worktree(self, wt); } - -PyDoc_STRVAR(Repository_remotes__doc__, "Returns all configured remotes."); - +PyDoc_STRVAR(Repository_list_worktrees__doc__, + "list_worktrees() -> list[str]\n" + "\n" + "Return a list with all the worktrees of this repository."); PyObject * -Repository_remotes__get__(Repository *self) +Repository_list_worktrees(Repository *self, PyObject *args) { - git_strarray remotes; - PyObject* py_list = NULL, *py_args = NULL; - Remote *py_remote; - size_t i; + git_strarray c_result; + PyObject *py_result, *py_string; + unsigned index; + int err; - git_remote_list(&remotes, self->repo); + /* Get the C result */ + err = git_worktree_list(&c_result, self->repo); + if (err < 0) + return Error_set(err); - py_list = PyList_New(remotes.count); - for (i=0; i < remotes.count; ++i) { - py_remote = PyObject_New(Remote, &RemoteType); - py_args = Py_BuildValue("Os", self, remotes.strings[i]); - Remote_init(py_remote, py_args, NULL); - PyList_SetItem(py_list, i, (PyObject*) py_remote); - } + /* Create a new PyTuple */ + py_result = PyList_New(c_result.count); + if (py_result == NULL) + goto out; - git_strarray_free(&remotes); + /* Fill it */ + for (index=0; index < c_result.count; index++) { + py_string = PyUnicode_DecodeFSDefault(c_result.strings[index]); + if (py_string == NULL) { + Py_CLEAR(py_result); + goto out; + } + PyList_SET_ITEM(py_result, index, py_string); + } - return (PyObject*) py_list; +out: + git_strarray_dispose(&c_result); + return py_result; } - -PyDoc_STRVAR(Repository_checkout_head__doc__, - "checkout_head(strategy)\n" - "\n" - "Checkout the head using the given strategy."); +PyDoc_STRVAR(Repository_apply__doc__, + "apply(diff: Diff, location: ApplyLocation = ApplyLocation.WORKDIR)\n" + "\n" + "Applies the given Diff object to HEAD, writing the results into the\n" + "working directory, the index, or both.\n" + "\n" + "Parameters:\n" + "\n" + "diff\n" + " The Diff to apply.\n" + "\n" + "location\n" + " The location to apply: ApplyLocation.WORKDIR (default),\n" + " ApplyLocation.INDEX, or ApplyLocation.BOTH.\n" + ); PyObject * -Repository_checkout_head(Repository *self, PyObject *args) +Repository_apply(Repository *self, PyObject *args, PyObject *kwds) { - git_checkout_opts opts = GIT_CHECKOUT_OPTS_INIT; - unsigned int strategy; - int err; + Diff *py_diff; + int location = GIT_APPLY_LOCATION_WORKDIR; + git_apply_options options = GIT_APPLY_OPTIONS_INIT; - if (!PyArg_ParseTuple(args, "I", &strategy)) + char* keywords[] = {"diff", "location", NULL}; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "O!|i", keywords, + &DiffType, &py_diff, + &location)) return NULL; - opts.checkout_strategy = strategy; - err = git_checkout_head(self->repo, &opts); - if (err < 0) + int err = git_apply(self->repo, py_diff->diff, location, &options); + if (err != 0) return Error_set(err); Py_RETURN_NONE; } - -PyDoc_STRVAR(Repository_checkout_index__doc__, - "checkout_index(strategy)\n" - "\n" - "Checkout the index using the given strategy."); +PyDoc_STRVAR(Repository_applies__doc__, + "applies(diff: Diff, location: int = GIT_APPLY_LOCATION_INDEX, raise_error: bool = False) -> bool\n" + "\n" + "Tests if the given patch will apply to HEAD, without writing it.\n" + "\n" + "Parameters:\n" + "\n" + "diff\n" + " The Diff to apply.\n" + "\n" + "location\n" + " The location to apply: GIT_APPLY_LOCATION_WORKDIR,\n" + " GIT_APPLY_LOCATION_INDEX (default), or GIT_APPLY_LOCATION_BOTH.\n" + "\n" + "raise_error\n" + " If the patch doesn't apply, raise an exception containing more details\n" + " about the failure instead of returning False.\n" + ); PyObject * -Repository_checkout_index(Repository *self, PyObject *args) +Repository_applies(Repository *self, PyObject *args, PyObject *kwds) { - git_checkout_opts opts = GIT_CHECKOUT_OPTS_INIT; - unsigned int strategy; - int err; + Diff *py_diff; + int location = GIT_APPLY_LOCATION_INDEX; + int raise_error = 0; + git_apply_options options = GIT_APPLY_OPTIONS_INIT; + options.flags |= GIT_APPLY_CHECK; - if (!PyArg_ParseTuple(args, "I", &strategy)) + char* keywords[] = {"diff", "location", "raise_error", NULL}; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "O!|ii", keywords, + &DiffType, &py_diff, + &location, &raise_error)) return NULL; - opts.checkout_strategy = strategy; - err = git_checkout_index(self->repo, NULL, &opts); - if (err < 0) - return Error_set(err); + int err = git_apply(self->repo, ((Diff*)py_diff)->diff, location, &options); + if (err != 0) { + if (raise_error) + return Error_set(err); + else + Py_RETURN_FALSE; + } - Py_RETURN_NONE; + Py_RETURN_TRUE; } - -PyDoc_STRVAR(Repository_checkout_tree__doc__, - "checkout_tree(treeish, strategy)\n" - "\n" - "Checkout the given tree, commit or tag, using the given strategy."); +PyDoc_STRVAR(Repository_set_odb__doc__, + "set_odb(odb: Odb)\n" + "\n" + "Sets the object database for this repository.\n" + "This is a low-level function, most users won't need it.\n"); PyObject * -Repository_checkout_tree(Repository *self, PyObject *args) +Repository_set_odb(Repository *self, Odb *odb) { - git_checkout_opts opts = GIT_CHECKOUT_OPTS_INIT; - unsigned int strategy; - Object *py_object; int err; - - if (!PyArg_ParseTuple(args, "O!I", &ObjectType, &py_object, &strategy)) - return NULL; - - opts.checkout_strategy = strategy; - err = git_checkout_tree(self->repo, py_object->obj, &opts); + err = git_repository_set_odb(self->repo, odb->odb); if (err < 0) return Error_set(err); - Py_RETURN_NONE; } - -PyDoc_STRVAR(Repository_notes__doc__, ""); +PyDoc_STRVAR(Repository_set_refdb__doc__, + "set_refdb(refdb: Refdb)\n" + "\n" + "Sets the reference database for this repository.\n" + "This is a low-level function, most users won't need it.\n"); PyObject * -Repository_notes(Repository *self, PyObject *args) +Repository_set_refdb(Repository *self, Refdb *refdb) { - NoteIter *iter = NULL; - char *ref = "refs/notes/commits"; - int err = GIT_ERROR; + int err; + err = git_repository_set_refdb(self->repo, refdb->refdb); + if (err < 0) + return Error_set(err); + Py_RETURN_NONE; +} - if (!PyArg_ParseTuple(args, "|s", &ref)) - return NULL; +static int foreach_stash_cb(size_t index, const char *message, const git_oid *stash_id, void *payload) +{ + int err; + Stash *py_stash; - iter = PyObject_New(NoteIter, &NoteIterType); - if (iter != NULL) { - iter->repo = self; - iter->ref = ref; + py_stash = PyObject_New(Stash, &StashType); + if (py_stash == NULL) + return GIT_EUSER; - err = git_note_iterator_new(&iter->iter, self->repo, iter->ref); - if (err == GIT_OK) { - Py_INCREF(self); - return (PyObject*)iter; - } + assert(message != NULL); + assert(stash_id != NULL); + + py_stash->commit_id = git_oid_to_python(stash_id); + if (py_stash->commit_id == NULL) + return GIT_EUSER; + + py_stash->message = strdup(message); + if (py_stash->message == NULL) { + PyErr_NoMemory(); + return GIT_EUSER; } - return Error_set(err); -} + PyObject* list = (PyObject*) payload; + err = PyList_Append(list, (PyObject*) py_stash); + Py_DECREF(py_stash); + if (err < 0) + return GIT_EUSER; + return 0; +} -PyDoc_STRVAR(Repository_create_note__doc__, - "create_note(message, author, committer, annotated_id [,ref, force]) -> Oid\n" +PyDoc_STRVAR(Repository_listall_stashes__doc__, + "listall_stashes() -> list[Stash]\n" "\n" - "Create a new note for an object, return its SHA-ID." - "If no ref is given 'refs/notes/commits' will be used."); + "Return a list with all stashed commits in the repository.\n"); PyObject * -Repository_create_note(Repository *self, PyObject* args) +Repository_listall_stashes(Repository *self, PyObject *args) { - git_oid note_id, annotated_id; - char *annotated = NULL, *message = NULL, *ref = "refs/notes/commits"; - int err = GIT_ERROR; - unsigned int force = 0; - Signature *py_author, *py_committer; + int err; - if (!PyArg_ParseTuple(args, "sO!O!s|si", - &message, - &SignatureType, &py_author, - &SignatureType, &py_committer, - &annotated, &ref, &force)) + PyObject *list = PyList_New(0); + if (list == NULL) return NULL; - err = git_oid_fromstr(&annotated_id, annotated); - if (err < 0) + err = git_stash_foreach(self->repo, foreach_stash_cb, (void*)list); + + if (err == 0) { + return list; + } else { + Py_CLEAR(list); + if (PyErr_Occurred()) + return NULL; return Error_set(err); + } +} - err = git_note_create(¬e_id, self->repo, py_author->signature, - py_committer->signature, ref, - &annotated_id, message, force); +static int foreach_mergehead_cb(const git_oid *oid, void *payload) +{ + PyObject* py_oid = git_oid_to_python(oid); + if (py_oid == NULL) + return GIT_EUSER; + + PyObject* list = (PyObject*) payload; + int err = PyList_Append(list, (PyObject*) py_oid); + Py_DECREF(py_oid); if (err < 0) - return Error_set(err); + return GIT_EUSER; - return git_oid_to_python(¬e_id); + return 0; } - -PyDoc_STRVAR(Repository_lookup_note__doc__, - "lookup_note(annotated_id [, ref]) -> Note\n" +PyDoc_STRVAR(Repository_listall_mergeheads__doc__, + "listall_mergeheads() -> list[Oid]\n" "\n" - "Lookup a note for an annotated object in a repository."); + "If a merge is in progress, return a list of all commit oids in the MERGE_HEAD file.\n" + "Return an empty list if there is no MERGE_HEAD file (no merge in progress)."); PyObject * -Repository_lookup_note(Repository *self, PyObject* args) +Repository_listall_mergeheads(Repository *self, PyObject *args) { - git_oid annotated_id; - char* annotated = NULL, *ref = "refs/notes/commits"; int err; - if (!PyArg_ParseTuple(args, "s|s", &annotated, &ref)) + PyObject *list = PyList_New(0); + if (list == NULL) return NULL; - err = git_oid_fromstr(&annotated_id, annotated); - if (err < 0) - return Error_set(err); + err = git_repository_mergehead_foreach(self->repo, foreach_mergehead_cb, (void*)list); - return (PyObject*) wrap_note(self, &annotated_id, ref); + if (err == 0) { + return list; + } else if (err == GIT_ENOTFOUND) { + /* MERGE_HEAD not found - return empty list */ + return list; + } + else { + Py_CLEAR(list); + if (PyErr_Occurred()) + return NULL; + return Error_set(err); + } } PyMethodDef Repository_methods[] = { METHOD(Repository, create_blob, METH_VARARGS), - METHOD(Repository, create_blob_fromworkdir, METH_VARARGS), - METHOD(Repository, create_blob_fromdisk, METH_VARARGS), + METHOD(Repository, create_blob_fromworkdir, METH_O), + METHOD(Repository, create_blob_fromdisk, METH_O), + METHOD(Repository, create_blob_fromiobase, METH_O), METHOD(Repository, create_commit, METH_VARARGS), + METHOD(Repository, create_commit_string, METH_VARARGS), + METHOD(Repository, create_commit_with_signature, METH_VARARGS), METHOD(Repository, create_tag, METH_VARARGS), METHOD(Repository, TreeBuilder, METH_VARARGS), METHOD(Repository, walk, METH_VARARGS), + METHOD(Repository, descendant_of, METH_VARARGS), METHOD(Repository, merge_base, METH_VARARGS), - METHOD(Repository, read, METH_O), - METHOD(Repository, write, METH_VARARGS), - METHOD(Repository, create_reference_direct, METH_VARARGS), - METHOD(Repository, create_reference_symbolic, METH_VARARGS), - METHOD(Repository, listall_references, METH_NOARGS), + METHOD(Repository, merge_base_many, METH_VARARGS), + METHOD(Repository, merge_base_octopus, METH_VARARGS), + METHOD(Repository, merge_analysis, METH_VARARGS), + METHOD(Repository, cherrypick, METH_O), + METHOD(Repository, apply, METH_VARARGS | METH_KEYWORDS), + METHOD(Repository, applies, METH_VARARGS | METH_KEYWORDS), + METHOD(Repository, create_reference_direct, METH_VARARGS | METH_KEYWORDS), + METHOD(Repository, create_reference_symbolic, METH_VARARGS | METH_KEYWORDS), + METHOD(Repository, compress_references, METH_NOARGS), + METHOD(Repository, raw_listall_references, METH_NOARGS), + METHOD(Repository, references_iterator_init, METH_NOARGS), + METHOD(Repository, references_iterator_next, METH_VARARGS), + METHOD(Repository, listall_submodules, METH_NOARGS), METHOD(Repository, lookup_reference, METH_O), + METHOD(Repository, lookup_reference_dwim, METH_O), METHOD(Repository, revparse_single, METH_O), - METHOD(Repository, status, METH_NOARGS), + METHOD(Repository, revparse_ext, METH_O), + METHOD(Repository, revparse, METH_O), + METHOD(Repository, status, METH_VARARGS | METH_KEYWORDS), METHOD(Repository, status_file, METH_O), - METHOD(Repository, create_remote, METH_VARARGS), - METHOD(Repository, checkout_head, METH_VARARGS), - METHOD(Repository, checkout_index, METH_VARARGS), - METHOD(Repository, checkout_tree, METH_VARARGS), METHOD(Repository, notes, METH_VARARGS), METHOD(Repository, create_note, METH_VARARGS), METHOD(Repository, lookup_note, METH_VARARGS), METHOD(Repository, git_object_lookup_prefix, METH_O), METHOD(Repository, lookup_branch, METH_VARARGS), + METHOD(Repository, path_is_ignored, METH_VARARGS), METHOD(Repository, listall_branches, METH_VARARGS), + METHOD(Repository, raw_listall_branches, METH_VARARGS), METHOD(Repository, create_branch, METH_VARARGS), + METHOD(Repository, reset, METH_VARARGS), + METHOD(Repository, free, METH_NOARGS), + METHOD(Repository, expand_id, METH_O), + METHOD(Repository, add_worktree, METH_VARARGS), + METHOD(Repository, lookup_worktree, METH_VARARGS), + METHOD(Repository, list_worktrees, METH_VARARGS), + METHOD(Repository, _from_c, METH_VARARGS), + METHOD(Repository, _disown, METH_NOARGS), + METHOD(Repository, set_odb, METH_O), + METHOD(Repository, set_refdb, METH_O), + METHOD(Repository, listall_stashes, METH_NOARGS), + METHOD(Repository, listall_mergeheads, METH_NOARGS), {NULL} }; PyGetSetDef Repository_getseters[] = { - GETTER(Repository, index), GETTER(Repository, path), - GETSET(Repository, head), + GETTER(Repository, head), GETTER(Repository, head_is_detached), - GETTER(Repository, head_is_orphaned), + GETTER(Repository, head_is_unborn), GETTER(Repository, is_empty), GETTER(Repository, is_bare), - GETTER(Repository, config), - GETTER(Repository, workdir), - GETTER(Repository, remotes), + GETTER(Repository, is_shallow), + GETSET(Repository, workdir), + GETTER(Repository, default_signature), + GETTER(Repository, odb), + GETTER(Repository, refdb), + GETTER(Repository, _pointer), {NULL} }; PyDoc_STRVAR(Repository__doc__, - "Repository(path) -> Repository\n" + "Repository(backend) -> Repository\n" "\n" "Git repository."); @@ -1503,7 +2510,7 @@ PyTypeObject RepositoryType = { (inquiry)Repository_clear, /* tp_clear */ 0, /* tp_richcompare */ 0, /* tp_weaklistoffset */ - (getiterfunc)Repository_as_iter, /* tp_iter */ + 0, /* tp_iter */ 0, /* tp_iternext */ Repository_methods, /* tp_methods */ 0, /* tp_members */ @@ -1517,3 +2524,35 @@ PyTypeObject RepositoryType = { 0, /* tp_alloc */ 0, /* tp_new */ }; + +PyDoc_STRVAR(RefsIterator__doc__, "References iterator."); + +PyTypeObject RefsIteratorType = { + PyVarObject_HEAD_INIT(NULL, 0) + "_pygit2.RefsIterator", /* tp_name */ + sizeof(Repository), /* tp_basicsize */ + 0, /* tp_itemsize */ + (destructor)References_iterator_dealloc, /* tp_dealloc */ + 0, /* tp_print */ + 0, /* tp_getattr */ + 0, /* tp_setattr */ + 0, /* tp_compare */ + 0, /* tp_repr */ + 0, /* tp_as_number */ + 0, /* tp_as_sequence */ + 0, /* tp_as_mapping */ + 0, /* tp_hash */ + 0, /* tp_call */ + 0, /* tp_str */ + 0, /* tp_getattro */ + 0, /* tp_setattro */ + 0, /* tp_as_buffer */ + Py_TPFLAGS_DEFAULT, /* tp_flags */ + RefsIterator__doc__, /* tp_doc */ + 0, /* tp_traverse */ + 0, /* tp_clear */ + 0, /* tp_richcompare */ + 0, /* tp_weaklistoffset */ + PyObject_SelfIter, /* tp_iter */ + (iternextfunc)Repository_references_iterator_next, /* tp_iternext */ +}; diff --git a/src/repository.h b/src/repository.h old mode 100644 new mode 100755 index fd9c524b7..059d774a5 --- a/src/repository.h +++ b/src/repository.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2013 The pygit2 contributors + * Copyright 2010-2025 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -33,38 +33,44 @@ #include #include "types.h" +typedef enum { + GIT_REFERENCES_ALL = 0, + GIT_REFERENCES_BRANCHES = 1, + GIT_REFERENCES_TAGS = 2, +} git_reference_iterator_return_t; + +PyObject *wrap_repository(git_repository *c_repo); + int Repository_init(Repository *self, PyObject *args, PyObject *kwds); int Repository_traverse(Repository *self, visitproc visit, void *arg); int Repository_clear(Repository *self); -int Repository_contains(Repository *self, PyObject *value); -git_odb_object* -Repository_read_raw(git_repository *repo, const git_oid *oid, size_t len); - -PyObject* Repository_head(Repository *self); -PyObject* Repository_getitem(Repository *self, PyObject *value); -PyObject* Repository_read(Repository *self, PyObject *py_hex); -PyObject* Repository_write(Repository *self, PyObject *args); -PyObject* Repository_get_index(Repository *self, void *closure); -PyObject* Repository_get_path(Repository *self, void *closure); -PyObject* Repository_get_workdir(Repository *self, void *closure); -PyObject* Repository_get_config(Repository *self, void *closure); PyObject* Repository_walk(Repository *self, PyObject *args); PyObject* Repository_create_blob(Repository *self, PyObject *args); -PyObject* Repository_create_blob_fromfile(Repository *self, PyObject *args); +PyObject* Repository_create_blob_fromdisk(Repository *self, PyObject *args); PyObject* Repository_create_commit(Repository *self, PyObject *args); +PyObject* Repository_create_commit_string(Repository *self, PyObject *args); +PyObject* Repository_create_commit_with_signature(Repository *self, PyObject *args); PyObject* Repository_create_tag(Repository *self, PyObject *args); PyObject* Repository_create_branch(Repository *self, PyObject *args); -PyObject* Repository_listall_references(Repository *self, PyObject *args); +PyObject* Repository_references_iterator_init(Repository *self, PyObject *args); +PyObject* Repository_references_iterator_next(Repository *self, PyObject *args); PyObject* Repository_listall_branches(Repository *self, PyObject *args); PyObject* Repository_lookup_reference(Repository *self, PyObject *py_name); +PyObject* Repository_add_worktree(Repository *self, PyObject *args); +PyObject* Repository_lookup_worktree(Repository *self, PyObject *py_name); +PyObject* Repository_list_worktrees(Repository *self, PyObject *args); -PyObject* -Repository_create_reference(Repository *self, PyObject *args, PyObject* kw); +PyObject* Repository_create_reference_direct(Repository *self, PyObject *args, PyObject* kw); +PyObject* Repository_create_reference_symbolic(Repository *self, PyObject *args, PyObject* kw); -PyObject* Repository_packall_references(Repository *self, PyObject *args); -PyObject* Repository_status(Repository *self, PyObject *args); +PyObject* Repository_compress_references(Repository *self); +PyObject* Repository_status(Repository *self, PyObject *args, PyObject *kw); PyObject* Repository_status_file(Repository *self, PyObject *value); PyObject* Repository_TreeBuilder(Repository *self, PyObject *args); +PyObject* Repository_cherrypick(Repository *self, PyObject *py_oid); +PyObject* Repository_apply(Repository *self, PyObject *py_diff, PyObject *kwds); +PyObject* Repository_merge_analysis(Repository *self, PyObject *args); + #endif diff --git a/src/revspec.c b/src/revspec.c new file mode 100644 index 000000000..64e462bda --- /dev/null +++ b/src/revspec.c @@ -0,0 +1,160 @@ +/* + * 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 "error.h" +#include "object.h" +#include "types.h" +#include "utils.h" + +extern PyTypeObject RevSpecType; + +PyObject* +wrap_revspec(git_revspec *revspec, Repository *repo) +{ + RevSpec *py_revspec; + + py_revspec = PyObject_New(RevSpec, &RevSpecType); + if (py_revspec) { + py_revspec->flags = revspec->flags; + + if (revspec->from != NULL) { + py_revspec->from = wrap_object(revspec->from, repo, NULL); + } else { + py_revspec->from = NULL; + } + + if (revspec->to != NULL) { + py_revspec->to = wrap_object(revspec->to, repo, NULL); + } else { + py_revspec->to = NULL; + } + } + + return (PyObject*) py_revspec; +} + +PyDoc_STRVAR(RevSpec_from_object__doc__, "From revision"); + +PyObject * +RevSpec_from_object__get__(RevSpec *self) +{ + if (self->from == NULL) + Py_RETURN_NONE; + + Py_INCREF(self->from); + return self->from; +} + +PyDoc_STRVAR(RevSpec_to_object__doc__, "To revision"); + +PyObject * +RevSpec_to_object__get__(RevSpec *self) +{ + if (self->to == NULL) + Py_RETURN_NONE; + + Py_INCREF(self->to); + return self->to; +} + +PyDoc_STRVAR(RevSpec_flags__doc__, + "A combination of enums.RevSpecFlag constants indicating the\n" + "intended behavior of the spec passed to Repository.revparse()"); + +PyObject * +RevSpec_flags__get__(RevSpec *self) +{ + return PyLong_FromLong(self->flags); +} + +static PyObject * +RevSpec_repr(RevSpec *self) +{ + return PyUnicode_FromFormat("", + (self->from != NULL) ? self->from : Py_None, + (self->to != NULL) ? self->to : Py_None); +} + +static void +RevSpec_dealloc(RevSpec *self) +{ + Py_XDECREF(self->from); + Py_XDECREF(self->to); + PyObject_Del(self); +} + +PyGetSetDef RevSpec_getsetters[] = { + GETTER(RevSpec, from_object), + GETTER(RevSpec, to_object), + GETTER(RevSpec, flags), + {NULL} +}; + +PyDoc_STRVAR(RevSpec__doc__, "RevSpec object, output from Repository.revparse()."); + +PyTypeObject RevSpecType = { + PyVarObject_HEAD_INIT(NULL, 0) + "_pygit2.RevSpec", /* tp_name */ + sizeof(RevSpec), /* tp_basicsize */ + 0, /* tp_itemsize */ + (destructor)RevSpec_dealloc, /* tp_dealloc */ + 0, /* tp_print */ + 0, /* tp_getattr */ + 0, /* tp_setattr */ + 0, /* tp_compare */ + (reprfunc)RevSpec_repr, /* tp_repr */ + 0, /* tp_as_number */ + 0, /* tp_as_sequence */ + 0, /* tp_as_mapping */ + 0, /* tp_hash */ + 0, /* tp_call */ + 0, /* tp_str */ + 0, /* tp_getattro */ + 0, /* tp_setattro */ + 0, /* tp_as_buffer */ + Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, /* tp_flags */ + RevSpec__doc__, /* tp_doc */ + 0, /* tp_traverse */ + 0, /* tp_clear */ + 0, /* tp_richcompare */ + 0, /* tp_weaklistoffset */ + 0, /* tp_iter */ + 0, /* tp_iternext */ + 0, /* tp_methods */ + 0, /* tp_members */ + RevSpec_getsetters, /* tp_getset */ + 0, /* tp_base */ + 0, /* tp_dict */ + 0, /* tp_descr_get */ + 0, /* tp_descr_set */ + 0, /* tp_dictoffset */ + 0, /* tp_init */ + 0, /* tp_alloc */ + 0, /* tp_new */ +}; diff --git a/src/revspec.h b/src/revspec.h new file mode 100644 index 000000000..2f80af913 --- /dev/null +++ b/src/revspec.h @@ -0,0 +1,38 @@ +/* + * 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_revspec_h +#define INCLUDE_pygit2_revspec_h + +#define PY_SSIZE_T_CLEAN +#include +#include +#include "types.h" + +PyObject* wrap_revspec(git_revspec *revspec, Repository *repo); + +#endif diff --git a/src/signature.c b/src/signature.c index 24e02a83e..f384bd7d0 100644 --- a/src/signature.c +++ b/src/signature.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2013 The pygit2 contributors + * Copyright 2010-2025 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -33,32 +33,32 @@ #include "oid.h" #include "signature.h" +extern PyTypeObject SignatureType; + int Signature_init(Signature *self, PyObject *args, PyObject *kwds) { char *keywords[] = {"name", "email", "time", "offset", "encoding", NULL}; PyObject *py_name; - char *name, *email, *encoding = "ascii"; + char *email, *encoding = NULL; long long time = -1; int offset = 0; - int err; - git_signature *signature; if (!PyArg_ParseTupleAndKeywords( - args, kwds, "Os|Lis", keywords, + args, kwds, "Os|Liz", keywords, &py_name, &email, &time, &offset, &encoding)) return -1; - name = py_str_to_c_str(py_name, encoding); + PyObject *tname; + const char *name = pgit_borrow_encoding( + py_name, value_or_default(encoding, "utf-8"), NULL, &tname); if (name == NULL) return -1; - if (time == -1) { - err = git_signature_now(&signature, name, email); - } else { - err = git_signature_new(&signature, name, email, time, offset); - } - free(name); + git_signature *signature; + int err = (time == -1) ? git_signature_now(&signature, name, email) + : git_signature_new(&signature, name, email, time, offset); + Py_DECREF(tname); if (err < 0) { Error_set(err); return -1; @@ -81,47 +81,56 @@ Signature_init(Signature *self, PyObject *args, PyObject *kwds) void Signature_dealloc(Signature *self) { - if (self->obj) + /* self->obj is the owner of the git_signature C structure, so we mustn't free it */ + if (self->obj) { Py_CLEAR(self->obj); - else { - git_signature_free((git_signature*)self->signature); - free((char*)self->encoding); + } else { + git_signature_free((git_signature *) self->signature); } + /* we own self->encoding */ + free(self->encoding); + PyObject_Del(self); } +PyDoc_STRVAR(Signature__pointer__doc__, "Get the signature's pointer. For internal use only."); +PyObject * +Signature__pointer__get__(Signature *self) +{ + /* Bytes means a raw buffer */ + return PyBytes_FromStringAndSize((char *) &self->signature, sizeof(git_signature *)); +} PyDoc_STRVAR(Signature__encoding__doc__, "Encoding."); PyObject * Signature__encoding__get__(Signature *self) { - const char *encoding; - - encoding = self->encoding; - if (encoding == NULL) + const char *encoding = self->encoding; + if (encoding == NULL) { encoding = "utf-8"; + } return to_encoding(encoding); } -PyDoc_STRVAR(Signature__name__doc__, "Name (bytes)."); +PyDoc_STRVAR(Signature_raw_name__doc__, "Name (bytes)."); PyObject * -Signature__name__get__(Signature *self) +Signature_raw_name__get__(Signature *self) { - return to_bytes(self->signature->name); + return PyBytes_FromString(self->signature->name); } -PyDoc_STRVAR(Signature__email__doc__, "Email (bytes)."); +PyDoc_STRVAR(Signature_raw_email__doc__, "Email (bytes)."); PyObject * -Signature__email__get__(Signature *self) +Signature_raw_email__get__(Signature *self) { - return to_bytes(self->signature->email); + return PyBytes_FromString(self->signature->email); } @@ -130,7 +139,7 @@ PyDoc_STRVAR(Signature_name__doc__, "Name."); PyObject * Signature_name__get__(Signature *self) { - return to_unicode(self->signature->name, self->encoding, "strict"); + return to_unicode(self->signature->name, self->encoding, NULL); } @@ -139,7 +148,7 @@ PyDoc_STRVAR(Signature_email__doc__, "Email address."); PyObject * Signature_email__get__(Signature *self) { - return to_unicode(self->signature->email, self->encoding, "strict"); + return to_unicode(self->signature->email, self->encoding, NULL); } @@ -162,15 +171,106 @@ Signature_offset__get__(Signature *self) PyGetSetDef Signature_getseters[] = { GETTER(Signature, _encoding), - GETTER(Signature, _name), - GETTER(Signature, _email), + GETTER(Signature, raw_name), + GETTER(Signature, raw_email), GETTER(Signature, name), GETTER(Signature, email), GETTER(Signature, time), GETTER(Signature, offset), + GETTER(Signature, _pointer), {NULL} }; +PyObject * +Signature_richcompare(PyObject *a, PyObject *b, int op) +{ + int eq; + Signature *sa, *sb; + + /* We only support comparing to another signature */ + if (!PyObject_TypeCheck(b, &SignatureType)) { + Py_INCREF(Py_NotImplemented); + return Py_NotImplemented; + } + + sa = (Signature *)a; + sb = (Signature *)b; + + eq = ( + strcmp(sa->signature->name, sb->signature->name) == 0 && + strcmp(sa->signature->email, sb->signature->email) == 0 && + sa->signature->when.time == sb->signature->when.time && + sa->signature->when.offset == sb->signature->when.offset && + sa->signature->when.sign == sb->signature->when.sign && + strcmp(value_or_default(sa->encoding, "utf-8"), + value_or_default(sb->encoding, "utf-8")) == 0); + + switch (op) { + case Py_EQ: + if (eq) { + Py_RETURN_TRUE; + } else { + Py_RETURN_FALSE; + } + case Py_NE: + if (eq) { + Py_RETURN_FALSE; + } else { + Py_RETURN_TRUE; + } + default: + Py_INCREF(Py_NotImplemented); + return Py_NotImplemented; + } + +} + +static PyObject * +Signature__str__(Signature *self) +{ + PyObject *name, *email, *str; + name = to_unicode_safe(self->signature->name, self->encoding); + email = to_unicode_safe(self->signature->email, self->encoding); + assert(name); + assert(email); + + str = PyUnicode_FromFormat("%U <%U>", name, email); + Py_DECREF(name); + Py_DECREF(email); + return str; +} + +static PyObject * +Signature__repr__(Signature *self) +{ + PyObject *name, *email, *encoding, *str; + name = to_unicode_safe(self->signature->name, self->encoding); + email = to_unicode_safe(self->signature->email, self->encoding); + + if (self->encoding) { + encoding = to_unicode_safe(self->encoding, self->encoding); + } else { + encoding = Py_None; + Py_INCREF(Py_None); + } + + assert(name); + assert(email); + assert(encoding); + + str = PyUnicode_FromFormat( + "pygit2.Signature(%R, %R, %lld, %ld, %R)", + name, + email, + self->signature->when.time, + self->signature->when.offset, + encoding); + Py_DECREF(name); + Py_DECREF(email); + Py_DECREF(encoding); + return str; +} + PyDoc_STRVAR(Signature__doc__, "Signature."); @@ -184,13 +284,13 @@ PyTypeObject SignatureType = { 0, /* tp_getattr */ 0, /* tp_setattr */ 0, /* tp_compare */ - 0, /* tp_repr */ + (reprfunc)Signature__repr__, /* tp_repr */ 0, /* tp_as_number */ 0, /* tp_as_sequence */ 0, /* tp_as_mapping */ 0, /* tp_hash */ 0, /* tp_call */ - 0, /* tp_str */ + (reprfunc)Signature__str__, /* tp_str */ 0, /* tp_getattro */ 0, /* tp_setattro */ 0, /* tp_as_buffer */ @@ -198,7 +298,7 @@ PyTypeObject SignatureType = { Signature__doc__, /* tp_doc */ 0, /* tp_traverse */ 0, /* tp_clear */ - 0, /* tp_richcompare */ + (richcmpfunc)Signature_richcompare, /* tp_richcompare */ 0, /* tp_weaklistoffset */ 0, /* tp_iter */ 0, /* tp_iternext */ @@ -222,12 +322,23 @@ build_signature(Object *obj, const git_signature *signature, Signature *py_signature; py_signature = PyObject_New(Signature, &SignatureType); + if (!py_signature) + goto on_error; - if (py_signature) { - Py_INCREF(obj); - py_signature->obj = obj; - py_signature->signature = signature; - py_signature->encoding = encoding; + py_signature->encoding = NULL; + if (encoding) { + py_signature->encoding = strdup(encoding); + if (!py_signature->encoding) + goto on_error; } + + Py_XINCREF(obj); + py_signature->obj = obj; + py_signature->signature = signature; + return (PyObject*)py_signature; + +on_error: + git_signature_free((git_signature *) signature); + return NULL; } diff --git a/src/signature.h b/src/signature.h index a0028a9d3..9c646d86c 100644 --- a/src/signature.h +++ b/src/signature.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2013 The pygit2 contributors + * Copyright 2010-2025 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -33,14 +33,6 @@ #include #include "types.h" -PyObject* Signature_get_encoding(Signature *self); -PyObject* Signature_get_raw_name(Signature *self); -PyObject* Signature_get_raw_email(Signature *self); -PyObject* Signature_get_name(Signature *self); -PyObject* Signature_get_email(Signature *self); -PyObject* Signature_get_time(Signature *self); -PyObject* Signature_get_offset(Signature *self); - PyObject* build_signature(Object *obj, const git_signature *signature, const char *encoding); diff --git a/src/stash.c b/src/stash.c new file mode 100644 index 000000000..e60dcb8b5 --- /dev/null +++ b/src/stash.c @@ -0,0 +1,175 @@ +/* + * 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 "object.h" +#include "error.h" +#include "types.h" +#include "utils.h" +#include "oid.h" + +PyTypeObject StashType; + + +PyDoc_STRVAR(Stash_commit_id__doc__, "The commit id of the stashed state."); + +PyObject * +Stash_commit_id__get__(Stash *self) +{ + Py_INCREF(self->commit_id); + return self->commit_id; +} + + +PyDoc_STRVAR(Stash_message__doc__, "Stash message."); + +PyObject * +Stash_message__get__(Stash *self) +{ + return to_unicode(self->message, "utf-8", "strict"); +} + + +PyDoc_STRVAR(Stash_raw_message__doc__, "Stash message (bytes)."); + +PyObject * +Stash_raw_message__get__(Stash *self) +{ + return PyBytes_FromString(self->message); +} + + +static void +Stash_dealloc(Stash *self) +{ + Py_CLEAR(self->commit_id); + free(self->message); + PyObject_Del(self); +} + + +static PyObject * +Stash_repr(Stash *self) +{ + return PyUnicode_FromFormat("", self->commit_id); +} + + +PyObject * +Stash_richcompare(PyObject *o1, PyObject *o2, int op) +{ + int eq = 0; + Stash *s1, *s2; + git_oid *oid1, *oid2; + + /* We only support comparing to another stash */ + if (!PyObject_TypeCheck(o2, &StashType)) { + Py_INCREF(Py_NotImplemented); + return Py_NotImplemented; + } + + s1 = (Stash *)o1; + s2 = (Stash *)o2; + + oid1 = &((Oid *)s1->commit_id)->oid; + oid2 = &((Oid *)s2->commit_id)->oid; + + eq = git_oid_equal(oid1, oid2) && + (0 == strcmp(s1->message, s2->message)); + + switch (op) { + case Py_EQ: + if (eq) { + Py_RETURN_TRUE; + } else { + Py_RETURN_FALSE; + } + case Py_NE: + if (eq) { + Py_RETURN_FALSE; + } else { + Py_RETURN_TRUE; + } + default: + Py_INCREF(Py_NotImplemented); + return Py_NotImplemented; + } +} + + +PyGetSetDef Stash_getseters[] = { + GETTER(Stash, commit_id), + GETTER(Stash, message), + GETTER(Stash, raw_message), + {NULL} +}; + + +PyDoc_STRVAR(Stash__doc__, "Stashed state."); + +PyTypeObject StashType = { + PyVarObject_HEAD_INIT(NULL, 0) + "_pygit2.Stash", /* tp_name */ + sizeof(Stash), /* tp_basicsize */ + 0, /* tp_itemsize */ + (destructor)Stash_dealloc, /* tp_dealloc */ + 0, /* tp_print */ + 0, /* tp_getattr */ + 0, /* tp_setattr */ + 0, /* tp_compare */ + (reprfunc)Stash_repr, /* tp_repr */ + 0, /* tp_as_number */ + 0, /* tp_as_sequence */ + 0, /* tp_as_mapping */ + 0, /* tp_hash */ + 0, /* tp_call */ + 0, /* tp_str */ + 0, /* tp_getattro */ + 0, /* tp_setattro */ + 0, /* tp_as_buffer */ + Py_TPFLAGS_DEFAULT, /* tp_flags */ + Stash__doc__, /* tp_doc */ + 0, /* tp_traverse */ + 0, /* tp_clear */ + Stash_richcompare, /* tp_richcompare */ + 0, /* tp_weaklistoffset */ + 0, /* tp_iter */ + 0, /* tp_iternext */ + 0, /* tp_methods */ + 0, /* tp_members */ + Stash_getseters, /* tp_getset */ + 0, /* tp_base */ + 0, /* tp_dict */ + 0, /* tp_descr_get */ + 0, /* tp_descr_set */ + 0, /* tp_dictoffset */ + 0, /* tp_init */ + 0, /* tp_alloc */ + 0, /* tp_new */ +}; + diff --git a/src/tag.c b/src/tag.c index bc4eaada7..0d42f0f56 100644 --- a/src/tag.c +++ b/src/tag.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2013 The pygit2 contributors + * Copyright 2010-2025 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -27,12 +27,12 @@ #define PY_SSIZE_T_CLEAN #include +#include "object.h" #include "error.h" #include "types.h" #include "utils.h" #include "signature.h" #include "oid.h" -#include "tag.h" PyDoc_STRVAR(Tag_target__doc__, "Tagged object."); @@ -40,31 +40,71 @@ PyDoc_STRVAR(Tag_target__doc__, "Tagged object."); PyObject * Tag_target__get__(Tag *self) { - const git_oid *oid; + if (Object__load((Object*)self) == NULL) { return NULL; } // Lazy load + + const git_oid *oid = git_tag_target_id(self->tag); - oid = git_tag_target_id(self->tag); return git_oid_to_python(oid); } +PyDoc_STRVAR(Tag_get_object__doc__, + "get_object() -> Object\n" + "\n" + "Retrieves the object the current tag is pointing to."); + +PyObject * +Tag_get_object(Tag *self) +{ + git_object* obj; + + if (Object__load((Object*)self) == NULL) { return NULL; } // Lazy load + + int err = git_tag_peel(&obj, self->tag); + if (err < 0) + return Error_set(err); + + return wrap_object(obj, self->repo, NULL); +} + + PyDoc_STRVAR(Tag_name__doc__, "Tag name."); PyObject * Tag_name__get__(Tag *self) { - const char *name; - name = git_tag_name(self->tag); + if (Object__load((Object*)self) == NULL) { return NULL; } // Lazy load + + const char *name = git_tag_name(self->tag); if (!name) Py_RETURN_NONE; + return to_unicode(name, "utf-8", "strict"); } +PyDoc_STRVAR(Tag_raw_name__doc__, "Tag name (bytes)."); + +PyObject * +Tag_raw_name__get__(Tag *self) +{ + if (Object__load((Object*)self) == NULL) { return NULL; } // Lazy load + + const char *name = git_tag_name(self->tag); + if (!name) + Py_RETURN_NONE; + + return PyBytes_FromString(name); +} + + PyDoc_STRVAR(Tag_tagger__doc__, "Tagger."); PyObject * Tag_tagger__get__(Tag *self) { + if (Object__load((Object*)self) == NULL) { return NULL; } // Lazy load + const git_signature *signature = git_tag_tagger(self->tag); if (!signature) Py_RETURN_NONE; @@ -78,28 +118,42 @@ PyDoc_STRVAR(Tag_message__doc__, "Tag message."); PyObject * Tag_message__get__(Tag *self) { - const char *message; - message = git_tag_message(self->tag); + if (Object__load((Object*)self) == NULL) { return NULL; } // Lazy load + + const char *message = git_tag_message(self->tag); if (!message) Py_RETURN_NONE; + return to_unicode(message, "utf-8", "strict"); } -PyDoc_STRVAR(Tag__message__doc__, "Tag message (bytes)."); +PyDoc_STRVAR(Tag_raw_message__doc__, "Tag message (bytes)."); PyObject * -Tag__message__get__(Tag *self) +Tag_raw_message__get__(Tag *self) { - return PyBytes_FromString(git_tag_message(self->tag)); + if (Object__load((Object*)self) == NULL) { return NULL; } // Lazy load + + const char *message = git_tag_message(self->tag); + if (!message) + Py_RETURN_NONE; + + return PyBytes_FromString(message); } +PyMethodDef Tag_methods[] = { + METHOD(Tag, get_object, METH_NOARGS), + {NULL} +}; + PyGetSetDef Tag_getseters[] = { GETTER(Tag, target), GETTER(Tag, name), + GETTER(Tag, raw_name), GETTER(Tag, tagger), GETTER(Tag, message), - GETTER(Tag, _message), + GETTER(Tag, raw_message), {NULL} }; @@ -126,7 +180,7 @@ PyTypeObject TagType = { 0, /* tp_getattro */ 0, /* tp_setattro */ 0, /* tp_as_buffer */ - Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, /* tp_flags */ + Py_TPFLAGS_DEFAULT, /* tp_flags */ Tag__doc__, /* tp_doc */ 0, /* tp_traverse */ 0, /* tp_clear */ @@ -134,7 +188,7 @@ PyTypeObject TagType = { 0, /* tp_weaklistoffset */ 0, /* tp_iter */ 0, /* tp_iternext */ - 0, /* tp_methods */ + Tag_methods, /* tp_methods */ 0, /* tp_members */ Tag_getseters, /* tp_getset */ 0, /* tp_base */ diff --git a/src/tree.c b/src/tree.c index e0abbbd57..ef40e275f 100644 --- a/src/tree.c +++ b/src/tree.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2013 The pygit2 contributors + * Copyright 2010-2025 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -31,6 +31,7 @@ #include "error.h" #include "utils.h" #include "repository.h" +#include "object.h" #include "oid.h" #include "tree.h" #include "diff.h" @@ -40,139 +41,53 @@ extern PyTypeObject DiffType; extern PyTypeObject TreeIterType; extern PyTypeObject IndexType; -void -TreeEntry_dealloc(TreeEntry *self) -{ - git_tree_entry_free((git_tree_entry*)self->entry); - PyObject_Del(self); -} - - -PyDoc_STRVAR(TreeEntry_filemode__doc__, "Filemode."); - -PyObject * -TreeEntry_filemode__get__(TreeEntry *self) -{ - return PyLong_FromLong(git_tree_entry_filemode(self->entry)); -} - - -PyDoc_STRVAR(TreeEntry_name__doc__, "Name."); - -PyObject * -TreeEntry_name__get__(TreeEntry *self) -{ - return to_path(git_tree_entry_name(self->entry)); -} - - -PyDoc_STRVAR(TreeEntry_oid__doc__, "Object id."); PyObject * -TreeEntry_oid__get__(TreeEntry *self) +treeentry_to_object(const git_tree_entry *entry, Repository *repo) { - const git_oid *oid; - - oid = git_tree_entry_id(self->entry); - return git_oid_to_python(oid); -} - - -PyDoc_STRVAR(TreeEntry_hex__doc__, "Hex oid."); + if (repo == NULL) { + PyErr_SetString(PyExc_ValueError, "expected repository"); + return NULL; + } -PyObject * -TreeEntry_hex__get__(TreeEntry *self) -{ - return git_oid_to_py_str(git_tree_entry_id(self->entry)); + return wrap_object(NULL, repo, entry); } - -PyGetSetDef TreeEntry_getseters[] = { - GETTER(TreeEntry, filemode), - GETTER(TreeEntry, name), - GETTER(TreeEntry, oid), - GETTER(TreeEntry, hex), - {NULL} -}; - - -PyDoc_STRVAR(TreeEntry__doc__, "TreeEntry objects."); - -PyTypeObject TreeEntryType = { - PyVarObject_HEAD_INIT(NULL, 0) - "_pygit2.TreeEntry", /* tp_name */ - sizeof(TreeEntry), /* tp_basicsize */ - 0, /* tp_itemsize */ - (destructor)TreeEntry_dealloc, /* tp_dealloc */ - 0, /* tp_print */ - 0, /* tp_getattr */ - 0, /* tp_setattr */ - 0, /* tp_compare */ - 0, /* tp_repr */ - 0, /* tp_as_number */ - 0, /* tp_as_sequence */ - 0, /* tp_as_mapping */ - 0, /* tp_hash */ - 0, /* tp_call */ - 0, /* tp_str */ - 0, /* tp_getattro */ - 0, /* tp_setattro */ - 0, /* tp_as_buffer */ - Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, /* tp_flags */ - TreeEntry__doc__, /* tp_doc */ - 0, /* tp_traverse */ - 0, /* tp_clear */ - 0, /* tp_richcompare */ - 0, /* tp_weaklistoffset */ - 0, /* tp_iter */ - 0, /* tp_iternext */ - 0, /* tp_methods */ - 0, /* tp_members */ - TreeEntry_getseters, /* tp_getset */ - 0, /* tp_base */ - 0, /* tp_dict */ - 0, /* tp_descr_get */ - 0, /* tp_descr_set */ - 0, /* tp_dictoffset */ - 0, /* tp_init */ - 0, /* tp_alloc */ - 0, /* tp_new */ -}; - Py_ssize_t Tree_len(Tree *self) { - assert(self->tree); + if (Object__load((Object*)self) == NULL) { return -1; } // Lazy load return (Py_ssize_t)git_tree_entrycount(self->tree); } int Tree_contains(Tree *self, PyObject *py_name) { - int result = 0; - char *name = py_path_to_c_str(py_name); + if (Object__load((Object*)self) == NULL) { return -1; } // Lazy load + + PyObject *tvalue; + char *name = pgit_borrow_fsdefault(py_name, &tvalue); if (name == NULL) return -1; - result = git_tree_entry_byname(self->tree, name) ? 1 : 0; - free(name); - return result; -} + git_tree_entry *entry; + int err = git_tree_entry_bypath(&entry, self->tree, name); + Py_DECREF(tvalue); -TreeEntry * -wrap_tree_entry(const git_tree_entry *entry) -{ - TreeEntry *py_entry; + if (err == GIT_ENOTFOUND) { + return 0; + } else if (err < 0) { + Error_set(err); + return -1; + } - py_entry = PyObject_New(TreeEntry, &TreeEntryType); - if (py_entry) - py_entry->entry = entry; + git_tree_entry_free(entry); - return py_entry; + return 1; } int -Tree_fix_index(Tree *self, PyObject *py_index) +Tree_fix_index(const git_tree *tree, PyObject *py_index) { long index; size_t len; @@ -182,7 +97,7 @@ Tree_fix_index(Tree *self, PyObject *py_index) if (PyErr_Occurred()) return -1; - len = git_tree_entrycount(self->tree); + len = git_tree_entrycount(tree); slen = (long)len; if (index >= slen) { PyErr_SetObject(PyExc_IndexError, py_index); @@ -205,6 +120,8 @@ Tree_iter(Tree *self) { TreeIter *iter; + if (Object__load((Object*)self) == NULL) { return NULL; } // Lazy load + iter = PyObject_New(TreeIter, &TreeIterType); if (iter) { Py_INCREF(self); @@ -214,194 +131,260 @@ Tree_iter(Tree *self) return (PyObject*)iter; } -TreeEntry * -Tree_getitem_by_index(Tree *self, PyObject *py_index) +PyObject* +tree_getentry_by_index(const git_tree *tree, Repository *repo, PyObject *py_index) { int index; - const git_tree_entry *entry; + const git_tree_entry *entry_src; + git_tree_entry *entry; - index = Tree_fix_index(self, py_index); + index = Tree_fix_index(tree, py_index); if (PyErr_Occurred()) return NULL; - entry = git_tree_entry_byindex(self->tree, index); - if (!entry) { + entry_src = git_tree_entry_byindex(tree, index); + if (!entry_src) { PyErr_SetObject(PyExc_IndexError, py_index); return NULL; } - entry = git_tree_entry_dup(entry); - if (entry == NULL) { + if (git_tree_entry_dup(&entry, entry_src) < 0) { PyErr_SetNone(PyExc_MemoryError); return NULL; } - return wrap_tree_entry(entry); + return treeentry_to_object(entry, repo); } -TreeEntry * -Tree_getitem(Tree *self, PyObject *value) +PyObject* +tree_getentry_by_path(const git_tree *tree, Repository *repo, PyObject *py_path) { - char *path; - git_tree_entry *entry; - int err; - - /* Case 1: integer */ - if (PyLong_Check(value)) - return Tree_getitem_by_index(self, value); - - /* Case 2: byte or text string */ - path = py_path_to_c_str(value); - if (path == NULL) + PyObject *tvalue; + char *path = pgit_borrow_fsdefault(py_path, &tvalue); + if (path == NULL) { + PyErr_SetString(PyExc_TypeError, "Value must be a path string"); return NULL; + } - err = git_tree_entry_bypath(&entry, self->tree, path); - free(path); + git_tree_entry *entry; + int err = git_tree_entry_bypath(&entry, tree, path); + Py_DECREF(tvalue); if (err == GIT_ENOTFOUND) { - PyErr_SetObject(PyExc_KeyError, value); + PyErr_SetObject(PyExc_KeyError, py_path); return NULL; } if (err < 0) - return (TreeEntry*)Error_set(err); + return Error_set(err); /* git_tree_entry_dup is already done in git_tree_entry_bypath */ - return wrap_tree_entry(entry); + return treeentry_to_object(entry, repo); +} + +PyObject* +Tree_subscript(Tree *self, PyObject *value) +{ + if (Object__load((Object*)self) == NULL) { return NULL; } // Lazy load + + /* Case 1: integer */ + if (PyLong_Check(value)) + return tree_getentry_by_index(self->tree, self->repo, value); + + /* Case 2: byte or text string */ + return tree_getentry_by_path(self->tree, self->repo, value); +} + +PyObject * +Tree_divide(Tree *self, PyObject *value) +{ + if (Object__load((Object*)self) == NULL) { return NULL; } // Lazy load + return tree_getentry_by_path(self->tree, self->repo, value); } PyDoc_STRVAR(Tree_diff_to_workdir__doc__, - "diff_to_workdir([flags, context_lines, interhunk_lines]) -> Diff\n" + "diff_to_workdir(flags: enums.DiffOption = enums.DiffOption.NORMAL, context_lines: int = 3, interhunk_lines: int = 0) -> Diff\n" "\n" "Show the changes between the :py:class:`~pygit2.Tree` and the workdir.\n" "\n" - "Arguments:\n" + "Parameters:\n" "\n" - "flag: a GIT_DIFF_* constant.\n" + "flags\n" + " A combination of enums.DiffOption constants.\n" "\n" - "context_lines: the number of unchanged lines that define the boundary\n" - " of a hunk (and to display before and after)\n" + "context_lines\n" + " The number of unchanged lines that define the boundary of a hunk\n" + " (and to display before and after).\n" "\n" - "interhunk_lines: the maximum number of unchanged lines between hunk\n" - " boundaries before the hunks will be merged into a one.\n"); + "interhunk_lines\n" + " The maximum number of unchanged lines between hunk boundaries before\n" + " the hunks will be merged into a one.\n"); PyObject * -Tree_diff_to_workdir(Tree *self, PyObject *args) +Tree_diff_to_workdir(Tree *self, PyObject *args, PyObject *kwds) { git_diff_options opts = GIT_DIFF_OPTIONS_INIT; - git_diff_list *diff; - Repository *py_repo; + git_diff *diff; int err; - if (!PyArg_ParseTuple(args, "|IHH", &opts.flags, &opts.context_lines, - &opts.interhunk_lines)) + char *keywords[] = {"flags", "context_lines", "interhunk_lines", NULL}; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "|IHH", keywords, &opts.flags, + &opts.context_lines, &opts.interhunk_lines)) return NULL; - py_repo = self->repo; - err = git_diff_tree_to_workdir(&diff, py_repo->repo, self->tree, &opts); + if (Object__load((Object*)self) == NULL) { return NULL; } // Lazy load + + err = git_diff_tree_to_workdir(&diff, self->repo->repo, self->tree, &opts); if (err < 0) return Error_set(err); - return wrap_diff(diff, py_repo); + return wrap_diff(diff, self->repo); } PyDoc_STRVAR(Tree_diff_to_index__doc__, - "diff_to_index(index, [flags, context_lines, interhunk_lines]) -> Diff\n" + "diff_to_index(index: Index, flags: enums.DiffOption = enums.DiffOption.NORMAL, context_lines: int = 3, interhunk_lines: int = 0) -> Diff\n" "\n" "Show the changes between the index and a given :py:class:`~pygit2.Tree`.\n" "\n" - "Arguments:\n" + "Parameters:\n" "\n" - "tree: the :py:class:`~pygit2.Tree` to diff.\n" + "index : :py:class:`~pygit2.Index`\n" + " The index to diff.\n" "\n" - "flag: a GIT_DIFF_* constant.\n" + "flags\n" + " A combination of enums.DiffOption constants.\n" "\n" - "context_lines: the number of unchanged lines that define the boundary\n" - " of a hunk (and to display before and after)\n" + "context_lines\n" + " The number of unchanged lines that define the boundary of a hunk\n" + " (and to display before and after).\n" "\n" - "interhunk_lines: the maximum number of unchanged lines between hunk\n" - " boundaries before the hunks will be merged into a one.\n"); + "interhunk_lines\n" + " The maximum number of unchanged lines between hunk boundaries before\n" + " the hunks will be merged into a one.\n"); PyObject * Tree_diff_to_index(Tree *self, PyObject *args, PyObject *kwds) { git_diff_options opts = GIT_DIFF_OPTIONS_INIT; - git_diff_list *diff; - Repository *py_repo; + git_diff *diff; + git_index *index; + char *buffer; + Py_ssize_t length; + PyObject *py_idx; int err; - Index *py_idx = NULL; - - if (!PyArg_ParseTuple(args, "O!|IHH", &IndexType, &py_idx, &opts.flags, + if (!PyArg_ParseTuple(args, "O|IHH", &py_idx, &opts.flags, &opts.context_lines, &opts.interhunk_lines)) return NULL; - py_repo = self->repo; - err = git_diff_tree_to_index(&diff, py_repo->repo, self->tree, - py_idx->index, &opts); + /* Check whether the first argument is an index. + * FIXME Uses duck typing. This would be easy and correct if we had + * _pygit2.Index. */ + PyObject *pygit2_index = PyObject_GetAttrString(py_idx, "_index"); + if (!pygit2_index) { + PyErr_SetString(PyExc_TypeError, "argument must be an Index"); + return NULL; + } + Py_DECREF(pygit2_index); + + /* Get git_index from cffi's pointer */ + PyObject *py_idx_ptr = PyObject_GetAttrString(py_idx, "_pointer"); + if (!py_idx_ptr) + return NULL; + + /* Here we need to do the opposite conversion from the _pointer getters */ + if (PyBytes_AsStringAndSize(py_idx_ptr, &buffer, &length)) + goto error; + + if (length != sizeof(git_index *)) { + PyErr_SetString(PyExc_TypeError, "passed value is not a pointer"); + goto error; + } + + 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 + + err = git_diff_tree_to_index(&diff, self->repo->repo, self->tree, index, &opts); + Py_DECREF(py_idx_ptr); + if (err < 0) return Error_set(err); - return wrap_diff(diff, py_repo); + return wrap_diff(diff, self->repo); + +error: + Py_DECREF(py_idx_ptr); + return NULL; } PyDoc_STRVAR(Tree_diff_to_tree__doc__, - "diff_to_tree([tree, flags, context_lines, interhunk_lines, swap]) -> Diff\n" + "diff_to_tree([tree: Tree, flags: enums.DiffOption = enums.DiffOption.NORMAL, context_lines: int = 3, interhunk_lines: int = 0, swap: bool = False]) -> Diff\n" "\n" - "Show the changes between two trees\n" + "Show the changes between two trees.\n" "\n" - "Arguments:\n" + "Parameters:\n" "\n" - "tree: the :py:class:`~pygit2.Tree` to diff. If no tree is given the empty\n" - " tree will be used instead.\n" + "tree: :py:class:`~pygit2.Tree`\n" + " The tree to diff. If no tree is given the empty tree will be used\n" + " instead.\n" "\n" - "flag: a GIT_DIFF_* constant.\n" + "flags\n" + " A combination of enums.DiffOption constants.\n" "\n" - "context_lines: the number of unchanged lines that define the boundary\n" - " of a hunk (and to display before and after)\n" + "context_lines\n" + " The number of unchanged lines that define the boundary of a hunk\n" + " (and to display before and after).\n" "\n" - "interhunk_lines: the maximum number of unchanged lines between hunk\n" - " boundaries before the hunks will be merged into a one.\n" + "interhunk_lines\n" + " The maximum number of unchanged lines between hunk boundaries before\n" + " the hunks will be merged into a one.\n" "\n" - "swap: instead of diffing a to b. Diff b to a.\n"); + "swap\n" + " Instead of diffing a to b. Diff b to a.\n"); PyObject * Tree_diff_to_tree(Tree *self, PyObject *args, PyObject *kwds) { git_diff_options opts = GIT_DIFF_OPTIONS_INIT; - git_diff_list *diff; - git_tree *from, *to, *tmp; - Repository *py_repo; + git_diff *diff; + git_tree *from, *to = NULL, *tmp; int err, swap = 0; - char *keywords[] = {"obj", "flags", "context_lines", "interhunk_lines", - "swap", NULL}; + char *keywords[] = {"obj", "flags", "context_lines", "interhunk_lines", "swap", NULL}; - Tree *py_tree = NULL; + Tree *other = NULL; if (!PyArg_ParseTupleAndKeywords(args, kwds, "|O!IHHi", keywords, - &TreeType, &py_tree, &opts.flags, + &TreeType, &other, &opts.flags, &opts.context_lines, &opts.interhunk_lines, &swap)) return NULL; - py_repo = self->repo; - to = (py_tree == NULL) ? NULL : py_tree->tree; + if (Object__load((Object*)self) == NULL) { return NULL; } // Lazy load from = self->tree; + + if (other) { + if (Object__load((Object*)other) == NULL) { return NULL; } // Lazy load + to = other->tree; + } + if (swap > 0) { tmp = from; from = to; to = tmp; } - err = git_diff_tree_to_tree(&diff, py_repo->repo, from, to, &opts); + err = git_diff_tree_to_tree(&diff, self->repo->repo, from, to, &opts); if (err < 0) return Error_set(err); - return wrap_diff(diff, py_repo); + return wrap_diff(diff, self->repo); } @@ -418,17 +401,58 @@ PySequenceMethods Tree_as_sequence = { PyMappingMethods Tree_as_mapping = { (lenfunc)Tree_len, /* mp_length */ - (binaryfunc)Tree_getitem, /* mp_subscript */ + (binaryfunc)Tree_subscript, /* mp_subscript */ 0, /* mp_ass_subscript */ }; PyMethodDef Tree_methods[] = { METHOD(Tree, diff_to_tree, METH_VARARGS | METH_KEYWORDS), - METHOD(Tree, diff_to_workdir, METH_VARARGS), + METHOD(Tree, diff_to_workdir, METH_VARARGS | METH_KEYWORDS), METHOD(Tree, diff_to_index, METH_VARARGS | METH_KEYWORDS), {NULL} }; +/* Py2/3 compatible structure + * see https://py3c.readthedocs.io/en/latest/ext-types.html#pynumbermethods + */ +PyNumberMethods Tree_as_number = { + 0, /* nb_add */ + 0, /* nb_subtract */ + 0, /* nb_multiply */ + 0, /* nb_remainder */ + 0, /* nb_divmod */ + 0, /* nb_power */ + 0, /* nb_negative */ + 0, /* nb_positive */ + 0, /* nb_absolute */ + 0, /* nb_bool (Py2: nb_nonzero) */ + 0, /* nb_invert */ + 0, /* nb_lshift */ + 0, /* nb_rshift */ + 0, /* nb_and */ + 0, /* nb_xor */ + 0, /* nb_or */ + 0, /* nb_int */ + 0, /* nb_reserved (Py2: nb_long) */ + 0, /* nb_float */ + 0, /* nb_inplace_add */ + 0, /* nb_inplace_subtract */ + 0, /* nb_inplace_multiply */ + 0, /* nb_inplace_remainder */ + 0, /* nb_inplace_power */ + 0, /* nb_inplace_lshift */ + 0, /* nb_inplace_rshift */ + 0, /* nb_inplace_and */ + 0, /* nb_inplace_xor */ + 0, /* nb_inplace_or */ + 0, /* nb_floor_divide */ + (binaryfunc)Tree_divide, /* nb_true_divide */ + 0, /* nb_inplace_floor_divide */ + 0, /* nb_inplace_true_divide */ + 0, /* nb_index */ + 0, /* nb_matrix_multiply */ + 0, /* nb_inplace_matrix_multiply */ +}; PyDoc_STRVAR(Tree__doc__, "Tree objects."); @@ -442,8 +466,8 @@ PyTypeObject TreeType = { 0, /* tp_getattr */ 0, /* tp_setattr */ 0, /* tp_compare */ - 0, /* tp_repr */ - 0, /* tp_as_number */ + (reprfunc)Object_repr, /* tp_repr */ + &Tree_as_number, /* tp_as_number */ &Tree_as_sequence, /* tp_as_sequence */ &Tree_as_mapping, /* tp_as_mapping */ 0, /* tp_hash */ @@ -452,7 +476,7 @@ PyTypeObject TreeType = { 0, /* tp_getattro */ 0, /* tp_setattro */ 0, /* tp_as_buffer */ - Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, /* tp_flags */ + Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, /* tp_flags */ Tree__doc__, /* tp_doc */ 0, /* tp_traverse */ 0, /* tp_clear */ @@ -481,23 +505,24 @@ TreeIter_dealloc(TreeIter *self) PyObject_Del(self); } -TreeEntry * +PyObject* TreeIter_iternext(TreeIter *self) { - const git_tree_entry *entry; + const git_tree_entry *entry_src; + git_tree_entry *entry; - entry = git_tree_entry_byindex(self->owner->tree, self->i); - if (!entry) + entry_src = git_tree_entry_byindex(self->owner->tree, self->i); + if (!entry_src) return NULL; self->i += 1; - entry = git_tree_entry_dup(entry); - if (entry == NULL) { + if (git_tree_entry_dup(&entry, entry_src) < 0) { PyErr_SetNone(PyExc_MemoryError); return NULL; } - return wrap_tree_entry(entry); + + return treeentry_to_object(entry, self->owner->repo); } @@ -523,7 +548,7 @@ PyTypeObject TreeIterType = { 0, /* tp_getattro */ 0, /* tp_setattro */ 0, /* tp_as_buffer */ - Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, /* tp_flags */ + Py_TPFLAGS_DEFAULT, /* tp_flags */ TreeIter__doc__, /* tp_doc */ 0, /* tp_traverse */ 0, /* tp_clear */ diff --git a/src/tree.h b/src/tree.h index 94d0ff184..f7866695a 100644 --- a/src/tree.h +++ b/src/tree.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2013 The pygit2 contributors + * Copyright 2010-2025 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -33,14 +33,7 @@ #include #include "types.h" -TreeEntry * wrap_tree_entry(const git_tree_entry *entry); -PyObject* TreeEntry_get_filemode(TreeEntry *self); -PyObject* TreeEntry_get_name(TreeEntry *self); -PyObject* TreeEntry_get_oid(TreeEntry *self); -PyObject* TreeEntry_get_hex(TreeEntry *self); +PyObject* treeentry_to_object(const git_tree_entry *entry, Repository *repo); -TreeEntry* Tree_getitem_by_index(Tree *self, PyObject *py_index); -TreeEntry* Tree_getitem(Tree *self, PyObject *value); -PyObject* Tree_diff_tree(Tree *self, PyObject *args); #endif diff --git a/src/treebuilder.c b/src/treebuilder.c index 8981e98c3..8c47477b8 100644 --- a/src/treebuilder.c +++ b/src/treebuilder.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2013 The pygit2 contributors + * Copyright 2010-2025 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -45,13 +45,15 @@ TreeBuilder_dealloc(TreeBuilder *self) PyDoc_STRVAR(TreeBuilder_insert__doc__, - "insert(name, oid, attr)\n" + "insert(name: str, oid: Oid, attr: FileMode)\n" "\n" "Insert or replace an entry in the treebuilder.\n" "\n" - "attr available values are GIT_FILEMODE_BLOB,\n" - " GIT_FILEMODE_BLOB_EXECUTABLE, GIT_FILEMODE_TREE,\n" - " GIT_FILEMODE_LINK and GIT_FILEMODE_COMMIT."); + "Parameters:\n" + "\n" + "attr\n" + " Available values are FileMode.BLOB, FileMode.BLOB_EXECUTABLE,\n" + " FileMode.TREE, FileMode.LINK and FileMode.COMMIT.\n"); PyObject * TreeBuilder_insert(TreeBuilder *self, PyObject *args) @@ -88,7 +90,7 @@ TreeBuilder_write(TreeBuilder *self) int err; git_oid oid; - err = git_treebuilder_write(&oid, self->repo->repo, self->bld); + err = git_treebuilder_write(&oid, self->bld); if (err < 0) return Error_set(err); @@ -97,51 +99,49 @@ TreeBuilder_write(TreeBuilder *self) PyDoc_STRVAR(TreeBuilder_get__doc__, - "get(name) -> TreeEntry\n" + "get(name: str) -> Object\n" "\n" - "Return the TreeEntry for the given name, or None if there is not."); + "Return the Object for the given name, or None if there is not."); PyObject * TreeBuilder_get(TreeBuilder *self, PyObject *py_filename) { - char *filename; - const git_tree_entry *entry; - - filename = py_path_to_c_str(py_filename); + PyObject *tvalue; + char *filename = pgit_borrow_fsdefault(py_filename, &tvalue); if (filename == NULL) return NULL; - entry = git_treebuilder_get(self->bld, filename); - free(filename); - if (entry == NULL) + const git_tree_entry *entry_src = git_treebuilder_get(self->bld, filename); + Py_DECREF(tvalue); + if (entry_src == NULL) Py_RETURN_NONE; - entry = git_tree_entry_dup(entry); - if (entry == NULL) { + git_tree_entry *entry; + if (git_tree_entry_dup(&entry, entry_src) < 0) { PyErr_SetNone(PyExc_MemoryError); return NULL; } - return (PyObject*)wrap_tree_entry(entry); + + return treeentry_to_object(entry, self->repo); } PyDoc_STRVAR(TreeBuilder_remove__doc__, - "remove(name)\n" + "remove(name: str)\n" "\n" "Remove an entry from the builder."); PyObject * TreeBuilder_remove(TreeBuilder *self, PyObject *py_filename) { - char *filename = py_path_to_c_str(py_filename); - int err = 0; - + PyObject *tvalue; + char *filename = pgit_borrow_fsdefault(py_filename, &tvalue); if (filename == NULL) return NULL; - err = git_treebuilder_remove(self->bld, filename); - free(filename); - if (err < 0) + int err = git_treebuilder_remove(self->bld, filename); + Py_DECREF(tvalue); + if (err) return Error_set(err); Py_RETURN_NONE; diff --git a/src/treebuilder.h b/src/treebuilder.h index ad000dfff..5a6c82427 100644 --- a/src/treebuilder.h +++ b/src/treebuilder.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2013 The pygit2 contributors + * Copyright 2010-2025 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/types.h b/src/types.h index 926d74ad2..24a66aa10 100644 --- a/src/types.h +++ b/src/types.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2013 The pygit2 contributors + * Copyright 2010-2025 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -31,6 +31,11 @@ #define PY_SSIZE_T_CLEAN #include #include +#include + +#if !(LIBGIT2_VER_MAJOR == 1 && LIBGIT2_VER_MINOR == 9) +#error You need a compatible libgit2 version (1.9.x) +#endif /* * Python objects @@ -43,6 +48,7 @@ typedef struct { git_repository *repo; PyObject *index; /* It will be None for a bare repository */ PyObject *config; /* It will be None for a bare repository */ + int owned; /* _from_c() sometimes means we don't own the C pointer */ } Repository; @@ -51,6 +57,42 @@ typedef struct { git_oid oid; } Oid; +typedef struct { + PyObject_HEAD + git_odb *odb; +} Odb; + +typedef struct { + PyObject_HEAD + git_odb_backend *odb_backend; +} OdbBackend; + +typedef struct { + OdbBackend super; +} OdbBackendPack; + +typedef struct { + OdbBackend super; +} OdbBackendLoose; + +typedef struct { + PyObject_HEAD + git_refdb *refdb; +} Refdb; + +typedef struct { + PyObject_HEAD + git_refdb_backend *refdb_backend; +} RefdbBackend; + +typedef struct { + RefdbBackend super; +} RefdbFsBackend; + +typedef struct { + PyObject_HEAD + git_reference_iterator *iterator; +} RefsIterator; #define SIMPLE_TYPE(_name, _ptr_type, _ptr_name) \ typedef struct {\ @@ -59,31 +101,35 @@ typedef struct { _ptr_type *_ptr_name;\ } _name; +#define OBJECT_TYPE(_name, _ptr_type, _ptr_name) \ + typedef struct {\ + PyObject_HEAD\ + Repository *repo;\ + _ptr_type *_ptr_name;\ + const git_tree_entry *entry;\ + } _name; + /* git object types * * The structs for some of the object subtypes are identical except for * the type of their object pointers. */ -SIMPLE_TYPE(Object, git_object, obj) -SIMPLE_TYPE(Commit, git_commit, commit) -SIMPLE_TYPE(Tree, git_tree, tree) -SIMPLE_TYPE(Blob, git_blob, blob) -SIMPLE_TYPE(Tag, git_tag, tag) - - -/* git_config */ -typedef struct { - PyObject_HEAD - git_config* config; -} Config; +OBJECT_TYPE(Object, git_object, obj) +OBJECT_TYPE(Commit, git_commit, commit) +OBJECT_TYPE(Tree, git_tree, tree) +OBJECT_TYPE(Blob, git_blob, blob) +OBJECT_TYPE(Tag, git_tag, tag) +SIMPLE_TYPE(Worktree, git_worktree, worktree) /* git_note */ typedef struct { PyObject_HEAD Repository *repo; + const char *ref; + PyObject *annotated_id; + PyObject *id; git_note *note; - char* annotated_id; } Note; typedef struct { @@ -93,45 +139,69 @@ typedef struct { char* ref; } NoteIter; +/* git_patch */ +typedef struct { + PyObject_HEAD + git_patch *patch; + Blob* oldblob; + Blob* newblob; +} Patch; -/* git _diff */ -SIMPLE_TYPE(Diff, git_diff_list, list) +/* git_diff */ +SIMPLE_TYPE(Diff, git_diff, diff) typedef struct { PyObject_HEAD - Diff* diff; + Diff *diff; size_t i; size_t n; -} DiffIter; +} DeltasIter; typedef struct { PyObject_HEAD - PyObject* hunks; - const char * old_file_path; - const char * new_file_path; - char* old_oid; - char* new_oid; - char status; - unsigned similarity; -} Patch; + Diff *diff; + size_t i; + size_t n; +} DiffIter; typedef struct { PyObject_HEAD - PyObject* lines; - int old_start; - int old_lines; - int new_start; - int new_lines; -} Hunk; + PyObject *id; + char *path; + PyObject *raw_path; + git_off_t size; + uint32_t flags; + uint16_t mode; +} DiffFile; +typedef struct { + PyObject_HEAD + git_delta_t status; + uint32_t flags; + uint16_t similarity; + uint16_t nfiles; + PyObject *old_file; + PyObject *new_file; +} DiffDelta; -/* git_tree_walk , git_treebuilder*/ -SIMPLE_TYPE(TreeBuilder, git_treebuilder, bld) +typedef struct { + PyObject_HEAD + Patch *patch; + const git_diff_hunk *hunk; + size_t idx; + size_t n_lines; +} DiffHunk; typedef struct { PyObject_HEAD - const git_tree_entry *entry; -} TreeEntry; + DiffHunk *hunk; + const git_diff_line *line; +} DiffLine; + +SIMPLE_TYPE(DiffStats, git_diff_stats, stats); + +/* git_tree_walk , git_treebuilder*/ +SIMPLE_TYPE(TreeBuilder, git_treebuilder, bld) typedef struct { PyObject_HEAD @@ -141,19 +211,11 @@ typedef struct { /* git_index */ -SIMPLE_TYPE(Index, git_index, index) - typedef struct { PyObject_HEAD - const git_index_entry *entry; + git_index_entry entry; } IndexEntry; -typedef struct { - PyObject_HEAD - Index *owner; - int i; -} IndexIter; - /* git_reference, git_reflog */ SIMPLE_TYPE(Walker, git_revwalk, walk) @@ -165,8 +227,8 @@ typedef Reference Branch; typedef struct { PyObject_HEAD git_signature *signature; - char *oid_old; - char *oid_new; + PyObject *oid_old; + PyObject *oid_new; char *message; } RefLogEntry; @@ -177,18 +239,37 @@ typedef struct { size_t size; } RefLogIter; +/* git_revspec */ +typedef struct { + PyObject_HEAD + PyObject *from; + PyObject *to; + unsigned int flags; +} RevSpec; /* git_signature */ typedef struct { PyObject_HEAD Object *obj; const git_signature *signature; - const char *encoding; + char *encoding; } Signature; +/* git_mailmap */ +typedef struct { + PyObject_HEAD + git_mailmap *mailmap; +} Mailmap; -/* git_remote */ -SIMPLE_TYPE(Remote, git_remote, remote) +typedef struct { + PyObject_HEAD + PyObject *commit_id; + char *message; +} Stash; +typedef struct { + PyObject_HEAD + const git_filter_source *src; +} FilterSource; #endif diff --git a/src/utils.c b/src/utils.c index 31f0472aa..614b9770b 100644 --- a/src/utils.c +++ b/src/utils.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2013 The pygit2 contributors + * Copyright 2010-2025 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -31,32 +31,177 @@ #include "utils.h" extern PyTypeObject ReferenceType; +extern PyTypeObject TreeType; +extern PyTypeObject CommitType; +extern PyTypeObject BlobType; +extern PyTypeObject TagType; -/* py_str_to_c_str() returns a newly allocated C string holding - * the string contained in the value argument. */ -char * -py_str_to_c_str(PyObject *value, const char *encoding) +/** + * Attempt to convert a C string to a Python string with the given encoding. + * If the conversion fails, return a fallback string. + */ +PyObject * +to_unicode_safe(const char *value, const char *encoding) { - char *c_str = NULL; - /* Case 1: byte string */ - if (PyBytes_Check(value)) - return strdup(PyBytes_AsString(value)); - - /* Case 2: text string */ - if (PyUnicode_Check(value)) { - if (encoding == NULL) - value = PyUnicode_AsUTF8String(value); - else - value = PyUnicode_AsEncodedString(value, encoding, "strict"); - if (value == NULL) + PyObject *py_str; + + if (!value) { + py_str = PyUnicode_FromString("None"); + } else { + py_str = to_unicode(value, encoding, "replace"); + + if (!py_str) { + assert(PyErr_Occurred()); + py_str = PyUnicode_FromString("(error)"); + PyErr_Clear(); + } + } + + assert(!PyErr_Occurred()); + assert(py_str); + + return py_str; +} + +char* +pgit_borrow_fsdefault(PyObject *value, PyObject **tvalue) +{ + PyObject *str = PyOS_FSPath(value); + if (str == NULL) { + return NULL; + } + + PyObject *bytes = PyUnicode_EncodeFSDefault(str); + if (bytes == NULL) { + return NULL; + } + + *tvalue = bytes; + return PyBytes_AS_STRING(bytes); +} + +/** + * Return a pointer to the underlying C string in 'value'. The pointer is + * guaranteed by 'tvalue', decrease its refcount when done with the string. + */ +const char* +pgit_borrow_encoding(PyObject *value, const char *encoding, const char *errors, PyObject **tvalue) +{ + PyObject *py_value = NULL; + PyObject *py_str = NULL; + + py_value = PyOS_FSPath(value); + if (py_value == NULL) { + Error_type_error("unexpected %.200s", value); + return NULL; + } + + // Get new PyBytes reference from value + if (PyUnicode_Check(py_value)) { // Text string + py_str = PyUnicode_AsEncodedString( + py_value, + encoding ? encoding : "utf-8", + errors ? errors : "strict" + ); + + Py_DECREF(py_value); + if (py_str == NULL) return NULL; - c_str = strdup(PyBytes_AsString(value)); - Py_DECREF(value); - return c_str; + } else if (PyBytes_Check(py_value)) { // Byte string + py_str = py_value; + } else { // Type error + Error_type_error("unexpected %.200s", value); + Py_DECREF(py_value); + return NULL; + } + + // Borrow c string from the new PyBytes reference + char *c_str = PyBytes_AsString(py_str); + if (c_str == NULL) { + Py_DECREF(py_str); + return NULL; + } + + // Return the borrowed c string and the new PyBytes reference + *tvalue = py_str; + return c_str; +} + + +/** + * Return a borrowed c string with the representation of the given Unicode or + * Bytes object: + * - If value is Unicode return the UTF-8 representation + * - If value is Bytes return the raw sttring + * In both cases the returned string is owned by value and must not be + * modified, nor freed. + */ +const char* +pgit_borrow(PyObject *value) +{ + if (PyUnicode_Check(value)) { // Text string + return PyUnicode_AsUTF8(value); + } else if (PyBytes_Check(value)) { // Byte string + return PyBytes_AsString(value); } - /* Type error */ - PyErr_Format(PyExc_TypeError, "unexpected %.200s", - Py_TYPE(value)->tp_name); + // Type error + Error_type_error("unexpected %.200s", value); return NULL; } + + +static git_otype +py_type_to_git_type(PyTypeObject *py_type) +{ + if (py_type == &CommitType) + return GIT_OBJECT_COMMIT; + else if (py_type == &TreeType) + return GIT_OBJECT_TREE; + else if (py_type == &BlobType) + return GIT_OBJECT_BLOB; + else if (py_type == &TagType) + return GIT_OBJECT_TAG; + + PyErr_SetString(PyExc_ValueError, "invalid target type"); + return GIT_OBJECT_INVALID; /* -1 */ +} + +git_otype +py_object_to_otype(PyObject *py_type) +{ + long value; + + if (py_type == Py_None) + return GIT_OBJECT_ANY; + + if (PyLong_Check(py_type)) { + value = PyLong_AsLong(py_type); + if (value == -1 && PyErr_Occurred()) + return GIT_OBJECT_INVALID; + + /* TODO Check whether the value is a valid value */ + return (git_otype)value; + } + + if (PyType_Check(py_type)) + return py_type_to_git_type((PyTypeObject *) py_type); + + PyErr_SetString(PyExc_ValueError, "invalid target type"); + return GIT_OBJECT_INVALID; /* -1 */ +} + + +/** + * Convert an integer to a reference to an IntEnum or IntFlag in pygit2.enums. + */ +PyObject * +pygit2_enum(PyObject *enum_type, int value) +{ + if (!enum_type) { + PyErr_SetString(PyExc_TypeError, "an enum has not been cached in _pygit2.cache_enums()"); + return NULL; + } + PyObject *enum_instance = PyObject_CallFunction(enum_type, "(i)", value); + return enum_instance; +} diff --git a/src/utils.h b/src/utils.h index 62699e835..c1b8989dd 100644 --- a/src/utils.h +++ b/src/utils.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2013 The pygit2 contributors + * Copyright 2010-2025 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -39,31 +39,11 @@ # define PYGIT2_FN_UNUSED #endif -/* Python 2 support */ -#if PY_MAJOR_VERSION == 2 - #define PyLong_FromSize_t PyInt_FromSize_t - #define PyLong_AsSize_t (size_t)PyInt_AsSsize_t - #define PyLong_AsLong PyInt_AsLong - #undef PyLong_Check - #define PyLong_Check PyInt_Check - #define PyLong_FromLong PyInt_FromLong - #define PyBytes_AS_STRING PyString_AS_STRING - #define PyBytes_AsString PyString_AsString - #define PyBytes_AsStringAndSize PyString_AsStringAndSize - #define PyBytes_Check PyString_Check - #define PyBytes_FromString PyString_FromString - #define PyBytes_FromStringAndSize PyString_FromStringAndSize - #define PyBytes_Size PyString_Size - #define to_path(x) to_bytes(x) - #define to_encoding(x) to_bytes(x) -#else - #define to_path(x) to_unicode(x, Py_FileSystemDefaultEncoding, "strict") - #define to_encoding(x) PyUnicode_DecodeASCII(x, strlen(x), "strict") +#if defined(PYPY_VERSION) +#define Py_FileSystemDefaultEncodeErrors "surrogateescape" #endif -#ifndef Py_hash_t - #define Py_hash_t long -#endif +#define to_encoding(x) PyUnicode_DecodeASCII(x, strlen(x), "strict") #define CHECK_REFERENCE(self)\ @@ -80,8 +60,9 @@ /* Utilities */ -#define to_unicode(x, encoding, errors)\ - to_unicode_n(x, strlen(x), encoding, errors) +#define to_unicode(x, encoding, errors) to_unicode_n(x, strlen(x), encoding, errors) + +PyObject *to_unicode_safe(const char *value, const char *encoding); PYGIT2_FN_UNUSED Py_LOCAL_INLINE(PyObject*) @@ -89,28 +70,37 @@ to_unicode_n(const char *value, size_t len, const char *encoding, const char *errors) { if (encoding == NULL) { - /* If the encoding is not explicit, it may not be UTF-8, so it - * is not safe to decode it strictly. This is rare in the - * wild, but does occur in old commits to git itself - * (e.g. c31820c2). */ - encoding = "utf-8"; - errors = "replace"; + encoding = "utf-8"; // Default to UTF-8 + + /* If the encoding is not explicit, it may not be UTF-8, so it is not + * safe to decode it strictly. This is rare in the wild, but does + * occur in old commits to git itself (e.g. c31820c2). + * https://github.com/libgit2/pygit2/issues/77 + */ + if (errors == NULL) { + errors = "replace"; + } } return PyUnicode_Decode(value, len, encoding, errors); } -PYGIT2_FN_UNUSED -Py_LOCAL_INLINE(PyObject*) -to_bytes(const char * value) -{ - return PyBytes_FromString(value); -} +#define value_or_default(x, _default) ((x) == NULL ? (_default) : (x)) -char * py_str_to_c_str(PyObject *value, 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); + + +//PyObject * get_pylist_from_git_strarray(git_strarray *strarray); +//int get_strarraygit_from_pylist(git_strarray *array, PyObject *pylist); + +git_otype py_object_to_otype(PyObject *py_type); + + +/* Enum utilities (pygit2.enums) */ +PyObject *pygit2_enum(PyObject *enum_type, int value); -#define py_path_to_c_str(py_path) \ - py_str_to_c_str(py_path, Py_FileSystemDefaultEncoding) /* Helpers to make shorter PyMethodDef and PyGetSetDef blocks */ #define METHOD(type, name, args)\ @@ -133,6 +123,9 @@ char * py_str_to_c_str(PyObject *value, const char *encoding); #define MEMBER(type, attr, attr_type, docstr)\ {#attr, attr_type, offsetof(type, attr), 0, PyDoc_STR(docstr)} +#define RMEMBER(type, attr, attr_type, docstr)\ + {#attr, attr_type, offsetof(type, attr), READONLY, PyDoc_STR(docstr)} + /* Helpers for memory allocation */ #define CALLOC(ptr, num, size, label) \ @@ -162,6 +155,15 @@ char * py_str_to_c_str(PyObject *value, const char *encoding); if (PyModule_AddObject(module, #type, (PyObject*) & type ## Type) == -1)\ return NULL; +#define ADD_EXC(m, name, base)\ + name = PyErr_NewException("_pygit2." #name, base, NULL);\ + 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 a02b2f45c..b4967a33f 100644 --- a/src/walker.c +++ b/src/walker.c @@ -1,5 +1,5 @@ /* - * Copyright 2010-2013 The pygit2 contributors + * Copyright 2010-2025 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, @@ -28,9 +28,10 @@ #define PY_SSIZE_T_CLEAN #include #include "error.h" -#include "utils.h" +#include "object.h" #include "oid.h" #include "tree.h" +#include "utils.h" #include "walker.h" extern PyTypeObject CommitType; @@ -45,7 +46,7 @@ Walker_dealloc(Walker *self) PyDoc_STRVAR(Walker_hide__doc__, - "hide(oid)\n" + "hide(oid: Oid)\n" "\n" "Mark a commit (and its ancestors) uninteresting for the output."); @@ -68,7 +69,7 @@ Walker_hide(Walker *self, PyObject *py_hex) PyDoc_STRVAR(Walker_push__doc__, - "push(oid)\n" + "push(oid: Oid)\n" "\n" "Mark a commit to start traversal from."); @@ -91,20 +92,20 @@ Walker_push(Walker *self, PyObject *py_hex) PyDoc_STRVAR(Walker_sort__doc__, - "sort(mode)\n" + "sort(mode: enums.SortMode)\n" "\n" "Change the sorting mode (this resets the walker)."); PyObject * Walker_sort(Walker *self, PyObject *py_sort_mode) { - int sort_mode; + long sort_mode; - sort_mode = (int)PyLong_AsLong(py_sort_mode); + sort_mode = PyLong_AsLong(py_sort_mode); if (sort_mode == -1 && PyErr_Occurred()) return NULL; - git_revwalk_sorting(self->walk, sort_mode); + git_revwalk_sorting(self->walk, (unsigned int)sort_mode); Py_RETURN_NONE; } @@ -122,6 +123,18 @@ Walker_reset(Walker *self) Py_RETURN_NONE; } +PyDoc_STRVAR(Walker_simplify_first_parent__doc__, + "simplify_first_parent()\n" + "\n" + "Simplify the history by first-parent."); + +PyObject * +Walker_simplify_first_parent(Walker *self) +{ + git_revwalk_simplify_first_parent(self->walk); + Py_RETURN_NONE; +} + PyObject * Walker_iter(Walker *self) { @@ -134,10 +147,12 @@ Walker_iternext(Walker *self) { int err; git_commit *commit; - Commit *py_commit; git_oid oid; + Py_BEGIN_ALLOW_THREADS err = git_revwalk_next(&oid, self->walk); + Py_END_ALLOW_THREADS + if (err < 0) return Error_set(err); @@ -145,19 +160,14 @@ Walker_iternext(Walker *self) if (err < 0) return Error_set(err); - py_commit = PyObject_New(Commit, &CommitType); - if (py_commit) { - py_commit->commit = commit; - Py_INCREF(self->repo); - py_commit->repo = self->repo; - } - return (PyObject*)py_commit; + return wrap_object((git_object*)commit, self->repo, NULL); } PyMethodDef Walker_methods[] = { METHOD(Walker, hide, METH_O), METHOD(Walker, push, METH_O), METHOD(Walker, reset, METH_NOARGS), + METHOD(Walker, simplify_first_parent, METH_NOARGS), METHOD(Walker, sort, METH_O), {NULL} }; diff --git a/src/walker.h b/src/walker.h index 9742ea97a..75b3afc92 100644 --- a/src/walker.h +++ b/src/walker.h @@ -1,5 +1,5 @@ /* - * Copyright 2010-2013 The pygit2 contributors + * Copyright 2010-2025 The pygit2 contributors * * This file is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, diff --git a/src/wildmatch.c b/src/wildmatch.c new file mode 100644 index 000000000..e69d076ea --- /dev/null +++ b/src/wildmatch.c @@ -0,0 +1,322 @@ +/* + * Copyright (C) the libgit2 contributors. All rights reserved. + * + * This file is part of libgit2, distributed under the GNU GPL v2 with + * a Linking Exception. For full terms see the included COPYING file. + * + * Do shell-style pattern matching for ?, \, [], and * characters. + * It is 8bit clean. + * + * Written by Rich $alz, mirror!rs, Wed Nov 26 19:03:17 EST 1986. + * Rich $alz is now . + * + * Modified by Wayne Davison to special-case '/' matching, to make '**' + * work differently than '*', and to fix the character-class code. + * + * Imported from git.git. + */ + +#include +#include +#include "wildmatch.h" + +#define GIT_SPACE 0x01 +#define GIT_DIGIT 0x02 +#define GIT_ALPHA 0x04 +#define GIT_GLOB_SPECIAL 0x08 +#define GIT_REGEX_SPECIAL 0x10 +#define GIT_PATHSPEC_MAGIC 0x20 +#define GIT_CNTRL 0x40 +#define GIT_PUNCT 0x80 + +enum { + S = GIT_SPACE, + A = GIT_ALPHA, + D = GIT_DIGIT, + G = GIT_GLOB_SPECIAL, /* *, ?, [, \\ */ + R = GIT_REGEX_SPECIAL, /* $, (, ), +, ., ^, {, | */ + P = GIT_PATHSPEC_MAGIC, /* other non-alnum, except for ] and } */ + X = GIT_CNTRL, + U = GIT_PUNCT, + Z = GIT_CNTRL | GIT_SPACE +}; + +static const unsigned char sane_ctype[256] = { + X, X, X, X, X, X, X, X, X, Z, Z, X, X, Z, X, X, /* 0.. 15 */ + X, X, X, X, X, X, X, X, X, X, X, X, X, X, X, X, /* 16.. 31 */ + S, P, P, P, R, P, P, P, R, R, G, R, P, P, R, P, /* 32.. 47 */ + D, D, D, D, D, D, D, D, D, D, P, P, P, P, P, G, /* 48.. 63 */ + P, A, A, A, A, A, A, A, A, A, A, A, A, A, A, A, /* 64.. 79 */ + A, A, A, A, A, A, A, A, A, A, A, G, G, U, R, P, /* 80.. 95 */ + P, A, A, A, A, A, A, A, A, A, A, A, A, A, A, A, /* 96..111 */ + A, A, A, A, A, A, A, A, A, A, A, R, R, U, P, X, /* 112..127 */ + /* Nothing in the 128.. range */ +}; + +#define sane_istest(x,mask) ((sane_ctype[(unsigned char)(x)] & (mask)) != 0) +#define is_glob_special(x) sane_istest(x,GIT_GLOB_SPECIAL) + +typedef unsigned char uchar; + +/* What character marks an inverted character class? */ +#define NEGATE_CLASS '!' +#define NEGATE_CLASS2 '^' + +#define CC_EQ(class, len, litmatch) ((len) == sizeof (litmatch)-1 \ + && *(class) == *(litmatch) \ + && strncmp((char*)class, litmatch, len) == 0) + +#if defined STDC_HEADERS || !defined isascii +# define ISASCII(c) 1 +#else +# define ISASCII(c) isascii(c) +#endif + +#ifdef isblank +# define ISBLANK(c) (ISASCII(c) && isblank(c)) +#else +# define ISBLANK(c) ((c) == ' ' || (c) == '\t') +#endif + +#ifdef isgraph +# define ISGRAPH(c) (ISASCII(c) && isgraph(c)) +#else +# define ISGRAPH(c) (ISASCII(c) && isprint(c) && !isspace(c)) +#endif + +#define ISPRINT(c) (ISASCII(c) && isprint(c)) +#define ISDIGIT(c) (ISASCII(c) && isdigit(c)) +#define ISALNUM(c) (ISASCII(c) && isalnum(c)) +#define ISALPHA(c) (ISASCII(c) && isalpha(c)) +#define ISCNTRL(c) (ISASCII(c) && iscntrl(c)) +#define ISLOWER(c) (ISASCII(c) && islower(c)) +#define ISPUNCT(c) (ISASCII(c) && ispunct(c)) +#define ISSPACE(c) (ISASCII(c) && isspace(c)) +#define ISUPPER(c) (ISASCII(c) && isupper(c)) +#define ISXDIGIT(c) (ISASCII(c) && isxdigit(c)) + +/* Match pattern "p" against "text" */ +static int dowild(const uchar *p, const uchar *text, unsigned int flags) +{ + uchar p_ch; + const uchar *pattern = p; + + for ( ; (p_ch = *p) != '\0'; text++, p++) { + int matched, match_slash, negated; + uchar t_ch, prev_ch; + if ((t_ch = *text) == '\0' && p_ch != '*') + return WM_ABORT_ALL; + if ((flags & WM_CASEFOLD) && ISUPPER(t_ch)) + t_ch = tolower(t_ch); + if ((flags & WM_CASEFOLD) && ISUPPER(p_ch)) + p_ch = tolower(p_ch); + switch (p_ch) { + case '\\': + /* Literal match with following character. Note that the test + * in "default" handles the p[1] == '\0' failure case. */ + p_ch = *++p; + /* FALLTHROUGH */ + default: + if (t_ch != p_ch) + return WM_NOMATCH; + continue; + case '?': + /* Match anything but '/'. */ + if ((flags & WM_PATHNAME) && t_ch == '/') + return WM_NOMATCH; + continue; + case '*': + if (*++p == '*') { + const uchar *prev_p = p - 2; + while (*++p == '*') {} + if (!(flags & WM_PATHNAME)) + /* without WM_PATHNAME, '*' == '**' */ + match_slash = 1; + else if ((prev_p < pattern || *prev_p == '/') && + (*p == '\0' || *p == '/' || + (p[0] == '\\' && p[1] == '/'))) { + /* + * Assuming we already match 'foo/' and are at + * , just assume it matches + * nothing and go ahead match the rest of the + * pattern with the remaining string. This + * helps make foo/<*><*>/bar (<> because + * otherwise it breaks C comment syntax) match + * both foo/bar and foo/a/bar. + */ + if (p[0] == '/' && + dowild(p + 1, text, flags) == WM_MATCH) + return WM_MATCH; + match_slash = 1; + } else /* WM_PATHNAME is set */ + match_slash = 0; + } else + /* without WM_PATHNAME, '*' == '**' */ + match_slash = flags & WM_PATHNAME ? 0 : 1; + if (*p == '\0') { + /* Trailing "**" matches everything. Trailing "*" matches + * only if there are no more slash characters. */ + if (!match_slash) { + if (strchr((char*)text, '/') != NULL) + return WM_NOMATCH; + } + return WM_MATCH; + } else if (!match_slash && *p == '/') { + /* + * _one_ asterisk followed by a slash + * with WM_PATHNAME matches the next + * directory + */ + const char *slash = strchr((char*)text, '/'); + if (!slash) + return WM_NOMATCH; + text = (const uchar*)slash; + /* the slash is consumed by the top-level for loop */ + break; + } + while (1) { + if (t_ch == '\0') + break; + /* + * Try to advance faster when an asterisk is + * followed by a literal. We know in this case + * that the string before the literal + * must belong to "*". + * If match_slash is false, do not look past + * the first slash as it cannot belong to '*'. + */ + if (!is_glob_special(*p)) { + p_ch = *p; + if ((flags & WM_CASEFOLD) && ISUPPER(p_ch)) + p_ch = tolower(p_ch); + while ((t_ch = *text) != '\0' && + (match_slash || t_ch != '/')) { + if ((flags & WM_CASEFOLD) && ISUPPER(t_ch)) + t_ch = tolower(t_ch); + if (t_ch == p_ch) + break; + text++; + } + if (t_ch != p_ch) + return WM_NOMATCH; + } + if ((matched = dowild(p, text, flags)) != WM_NOMATCH) { + if (!match_slash || matched != WM_ABORT_TO_STARSTAR) + return matched; + } else if (!match_slash && t_ch == '/') + return WM_ABORT_TO_STARSTAR; + t_ch = *++text; + } + return WM_ABORT_ALL; + case '[': + p_ch = *++p; +#ifdef NEGATE_CLASS2 + if (p_ch == NEGATE_CLASS2) + p_ch = NEGATE_CLASS; +#endif + /* Assign literal 1/0 because of "matched" comparison. */ + negated = p_ch == NEGATE_CLASS ? 1 : 0; + if (negated) { + /* Inverted character class. */ + p_ch = *++p; + } + prev_ch = 0; + matched = 0; + do { + if (!p_ch) + return WM_ABORT_ALL; + if (p_ch == '\\') { + p_ch = *++p; + if (!p_ch) + return WM_ABORT_ALL; + if (t_ch == p_ch) + matched = 1; + } else if (p_ch == '-' && prev_ch && p[1] && p[1] != ']') { + p_ch = *++p; + if (p_ch == '\\') { + p_ch = *++p; + if (!p_ch) + return WM_ABORT_ALL; + } + if (t_ch <= p_ch && t_ch >= prev_ch) + matched = 1; + else if ((flags & WM_CASEFOLD) && ISLOWER(t_ch)) { + uchar t_ch_upper = toupper(t_ch); + if (t_ch_upper <= p_ch && t_ch_upper >= prev_ch) + matched = 1; + } + p_ch = 0; /* This makes "prev_ch" get set to 0. */ + } else if (p_ch == '[' && p[1] == ':') { + const uchar *s; + int i; + for (s = p += 2; (p_ch = *p) && p_ch != ']'; p++) {} /*SHARED ITERATOR*/ + if (!p_ch) + return WM_ABORT_ALL; + i = (int)(p - s - 1); + if (i < 0 || p[-1] != ':') { + /* Didn't find ":]", so treat like a normal set. */ + p = s - 2; + p_ch = '['; + if (t_ch == p_ch) + matched = 1; + continue; + } + if (CC_EQ(s,i, "alnum")) { + if (ISALNUM(t_ch)) + matched = 1; + } else if (CC_EQ(s,i, "alpha")) { + if (ISALPHA(t_ch)) + matched = 1; + } else if (CC_EQ(s,i, "blank")) { + if (ISBLANK(t_ch)) + matched = 1; + } else if (CC_EQ(s,i, "cntrl")) { + if (ISCNTRL(t_ch)) + matched = 1; + } else if (CC_EQ(s,i, "digit")) { + if (ISDIGIT(t_ch)) + matched = 1; + } else if (CC_EQ(s,i, "graph")) { + if (ISGRAPH(t_ch)) + matched = 1; + } else if (CC_EQ(s,i, "lower")) { + if (ISLOWER(t_ch)) + matched = 1; + } else if (CC_EQ(s,i, "print")) { + if (ISPRINT(t_ch)) + matched = 1; + } else if (CC_EQ(s,i, "punct")) { + if (ISPUNCT(t_ch)) + matched = 1; + } else if (CC_EQ(s,i, "space")) { + if (ISSPACE(t_ch)) + matched = 1; + } else if (CC_EQ(s,i, "upper")) { + if (ISUPPER(t_ch)) + matched = 1; + else if ((flags & WM_CASEFOLD) && ISLOWER(t_ch)) + matched = 1; + } else if (CC_EQ(s,i, "xdigit")) { + if (ISXDIGIT(t_ch)) + matched = 1; + } else /* malformed [:class:] string */ + return WM_ABORT_ALL; + p_ch = 0; /* This makes "prev_ch" get set to 0. */ + } else if (t_ch == p_ch) + matched = 1; + } while (prev_ch = p_ch, (p_ch = *++p) != ']'); + if (matched == negated || + ((flags & WM_PATHNAME) && t_ch == '/')) + return WM_NOMATCH; + continue; + } + } + + return *text ? WM_NOMATCH : WM_MATCH; +} + +/* Match the "pattern" against the "text" string. */ +int wildmatch(const char *pattern, const char *text, unsigned int flags) +{ + return dowild((const uchar*)pattern, (const uchar*)text, flags); +} diff --git a/src/wildmatch.h b/src/wildmatch.h new file mode 100644 index 000000000..1cbf1bd1d --- /dev/null +++ b/src/wildmatch.h @@ -0,0 +1,21 @@ +/* + * Copyright (C) the libgit2 contributors. All rights reserved. + * + * This file is part of libgit2, distributed under the GNU GPL v2 with + * a Linking Exception. For full terms see the included COPYING file. + */ + +#ifndef INCLUDE_wildmatch_h__ +#define INCLUDE_wildmatch_h__ + +#define WM_CASEFOLD 1 +#define WM_PATHNAME 2 + +#define WM_NOMATCH 1 +#define WM_MATCH 0 +#define WM_ABORT_ALL -1 +#define WM_ABORT_TO_STARSTAR -2 + +int wildmatch(const char *pattern, const char *text, unsigned int flags); + +#endif diff --git a/src/worktree.c b/src/worktree.c new file mode 100644 index 000000000..2ed3772fc --- /dev/null +++ b/src/worktree.c @@ -0,0 +1,169 @@ +/* + * 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 "utils.h" +#include "types.h" +#include "worktree.h" + + +PyDoc_STRVAR(Worktree_name__doc__, + "Gets name worktree\n"); +PyObject * +Worktree_name__get__(Worktree *self) +{ + return to_unicode(git_worktree_name(self->worktree), NULL, NULL); +} + +PyDoc_STRVAR(Worktree_path__doc__, + "Gets path worktree\n"); +PyObject * +Worktree_path__get__(Worktree *self) +{ + return to_unicode(git_worktree_path(self->worktree), NULL, NULL); +} + +PyDoc_STRVAR(Worktree_is_prunable__doc__, + "Is the worktree prunable with the given set of flags?\n"); +PyObject * +Worktree_is_prunable__get__(Worktree *self, PyObject *args) +{ + if (git_worktree_is_prunable(self->worktree, 0) > 0) + Py_RETURN_TRUE; + + Py_RETURN_FALSE; +} + +PyDoc_STRVAR(Worktree_prune__doc__, + "prune(force=False)\n" + "\n" + "Prune a worktree object."); +PyObject * +Worktree_prune(Worktree *self, PyObject *args) +{ + int err, force = 0; + git_worktree_prune_options prune_opts; + + if (!PyArg_ParseTuple(args, "|i", &force)) + return NULL; + + git_worktree_prune_options_init(&prune_opts, GIT_WORKTREE_PRUNE_OPTIONS_VERSION); + prune_opts.flags = force & (GIT_WORKTREE_PRUNE_VALID | GIT_WORKTREE_PRUNE_LOCKED); + + err = git_worktree_prune(self->worktree, &prune_opts); + if (err < 0) + return Error_set(err); + + Py_RETURN_NONE; +} + +static void +Worktree_dealloc(Worktree *self) +{ + Py_CLEAR(self->repo); + git_worktree_free(self->worktree); + PyObject_Del(self); +} + + +PyMethodDef Worktree_methods[] = { + METHOD(Worktree, prune, METH_VARARGS), + {NULL} +}; + +PyGetSetDef Worktree_getseters[] = { + GETTER(Worktree, path), + GETTER(Worktree, name), + GETTER(Worktree, is_prunable), + {NULL} +}; + +PyDoc_STRVAR(Worktree__doc__, "Worktree object."); + +PyTypeObject WorktreeType = { + PyVarObject_HEAD_INIT(NULL, 0) + "_pygit2.Worktree", /* tp_name */ + sizeof(Worktree), /* tp_basicsize */ + 0, /* tp_itemsize */ + (destructor)Worktree_dealloc, /* tp_dealloc */ + 0, /* tp_print */ + 0, /* tp_getattr */ + 0, /* tp_setattr */ + 0, /* tp_compare */ + 0, /* tp_repr */ + 0, /* tp_as_number */ + 0, /* tp_as_sequence */ + 0, /* tp_as_mapping */ + 0, /* tp_hash */ + 0, /* tp_call */ + 0, /* tp_str */ + 0, /* tp_getattro */ + 0, /* tp_setattro */ + 0, /* tp_as_buffer */ + Py_TPFLAGS_DEFAULT, /* tp_flags */ + Worktree__doc__, /* tp_doc */ + 0, /* tp_traverse */ + 0, /* tp_clear */ + 0, /* tp_richcompare */ + 0, /* tp_weaklistoffset */ + 0, /* tp_iter */ + 0, /* tp_iternext */ + Worktree_methods, /* tp_methods */ + 0, /* tp_members */ + Worktree_getseters, /* tp_getset */ + 0, /* tp_base */ + 0, /* tp_dict */ + 0, /* tp_descr_get */ + 0, /* tp_descr_set */ + 0, /* tp_dictoffset */ + 0, /* tp_init */ + 0, /* tp_alloc */ + 0, /* tp_new */ +}; + +PyObject * +wrap_worktree(Repository* repo, git_worktree* wt) +{ + Worktree* py_wt = NULL; + + py_wt = PyObject_New(Worktree, &WorktreeType); + if (py_wt == NULL) { + PyErr_NoMemory(); + return NULL; + } + + py_wt->repo = repo; + Py_INCREF(repo); + py_wt->worktree = wt; + + return (PyObject*) py_wt; +} + + diff --git a/src/worktree.h b/src/worktree.h new file mode 100644 index 000000000..198f7b076 --- /dev/null +++ b/src/worktree.h @@ -0,0 +1,38 @@ +/* + * 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_worktree_h +#define INCLUDE_pygit2_worktree_h + +#define PY_SSIZE_T_CLEAN +#include +#include +#include + +PyObject* wrap_worktree(Repository* repo, git_worktree* wt); + +#endif diff --git a/test/__init__.py b/test/__init__.py index 1cbb37d02..7fb15c4fc 100644 --- a/test/__init__.py +++ b/test/__init__.py @@ -1,6 +1,4 @@ -# -*- coding: UTF-8 -*- -# -# Copyright 2010-2013 The pygit2 contributors +# Copyright 2010-2025 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -25,39 +23,12 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. -"""Pygit2 test definitions. - -These tests are run automatically with 'setup.py test', but can also be run -manually. -""" - -from os import listdir -from os.path import dirname +# Move the current directory to the end of the list of import paths. This way +# we avoid the "ModuleNotFoundError: No module named 'pygit2._pygit2'" error +# when running the test suite in Continuous Integration. +import os import sys -import unittest - - -def test_suite(): - # Sometimes importing pygit2 fails, we try this first to get an - # informative traceback. - import pygit2 - - # Build the list of modules - modules = [] - for name in listdir(dirname(__file__)): - if name.startswith('test_') and name.endswith('.py'): - module = 'test.%s' % name[:-3] - # Check the module imports correctly, have a nice error otherwise - __import__(module) - modules.append(module) - - # Go - return unittest.defaultTestLoader.loadTestsFromNames(modules) - - -def main(): - unittest.main(module=__name__, defaultTest='test_suite', argv=sys.argv[:1]) - -if __name__ == '__main__': - main() +cwd = os.getcwd() +sys.path.remove(cwd) +sys.path.append(cwd) diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 000000000..7b0d4149b --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,104 @@ +import platform +from collections.abc import Generator +from pathlib import Path + +import pytest + +import pygit2 +from pygit2 import Repository + +from . import utils + + +@pytest.fixture(scope='session', autouse=True) +def global_git_config() -> None: + # Do not use global config for better test reproducibility. + # https://github.com/libgit2/pygit2/issues/989 + levels = [ + pygit2.enums.ConfigLevel.GLOBAL, + pygit2.enums.ConfigLevel.XDG, + pygit2.enums.ConfigLevel.SYSTEM, + ] + for level in levels: + pygit2.settings.search_path[level] = '' + + # Fix tests running in Windows + # XXX Still needed now we have moved to GitHub CI? + if platform.system() == 'Windows': + pygit2.option(pygit2.enums.Option.SET_OWNER_VALIDATION, False) + + +@pytest.fixture +def pygit2_empty_key() -> tuple[Path, str, str]: + path = Path(__file__).parent / 'keys' / 'pygit2_empty' + return path, f'{path}.pub', 'empty' + + +@pytest.fixture +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: 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: Path) -> Generator[Repository, None, None]: + with utils.TemporaryRepository('blameflagsrepo.zip', tmp_path) as path: + yield pygit2.Repository(path) + + +@pytest.fixture +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: 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) + yield repo + + +@pytest.fixture +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: Path) -> Generator[Repository, None, None]: + with utils.TemporaryRepository('testrepoformerging.zip', tmp_path) as path: + yield pygit2.Repository(path) + + +@pytest.fixture +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: 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: Path) -> Generator[Repository, None, None]: + with utils.TemporaryRepository('testrepopacked.zip', tmp_path) as path: + yield pygit2.Repository(path) + + +@pytest.fixture +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/data/barerepo.zip b/test/data/barerepo.zip new file mode 100644 index 000000000..ee5fcc3d0 Binary files /dev/null and b/test/data/barerepo.zip differ diff --git a/test/data/binaryfilerepo.zip b/test/data/binaryfilerepo.zip new file mode 100644 index 000000000..55c5738a4 Binary files /dev/null and b/test/data/binaryfilerepo.zip differ diff --git a/test/data/blameflagsrepo.zip b/test/data/blameflagsrepo.zip new file mode 100644 index 000000000..d1251535e Binary files /dev/null and b/test/data/blameflagsrepo.zip differ diff --git a/test/data/dirtyrepo.tar b/test/data/dirtyrepo.tar deleted file mode 100644 index d9ac4425a..000000000 Binary files a/test/data/dirtyrepo.tar and /dev/null differ diff --git a/test/data/dirtyrepo.zip b/test/data/dirtyrepo.zip new file mode 100644 index 000000000..93c0431aa Binary files /dev/null and b/test/data/dirtyrepo.zip differ diff --git a/test/data/emptyrepo.tar b/test/data/emptyrepo.tar deleted file mode 100644 index 20858175b..000000000 Binary files a/test/data/emptyrepo.tar and /dev/null differ diff --git a/test/data/emptyrepo.zip b/test/data/emptyrepo.zip new file mode 100644 index 000000000..d5c3b8298 Binary files /dev/null and b/test/data/emptyrepo.zip differ diff --git a/test/data/encoding.zip b/test/data/encoding.zip new file mode 100644 index 000000000..08212c354 Binary files /dev/null and b/test/data/encoding.zip differ diff --git a/test/data/gpgsigned.zip b/test/data/gpgsigned.zip new file mode 100644 index 000000000..cb61a05b9 Binary files /dev/null and b/test/data/gpgsigned.zip differ diff --git a/test/data/submodulerepo.zip b/test/data/submodulerepo.zip new file mode 100644 index 000000000..fd03533ba Binary files /dev/null and b/test/data/submodulerepo.zip differ diff --git a/test/data/testrepo.git/HEAD b/test/data/testrepo.git/HEAD deleted file mode 100644 index cb089cd89..000000000 --- a/test/data/testrepo.git/HEAD +++ /dev/null @@ -1 +0,0 @@ -ref: refs/heads/master diff --git a/test/data/testrepo.git/config b/test/data/testrepo.git/config deleted file mode 100644 index 6a442c4cb..000000000 --- a/test/data/testrepo.git/config +++ /dev/null @@ -1,7 +0,0 @@ -[core] - repositoryformatversion = 0 - filemode = true - bare = true - editor = 'ed' -[gc] - auto = no diff --git a/test/data/testrepo.git/objects/05/6e626e51b1fc1ee2182800e399ed8d84c8f082 b/test/data/testrepo.git/objects/05/6e626e51b1fc1ee2182800e399ed8d84c8f082 deleted file mode 100644 index 9ec45d6f1..000000000 Binary files a/test/data/testrepo.git/objects/05/6e626e51b1fc1ee2182800e399ed8d84c8f082 and /dev/null differ diff --git a/test/data/testrepo.git/objects/10/2374bdb1e8efca5e66cded18fd8f30571654a5 b/test/data/testrepo.git/objects/10/2374bdb1e8efca5e66cded18fd8f30571654a5 deleted file mode 100644 index 4cb6eb7dd..000000000 Binary files a/test/data/testrepo.git/objects/10/2374bdb1e8efca5e66cded18fd8f30571654a5 and /dev/null differ diff --git a/test/data/testrepo.git/objects/11/19926b06311143cab273f0af84eae77f5b3462 b/test/data/testrepo.git/objects/11/19926b06311143cab273f0af84eae77f5b3462 deleted file mode 100644 index 8ac529e9a..000000000 Binary files a/test/data/testrepo.git/objects/11/19926b06311143cab273f0af84eae77f5b3462 and /dev/null differ diff --git a/test/data/testrepo.git/objects/18/e2d2e9db075f9eb43bcb2daa65a2867d29a15e b/test/data/testrepo.git/objects/18/e2d2e9db075f9eb43bcb2daa65a2867d29a15e deleted file mode 100644 index 4e796d7e7..000000000 Binary files a/test/data/testrepo.git/objects/18/e2d2e9db075f9eb43bcb2daa65a2867d29a15e and /dev/null differ diff --git a/test/data/testrepo.git/objects/19/bf31524643d743751b09cf719456914bbd8bd5 b/test/data/testrepo.git/objects/19/bf31524643d743751b09cf719456914bbd8bd5 deleted file mode 100644 index af11713b3..000000000 Binary files a/test/data/testrepo.git/objects/19/bf31524643d743751b09cf719456914bbd8bd5 and /dev/null differ diff --git a/test/data/testrepo.git/objects/1a/f81f24e48f92009ca02a874edb6151c71f60de b/test/data/testrepo.git/objects/1a/f81f24e48f92009ca02a874edb6151c71f60de deleted file mode 100644 index 46f991f47..000000000 Binary files a/test/data/testrepo.git/objects/1a/f81f24e48f92009ca02a874edb6151c71f60de and /dev/null differ diff --git a/test/data/testrepo.git/objects/29/7efb891a47de80be0cfe9c639e4b8c9b450989 b/test/data/testrepo.git/objects/29/7efb891a47de80be0cfe9c639e4b8c9b450989 deleted file mode 100644 index fed0df435..000000000 Binary files a/test/data/testrepo.git/objects/29/7efb891a47de80be0cfe9c639e4b8c9b450989 and /dev/null differ diff --git a/test/data/testrepo.git/objects/2a/d1d3456c5c4a1c9e40aeeddb9cd20b409623c8 b/test/data/testrepo.git/objects/2a/d1d3456c5c4a1c9e40aeeddb9cd20b409623c8 deleted file mode 100644 index 1c1c60d6d..000000000 Binary files a/test/data/testrepo.git/objects/2a/d1d3456c5c4a1c9e40aeeddb9cd20b409623c8 and /dev/null differ diff --git a/test/data/testrepo.git/objects/2c/dae28389c059815e951d0bb9eed6533f61a46b b/test/data/testrepo.git/objects/2c/dae28389c059815e951d0bb9eed6533f61a46b deleted file mode 100644 index 4210e780f..000000000 --- a/test/data/testrepo.git/objects/2c/dae28389c059815e951d0bb9eed6533f61a46b +++ /dev/null @@ -1,2 +0,0 @@ -xOIj1YA֑ }!д̂ ~~I^[PE<7E:3RMu>D -䋡 scjquV:/ ; %ĈH#V~4]K ^7=={7|B~{\rMQHz])ۧST+ ?.mEZ}s] \ No newline at end of file diff --git a/test/data/testrepo.git/objects/39/a3001fcc2b9541fdcf4be2d662618a5d213f47 b/test/data/testrepo.git/objects/39/a3001fcc2b9541fdcf4be2d662618a5d213f47 deleted file mode 100644 index aa5e5ff1c..000000000 --- a/test/data/testrepo.git/objects/39/a3001fcc2b9541fdcf4be2d662618a5d213f47 +++ /dev/null @@ -1 +0,0 @@ -xO9N1$+:Gz|=B|G{m-s/ RJ6w"p*蝵Q4'CK^b&/NUG1B=4)1)늝g11qBn\AWx9d}[!&FgEϸg` 9p>)cj.4+]̝OW)M]= \ No newline at end of file diff --git a/test/data/testrepo.git/objects/3d/2962987c695a29f1f80b6c3aa4ec046ef44369 b/test/data/testrepo.git/objects/3d/2962987c695a29f1f80b6c3aa4ec046ef44369 deleted file mode 100644 index e57bfbfde..000000000 --- a/test/data/testrepo.git/objects/3d/2962987c695a29f1f80b6c3aa4ec046ef44369 +++ /dev/null @@ -1,2 +0,0 @@ -x-A -0@Q9\m@$T,#ePAH%a:`{Bіl%xؚ=TOnUTvBJ6`P)mA3X]f \ No newline at end of file diff --git a/test/data/testrepo.git/objects/61/4fd9a3094bf618ea938fffc00e7d1a54f89ad0 b/test/data/testrepo.git/objects/61/4fd9a3094bf618ea938fffc00e7d1a54f89ad0 deleted file mode 100644 index c3ba7b074..000000000 Binary files a/test/data/testrepo.git/objects/61/4fd9a3094bf618ea938fffc00e7d1a54f89ad0 and /dev/null differ diff --git a/test/data/testrepo.git/objects/62/cc88a53cfb046fcf603b3aaeb73b8e18215442 b/test/data/testrepo.git/objects/62/cc88a53cfb046fcf603b3aaeb73b8e18215442 deleted file mode 100644 index 8c78013ea..000000000 Binary files a/test/data/testrepo.git/objects/62/cc88a53cfb046fcf603b3aaeb73b8e18215442 and /dev/null differ diff --git a/test/data/testrepo.git/objects/63/59f8019b0954201a807b766547526173f3cc67 b/test/data/testrepo.git/objects/63/59f8019b0954201a807b766547526173f3cc67 deleted file mode 100644 index 970393606..000000000 Binary files a/test/data/testrepo.git/objects/63/59f8019b0954201a807b766547526173f3cc67 and /dev/null differ diff --git a/test/data/testrepo.git/objects/6a/270c81bc80b59591e0d2e3abd7d03450c0c395 b/test/data/testrepo.git/objects/6a/270c81bc80b59591e0d2e3abd7d03450c0c395 deleted file mode 100644 index 7db6959f0..000000000 Binary files a/test/data/testrepo.git/objects/6a/270c81bc80b59591e0d2e3abd7d03450c0c395 and /dev/null differ diff --git a/test/data/testrepo.git/objects/72/abb8755b2cc6c4e40fd9f50f54384d973a2f22 b/test/data/testrepo.git/objects/72/abb8755b2cc6c4e40fd9f50f54384d973a2f22 deleted file mode 100644 index ad41240ec..000000000 Binary files a/test/data/testrepo.git/objects/72/abb8755b2cc6c4e40fd9f50f54384d973a2f22 and /dev/null differ diff --git a/test/data/testrepo.git/objects/77/88019febe4f40259a64c529a9aed561e64ddbd b/test/data/testrepo.git/objects/77/88019febe4f40259a64c529a9aed561e64ddbd deleted file mode 100644 index 189c2d58d..000000000 Binary files a/test/data/testrepo.git/objects/77/88019febe4f40259a64c529a9aed561e64ddbd and /dev/null differ diff --git a/test/data/testrepo.git/objects/78/4855caf26449a1914d2cf62d12b9374d76ae78 b/test/data/testrepo.git/objects/78/4855caf26449a1914d2cf62d12b9374d76ae78 deleted file mode 100644 index 69cb9de4a..000000000 Binary files a/test/data/testrepo.git/objects/78/4855caf26449a1914d2cf62d12b9374d76ae78 and /dev/null differ diff --git a/test/data/testrepo.git/objects/7f/129fd57e31e935c6d60a0c794efe4e6927664b b/test/data/testrepo.git/objects/7f/129fd57e31e935c6d60a0c794efe4e6927664b deleted file mode 100644 index 1abe9e834..000000000 Binary files a/test/data/testrepo.git/objects/7f/129fd57e31e935c6d60a0c794efe4e6927664b and /dev/null differ diff --git a/test/data/testrepo.git/objects/96/7fce8df97cc71722d3c2a5930ef3e6f1d27b12 b/test/data/testrepo.git/objects/96/7fce8df97cc71722d3c2a5930ef3e6f1d27b12 deleted file mode 100644 index 2070adf43..000000000 Binary files a/test/data/testrepo.git/objects/96/7fce8df97cc71722d3c2a5930ef3e6f1d27b12 and /dev/null differ diff --git a/test/data/testrepo.git/objects/97/d615e1bc273c40c94a726814e7b93fdb5a1b36 b/test/data/testrepo.git/objects/97/d615e1bc273c40c94a726814e7b93fdb5a1b36 deleted file mode 100644 index 2a7331e8d..000000000 Binary files a/test/data/testrepo.git/objects/97/d615e1bc273c40c94a726814e7b93fdb5a1b36 and /dev/null differ diff --git a/test/data/testrepo.git/objects/a0/75f5a7394b0838a9f54dfc511e1a3fbbb3b973 b/test/data/testrepo.git/objects/a0/75f5a7394b0838a9f54dfc511e1a3fbbb3b973 deleted file mode 100644 index 87fe312e0..000000000 Binary files a/test/data/testrepo.git/objects/a0/75f5a7394b0838a9f54dfc511e1a3fbbb3b973 and /dev/null differ diff --git a/test/data/testrepo.git/objects/ab/533997b80705767be3dae8cbb06a0740809f79 b/test/data/testrepo.git/objects/ab/533997b80705767be3dae8cbb06a0740809f79 deleted file mode 100644 index d372df8ce..000000000 Binary files a/test/data/testrepo.git/objects/ab/533997b80705767be3dae8cbb06a0740809f79 and /dev/null differ diff --git a/test/data/testrepo.git/objects/c2/c2f6b06efdb6c1e4b1337811f0629fc0cadbd1 b/test/data/testrepo.git/objects/c2/c2f6b06efdb6c1e4b1337811f0629fc0cadbd1 deleted file mode 100644 index c3fb3a060..000000000 Binary files a/test/data/testrepo.git/objects/c2/c2f6b06efdb6c1e4b1337811f0629fc0cadbd1 and /dev/null differ diff --git a/test/data/testrepo.git/objects/cc/ca47fbb26183e71a7a46d165299b84e2e6c0b3 b/test/data/testrepo.git/objects/cc/ca47fbb26183e71a7a46d165299b84e2e6c0b3 deleted file mode 100644 index bed3d1e9f..000000000 --- a/test/data/testrepo.git/objects/cc/ca47fbb26183e71a7a46d165299b84e2e6c0b3 +++ /dev/null @@ -1,2 +0,0 @@ -x[j0DUB#%(%[[ -Iwп9ҷ> a"I֓咭06Ilb㡮b.}Iވ9ss0|YXN[}ZmS \ No newline at end of file diff --git a/test/data/testrepo.git/objects/d8/79714d880671ed84f8aaed8b27fca23ba01f27 b/test/data/testrepo.git/objects/d8/79714d880671ed84f8aaed8b27fca23ba01f27 deleted file mode 100644 index 6b4186cf6..000000000 Binary files a/test/data/testrepo.git/objects/d8/79714d880671ed84f8aaed8b27fca23ba01f27 and /dev/null differ diff --git a/test/data/testrepo.git/objects/f5/e5aa4e36ab0fe62ee1ccc6eb8f79b866863b87 b/test/data/testrepo.git/objects/f5/e5aa4e36ab0fe62ee1ccc6eb8f79b866863b87 deleted file mode 100644 index 5f611748f..000000000 Binary files a/test/data/testrepo.git/objects/f5/e5aa4e36ab0fe62ee1ccc6eb8f79b866863b87 and /dev/null differ diff --git a/test/data/testrepo.git/objects/info/packs b/test/data/testrepo.git/objects/info/packs deleted file mode 100644 index e6cd1f1df..000000000 --- a/test/data/testrepo.git/objects/info/packs +++ /dev/null @@ -1,2 +0,0 @@ -P pack-822653eb59791a6df714f8aa5fbf9f1c1951478e.pack - diff --git a/test/data/testrepo.git/objects/pack/pack-822653eb59791a6df714f8aa5fbf9f1c1951478e.idx b/test/data/testrepo.git/objects/pack/pack-822653eb59791a6df714f8aa5fbf9f1c1951478e.idx deleted file mode 100644 index e2e1819d5..000000000 Binary files a/test/data/testrepo.git/objects/pack/pack-822653eb59791a6df714f8aa5fbf9f1c1951478e.idx and /dev/null differ diff --git a/test/data/testrepo.git/objects/pack/pack-822653eb59791a6df714f8aa5fbf9f1c1951478e.pack b/test/data/testrepo.git/objects/pack/pack-822653eb59791a6df714f8aa5fbf9f1c1951478e.pack deleted file mode 100644 index a9d53076c..000000000 Binary files a/test/data/testrepo.git/objects/pack/pack-822653eb59791a6df714f8aa5fbf9f1c1951478e.pack and /dev/null differ diff --git a/test/data/testrepo.git/packed-refs b/test/data/testrepo.git/packed-refs deleted file mode 100644 index 506a416ff..000000000 --- a/test/data/testrepo.git/packed-refs +++ /dev/null @@ -1,2 +0,0 @@ -# pack-refs with: peeled -c2792cfa289ae6321ecf2cd5806c2194b0fd070c refs/heads/master diff --git a/test/data/testrepo.git/refs/heads/master b/test/data/testrepo.git/refs/heads/master deleted file mode 100644 index 436950fb2..000000000 --- a/test/data/testrepo.git/refs/heads/master +++ /dev/null @@ -1 +0,0 @@ -784855caf26449a1914d2cf62d12b9374d76ae78 diff --git a/test/data/testrepo.git/refs/notes/commits b/test/data/testrepo.git/refs/notes/commits deleted file mode 100644 index 54f0c9aea..000000000 --- a/test/data/testrepo.git/refs/notes/commits +++ /dev/null @@ -1 +0,0 @@ -a075f5a7394b0838a9f54dfc511e1a3fbbb3b973 diff --git a/test/data/testrepo.git/refs/tags/root b/test/data/testrepo.git/refs/tags/root deleted file mode 100644 index 3a5c17a76..000000000 --- a/test/data/testrepo.git/refs/tags/root +++ /dev/null @@ -1 +0,0 @@ -3d2962987c695a29f1f80b6c3aa4ec046ef44369 diff --git a/test/data/testrepo.tar b/test/data/testrepo.tar deleted file mode 100644 index 9c451b2f2..000000000 Binary files a/test/data/testrepo.tar and /dev/null differ diff --git a/test/data/testrepo.zip b/test/data/testrepo.zip new file mode 100644 index 000000000..f7b8c135a Binary files /dev/null and b/test/data/testrepo.zip differ diff --git a/test/data/testrepoformerging.zip b/test/data/testrepoformerging.zip new file mode 100644 index 000000000..c6b5ff4c0 Binary files /dev/null and b/test/data/testrepoformerging.zip differ diff --git a/test/data/testrepopacked.zip b/test/data/testrepopacked.zip new file mode 100644 index 000000000..34d6e239a Binary files /dev/null and b/test/data/testrepopacked.zip differ diff --git a/test/data/trailerrepo.zip b/test/data/trailerrepo.zip new file mode 100644 index 000000000..5878a4711 Binary files /dev/null and b/test/data/trailerrepo.zip differ diff --git a/test/data/utf8branchrepo.zip b/test/data/utf8branchrepo.zip new file mode 100644 index 000000000..533041a0b Binary files /dev/null and b/test/data/utf8branchrepo.zip differ diff --git a/test/keys/pygit2_empty b/test/keys/pygit2_empty new file mode 100644 index 000000000..3333da96a --- /dev/null +++ b/test/keys/pygit2_empty @@ -0,0 +1,39 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABCcT0eRuC +NRvnDkorGSaQqaAAAAEAAAAAEAAAGXAAAAB3NzaC1yc2EAAAADAQABAAABgQC5bu0UuVdG +eimAo2mu87uy/hWCbvie6N8H4a7ZjnSThpDIv0clcnbycCukDb/d8o9FRIPNNp2astBlWp +nZ9vCHByP8z8ITO/pG5Mu0Wp0n1Hrja5UVdk4cBvvoEcr2sdNqj3wUUhqeAnNCYeeJuW9V +j2JNV2wwJYPAZBDi6GJTM9FPlCJz+QA6dJLkUiIwngrL8bSPzXd+1mzHOrIcqjxKm8J+ed +3tVSlRq7YvefrACUOTI9ymXwYr8a/TiwA76nTHvNPYIhmzH8PEF65QsFTs/qk0nqSwmqKA +vineapBMmrb3Vf/Hd/c0nEMgHt6gy/+dtlgPh1bTqaQaS/oeo5OtjMDK/w93SR9M3UZBX+ +eeMUGfyhvYT4Nvo3TumKzaZ6peGu1T0MKprSwgRdI+v1q+58sKfzSK5QevTpKiX1+4Leo6 +BiNKKu6doD19fgNSD/dwfOWehxFtD8q/1J5k0QPgqslFkqyZJXRCzzowRYunSN+AaHVD3W +o4AuqtfTiazPMAAAWQVrRkwWjO1Fcw7zebagqfBufB05nc08wL911ZPCVwqVSIepcEK/hM +CJ/5/N+UILn9BXGe9qmOHPUuMa9UaLBSyzmlJ1s/NMGLzYWiv62SX1QNEXPegxwLasQvbL +njjzdESGX+qUHxT4okNH52zi4DcBLX4HPL/TYQsKTNxCOclOljPDo+3IfHzx76yG5dAl5L +C7ghLsd1zxpwZI+ag7NhNzZ4hBxX9JUenAfGyuOL+YCTp8JnU+dXJ3XaA3WAVGnvsZlAaq +GJUGCdLlMiacO0eXNTm53xc92X9tPmetEVwhuD/Af7Vc4dOmH9Zu+7n9z9bLPrOowNr7ue +w8YCqCg83iuQYmSSPj/JTvCzaoGDfW+yjALlb5RJUAIMJ51k0WyVIyqS0TE8+EINKETlj7 +iIx1Y5z54ZnldlqrD2vLImO2b401oOb7fJUEU9Ke5NPi93tsps8nYKhatcRYLnLq9gsFv9 +YlDCueoJJobg1k9TO+IwxraPgz3jl24zskSKT/tLFvsz0fQM5QWha2vB8kyZI067ojuNLb +/mj5itgLIDmISa9cf98HhafeE8kGAtKEJR2rLwvb79EAhZ2ypt2I8LVur5hCM4cC9vSVyS +dq/f4sgQpyQqSByMXeLEFYJSCDDc/PL3RC0Q9PqrQYZ1+pqj/6esV3ujLogMAHqEuW4EVw +tMDUvjzfnC8MVUQpc5H4yonsWjGeGhH+VEkBSVISpABTSrYFN5kBodPD16wmRTbFF4tTQq +Egmj5vUmxSY+a2EjDJREQBerMhj3W5sPhAB1QGVVn5kyFvmsjM4t06zzZj/R5muIcX0cT+ +Th3N+xeYIuVi9kS5v7yOBlMk0KGq8QATSL/u+niO0e0neoT5Jv6E7EIafAFrn3Ji0rNave +ObCqse3yZct0pbspM4f0c9mHaVbzmvwwtjmUFGdMJgse0UARXqvOlF9PUaN/AhqQlIyVjj +ednPLrOz617XDSixiP+tKzKmqjZsBASZzpGwpHKii9/k7Q7aG5/Int8ulBS3H8C6ipMSxW +EKSMJ4g6k33RY1EFL3dWtJYWhReAhY6kvIc3lmSeo7I9SQWXLupx0NUnkXeO63hLmJ9tjD +CXeI0cwb2a6DWKLh6c2gQ5LPNb/8xzvYJfdop2Oxr+9L2NP7pIgvYr/cmgPtF5WkLT2Ypk +z+KgwWxUKRiK/3G+dVe27u0Id7Yi596wnNGxJfZmlnbfioY4i+K9KcyS08LxlmgsIsQHiY +Scv6SuamPdjiHdSwK/GuAcQUVwXQRA7DoV2uxOosAaUXWMiiSjJ3n1L8IVgp17OKxVN0Bd +5phre4VhYFoXGnq43xFAY3XQJctBqLPdb47RNi3JlhVK+Q1WKzK9OWbDoiseoNnMD5NXOt +Wqf/vxD6AJEyO8sOT55l6hZAkNHIfFUGx4MNmLl12hJYSZgY9tx7aizz8RMT6GMBammQcU +Q0pNDF1RBFOtxgb/QE+9/Vym4dMGnJrhhdbcYZbKngcsho4Qs39qMQvv0V23zAExreQH8U +TBTZYyYkiPqdUiB2fNCW89QWksvBe3CXZAC0T0tdBcEYe5UPJRQ/K2FS6bJTYmxDkDWzHD +9iHbiu3Z8JGB9kHT6B5AgM+fYgEhpCgieDEHdF85cXtGSt8rjFFW6SMS70aLkgzFpYVeD0 +zgzumI6JRY3vSMpUY60NCz+FOmIxy7oIpv7nDf/6Ubvah/heUF/P6IQwOQXumVMK9/Khqx +j5TxaRCZ7fXV7IXH1hjQgWSZkGNUHc+rEAZdPOYFXlQb/2+DkO8IE7SxSWwk8tDzS0L7+H +hWXgIm7mjIB6HDNfRb2zPL7gOgm83qZfrhSdP76XqnuV1LvvZMIs2dC8lKFfLk6oayzUvQ +z5AMR0EutUSCby7+DKyBmaYSq0s= +-----END OPENSSH PRIVATE KEY----- diff --git a/test/keys/pygit2_empty.pub b/test/keys/pygit2_empty.pub new file mode 100644 index 000000000..8db2e0019 --- /dev/null +++ b/test/keys/pygit2_empty.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC5bu0UuVdGeimAo2mu87uy/hWCbvie6N8H4a7ZjnSThpDIv0clcnbycCukDb/d8o9FRIPNNp2astBlWpnZ9vCHByP8z8ITO/pG5Mu0Wp0n1Hrja5UVdk4cBvvoEcr2sdNqj3wUUhqeAnNCYeeJuW9Vj2JNV2wwJYPAZBDi6GJTM9FPlCJz+QA6dJLkUiIwngrL8bSPzXd+1mzHOrIcqjxKm8J+ed3tVSlRq7YvefrACUOTI9ymXwYr8a/TiwA76nTHvNPYIhmzH8PEF65QsFTs/qk0nqSwmqKAvineapBMmrb3Vf/Hd/c0nEMgHt6gy/+dtlgPh1bTqaQaS/oeo5OtjMDK/w93SR9M3UZBX+eeMUGfyhvYT4Nvo3TumKzaZ6peGu1T0MKprSwgRdI+v1q+58sKfzSK5QevTpKiX1+4Leo6BiNKKu6doD19fgNSD/dwfOWehxFtD8q/1J5k0QPgqslFkqyZJXRCzzowRYunSN+AaHVD3Wo4AuqtfTiazPM= pygit2_empty diff --git a/test/test_apply_diff.py b/test/test_apply_diff.py new file mode 100644 index 000000000..9277d7eb1 --- /dev/null +++ b/test/test_apply_diff.py @@ -0,0 +1,182 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +import os +from pathlib import Path + +import pytest + +import pygit2 +from pygit2 import Diff, Repository +from pygit2.enums import ApplyLocation, CheckoutStrategy, FileStatus + + +def read_content(testrepo: Repository) -> str: + with (Path(testrepo.workdir) / 'hello.txt').open('rb') as f: + return f.read().decode('utf-8') + + +@pytest.fixture +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: Repository) -> str: + with (Path(testrepo.workdir) / 'hello.txt').open('rb') as f: + return f.read().decode('utf-8') + + +@pytest.fixture +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) + + # Return the diff + return pygit2.Diff.parse_diff(patch) + + +@pytest.fixture +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 ++++ b/this_file_does_not_exist +@@ -1 +1 @@ +-a contents 2 ++a contents +""" + return pygit2.Diff.parse_diff(patch_contents) + + +def test_apply_type_error(testrepo: Repository) -> None: + # Check apply type error + with pytest.raises(TypeError): + testrepo.apply('HEAD') # type: ignore + + +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) + + assert read_content(testrepo) == new_content + assert testrepo.status_file('hello.txt') == FileStatus.WT_MODIFIED + + +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) + + assert read_content(testrepo) == old_content + assert testrepo.status_file('hello.txt') & FileStatus.INDEX_MODIFIED + + +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) + + assert read_content(testrepo) == new_content + assert testrepo.status_file('hello.txt') & FileStatus.INDEX_MODIFIED + + +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) + + # Ensure it was a dry run + assert read_content(testrepo) == old_content + + # Apply patch for real, then ensure it can't be applied again + testrepo.apply(patch_diff, ApplyLocation.WORKDIR) + assert not testrepo.applies(patch_diff, ApplyLocation.WORKDIR) + + # It can still be applied to the index, though + assert testrepo.applies(patch_diff, ApplyLocation.INDEX) + + +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) + + # Ensure it was a dry run + assert read_content(testrepo) == old_content + + # Apply patch for real, then ensure it can't be applied again + testrepo.apply(patch_diff, ApplyLocation.INDEX) + assert not testrepo.applies(patch_diff, ApplyLocation.INDEX) + + # It can still be applied to the workdir, though + assert testrepo.applies(patch_diff, ApplyLocation.WORKDIR) + + +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) + + # Ensure it was a dry run + assert read_content(testrepo) == old_content + + # Apply patch for real, then ensure it can't be applied again + testrepo.apply(patch_diff, ApplyLocation.BOTH) + assert not testrepo.applies(patch_diff, ApplyLocation.BOTH) + assert not testrepo.applies(patch_diff, ApplyLocation.WORKDIR) + assert not testrepo.applies(patch_diff, ApplyLocation.INDEX) + + +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): + testrepo.applies(foreign_patch_diff, ApplyLocation.BOTH, raise_error=True) + + # Apply a valid patch + testrepo.apply(patch_diff, ApplyLocation.BOTH) + + # Ensure it can't be applied again and we get an exception about it (due to raise_error) + with pytest.raises(pygit2.GitError): + testrepo.applies(patch_diff, ApplyLocation.BOTH, raise_error=True) diff --git a/test/test_archive.py b/test/test_archive.py new file mode 100644 index 000000000..bd8ef8644 --- /dev/null +++ b/test/test_archive.py @@ -0,0 +1,69 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +import tarfile +from pathlib import Path + +from pygit2 import Index, Object, Oid, Repository, Tree + +TREE_HASH = 'fd937514cb799514d4b81bb24c5fcfeb6472b245' +COMMIT_HASH = '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98' + + +def check_writing( + repo: Repository, treeish: str | Tree | Oid | Object, timestamp: int | None = None +) -> None: + archive = tarfile.open('foo.tar', mode='w') + repo.write_archive(treeish, archive) + + index = Index() + if isinstance(treeish, Object): + index.read_tree(treeish.peel(Tree)) + else: + index.read_tree(repo[treeish].peel(Tree)) + + assert len(index) == len(archive.getmembers()) + + if timestamp: + fileinfo = archive.getmembers()[0] + assert timestamp == fileinfo.mtime + + archive.close() + path = Path('foo.tar') + assert path.is_file() + path.unlink() + + +def test_write_tree(testrepo: Repository) -> None: + check_writing(testrepo, TREE_HASH) + check_writing(testrepo, Oid(hex=TREE_HASH)) + check_writing(testrepo, testrepo[TREE_HASH]) + + +def test_write_commit(testrepo: Repository) -> None: + commit_timestamp = testrepo[COMMIT_HASH].committer.time + check_writing(testrepo, COMMIT_HASH, commit_timestamp) + check_writing(testrepo, Oid(hex=COMMIT_HASH), commit_timestamp) + check_writing(testrepo, testrepo[COMMIT_HASH], commit_timestamp) diff --git a/test/test_attributes.py b/test/test_attributes.py new file mode 100644 index 000000000..12f9106ba --- /dev/null +++ b/test/test_attributes.py @@ -0,0 +1,50 @@ +# 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. + +# Standard Library +from pathlib import Path + +from pygit2 import Repository + + +def test_no_attr(testrepo: Repository) -> None: + assert testrepo.get_attr('file', 'foo') is None + + with (Path(testrepo.workdir) / '.gitattributes').open('w+') as f: + print('*.py text\n', file=f) + print('*.jpg -text\n', file=f) + print('*.sh eol=lf\n', file=f) + + assert testrepo.get_attr('file.py', 'foo') is None + assert testrepo.get_attr('file.py', 'text') + assert not testrepo.get_attr('file.jpg', 'text') + assert 'lf' == testrepo.get_attr('file.sh', 'eol') + + +def test_no_attr_aspath(testrepo: Repository) -> None: + with (Path(testrepo.workdir) / '.gitattributes').open('w+') as f: + print('*.py text\n', file=f) + + assert testrepo.get_attr(Path('file.py'), 'text') diff --git a/test/test_blame.py b/test/test_blame.py new file mode 100644 index 000000000..cb1223954 --- /dev/null +++ b/test/test_blame.py @@ -0,0 +1,157 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +"""Tests for Blame objects.""" + +import pytest + +from pygit2 import Oid, Repository, Signature +from pygit2.enums import BlameFlag + +PATH = 'hello.txt' + +HUNKS = [ + ( + Oid(hex='acecd5ea2924a4b900e7e149496e1f4b57976e51'), + 1, + Signature( + 'J. David Ibañez', 'jdavid@itaapy.com', 1297179898, 60, encoding='utf-8' + ), + True, + ), + ( + Oid(hex='6aaa262e655dd54252e5813c8e5acd7780ed097d'), + 2, + Signature( + 'J. David Ibañez', 'jdavid@itaapy.com', 1297696877, 60, encoding='utf-8' + ), + False, + ), + ( + Oid(hex='4ec4389a8068641da2d6578db0419484972284c8'), + 3, + Signature( + 'J. David Ibañez', 'jdavid@itaapy.com', 1297696908, 60, encoding='utf-8' + ), + False, + ), +] + + +def test_blame_index(testrepo: Repository) -> None: + blame = testrepo.blame(PATH) + + assert len(blame) == 3 + + for i, hunk in enumerate(blame): + assert hunk.lines_in_hunk == 1 + assert HUNKS[i][0] == hunk.final_commit_id + assert HUNKS[i][1] == hunk.final_start_line_number + assert HUNKS[i][2] == hunk.final_committer + assert HUNKS[i][0] == hunk.orig_commit_id + assert hunk.orig_path == PATH + assert HUNKS[i][1] == hunk.orig_start_line_number + assert HUNKS[i][2] == hunk.orig_committer + assert HUNKS[i][3] == hunk.boundary + + +def test_blame_flags(blameflagsrepo: Repository) -> None: + blame = blameflagsrepo.blame(PATH, flags=BlameFlag.IGNORE_WHITESPACE) + + assert len(blame) == 3 + + for i, hunk in enumerate(blame): + assert hunk.lines_in_hunk == 1 + assert HUNKS[i][0] == hunk.final_commit_id + assert HUNKS[i][1] == hunk.final_start_line_number + assert HUNKS[i][2] == hunk.final_committer + assert HUNKS[i][0] == hunk.orig_commit_id + assert hunk.orig_path == PATH + assert HUNKS[i][1] == hunk.orig_start_line_number + assert HUNKS[i][2] == hunk.orig_committer + assert HUNKS[i][3] == hunk.boundary + + +def test_blame_with_invalid_index(testrepo: Repository) -> None: + blame = testrepo.blame(PATH) + + with pytest.raises(IndexError): + blame[100000] + + with pytest.raises(OverflowError): + blame[-1] + + +def test_blame_for_line(testrepo: Repository) -> None: + blame = testrepo.blame(PATH) + + for i, line in zip(range(0, 2), range(1, 3)): + hunk = blame.for_line(line) + + assert hunk.lines_in_hunk == 1 + assert HUNKS[i][0] == hunk.final_commit_id + assert HUNKS[i][1] == hunk.final_start_line_number + assert HUNKS[i][2] == hunk.final_committer + assert HUNKS[i][0] == hunk.orig_commit_id + assert hunk.orig_path == PATH + assert HUNKS[i][1] == hunk.orig_start_line_number + assert HUNKS[i][2] == hunk.orig_committer + assert HUNKS[i][3] == hunk.boundary + + +def test_blame_with_invalid_line(testrepo: Repository) -> None: + blame = testrepo.blame(PATH) + + with pytest.raises(IndexError): + blame.for_line(0) + with pytest.raises(IndexError): + blame.for_line(100000) + with pytest.raises(IndexError): + blame.for_line(-1) + + +def test_blame_newest(testrepo: Repository) -> None: + revs = [ + ('master^2', 3), + ('master^2^', 2), + ('master^2^^', 1), + ] + + for rev, num_commits in revs: + commit = testrepo.revparse_single(rev) + blame = testrepo.blame(PATH, newest_commit=commit.id) + + assert len(blame) == num_commits + + for i, hunk in enumerate(tuple(blame)[:num_commits]): + assert hunk.lines_in_hunk == 1 + assert HUNKS[i][0] == hunk.final_commit_id + assert HUNKS[i][1] == hunk.final_start_line_number + assert HUNKS[i][2] == hunk.final_committer + assert HUNKS[i][0] == hunk.orig_commit_id + assert hunk.orig_path == PATH + assert HUNKS[i][1] == hunk.orig_start_line_number + assert HUNKS[i][2] == hunk.orig_committer + assert HUNKS[i][3] == hunk.boundary diff --git a/test/test_blob.py b/test/test_blob.py index 8105455f7..dcce71f4c 100644 --- a/test/test_blob.py +++ b/test/test_blob.py @@ -1,6 +1,4 @@ -# -*- coding: UTF-8 -*- -# -# Copyright 2010-2013 The pygit2 contributors +# Copyright 2010-2025 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -27,14 +25,18 @@ """Tests for Blob objects.""" -from __future__ import absolute_import -from __future__ import unicode_literals -from os.path import dirname, join -import unittest +import io +from pathlib import Path +from queue import Queue +from threading import Event + +import pytest import pygit2 -from . import utils +from pygit2 import Repository +from pygit2.enums import ObjectType +from . import utils BLOB_SHA = 'a520c24d85fbfc815d385957eed41406ca5a860b' BLOB_CONTENT = """hello world @@ -44,66 +46,223 @@ BLOB_NEW_CONTENT = b'foo bar\n' BLOB_FILE_CONTENT = b'bye world\n' +BLOB_PATCH = r"""diff --git a/file b/file +index a520c24..95d09f2 100644 +--- a/file ++++ b/file +@@ -1,3 +1 @@ +-hello world +-hola mundo +-bonjour le monde ++hello world +\ No newline at end of file +""" + +BLOB_PATCH_2 = """diff --git a/file b/file +index a520c24..d675fa4 100644 +--- a/file ++++ b/file +@@ -1,3 +1 @@ +-hello world +-hola mundo +-bonjour le monde ++foo bar +""" + +BLOB_PATCH_DELETED = """diff --git a/file b/file +deleted file mode 100644 +index a520c24..0000000 +--- a/file ++++ /dev/null +@@ -1,3 +0,0 @@ +-hello world +-hola mundo +-bonjour le monde +""" + + +def test_read_blob(testrepo: Repository) -> None: + blob = testrepo[BLOB_SHA] + assert blob.id == BLOB_SHA + assert blob.id == BLOB_SHA + assert isinstance(blob, pygit2.Blob) + assert not blob.is_binary + assert ObjectType.BLOB == blob.type + assert BLOB_CONTENT == blob.data + assert len(BLOB_CONTENT) == blob.size + assert BLOB_CONTENT == blob.read_raw() + + +def test_create_blob(testrepo: Repository) -> None: + blob_oid = testrepo.create_blob(BLOB_NEW_CONTENT) + blob = testrepo[blob_oid] + + assert isinstance(blob, pygit2.Blob) + assert ObjectType.BLOB == blob.type + + assert blob_oid == blob.id + assert utils.gen_blob_sha1(BLOB_NEW_CONTENT) == blob_oid + + assert BLOB_NEW_CONTENT == blob.data + assert len(BLOB_NEW_CONTENT) == blob.size + assert BLOB_NEW_CONTENT == blob.read_raw() + blob_buffer = memoryview(blob) + assert len(BLOB_NEW_CONTENT) == len(blob_buffer) + assert BLOB_NEW_CONTENT == blob_buffer + + def set_content() -> None: + blob_buffer[:2] = b'hi' + + with pytest.raises(TypeError): + set_content() + + +def test_create_blob_fromworkdir(testrepo: Repository) -> None: + blob_oid = testrepo.create_blob_fromworkdir('bye.txt') + blob = testrepo[blob_oid] + + assert isinstance(blob, pygit2.Blob) + assert ObjectType.BLOB == blob.type + + assert blob_oid == blob.id + assert utils.gen_blob_sha1(BLOB_FILE_CONTENT) == blob_oid + + assert BLOB_FILE_CONTENT == blob.data + assert len(BLOB_FILE_CONTENT) == blob.size + assert BLOB_FILE_CONTENT == blob.read_raw() + + +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: Repository) -> None: + with pytest.raises(KeyError): + testrepo.create_blob_fromworkdir(__file__) + + +def test_create_blob_fromdisk(testrepo: Repository) -> None: + blob_oid = testrepo.create_blob_fromdisk(__file__) + blob = testrepo[blob_oid] + + assert isinstance(blob, pygit2.Blob) + assert ObjectType.BLOB == blob.type + + +def test_create_blob_fromiobase(testrepo: Repository) -> None: + with pytest.raises(TypeError): + testrepo.create_blob_fromiobase('bad type') # type: ignore + + f = io.BytesIO(BLOB_CONTENT) + blob_oid = testrepo.create_blob_fromiobase(f) + blob = testrepo[blob_oid] + + assert isinstance(blob, pygit2.Blob) + assert ObjectType.BLOB == blob.type + + assert blob_oid == blob.id + assert BLOB_SHA == blob_oid + + +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: 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: Repository) -> None: + blob = testrepo[BLOB_SHA] + assert isinstance(blob, pygit2.Blob) + patch = blob.diff_to_buffer('hello world') + assert patch.text == BLOB_PATCH -class BlobTest(utils.RepoTestCase): - def test_read_blob(self): - blob = self.repo[BLOB_SHA] - self.assertEqual(blob.hex, BLOB_SHA) - sha = blob.oid.hex - self.assertEqual(sha, BLOB_SHA) - self.assertTrue(isinstance(blob, pygit2.Blob)) - self.assertEqual(pygit2.GIT_OBJ_BLOB, blob.type) - self.assertEqual(BLOB_CONTENT, blob.data) - self.assertEqual(len(BLOB_CONTENT), blob.size) - self.assertEqual(BLOB_CONTENT, blob.read_raw()) +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_create_blob(self): - blob_oid = self.repo.create_blob(BLOB_NEW_CONTENT) - blob = self.repo[blob_oid] - self.assertTrue(isinstance(blob, pygit2.Blob)) - self.assertEqual(pygit2.GIT_OBJ_BLOB, blob.type) +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) - self.assertEqual(blob_oid, blob.oid) - self.assertEqual( - utils.gen_blob_sha1(BLOB_NEW_CONTENT), - blob_oid.hex) + patch = old.diff(new) + assert patch.text == BLOB_PATCH_2 - self.assertEqual(BLOB_NEW_CONTENT, blob.data) - self.assertEqual(len(BLOB_NEW_CONTENT), blob.size) - self.assertEqual(BLOB_NEW_CONTENT, blob.read_raw()) - def test_create_blob_fromworkdir(self): +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_oid = self.repo.create_blob_fromworkdir("bye.txt") - blob = self.repo[blob_oid] + blob = testrepo[BLOB_SHA] + assert isinstance(blob, pygit2.Blob) + patch_two = blob.diff_to_buffer(None) - self.assertTrue(isinstance(blob, pygit2.Blob)) - self.assertEqual(pygit2.GIT_OBJ_BLOB, blob.type) + assert patch_one.text == patch_two.text - self.assertEqual(blob_oid, blob.oid) - self.assertEqual( - utils.gen_blob_sha1(BLOB_FILE_CONTENT), - blob_oid.hex) - self.assertEqual(BLOB_FILE_CONTENT, blob.data) - self.assertEqual(len(BLOB_FILE_CONTENT), blob.size) - self.assertEqual(BLOB_FILE_CONTENT, blob.read_raw()) +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() + chunks = [] + while not queue.empty(): + chunks.append(queue.get()) + assert BLOB_CONTENT == b''.join(chunks) - def test_create_blob_outside_workdir(self): - path = join(dirname(__file__), 'data', self.repo_dir + '.tar') - self.assertRaises(KeyError, self.repo.create_blob_fromworkdir, path) +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() + chunks = [] + while not queue.empty(): + chunks.append(queue.get()) + assert b'bye world\n' == b''.join(chunks) - def test_create_blob_fromdisk(self): - path = join(dirname(__file__), 'data', self.repo_dir + '.tar') - blob_oid = self.repo.create_blob_fromdisk(path) - blob = self.repo[blob_oid] +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() # type: ignore[attr-defined] - self.assertTrue(isinstance(blob, pygit2.Blob)) - self.assertEqual(pygit2.GIT_OBJ_BLOB, blob.type) -if __name__ == '__main__': - unittest.main() +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() # type: ignore[attr-defined] diff --git a/test/test_branch.py b/test/test_branch.py index 8dc62cb1a..63923cd66 100644 --- a/test/test_branch.py +++ b/test/test_branch.py @@ -1,6 +1,4 @@ -# -*- coding: UTF-8 -*- -# -# Copyright 2010-2013 The pygit2 contributors +# Copyright 2010-2025 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -27,153 +25,246 @@ """Tests for branch methods.""" -from __future__ import absolute_import -from __future__ import unicode_literals -import unittest +import os + +import pytest import pygit2 -from . import utils +from pygit2 import Commit, Repository +from pygit2.enums import BranchType LAST_COMMIT = '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98' I18N_LAST_COMMIT = '5470a671a80ac3789f1a6a8cefbcf43ce7af0563' ORIGIN_MASTER_COMMIT = '784855caf26449a1914d2cf62d12b9374d76ae78' +EXCLUSIVE_MASTER_COMMIT = '5ebeeebb320790caf276b9fc8b24546d63316533' +SHARED_COMMIT = '4ec4389a8068641da2d6578db0419484972284c8' -class BranchesTestCase(utils.RepoTestCase): - def test_lookup_branch_local(self): - branch = self.repo.lookup_branch('master') - self.assertEqual(branch.target.hex, LAST_COMMIT) +def test_branches_getitem(testrepo: Repository) -> None: + branch = testrepo.branches['master'] + assert branch.target == LAST_COMMIT - branch = self.repo.lookup_branch('i18n', pygit2.GIT_BRANCH_LOCAL) - self.assertEqual(branch.target.hex, I18N_LAST_COMMIT) + branch = testrepo.branches.local['i18n'] + assert branch.target == I18N_LAST_COMMIT + assert testrepo.branches.get('not-exists') is None + with pytest.raises(KeyError): + testrepo.branches['not-exists'] - self.assertTrue(self.repo.lookup_branch('not-exists') is None) - def test_listall_branches(self): - branches = sorted(self.repo.listall_branches()) - self.assertEqual(branches, ['i18n', 'master']) +def test_branches(testrepo: Repository) -> None: + branches = sorted(testrepo.branches) + assert branches == ['i18n', 'master'] - def test_create_branch(self): - commit = self.repo[LAST_COMMIT] - reference = self.repo.create_branch('version1', commit) - refs = self.repo.listall_branches() - self.assertTrue('version1' in refs) - reference = self.repo.lookup_branch('version1') - self.assertEqual(reference.target.hex, LAST_COMMIT) - # try to create existing reference - self.assertRaises(ValueError, - lambda: self.repo.create_branch('version1', commit)) +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'] + assert reference.target == LAST_COMMIT - # try to create existing reference with force - reference = self.repo.create_branch('version1', commit, True) - self.assertEqual(reference.target.hex, LAST_COMMIT) + # try to create existing reference + with pytest.raises(ValueError): + testrepo.branches.create('version1', commit) - def test_delete(self): - branch = self.repo.lookup_branch('i18n') - branch.delete() + # try to create existing reference with force + reference = testrepo.branches.create('version1', commit, True) + assert reference.target == LAST_COMMIT + + +def test_branches_delete(testrepo: Repository) -> None: + testrepo.branches.delete('i18n') + assert testrepo.branches.get('i18n') is None + + +def test_branches_delete_error(testrepo: Repository) -> None: + with pytest.raises(pygit2.GitError): + testrepo.branches.delete('master') + + +def test_branches_is_head(testrepo: Repository) -> None: + branch = testrepo.branches.get('master') + assert branch.is_head() + + +def test_branches_is_not_head(testrepo: Repository) -> None: + branch = testrepo.branches.get('i18n') + assert not branch.is_head() + + +def test_branches_rename(testrepo: Repository) -> None: + new_branch = testrepo.branches['i18n'].rename('new-branch') + assert new_branch.target == I18N_LAST_COMMIT + + new_branch_2 = testrepo.branches.get('new-branch') + assert new_branch_2.target == I18N_LAST_COMMIT + + +def test_branches_rename_error(testrepo: Repository) -> None: + original_branch = testrepo.branches.get('i18n') + with pytest.raises(ValueError): + original_branch.rename('master') + + +def test_branches_rename_force(testrepo: Repository) -> None: + original_branch = testrepo.branches.get('master') + new_branch = original_branch.rename('i18n', True) + assert new_branch.target == LAST_COMMIT + + +def test_branches_rename_invalid(testrepo: Repository) -> None: + original_branch = testrepo.branches.get('i18n') + with pytest.raises(ValueError): + original_branch.rename('abc@{123') + + +def test_branches_name(testrepo: Repository) -> None: + branch = testrepo.branches.get('master') + assert branch.branch_name == 'master' + assert branch.name == 'refs/heads/master' + assert branch.raw_branch_name == branch.branch_name.encode('utf-8') - self.assertTrue(self.repo.lookup_branch('i18n') is None) + branch = testrepo.branches.get('i18n') + assert branch.branch_name == 'i18n' + assert branch.name == 'refs/heads/i18n' + assert branch.raw_branch_name == branch.branch_name.encode('utf-8') - def test_cant_delete_master(self): - branch = self.repo.lookup_branch('master') - self.assertRaises(pygit2.GitError, lambda: branch.delete()) +def test_branches_with_commit(testrepo: Repository) -> None: + branches = testrepo.branches.with_commit(EXCLUSIVE_MASTER_COMMIT) + assert sorted(branches) == ['master'] + assert branches.get('i18n') is None + assert branches['master'].branch_name == 'master' - def test_branch_is_head_returns_true_if_branch_is_head(self): - branch = self.repo.lookup_branch('master') - self.assertTrue(branch.is_head()) + branches = testrepo.branches.with_commit(SHARED_COMMIT) + assert sorted(branches) == ['i18n', 'master'] - def test_branch_is_head_returns_false_if_branch_is_not_head(self): - branch = self.repo.lookup_branch('i18n') - self.assertFalse(branch.is_head()) + branches = testrepo.branches.with_commit(LAST_COMMIT) + assert sorted(branches) == ['master'] - def test_branch_rename_succeeds(self): - original_branch = self.repo.lookup_branch('i18n') - new_branch = original_branch.rename('new-branch') - self.assertEqual(new_branch.target.hex, I18N_LAST_COMMIT) + commit = testrepo[LAST_COMMIT] + assert isinstance(commit, Commit) + branches = testrepo.branches.with_commit(commit) + assert sorted(branches) == ['master'] - new_branch_2 = self.repo.lookup_branch('new-branch') - self.assertEqual(new_branch_2.target.hex, I18N_LAST_COMMIT) + branches = testrepo.branches.remote.with_commit(LAST_COMMIT) + assert sorted(branches) == [] - def test_branch_rename_fails_if_destination_already_exists(self): - original_branch = self.repo.lookup_branch('i18n') - self.assertRaises(ValueError, lambda: original_branch.rename('master')) - def test_branch_rename_not_fails_if_force_is_true(self): - original_branch = self.repo.lookup_branch('master') - new_branch = original_branch.rename('i18n', True) - self.assertEqual(new_branch.target.hex, LAST_COMMIT) +# +# Low level API written in C, repo.branches call these. +# + + +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 + + assert testrepo.lookup_branch('i18n', BranchType.LOCAL).target == I18N_LAST_COMMIT + assert testrepo.lookup_branch(b'i18n', BranchType.LOCAL).target == I18N_LAST_COMMIT + + assert testrepo.lookup_branch('not-exists') is None + assert testrepo.lookup_branch(b'not-exists') is None + if os.name == 'posix': # this call fails with an InvalidSpecError on NT + assert testrepo.lookup_branch(b'\xb1') is None + + +def test_listall_branches(testrepo: Repository) -> None: + branches = sorted(testrepo.listall_branches()) + assert branches == ['i18n', 'master'] + + branches_raw = sorted(testrepo.raw_listall_branches()) + assert branches_raw == [b'i18n', b'master'] + + +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 + assert testrepo.lookup_branch('version1').target == LAST_COMMIT + assert testrepo.lookup_branch(b'version1').target == LAST_COMMIT + + # try to create existing reference + with pytest.raises(ValueError): + testrepo.create_branch('version1', commit) + + # try to create existing reference with force + assert testrepo.create_branch('version1', commit, True).target == LAST_COMMIT + + +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: Repository) -> None: + branch = testrepo.lookup_branch('master') + + with pytest.raises(pygit2.GitError): + branch.delete() + - def test_branch_rename_fails_with_invalid_names(self): - original_branch = self.repo.lookup_branch('i18n') - self.assertRaises(ValueError, - lambda: original_branch.rename('abc@{123')) +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_name(self): - branch = self.repo.lookup_branch('master') - self.assertEqual(branch.branch_name, 'master') - self.assertEqual(branch.name, 'refs/heads/master') - branch = self.repo.lookup_branch('i18n') - self.assertEqual(branch.branch_name, 'i18n') - self.assertEqual(branch.name, 'refs/heads/i18n') +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() -class BranchesEmptyRepoTestCase(utils.EmptyRepoTestCase): - def setUp(self): - super(utils.EmptyRepoTestCase, self).setUp() +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() - remote = self.repo.remotes[0] - remote.fetch() - def test_lookup_branch_remote(self): - branch = self.repo.lookup_branch('origin/master', - pygit2.GIT_BRANCH_REMOTE) - self.assertEqual(branch.target.hex, ORIGIN_MASTER_COMMIT) +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() - self.assertTrue( - self.repo.lookup_branch('origin/not-exists', - pygit2.GIT_BRANCH_REMOTE) is None) - def test_listall_branches(self): - branches = sorted(self.repo.listall_branches(pygit2.GIT_BRANCH_REMOTE)) - self.assertEqual(branches, ['origin/master']) +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_remote_name(self): - self.repo.remotes[0].fetch() - branch = self.repo.lookup_branch('origin/master', - pygit2.GIT_BRANCH_REMOTE) - self.assertEqual(branch.remote_name, 'origin') - def test_branch_upstream(self): - self.repo.remotes[0].fetch() - remote_master = self.repo.lookup_branch('origin/master', - pygit2.GIT_BRANCH_REMOTE) - master = self.repo.create_branch('master', - self.repo[remote_master.target.hex]) +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') - self.assertTrue(master.upstream is None) - master.upstream = remote_master - self.assertEqual(master.upstream.branch_name, 'origin/master') - def set_bad_upstream(): - master.upstream = 2.5 - self.assertRaises(TypeError, set_bad_upstream) +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 - master.upstream = None - self.assertTrue(master.upstream is None) - def test_branch_upstream_name(self): - self.repo.remotes[0].fetch() - remote_master = self.repo.lookup_branch('origin/master', - pygit2.GIT_BRANCH_REMOTE) - master = self.repo.create_branch('master', - self.repo[remote_master.target.hex]) +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') - master.upstream = remote_master - self.assertEqual(master.upstream_name, 'refs/remotes/origin/master') +def test_branch_name(testrepo: Repository) -> None: + branch = testrepo.lookup_branch('master') + assert branch.branch_name == 'master' + assert branch.name == 'refs/heads/master' -if __name__ == '__main__': - unittest.main() + branch = testrepo.lookup_branch('i18n') + assert branch.branch_name == 'i18n' + assert branch.name == 'refs/heads/i18n' diff --git a/test/test_branch_empty.py b/test/test_branch_empty.py new file mode 100644 index 000000000..5b2beabd8 --- /dev/null +++ b/test/test_branch_empty.py @@ -0,0 +1,140 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +from collections.abc import Generator + +import pytest + +from pygit2 import Commit, Repository +from pygit2.enums import BranchType + +ORIGIN_MASTER_COMMIT = '784855caf26449a1914d2cf62d12b9374d76ae78' + + +@pytest.fixture +def repo(emptyrepo: Repository) -> Generator[Repository, None, None]: + remote = emptyrepo.remotes[0] + remote.fetch() + yield emptyrepo + + +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: Repository) -> None: + branches = sorted(repo.branches.remote) + assert branches == ['origin/master'] + + +def test_branches_remote_getitem(repo: Repository) -> None: + branch = repo.branches.remote['origin/master'] + assert branch.remote_name == 'origin' + + +def test_branches_upstream(repo: Repository) -> None: + remote_master = repo.branches.remote['origin/master'] + commit = repo[remote_master.target] + assert isinstance(commit, Commit) + master = repo.branches.create('master', commit) + + assert master.upstream is None + master.upstream = remote_master + assert master.upstream.branch_name == 'origin/master' + + def set_bad_upstream(): + master.upstream = 2.5 + + with pytest.raises(TypeError): + set_bad_upstream() + + master.upstream = None + assert master.upstream is None + + +def test_branches_upstream_name(repo: Repository) -> None: + remote_master = repo.branches.remote['origin/master'] + 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' + + +# +# Low level API written in C, repo.remotes call these. +# + + +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: Repository) -> None: + branches = sorted(repo.listall_branches(BranchType.REMOTE)) + assert branches == ['origin/master'] + + branches_raw = sorted(repo.raw_listall_branches(BranchType.REMOTE)) + assert branches_raw == [b'origin/master'] + + +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: Repository) -> None: + remote_master = repo.lookup_branch('origin/master', BranchType.REMOTE) + commit = repo[remote_master.target] + assert isinstance(commit, Commit) + master = repo.create_branch('master', commit) + + assert master.upstream is None + master.upstream = remote_master + assert master.upstream.branch_name == 'origin/master' + + def set_bad_upstream(): + master.upstream = 2.5 + + with pytest.raises(TypeError): + set_bad_upstream() + + master.upstream = None + assert master.upstream is None + + +def test_branch_upstream_name(repo: Repository) -> None: + remote_master = repo.lookup_branch('origin/master', BranchType.REMOTE) + 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 new file mode 100644 index 000000000..6c003223b --- /dev/null +++ b/test/test_cherrypick.py @@ -0,0 +1,81 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +"""Tests for 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: Repository) -> None: + with pytest.raises(TypeError): + mergerepo.cherrypick(None) # type: ignore + + +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: Repository) -> None: + branch_head_hex = '03490f16b15a09913edb3a067a3dc67fbb8d41f1' + 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') + with pytest.raises(pygit2.GitError): + mergerepo.cherrypick(branch_oid) + + +def test_cherrypick_remove_conflicts(mergerepo: Repository) -> None: + assert mergerepo.state() == RepositoryState.NONE + assert not mergerepo.message + + other_branch_tip = '1b2bae55ac95a4be3f8983b86cd579226d0eb247' + mergerepo.cherrypick(other_branch_tip) + + assert mergerepo.state() == RepositoryState.CHERRYPICK + assert mergerepo.message.startswith('commit to provoke a conflict') + + idx = mergerepo.index + conflicts = idx.conflicts + assert conflicts is not None + conflicts['.gitignore'] + del idx.conflicts['.gitignore'] + with pytest.raises(KeyError): + conflicts.__getitem__('.gitignore') + assert idx.conflicts is None + + mergerepo.state_cleanup() + assert mergerepo.state() == RepositoryState.NONE + assert not mergerepo.message diff --git a/test/test_commit.py b/test/test_commit.py index 13acf982e..214340262 100644 --- a/test/test_commit.py +++ b/test/test_commit.py @@ -1,6 +1,4 @@ -# -*- coding: UTF-8 -*- -# -# Copyright 2010-2013 The pygit2 contributors +# Copyright 2010-2025 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -27,113 +25,275 @@ """Tests for Commit objects.""" -from __future__ import absolute_import -from __future__ import unicode_literals -import unittest +import sys -from pygit2 import GIT_OBJ_COMMIT, Signature -from . import utils +import pytest + +from pygit2 import Commit, GitError, Oid, Repository, Signature, Tree +from pygit2.enums import ObjectType +from . import utils COMMIT_SHA = '5fe808e8953c12735680c257f56600cb0de44b10' +COMMIT_SHA_TO_AMEND = ( + '784855caf26449a1914d2cf62d12b9374d76ae78' # tip of the master branch +) + + +@utils.requires_refcount +def test_commit_refcount(barerepo: Repository) -> None: + commit = barerepo[COMMIT_SHA] + start = sys.getrefcount(commit) + tree = commit.tree + del tree + end = sys.getrefcount(commit) + assert start == end + + +def test_read_commit(barerepo: Repository) -> None: + commit = barerepo[COMMIT_SHA] + assert isinstance(commit, Commit) + assert COMMIT_SHA == commit.id + parents = commit.parents + assert 1 == len(parents) + assert 'c2792cfa289ae6321ecf2cd5806c2194b0fd070c' == parents[0].id + assert commit.message_encoding is None + assert commit.message == ( + 'Second test data commit.\n\nThis commit has some additional text.\n' + ) + commit_time = 1288481576 + assert commit_time == commit.commit_time + assert commit.committer == Signature( + 'Dave Borowitz', 'dborowitz@google.com', commit_time, -420 + ) + assert commit.author == Signature( + 'Dave Borowitz', 'dborowitz@google.com', 1288477363, -420 + ) + assert '967fce8df97cc71722d3c2a5930ef3e6f1d27b12' == commit.tree.id + + +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) + author = Signature( + 'J. David Ibáñez', 'jdavid@example.com', 12345, 0, encoding='utf-8' + ) + tree = '967fce8df97cc71722d3c2a5930ef3e6f1d27b12' + tree_prefix = tree[:5] + too_short_prefix = tree[:3] + + parents = [COMMIT_SHA[:5]] + with pytest.raises(ValueError): + repo.create_commit(None, author, committer, message, too_short_prefix, parents) + + sha = repo.create_commit(None, author, committer, message, tree_prefix, parents) + commit = repo[sha] + assert isinstance(commit, Commit) + + assert ObjectType.COMMIT.value == commit.type + assert '98286caaab3f1fde5bf52c8369b2b0423bad743b' == commit.id + assert commit.message_encoding is None + assert message == commit.message + assert 12346 == commit.commit_time + assert committer == commit.committer + assert author == commit.author + assert tree == commit.tree.id + assert Oid(hex=tree) == commit.tree_id + assert 1 == len(commit.parents) + assert COMMIT_SHA == commit.parents[0].id + assert Oid(hex=COMMIT_SHA) == commit.parent_ids[0] + + +def test_new_commit_encoding(barerepo: Repository) -> None: + repo = barerepo + encoding = 'iso-8859-1' + message = 'New commit.\n\nMessage with non-ascii chars: ééé.\n' + committer = Signature('John Doe', 'jdoe@example.com', 12346, 0, encoding) + author = Signature('J. David Ibáñez', 'jdavid@example.com', 12345, 0, encoding) + tree = '967fce8df97cc71722d3c2a5930ef3e6f1d27b12' + tree_prefix = tree[:5] + + parents = [COMMIT_SHA[:5]] + sha = repo.create_commit( + None, author, committer, message, tree_prefix, parents, encoding + ) + commit = repo[sha] + assert isinstance(commit, Commit) + + 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 + assert committer == commit.committer + assert author == commit.author + assert tree == commit.tree.id + assert Oid(hex=tree) == commit.tree_id + assert 1 == len(commit.parents) + assert COMMIT_SHA == commit.parents[0].id + assert Oid(hex=COMMIT_SHA) == commit.parent_ids[0] + + +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) + + commit = barerepo[COMMIT_SHA] + + with pytest.raises(AttributeError): + setattr(commit, 'message', message) + with pytest.raises(AttributeError): + setattr(commit, 'committer', committer) + with pytest.raises(AttributeError): + setattr(commit, 'author', author) + with pytest.raises(AttributeError): + setattr(commit, 'tree', None) + with pytest.raises(AttributeError): + setattr(commit, 'parents', None) + + +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' + amended_message = 'Amended commit message.\n\nMessage with non-ascii chars: ééé.\n' + amended_author = Signature( + 'Jane Author', 'jane@example.com', 12345, 0, encoding=encoding + ) + amended_committer = Signature( + 'John Committer', 'john@example.com', 12346, 0, encoding=encoding + ) + + amended_oid = repo.amend_commit( + commit, + 'HEAD', + message=amended_message, + author=amended_author, + committer=amended_committer, + encoding=encoding, + ) + amended_commit = repo[amended_oid] + assert isinstance(amended_commit, Commit) + + assert repo.head.target == amended_oid + 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 + assert commit.author != amended_commit.author + assert commit.committer != amended_commit.committer + assert commit.tree == amended_commit.tree # we didn't touch the tree + + +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' + tree_prefix = tree[:5] + + 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.value == amended_commit.type + assert commit.message == amended_commit.message + assert commit.author == amended_commit.author + assert commit.committer == amended_commit.committer + assert commit.tree_id != amended_commit.tree_id + assert Oid(hex=tree) == amended_commit.tree_id + + +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. + with pytest.raises(GitError): + repo.amend_commit(commit, 'HEAD', message="this won't work!") + + # We can still amend the commit if we don't try to update a ref. + repo.amend_commit(commit, None, message='this will work') + + +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: Repository) -> None: + repo = barerepo + + some_tree = repo['967fce8df97cc71722d3c2a5930ef3e6f1d27b12'] + commit = repo[COMMIT_SHA_TO_AMEND] + 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) # type: ignore + with pytest.raises(TypeError): + repo.amend_commit(some_tree, None) # type: ignore + + # Pass bad types for signatures + with pytest.raises(TypeError): + repo.amend_commit(commit, None, author='Toto') # type: ignore + with pytest.raises(TypeError): + repo.amend_commit(commit, None, committer='Toto') # type: ignore + + # Pass bad refnames + with pytest.raises(ValueError): + repo.amend_commit(commit, 'this-ref-doesnt-exist') # type: ignore + with pytest.raises(TypeError): + repo.amend_commit(commit, repo) # type: ignore + + # Pass bad trees + with pytest.raises(ValueError): + repo.amend_commit(commit, None, tree="can't parse this") # type: ignore + with pytest.raises(KeyError): + 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') + amended_commit = repo[amended_oid] + assert ObjectType.COMMIT == amended_commit.type + assert amended_oid != COMMIT_SHA_TO_AMEND + # 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 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 -class CommitTest(utils.BareRepoTestCase): - - def test_read_commit(self): - commit = self.repo[COMMIT_SHA] - self.assertEqual(COMMIT_SHA, commit.hex) - parents = commit.parents - self.assertEqual(1, len(parents)) - self.assertEqual('c2792cfa289ae6321ecf2cd5806c2194b0fd070c', - parents[0].hex) - self.assertEqual(None, commit.message_encoding) - self.assertEqual(('Second test data commit.\n\n' - 'This commit has some additional text.\n'), - commit.message) - commit_time = 1288481576 - self.assertEqual(commit_time, commit.commit_time) - self.assertEqualSignature( - commit.committer, - Signature('Dave Borowitz', 'dborowitz@google.com', - commit_time, -420)) - self.assertEqualSignature( - commit.author, - Signature('Dave Borowitz', 'dborowitz@google.com', 1288477363, - -420)) - self.assertEqual( - '967fce8df97cc71722d3c2a5930ef3e6f1d27b12', commit.tree.hex) - - def test_new_commit(self): - repo = self.repo - message = 'New commit.\n\nMessage with non-ascii chars: ééé.\n' - committer = Signature('John Doe', 'jdoe@example.com', 12346, 0) - author = Signature( - 'J. David Ibáñez', 'jdavid@example.com', 12345, 0, - encoding='utf-8') - tree = '967fce8df97cc71722d3c2a5930ef3e6f1d27b12' - tree_prefix = tree[:5] - too_short_prefix = tree[:3] - - parents = [COMMIT_SHA[:5]] - self.assertRaises(ValueError, repo.create_commit, None, author, - committer, message, too_short_prefix, parents) - - sha = repo.create_commit(None, author, committer, message, - tree_prefix, parents) - commit = repo[sha] - - self.assertEqual(GIT_OBJ_COMMIT, commit.type) - self.assertEqual('98286caaab3f1fde5bf52c8369b2b0423bad743b', - commit.hex) - self.assertEqual(None, commit.message_encoding) - self.assertEqual(message, commit.message) - self.assertEqual(12346, commit.commit_time) - self.assertEqualSignature(committer, commit.committer) - self.assertEqualSignature(author, commit.author) - self.assertEqual(tree, commit.tree.hex) - self.assertEqual(1, len(commit.parents)) - self.assertEqual(COMMIT_SHA, commit.parents[0].hex) - - def test_new_commit_encoding(self): - repo = self.repo - encoding = 'iso-8859-1' - message = 'New commit.\n\nMessage with non-ascii chars: ééé.\n' - committer = Signature('John Doe', 'jdoe@example.com', 12346, 0, - encoding) - author = Signature('J. David Ibáñez', 'jdavid@example.com', 12345, 0, - encoding) - tree = '967fce8df97cc71722d3c2a5930ef3e6f1d27b12' - tree_prefix = tree[:5] - - parents = [COMMIT_SHA[:5]] - sha = repo.create_commit(None, author, committer, message, - tree_prefix, parents, encoding) - commit = repo[sha] - - self.assertEqual(GIT_OBJ_COMMIT, commit.type) - self.assertEqual('iso-8859-1', commit.message_encoding) - self.assertEqual(message, commit.message) - self.assertEqual(12346, commit.commit_time) - self.assertEqualSignature(committer, commit.committer) - self.assertEqualSignature(author, commit.author) - self.assertEqual(tree, commit.tree.hex) - self.assertEqual(1, len(commit.parents)) - self.assertEqual(COMMIT_SHA, commit.parents[0].hex) - - def test_modify_commit(self): - message = 'New commit.\n\nMessage.\n' - committer = ('John Doe', 'jdoe@example.com', 12346) - author = ('Jane Doe', 'jdoe2@example.com', 12345) - - commit = self.repo[COMMIT_SHA] - self.assertRaises(AttributeError, setattr, commit, 'message', message) - self.assertRaises(AttributeError, setattr, commit, 'committer', - committer) - self.assertRaises(AttributeError, setattr, commit, 'author', author) - self.assertRaises(AttributeError, setattr, commit, 'tree', None) - self.assertRaises(AttributeError, setattr, commit, 'parents', None) - - -if __name__ == '__main__': - unittest.main() + # Pass an actual reference object for refname + # (Warning: the tip of the branch will be altered after this test!) + amended_oid = repo.amend_commit(alt_commit2, alt_refname, message='Hello') + amended_commit = repo[amended_oid] + assert ObjectType.COMMIT == amended_commit.type + assert amended_oid != COMMIT_SHA_TO_AMEND + assert repo.head.target == amended_oid diff --git a/test/test_commit_gpg.py b/test/test_commit_gpg.py new file mode 100644 index 000000000..d20f584ef --- /dev/null +++ b/test/test_commit_gpg.py @@ -0,0 +1,146 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +from pygit2 import Commit, Oid, Repository, Signature +from pygit2.enums import ObjectType + +content = """\ +tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904 +parent 8496071c1b46c854b31185ea97743be6a8774479 +author Ben Burkert 1358451456 -0800 +committer Ben Burkert 1358451456 -0800 + +a simple commit which works\ +""" + +gpgsig = """\ +-----BEGIN PGP SIGNATURE----- +Version: GnuPG v1.4.12 (Darwin) + +iQIcBAABAgAGBQJQ+FMIAAoJEH+LfPdZDSs1e3EQAJMjhqjWF+WkGLHju7pTw2al +o6IoMAhv0Z/LHlWhzBd9e7JeCnanRt12bAU7yvYp9+Z+z+dbwqLwDoFp8LVuigl8 +JGLcnwiUW3rSvhjdCp9irdb4+bhKUnKUzSdsR2CK4/hC0N2i/HOvMYX+BRsvqweq +AsAkA6dAWh+gAfedrBUkCTGhlNYoetjdakWqlGL1TiKAefEZrtA1TpPkGn92vbLq +SphFRUY9hVn1ZBWrT3hEpvAIcZag3rTOiRVT1X1flj8B2vGCEr3RrcwOIZikpdaW +who/X3xh/DGbI2RbuxmmJpxxP/8dsVchRJJzBwG+yhwU/iN3MlV2c5D69tls/Dok +6VbyU4lm/ae0y3yR83D9dUlkycOnmmlBAHKIZ9qUts9X7mWJf0+yy2QxJVpjaTGG +cmnQKKPeNIhGJk2ENnnnzjEve7L7YJQF6itbx5VCOcsGh3Ocb3YR7DMdWjt7f8pu +c6j+q1rP7EpE2afUN/geSlp5i3x8aXZPDj67jImbVCE/Q1X9voCtyzGJH7MXR0N9 +ZpRF8yzveRfMH8bwAJjSOGAFF5XkcR/RNY95o+J+QcgBLdX48h+ZdNmUf6jqlu3J +7KmTXXQcOVpN6dD3CmRFsbjq+x6RHwa8u1iGn+oIkX908r97ckfB/kHKH7ZdXIJc +cpxtDQQMGYFpXK/71stq +=ozeK +-----END PGP SIGNATURE-----\ +""" + +gpgsig_content = """\ +tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904 +parent 8496071c1b46c854b31185ea97743be6a8774479 +author Ben Burkert 1358451456 -0800 +committer Ben Burkert 1358451456 -0800 +gpgsig -----BEGIN PGP SIGNATURE----- + Version: GnuPG v1.4.12 (Darwin) + + iQIcBAABAgAGBQJQ+FMIAAoJEH+LfPdZDSs1e3EQAJMjhqjWF+WkGLHju7pTw2al + o6IoMAhv0Z/LHlWhzBd9e7JeCnanRt12bAU7yvYp9+Z+z+dbwqLwDoFp8LVuigl8 + JGLcnwiUW3rSvhjdCp9irdb4+bhKUnKUzSdsR2CK4/hC0N2i/HOvMYX+BRsvqweq + AsAkA6dAWh+gAfedrBUkCTGhlNYoetjdakWqlGL1TiKAefEZrtA1TpPkGn92vbLq + SphFRUY9hVn1ZBWrT3hEpvAIcZag3rTOiRVT1X1flj8B2vGCEr3RrcwOIZikpdaW + who/X3xh/DGbI2RbuxmmJpxxP/8dsVchRJJzBwG+yhwU/iN3MlV2c5D69tls/Dok + 6VbyU4lm/ae0y3yR83D9dUlkycOnmmlBAHKIZ9qUts9X7mWJf0+yy2QxJVpjaTGG + cmnQKKPeNIhGJk2ENnnnzjEve7L7YJQF6itbx5VCOcsGh3Ocb3YR7DMdWjt7f8pu + c6j+q1rP7EpE2afUN/geSlp5i3x8aXZPDj67jImbVCE/Q1X9voCtyzGJH7MXR0N9 + ZpRF8yzveRfMH8bwAJjSOGAFF5XkcR/RNY95o+J+QcgBLdX48h+ZdNmUf6jqlu3J + 7KmTXXQcOVpN6dD3CmRFsbjq+x6RHwa8u1iGn+oIkX908r97ckfB/kHKH7ZdXIJc + cpxtDQQMGYFpXK/71stq + =ozeK + -----END PGP SIGNATURE----- + +a simple commit which works\ +""" +# NOTE: ^^^ mind the gap (space must exist after GnuPG header) ^^^ +# XXX: seems macos wants the space while linux does not + + +def test_commit_signing(gpgsigned: Repository) -> None: + repo = gpgsigned + message = 'a simple commit which works' + author = Signature( + name='Ben Burkert', + email='ben@benburkert.com', + time=1358451456, + offset=-480, + ) + committer = Signature( + name='Ben Burkert', + email='ben@benburkert.com', + time=1358451456, + offset=-480, + ) + tree = '4b825dc642cb6eb9a060e54bf8d69288fbee4904' + parent = '8496071c1b46c854b31185ea97743be6a8774479' + + # create commit string + commit_string = repo.create_commit_string( + author, committer, message, tree, [parent] + ) + assert commit_string == content + + # 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 + assert content == payload.decode('utf-8') + assert gpgsig == signature.decode('utf-8') + assert gpgsig_content == commit.read_raw().decode('utf-8') + + # perform sanity checks + assert ObjectType.COMMIT == commit.type + assert '6569fdf71dbd99081891154641869c537784a3ba' == commit.id + assert commit.message_encoding is None + assert message == commit.message + assert 1358451456 == commit.commit_time + assert committer == commit.committer + assert author == commit.author + assert tree == commit.tree.id + assert Oid(hex=tree) == commit.tree_id + assert 1 == len(commit.parents) + assert parent == commit.parents[0].id + assert Oid(hex=parent) == commit.parent_ids[0] + + +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 + assert payload is None diff --git a/test/test_commit_trailer.py b/test/test_commit_trailer.py new file mode 100644 index 000000000..efe5434f2 --- /dev/null +++ b/test/test_commit_trailer.py @@ -0,0 +1,54 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +from 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: Path) -> Generator[Repository, None, None]: + with utils.TemporaryRepository('trailerrepo.zip', tmp_path) as path: + yield pygit2.Repository(path) + + +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'] + assert trailers['Signed-off-by'] == expected_trailers['Signed-off-by'] diff --git a/test/test_config.py b/test/test_config.py index a8afe86cb..247d55c51 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -1,6 +1,4 @@ -# -*- coding: UTF-8 -*- -# -# Copyright 2010-2013 The pygit2 contributors +# Copyright 2010-2025 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -25,162 +23,171 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. -"""Tests for Index files.""" +from collections.abc import Generator +from pathlib import Path + +import pytest -import os -import unittest +from pygit2 import Config, Repository -from pygit2 import Config from . import utils +CONFIG_FILENAME = 'test_config' + + +@pytest.fixture +def config(testrepo: Repository) -> Generator[object, None, None]: + yield testrepo.config + try: + Path(CONFIG_FILENAME).unlink() + except OSError: + pass + + +def test_config(config: Config) -> None: + assert config is not None + + +def test_global_config() -> None: + try: + assert Config.get_global_config() is not None + except IOError: + # There is no user config + pass + + +def test_system_config() -> None: + try: + assert Config.get_system_config() is not None + except IOError: + # There is no system config + pass + + +def test_new() -> None: + # Touch file + open(CONFIG_FILENAME, 'w').close() + + config_write = Config(CONFIG_FILENAME) + assert config_write is not None + + config_write['core.bare'] = False + config_write['core.editor'] = 'ed' + + config_read = Config(CONFIG_FILENAME) + assert 'core.bare' in config_read + assert not config_read.get_bool('core.bare') + assert 'core.editor' in config_read + assert config_read['core.editor'] == 'ed' + + +def test_add() -> None: + with open(CONFIG_FILENAME, 'w') as new_file: + new_file.write('[this]\n\tthat = true\n') + new_file.write('[something "other"]\n\there = false') + + config = Config() + config.add_file(CONFIG_FILENAME, 0) + assert 'this.that' in config + assert config.get_bool('this.that') + assert 'something.other.here' in config + assert not config.get_bool('something.other.here') + + +def test_add_aspath() -> None: + with open(CONFIG_FILENAME, 'w') as new_file: + new_file.write('[this]\n\tthat = true\n') + + config = Config() + config.add_file(Path(CONFIG_FILENAME), 0) + assert 'this.that' in config + + +def test_read(config: Config) -> None: + with pytest.raises(TypeError): + config[()] # type: ignore + with pytest.raises(TypeError): + config[-4] # type: ignore + utils.assertRaisesWithArg( + ValueError, "invalid config item name 'abc'", lambda: config['abc'] + ) + utils.assertRaisesWithArg(KeyError, 'abc.def', lambda: config['abc.def']) + + 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 + + +def test_write(config: Config) -> None: + with pytest.raises(TypeError): + config.__setitem__((), 'This should not work') # type: ignore + + assert 'core.dummy1' not in config + config['core.dummy1'] = 42 + assert 'core.dummy1' in config + assert config.get_int('core.dummy1') == 42 + + assert 'core.dummy2' not in config + config['core.dummy2'] = 'foobar' + assert 'core.dummy2' in config + assert config['core.dummy2'] == 'foobar' + + assert 'core.dummy3' not in config + config['core.dummy3'] = True + assert 'core.dummy3' in config + assert config['core.dummy3'] + + del config['core.dummy1'] + assert 'core.dummy1' not in config + del config['core.dummy2'] + assert 'core.dummy2' not in config + del config['core.dummy3'] + assert 'core.dummy3' not in config + + +def test_multivar() -> None: + with open(CONFIG_FILENAME, 'w') as new_file: + new_file.write('[this]\n\tthat = foobar\n\tthat = foobeer\n') + + config = Config() + config.add_file(CONFIG_FILENAME, 6) + assert 'this.that' in config + + assert ['foobar', 'foobeer'] == list(config.get_multivar('this.that')) + assert ['foobar'] == list(config.get_multivar('this.that', 'bar')) + assert ['foobar', 'foobeer'] == list(config.get_multivar('this.that', 'foo.*')) + + config.set_multivar('this.that', '^.*beer', 'fool') + assert ['fool'] == list(config.get_multivar('this.that', 'fool')) + + config.set_multivar('this.that', 'foo.*', 'foo-123456') + assert ['foo-123456', 'foo-123456'] == list( + config.get_multivar('this.that', 'foo.*') + ) + + config.delete_multivar('this.that', 'bar') + assert ['foo-123456', 'foo-123456'] == list(config.get_multivar('this.that', '')) + + config.delete_multivar('this.that', 'foo-[0-9]+') + assert [] == list(config.get_multivar('this.that', '')) + + +def test_iterator(config: Config) -> None: + lst = {} + for entry in config: + assert entry.level > -1 + lst[entry.name] = entry.value -CONFIG_FILENAME = "test_config" - + assert 'core.bare' in lst + assert lst['core.bare'] -def foreach_test_wrapper(key, name, lst): - lst[key] = name - return 0 -foreach_test_wrapper.__test__ = False +def test_parsing() -> None: + assert Config.parse_bool('on') + assert Config.parse_bool('1') -class ConfigTest(utils.RepoTestCase): - - def tearDown(self): - try: - os.remove(CONFIG_FILENAME) - except OSError: - pass - - def test_config(self): - self.assertNotEqual(None, self.repo.config) - - def test_global_config(self): - try: - self.assertNotEqual(None, Config.get_global_config()) - except IOError: - # There is no user config - pass - - def test_system_config(self): - try: - self.assertNotEqual(None, Config.get_system_config()) - except IOError: - # There is no system config - pass - - def test_new(self): - # Touch file - open(CONFIG_FILENAME, 'w').close() - - config_write = Config(CONFIG_FILENAME) - self.assertNotEqual(config_write, None) - - config_write['core.bare'] = False - config_write['core.editor'] = 'ed' - - config_read = Config(CONFIG_FILENAME) - self.assertTrue('core.bare' in config_read) - self.assertFalse(config_read['core.bare']) - self.assertTrue('core.editor' in config_read) - self.assertEqual(config_read['core.editor'], 'ed') - - def test_add(self): - config = Config() - - new_file = open(CONFIG_FILENAME, "w") - new_file.write("[this]\n\tthat = true\n") - new_file.write("[something \"other\"]\n\there = false") - new_file.close() - - config.add_file(CONFIG_FILENAME, 0) - self.assertTrue('this.that' in config) - self.assertTrue(config['this.that']) - self.assertTrue('something.other.here' in config) - self.assertFalse(config['something.other.here']) - - def test_read(self): - config = self.repo.config - - self.assertRaises(TypeError, lambda: config[()]) - self.assertRaises(TypeError, lambda: config[-4]) - self.assertRaisesWithArg(ValueError, "Invalid config item name 'abc'", - lambda: config['abc']) - self.assertRaisesWithArg(KeyError, 'abc.def', - lambda: config['abc.def']) - - self.assertTrue('core.bare' in config) - self.assertFalse(config['core.bare']) - self.assertTrue('core.editor' in config) - self.assertEqual(config['core.editor'], 'ed') - self.assertTrue('core.repositoryformatversion' in config) - self.assertEqual(config['core.repositoryformatversion'], 0) - - new_file = open(CONFIG_FILENAME, "w") - new_file.write("[this]\n\tthat = foobar\n\tthat = foobeer\n") - new_file.close() - - config.add_file(CONFIG_FILENAME, 0) - self.assertTrue('this.that' in config) - self.assertEqual(len(config.get_multivar('this.that')), 2) - l = config.get_multivar('this.that', 'bar') - self.assertEqual(len(l), 1) - self.assertEqual(l[0], 'foobar') - - def test_write(self): - config = self.repo.config - - self.assertRaises(TypeError, config.__setitem__, - (), 'This should not work') - - self.assertFalse('core.dummy1' in config) - config['core.dummy1'] = 42 - self.assertTrue('core.dummy1' in config) - self.assertEqual(config['core.dummy1'], 42) - - self.assertFalse('core.dummy2' in config) - config['core.dummy2'] = 'foobar' - self.assertTrue('core.dummy2' in config) - self.assertEqual(config['core.dummy2'], 'foobar') - - self.assertFalse('core.dummy3' in config) - config['core.dummy3'] = True - self.assertTrue('core.dummy3' in config) - self.assertTrue(config['core.dummy3']) - - del config['core.dummy1'] - self.assertFalse('core.dummy1' in config) - del config['core.dummy2'] - self.assertFalse('core.dummy2' in config) - del config['core.dummy3'] - self.assertFalse('core.dummy3' in config) - - new_file = open(CONFIG_FILENAME, "w") - new_file.write("[this]\n\tthat = foobar\n\tthat = foobeer\n") - new_file.close() - - config.add_file(CONFIG_FILENAME, 5) - self.assertTrue('this.that' in config) - l = config.get_multivar('this.that', 'foo.*') - self.assertEqual(len(l), 2) - - config.set_multivar('this.that', '^.*beer', 'fool') - l = config.get_multivar('this.that', 'fool') - self.assertEqual(len(l), 1) - self.assertEqual(l[0], 'fool') - - config.set_multivar('this.that', 'foo.*', 'foo-123456') - l = config.get_multivar('this.that', 'foo.*') - self.assertEqual(len(l), 2) - for i in l: - self.assertEqual(i, 'foo-123456') - - def test_foreach(self): - config = self.repo.config - lst = {} - config.foreach(foreach_test_wrapper, lst) - self.assertTrue('core.bare' in lst) - self.assertTrue(lst['core.bare']) - - -if __name__ == '__main__': - unittest.main() + assert 5 == Config.parse_int('5') + assert 1024 == Config.parse_int('1k') diff --git a/test/test_credentials.py b/test/test_credentials.py new file mode 100644 index 000000000..dbc98823a --- /dev/null +++ b/test/test_credentials.py @@ -0,0 +1,221 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +import platform +from pathlib import Path + +import pytest + +import pygit2 +from pygit2 import ( + Keypair, + KeypairFromAgent, + KeypairFromMemory, + Repository, + Username, + UserPass, +) +from pygit2.enums import CredentialType + +from . import utils + +REMOTE_NAME = 'origin' +REMOTE_URL = 'git://github.com/libgit2/pygit2.git' +REMOTE_FETCHSPEC_SRC = 'refs/heads/*' +REMOTE_FETCHSPEC_DST = 'refs/remotes/origin/*' +REMOTE_REPO_OBJECTS = 30 +REMOTE_REPO_BYTES = 2758 + +ORIGIN_REFSPEC = '+refs/heads/*:refs/remotes/origin/*' + + +def test_username() -> None: + username = 'git' + cred = Username(username) + assert (username,) == cred.credential_tuple + + +def test_userpass() -> None: + username = 'git' + password = 'sekkrit' + + cred = UserPass(username, password) + assert (username, password) == cred.credential_tuple + + +def test_ssh_key() -> None: + username = 'git' + pubkey = 'id_rsa.pub' + privkey = 'id_rsa' + passphrase = 'bad wolf' + + cred = Keypair(username, pubkey, privkey, passphrase) + assert (username, pubkey, privkey, passphrase) == cred.credential_tuple + + +def test_ssh_key_aspath() -> None: + username = 'git' + pubkey = Path('id_rsa.pub') + privkey = Path('id_rsa') + passphrase = 'bad wolf' + + cred = Keypair(username, pubkey, privkey, passphrase) + assert (username, pubkey, privkey, passphrase) == cred.credential_tuple + + +def test_ssh_agent() -> None: + username = 'git' + + cred = KeypairFromAgent(username) + assert (username, None, None, None) == cred.credential_tuple + + +def test_ssh_from_memory() -> None: + username = 'git' + pubkey = 'public key data' + privkey = 'private key data' + passphrase = 'secret passphrase' + + cred = KeypairFromMemory(username, pubkey, privkey, passphrase) + assert (username, pubkey, privkey, passphrase) == cred.credential_tuple + + +@utils.requires_network +@utils.requires_ssh +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) + + prv, pub, secret = pygit2_empty_key + + keypair = pygit2.Keypair('git', pub, prv, secret) + callbacks = pygit2.RemoteCallbacks(credentials=keypair) + pygit2.clone_repository(url, tmp_path, callbacks=callbacks) + + +@utils.requires_network +@utils.requires_ssh +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) + + prv, pub, secret = pygit2_empty_key + with open(prv) as f: + prv_mem = f.read() + + with open(pub) as f: + pub_mem = f.read() + + keypair = pygit2.KeypairFromMemory('git', pub_mem, prv_mem, secret) + callbacks = pygit2.RemoteCallbacks(credentials=keypair) + pygit2.clone_repository(url, tmp_path, callbacks=callbacks) + + +def test_callback(testrepo: Repository) -> None: + class MyCallbacks(pygit2.RemoteCallbacks): + 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' + remote = testrepo.remotes.create('github', url) + with pytest.raises(Exception): + remote.fetch(callbacks=MyCallbacks()) + + +@utils.requires_network +def test_bad_cred_type(testrepo: Repository) -> None: + class MyCallbacks(pygit2.RemoteCallbacks): + 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' + remote = testrepo.remotes.create('github', url) + with pytest.raises(TypeError): + remote.fetch(callbacks=MyCallbacks()) + + +@utils.requires_network +def test_fetch_certificate_check(testrepo: Repository) -> None: + class MyCallbacks(pygit2.RemoteCallbacks): + def certificate_check( + self, certificate: None, valid: bool, host: bytes + ) -> bool: + assert certificate is None + assert valid is True + assert host == b'github.com' + return False + + url = 'https://github.com/libgit2/pygit2.git' + remote = testrepo.remotes.create('https', url) + with pytest.raises(pygit2.GitError) as exc: + remote.fetch(callbacks=MyCallbacks()) + + # libgit2 uses different error message for Linux and Windows + value = str(exc.value) + if platform.system() == 'Windows': + assert value == 'user cancelled certificate check' # winhttp + else: + assert value == 'user rejected certificate for github.com' # httpclient + + # TODO Add GitError.error_code + # assert exc.value.error_code == pygit2.GIT_ERROR_HTTP + + +@utils.requires_network +def test_user_pass(testrepo: Repository) -> None: + credentials = UserPass('libgit2', 'libgit2') + callbacks = pygit2.RemoteCallbacks(credentials=credentials) + + url = 'https://github.com/libgit2/TestGitRepository' + remote = testrepo.remotes.create('bb', url) + remote.fetch(callbacks=callbacks) + + +@utils.requires_proxy +@utils.requires_network +@utils.requires_future_libgit2 +def test_proxy(testrepo: Repository) -> None: + credentials = UserPass('libgit2', 'libgit2') + callbacks = pygit2.RemoteCallbacks(credentials=credentials) + + url = 'https://github.com/libgit2/TestGitRepository' + remote = testrepo.remotes.create('bb', url) + remote.fetch(callbacks=callbacks, proxy='http://localhost:8888') diff --git a/test/test_describe.py b/test/test_describe.py new file mode 100644 index 000000000..963649b4a --- /dev/null +++ b/test/test_describe.py @@ -0,0 +1,114 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +"""Tests for describing commits.""" + +import pytest + +import pygit2 +from pygit2 import Oid, Repository +from pygit2.enums import DescribeStrategy, ObjectType + + +def add_tag(repo: Repository, name: str, target: str) -> Oid: + message = 'Example tag.\n' + tagger = pygit2.Signature('John Doe', 'jdoe@example.com', 12347, 0) + + sha = repo.create_tag(name, target, ObjectType.COMMIT, tagger, message) + return sha + + +def test_describe(testrepo: Repository) -> None: + add_tag(testrepo, 'thetag', '4ec4389a8068641da2d6578db0419484972284c8') + assert 'thetag-2-g2be5719' == testrepo.describe() + + +def test_describe_without_ref(testrepo: Repository) -> None: + with pytest.raises(pygit2.GitError): + testrepo.describe() + + +def test_describe_default_oid(testrepo: Repository) -> None: + assert '2be5719' == testrepo.describe(show_commit_oid_as_fallback=True) + + +def test_describe_strategies(testrepo: Repository) -> None: + assert 'heads/master' == testrepo.describe(describe_strategy=DescribeStrategy.ALL) + + testrepo.create_reference( + 'refs/tags/thetag', '4ec4389a8068641da2d6578db0419484972284c8' + ) + with pytest.raises(KeyError): + testrepo.describe() + assert 'thetag-2-g2be5719' == testrepo.describe( + describe_strategy=DescribeStrategy.TAGS + ) + + +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: Repository) -> None: + add_tag(testrepo, 'thetag', 'acecd5ea2924a4b900e7e149496e1f4b57976e51') + assert 'thetag-4-g2be5719' == testrepo.describe(committish='HEAD') + assert 'thetag-1-g5ebeeeb' == testrepo.describe(committish='HEAD^') + + assert 'thetag-4-g2be5719' == testrepo.describe(committish=testrepo.head) + + assert 'thetag-1-g6aaa262' == testrepo.describe( + committish='6aaa262e655dd54252e5813c8e5acd7780ed097d' + ) + assert 'thetag-1-g6aaa262' == testrepo.describe(committish='6aaa262') + + +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: 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: Repository) -> None: + add_tag(testrepo, 'thetag', '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98') + assert 'thetag-0-g2be5719' == testrepo.describe(always_use_long_format=True) + + +def test_describe_dirty(dirtyrepo: Repository) -> None: + add_tag(dirtyrepo, 'thetag', 'a763aa560953e7cfb87ccbc2f536d665aa4dff22') + assert 'thetag' == dirtyrepo.describe() + + +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 697b8be19..838d4dd08 100644 --- a/test/test_diff.py +++ b/test/test_diff.py @@ -1,6 +1,4 @@ -# -*- coding: UTF-8 -*- -# -# Copyright 2010-2013 The pygit2 contributors +# Copyright 2010-2025 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -27,15 +25,15 @@ """Tests for Diff objects.""" -from __future__ import absolute_import -from __future__ import unicode_literals -import unittest -import pygit2 -from pygit2 import GIT_DIFF_INCLUDE_UNMODIFIED -from pygit2 import GIT_DIFF_IGNORE_WHITESPACE, GIT_DIFF_IGNORE_WHITESPACE_EOL -from . import utils +import textwrap +from collections.abc import Iterator from itertools import chain +import pytest + +import pygit2 +from pygit2 import Diff, Repository +from pygit2.enums import DeltaStatus, DiffFlag, DiffOption, DiffStatsFormat, FileMode COMMIT_SHA1_1 = '5fe808e8953c12735680c257f56600cb0de44b10' COMMIT_SHA1_2 = 'c2792cfa289ae6321ecf2cd5806c2194b0fd070c' @@ -62,7 +60,10 @@ -c/d contents """ +PATCHID = 'f31412498a17e6c3fbc635f2c5f9aa3ef4c1a9b7' + DIFF_HEAD_TO_INDEX_EXPECTED = [ + '.gitignore', 'staged_changes', 'staged_changes_file_deleted', 'staged_changes_file_modified', @@ -70,7 +71,7 @@ 'staged_delete_file_modified', 'staged_new', 'staged_new_file_deleted', - 'staged_new_file_modified' + 'staged_new_file_modified', ] DIFF_HEAD_TO_WORKDIR_EXPECTED = [ @@ -82,10 +83,11 @@ 'staged_delete', 'staged_delete_file_modified', 'subdir/deleted_file', - 'subdir/modified_file' + 'subdir/modified_file', ] DIFF_INDEX_TO_WORK_EXPECTED = [ + '.gitignore', 'file_deleted', 'modified_file', 'staged_changes_file_deleted', @@ -93,196 +95,367 @@ 'staged_new_file_deleted', 'staged_new_file_modified', 'subdir/deleted_file', - 'subdir/modified_file' + 'subdir/modified_file', ] HUNK_EXPECTED = """- a contents 2 + a contents """ +STATS_EXPECTED = """ a | 2 +- + c/d | 1 - + 2 files changed, 1 insertion(+), 2 deletions(-) + delete mode 100644 c/d +""" + +TEXT_BLOB1 = """Common header of the file +Blob 1 line 1 +Common middle line 1 +Common middle line 2 +Common middle line 3 +Blob 1 line 2 +Common footer of the file +""" + +TEXT_BLOB2 = """Common header of the file +Blob 2 line 1 +Common middle line 1 +Common middle line 2 +Common middle line 3 +Blob 2 line 2 +Common footer of the file +""" -class DiffDirtyTest(utils.DirtyRepoTestCase): - def test_diff_empty_index(self): - repo = self.repo +PATCH_BLOBS_DEFAULT = """diff --git a/file b/file +index 0b5ac93..ddfdbcc 100644 +--- a/file ++++ b/file +@@ -1,7 +1,7 @@ + Common header of the file +-Blob 1 line 1 ++Blob 2 line 1 + Common middle line 1 + Common middle line 2 + Common middle line 3 +-Blob 1 line 2 ++Blob 2 line 2 + Common footer of the file +""" - head = repo[repo.lookup_reference('HEAD').resolve().target] - diff = head.tree.diff_to_index(repo.index) - files = [patch.new_file_path for patch in diff] - self.assertEqual(DIFF_HEAD_TO_INDEX_EXPECTED, files) +PATCH_BLOBS_NO_LEEWAY = """diff --git a/file b/file +index 0b5ac93..ddfdbcc 100644 +--- a/file ++++ b/file +@@ -2 +2 @@ Common header of the file +-Blob 1 line 1 ++Blob 2 line 1 +@@ -6 +6 @@ Common middle line 3 +-Blob 1 line 2 ++Blob 2 line 2 +""" - diff = repo.diff('HEAD', cached=True) - files = [patch.new_file_path for patch in diff] - self.assertEqual(DIFF_HEAD_TO_INDEX_EXPECTED, files) +PATCH_BLOBS_ONE_CONTEXT_LINE = """diff --git a/file b/file +index 0b5ac93..ddfdbcc 100644 +--- a/file ++++ b/file +@@ -1,3 +1,3 @@ + Common header of the file +-Blob 1 line 1 ++Blob 2 line 1 + Common middle line 1 +@@ -5,3 +5,3 @@ Common middle line 2 + Common middle line 3 +-Blob 1 line 2 ++Blob 2 line 2 + Common footer of the file +""" - def test_workdir_to_tree(self): - repo = self.repo - head = repo[repo.lookup_reference('HEAD').resolve().target] - diff = head.tree.diff_to_workdir() - files = [patch.new_file_path for patch in diff] - self.assertEqual(DIFF_HEAD_TO_WORKDIR_EXPECTED, files) +def test_diff_empty_index(dirtyrepo: Repository) -> None: + repo = dirtyrepo + head = repo[repo.lookup_reference('HEAD').resolve().target] - diff = repo.diff('HEAD') - files = [patch.new_file_path for patch in diff] - self.assertEqual(DIFF_HEAD_TO_WORKDIR_EXPECTED, files) + diff = head.tree.diff_to_index(repo.index) + files = [patch.delta.new_file.path for patch in diff] + assert DIFF_HEAD_TO_INDEX_EXPECTED == files - def test_index_to_workdir(self): - diff = self.repo.diff() - files = [patch.new_file_path for patch in diff] - self.assertEqual(DIFF_INDEX_TO_WORK_EXPECTED, files) + diff = repo.diff('HEAD', cached=True) + files = [patch.delta.new_file.path for patch in diff] + assert DIFF_HEAD_TO_INDEX_EXPECTED == files -class DiffTest(utils.BareRepoTestCase): +def test_workdir_to_tree(dirtyrepo: Repository) -> None: + repo = dirtyrepo + head = repo[repo.lookup_reference('HEAD').resolve().target] - def test_diff_invalid(self): - commit_a = self.repo[COMMIT_SHA1_1] - commit_b = self.repo[COMMIT_SHA1_2] - self.assertRaises(TypeError, commit_a.tree.diff_to_tree, commit_b) - self.assertRaises(TypeError, commit_a.tree.diff_to_index, commit_b) + diff = head.tree.diff_to_workdir() + files = [patch.delta.new_file.path for patch in diff] + assert DIFF_HEAD_TO_WORKDIR_EXPECTED == files - def test_diff_empty_index(self): - repo = self.repo - head = repo[repo.lookup_reference('HEAD').resolve().target] + diff = repo.diff('HEAD') + files = [patch.delta.new_file.path for patch in diff] + assert DIFF_HEAD_TO_WORKDIR_EXPECTED == files - diff = self.repo.index.diff_to_tree(head.tree) - files = [patch.new_file_path.split('/')[0] for patch in diff] - self.assertEqual([x.name for x in head.tree], files) - diff = head.tree.diff_to_index(repo.index) - files = [patch.new_file_path.split('/')[0] for patch in diff] - self.assertEqual([x.name for x in head.tree], files) +def test_index_to_workdir(dirtyrepo: Repository) -> None: + diff = dirtyrepo.diff() + files = [patch.delta.new_file.path for patch in diff] + assert DIFF_INDEX_TO_WORK_EXPECTED == files - diff = repo.diff('HEAD', cached=True) - files = [patch.new_file_path.split('/')[0] for patch in diff] - self.assertEqual([x.name for x in head.tree], files) - def test_diff_tree(self): - commit_a = self.repo[COMMIT_SHA1_1] - commit_b = self.repo[COMMIT_SHA1_2] +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) # type: ignore + with pytest.raises(TypeError): + commit_a.tree.diff_to_index(commit_b) # type: ignore - def _test(diff): - # self.assertIsNotNone is 2.7 only - self.assertTrue(diff is not None) - # self.assertIn is 2.7 only - self.assertEqual(2, sum(map(lambda x: len(x.hunks), diff))) - patch = diff[0] - hunk = patch.hunks[0] - self.assertEqual(hunk.old_start, 1) - self.assertEqual(hunk.old_lines, 1) - self.assertEqual(hunk.new_start, 1) - self.assertEqual(hunk.new_lines, 1) +def test_diff_empty_index_bare(barerepo: Repository) -> None: + repo = barerepo + head = repo[repo.lookup_reference('HEAD').resolve().target] - self.assertEqual(patch.old_file_path, 'a') - self.assertEqual(patch.new_file_path, 'a') + diff = barerepo.index.diff_to_tree(head.tree) + files = [patch.delta.new_file.path.split('/')[0] for patch in diff] + assert [x.name for x in head.tree] == files - _test(commit_a.tree.diff_to_tree(commit_b.tree)) - _test(self.repo.diff(COMMIT_SHA1_1, COMMIT_SHA1_2)) + diff = head.tree.diff_to_index(repo.index) + files = [patch.delta.new_file.path.split('/')[0] for patch in 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] + assert [x.name for x in head.tree] == files - def test_diff_empty_tree(self): - commit_a = self.repo[COMMIT_SHA1_1] - diff = commit_a.tree.diff_to_tree() - def get_context_for_lines(diff): - hunks = chain(*map(lambda x: x.hunks, [p for p in diff])) - lines = chain(*map(lambda x: x.lines, hunks)) - return map(lambda x: x[0], lines) +def test_diff_tree(barerepo: Repository) -> None: + commit_a = barerepo[COMMIT_SHA1_1] + commit_b = barerepo[COMMIT_SHA1_2] - entries = [p.new_file_path for p in diff] - self.assertAll(lambda x: commit_a.tree[x], entries) - self.assertAll(lambda x: '-' == x, get_context_for_lines(diff)) + def _test(diff: Diff) -> None: + assert diff is not None + assert 2 == sum(map(lambda x: len(x.hunks), diff)) - diff_swaped = commit_a.tree.diff_to_tree(swap=True) - entries = [p.new_file_path for p in diff_swaped] - self.assertAll(lambda x: commit_a.tree[x], entries) - self.assertAll(lambda x: '+' == x, get_context_for_lines(diff_swaped)) + patch = diff[0] + hunk = patch.hunks[0] + assert hunk.old_start == 1 + assert hunk.old_lines == 1 + assert hunk.new_start == 1 + assert hunk.new_lines == 1 - def test_diff_revparse(self): - diff = self.repo.diff('HEAD', 'HEAD~6') - self.assertEqual(type(diff), pygit2.Diff) + assert not patch.delta.is_binary + assert patch.delta.flags & DiffFlag.NOT_BINARY - def test_diff_tree_opts(self): - commit_c = self.repo[COMMIT_SHA1_3] - commit_d = self.repo[COMMIT_SHA1_4] + for dfile in patch.delta.old_file, patch.delta.new_file: + assert dfile.path == 'a' + assert ( + dfile.flags + == DiffFlag.NOT_BINARY + | DiffFlag.VALID_ID + | DiffFlag.VALID_SIZE + | DiffFlag.EXISTS + ) + assert dfile.mode == FileMode.BLOB - for flag in [GIT_DIFF_IGNORE_WHITESPACE, - GIT_DIFF_IGNORE_WHITESPACE_EOL]: - diff = commit_c.tree.diff_to_tree(commit_d.tree, flag) - self.assertTrue(diff is not None) - self.assertEqual(0, len(diff[0].hunks)) + _test(commit_a.tree.diff_to_tree(commit_b.tree)) + _test(barerepo.diff(COMMIT_SHA1_1, COMMIT_SHA1_2)) - diff = commit_c.tree.diff_to_tree(commit_d.tree) - self.assertTrue(diff is not None) - self.assertEqual(1, len(diff[0].hunks)) - def test_diff_merge(self): - commit_a = self.repo[COMMIT_SHA1_1] - commit_b = self.repo[COMMIT_SHA1_2] - commit_c = self.repo[COMMIT_SHA1_3] +def test_diff_empty_tree(barerepo: Repository) -> None: + commit_a = barerepo[COMMIT_SHA1_1] + diff = commit_a.tree.diff_to_tree() - diff_b = commit_a.tree.diff_to_tree(commit_b.tree) - # self.assertIsNotNone is 2.7 only - self.assertTrue(diff_b is not None) + def get_context_for_lines(diff: Diff) -> Iterator[str]: + hunks = chain.from_iterable(map(lambda x: x.hunks, diff)) + lines = chain.from_iterable(map(lambda x: x.lines, hunks)) + return map(lambda x: x.origin, lines) - diff_c = commit_b.tree.diff_to_tree(commit_c.tree) - # self.assertIsNotNone is 2.7 only - self.assertTrue(diff_c is not None) + entries = [p.delta.new_file.path for p in 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] + assert all(commit_a.tree[x] for x in entries) + assert all('+' == x for x in get_context_for_lines(diff_swaped)) - # assertIn / assertNotIn are 2.7 only - self.assertFalse('b' in [patch.new_file_path for patch in diff_b]) - self.assertTrue('b' in [patch.new_file_path for patch in diff_c]) - diff_b.merge(diff_c) - - # assertIn is 2.7 only - self.assertTrue('b' in [patch.new_file_path for patch in diff_b]) +def test_diff_revparse(barerepo: Repository) -> None: + diff = barerepo.diff('HEAD', 'HEAD~6') + assert type(diff) is pygit2.Diff - patch = diff_b[0] - hunk = patch.hunks[0] - self.assertEqual(hunk.old_start, 1) - self.assertEqual(hunk.old_lines, 1) - self.assertEqual(hunk.new_start, 1) - self.assertEqual(hunk.new_lines, 1) - - self.assertEqual(patch.old_file_path, 'a') - self.assertEqual(patch.new_file_path, 'a') - - def test_diff_patch(self): - commit_a = self.repo[COMMIT_SHA1_1] - commit_b = self.repo[COMMIT_SHA1_2] - - diff = commit_a.tree.diff_to_tree(commit_b.tree) - self.assertEqual(diff.patch, PATCH) - - def test_diff_oids(self): - commit_a = self.repo[COMMIT_SHA1_1] - commit_b = self.repo[COMMIT_SHA1_2] - patch = commit_a.tree.diff_to_tree(commit_b.tree)[0] - self.assertEqual(patch.old_oid, - '7f129fd57e31e935c6d60a0c794efe4e6927664b') - self.assertEqual(patch.new_oid, - 'af431f20fc541ed6d5afede3e2dc7160f6f01f16') - - def test_hunk_content(self): - commit_a = self.repo[COMMIT_SHA1_1] - commit_b = self.repo[COMMIT_SHA1_2] - patch = commit_a.tree.diff_to_tree(commit_b.tree)[0] - hunk = patch.hunks[0] - lines = ('{0} {1}'.format(*x) for x in hunk.lines) - self.assertEqual(HUNK_EXPECTED, ''.join(lines)) - - def test_find_similar(self): - commit_a = self.repo[COMMIT_SHA1_6] - commit_b = self.repo[COMMIT_SHA1_7] - - #~ Must pass GIT_DIFF_INCLUDE_UNMODIFIED if you expect to emulate - #~ --find-copies-harder during rename transformion... - diff = commit_a.tree.diff_to_tree(commit_b.tree, - GIT_DIFF_INCLUDE_UNMODIFIED) - self.assertAll(lambda x: x.status != 'R', diff) - diff.find_similar() - self.assertAny(lambda x: x.status == 'R', diff) - -if __name__ == '__main__': - unittest.main() + +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) + + diff = commit_c.tree.diff_to_tree(commit_d.tree) + assert diff is not None + assert 1 == len(diff[0].hunks) + + +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] + + diff_b = commit_a.tree.diff_to_tree(commit_b.tree) + assert diff_b is not None + + diff_c = commit_b.tree.diff_to_tree(commit_c.tree) + assert diff_c is not None + assert 'b' not in [patch.delta.new_file.path for patch in diff_b] + assert 'b' in [patch.delta.new_file.path for patch in diff_c] + + diff_b.merge(diff_c) + assert 'b' in [patch.delta.new_file.path for patch in diff_b] + + patch = diff_b[0] + hunk = patch.hunks[0] + assert hunk.old_start == 1 + assert hunk.old_lines == 1 + assert hunk.new_start == 1 + assert hunk.new_lines == 1 + + assert patch.delta.old_file.path == 'a' + assert patch.delta.new_file.path == 'a' + + +def test_diff_patch(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) + assert diff.patch == PATCH + assert len(diff) == len([patch for patch in diff]) + + +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] + delta = patch.delta + assert delta.old_file.id == '7f129fd57e31e935c6d60a0c794efe4e6927664b' + assert delta.new_file.id == 'af431f20fc541ed6d5afede3e2dc7160f6f01f16' + + +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) + assert diff.patch == PATCH + assert diff.patchid == PATCHID + + +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] + hunk = patch.hunks[0] + lines = (f'{x.origin} {x.content}' for x in hunk.lines) + assert HUNK_EXPECTED == ''.join(lines) + for line in hunk.lines: + assert line.content == line.raw_content.decode() + + +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) + 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) + + +def test_diff_stats(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) + stats = diff.stats + assert 1 == stats.insertions + assert 2 == stats.deletions + assert 2 == stats.files_changed + formatted = stats.format( + format=DiffStatsFormat.FULL | DiffStatsFormat.INCLUDE_SUMMARY, width=80 + ) + assert STATS_EXPECTED == formatted + + +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) + assert len(deltas) == len(patches) + for i, delta in enumerate(deltas): + patch_delta = patches[i].delta + assert isinstance(delta.status, DeltaStatus) + assert isinstance(patch_delta.status, DeltaStatus) + assert delta.status == patch_delta.status + assert delta.similarity == patch_delta.similarity + assert delta.nfiles == patch_delta.nfiles + assert delta.old_file.id == patch_delta.old_file.id + 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 + + +def test_diff_parse(barerepo: Repository) -> None: + diff = pygit2.Diff.parse_diff(PATCH) + + stats = diff.stats + assert 2 == stats.deletions + assert 1 == stats.insertions + assert 2 == stats.files_changed + + deltas = list(diff.deltas) + assert 2 == len(deltas) + + +def test_parse_diff_null() -> None: + with pytest.raises(TypeError): + pygit2.Diff.parse_diff(None) # type: ignore + + +def test_parse_diff_bad() -> None: + diff = textwrap.dedent( + """ + diff --git a/file1 b/file1 + old mode 0644 + new mode 0644 + @@ -1,1 +1,1 @@ + -Hi! + """ + ) + with pytest.raises(pygit2.GitError): + pygit2.Diff.parse_diff(diff) + + +def test_diff_blobs(emptyrepo: Repository) -> None: + repo = emptyrepo + blob1 = repo.create_blob(TEXT_BLOB1.encode()) + blob2 = repo.create_blob(TEXT_BLOB2.encode()) + diff_default = repo.diff(blob1, blob2) + assert diff_default.text == PATCH_BLOBS_DEFAULT + diff_no_leeway = repo.diff(blob1, blob2, context_lines=0) + assert diff_no_leeway.text == PATCH_BLOBS_NO_LEEWAY + diff_one_context_line = repo.diff(blob1, blob2, context_lines=1) + assert diff_one_context_line.text == PATCH_BLOBS_ONE_CONTEXT_LINE + diff_all_together = repo.diff(blob1, blob2, context_lines=1, interhunk_lines=1) + assert diff_all_together.text == PATCH_BLOBS_DEFAULT diff --git a/test/test_diff_binary.py b/test/test_diff_binary.py new file mode 100644 index 000000000..2947e403d --- /dev/null +++ b/test/test_diff_binary.py @@ -0,0 +1,69 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +from 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: Path) -> Generator[Repository, None, None]: + with utils.TemporaryRepository('binaryfilerepo.zip', tmp_path) as path: + yield pygit2.Repository(path) + + +PATCH_BINARY = """diff --git a/binary_file b/binary_file +index 86e5c10..b835d73 100644 +Binary files a/binary_file and b/binary_file differ +""" + +PATCH_BINARY_SHOW = """diff --git a/binary_file b/binary_file +index 86e5c1008b5ce635d3e3fffa4434c5eccd8f00b6..b835d73543244b6694f36a8c5dfdffb71b153db7 100644 +GIT binary patch +literal 8 +Pc${NM%FIhFs^kIy3n&7R + +literal 8 +Pc${NM&PdElPvrst3ey5{ + +""" + + +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) + assert PATCH_BINARY_SHOW == diff.patch + diff = repo.diff(b'HEAD', b'HEAD^') + assert PATCH_BINARY == diff.patch + diff = repo.diff(b'HEAD', b'HEAD^', flags=DiffOption.SHOW_BINARY) + assert PATCH_BINARY_SHOW == diff.patch diff --git a/test/test_filter.py b/test/test_filter.py new file mode 100644 index 000000000..26a45095f --- /dev/null +++ b/test/test_filter.py @@ -0,0 +1,138 @@ +import codecs +from collections.abc import Callable, Generator +from io import BytesIO + +import pytest + +import pygit2 +from pygit2 import Blob, Filter, FilterSource, Repository +from pygit2.enums import BlobFilter +from pygit2.errors import Passthrough + + +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: 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) -> None: + super().__init__() + self.buf = BytesIO() + + def write( + self, + data: bytes, + src: FilterSource, + write_next: Callable[[bytes], None], + ) -> None: + self.buf.write(data) + + def close(self, write_next: Callable[[bytes], None]) -> None: + write_next(_rot13(self.buf.getvalue())) + + +class _PassthroughFilter(_Rot13Filter): + def check(self, src: FilterSource, attr_values: list[str | None]) -> None: + assert attr_values == [None] + assert src.repo + raise Passthrough + + +class _UnmatchedFilter(_Rot13Filter): + attributes = 'filter=rot13' + + +@pytest.fixture +def rot13_filter() -> Generator[None, None, None]: + pygit2.filter_register('rot13', _Rot13Filter) + yield + pygit2.filter_unregister('rot13') + + +@pytest.fixture +def passthrough_filter() -> Generator[None, None, None]: + pygit2.filter_register('passthrough-rot13', _PassthroughFilter) + yield + pygit2.filter_unregister('passthrough-rot13') + + +@pytest.fixture +def buffered_filter() -> Generator[None, None, None]: + pygit2.filter_register('buffered-rot13', _BufferedFilter) + yield + pygit2.filter_unregister('buffered-rot13') + + +@pytest.fixture +def unmatched_filter() -> Generator[None, None, None]: + pygit2.filter_register('unmatched-rot13', _UnmatchedFilter) + yield + pygit2.filter_unregister('unmatched-rot13') + + +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: + assert b'olr jbeyq\n' == reader.read() + with pygit2.BlobIO(blob, as_path='bye.txt', flags=flags) as reader: + assert b'bye world\n' == reader.read() + + +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: + assert b'olr jbeyq\n' == reader.read() + with pygit2.BlobIO(blob, 'bye.txt', flags=flags) as reader: + assert b'bye world\n' == reader.read() + + +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: + assert b'bye world\n' == reader.read() + with pygit2.BlobIO(blob, 'bye.txt', flags=flags) as reader: + assert b'bye world\n' == reader.read() + + +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: + assert b'bye world\n' == reader.read() + with pygit2.BlobIO(blob, as_path='bye.txt', flags=flags) as reader: + assert b'bye world\n' == reader.read() + + +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() diff --git a/test/test_index.py b/test/test_index.py index 8613020e4..01b04417c 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -1,6 +1,4 @@ -# -*- coding: UTF-8 -*- -# -# Copyright 2010-2013 The pygit2 contributors +# Copyright 2010-2025 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -27,118 +25,315 @@ """Tests for Index files.""" -from __future__ import absolute_import -from __future__ import unicode_literals -import os -import unittest +from pathlib import Path + +import pytest import pygit2 +from pygit2 import Index, IndexEntry, Oid, Repository, Tree +from pygit2.enums import FileMode + from . import utils -class IndexBareTest(utils.BareRepoTestCase): +def test_bare(barerepo: Repository) -> None: + assert len(barerepo.index) == 0 - def test_bare(self): - index = self.repo.index - self.assertEqual(len(index), 0) +def test_index(testrepo: Repository) -> None: + assert testrepo.index is not None -class IndexTest(utils.RepoTestCase): - def test_index(self): - self.assertNotEqual(None, self.repo.index) +def test_read(testrepo: Repository) -> None: + index = testrepo.index + assert len(index) == 2 - def test_read(self): - index = self.repo.index - self.assertEqual(len(index), 2) + with pytest.raises(TypeError): + index[()] + utils.assertRaisesWithArg(ValueError, -4, lambda: index[-4]) + utils.assertRaisesWithArg(KeyError, 'abc', lambda: index['abc']) - self.assertRaises(TypeError, lambda: index[()]) - self.assertRaisesWithArg(ValueError, -4, lambda: index[-4]) - self.assertRaisesWithArg(KeyError, 'abc', lambda: index['abc']) + sha = 'a520c24d85fbfc815d385957eed41406ca5a860b' + assert 'hello.txt' in index + assert index['hello.txt'].id == sha + assert index['hello.txt'].path == 'hello.txt' + assert index[1].id == sha - sha = 'a520c24d85fbfc815d385957eed41406ca5a860b' - self.assertTrue('hello.txt' in index) - self.assertEqual(index['hello.txt'].hex, sha) - self.assertEqual(index['hello.txt'].path, 'hello.txt') - self.assertEqual(index[1].hex, sha) - def test_add(self): - index = self.repo.index +def test_add(testrepo: Repository) -> None: + index = testrepo.index - sha = '0907563af06c7464d62a70cdd135a6ba7d2b41d8' - self.assertFalse('bye.txt' in index) - index.add('bye.txt') - self.assertTrue('bye.txt' in index) - self.assertEqual(len(index), 3) - self.assertEqual(index['bye.txt'].hex, sha) - - def test_clear(self): - index = self.repo.index - self.assertEqual(len(index), 2) - index.clear() - self.assertEqual(len(index), 0) - - def test_write(self): - index = self.repo.index + sha = '0907563af06c7464d62a70cdd135a6ba7d2b41d8' + assert 'bye.txt' not in index + index.add('bye.txt') + assert 'bye.txt' in index + assert len(index) == 3 + assert index['bye.txt'].id == sha + + +def test_add_aspath(testrepo: Repository) -> None: + index = testrepo.index + + assert 'bye.txt' not in index + index.add(Path('bye.txt')) + assert 'bye.txt' in index + + +def test_add_all(testrepo: Repository) -> None: + clear(testrepo) + + sha_bye = '0907563af06c7464d62a70cdd135a6ba7d2b41d8' + sha_hello = 'a520c24d85fbfc815d385957eed41406ca5a860b' + + index = testrepo.index + index.add_all(['*.txt']) + + assert 'bye.txt' in index + assert 'hello.txt' in index + + assert index['bye.txt'].id == sha_bye + assert index['hello.txt'].id == sha_hello + + clear(testrepo) + + index.add_all(['bye.t??', 'hello.*']) + assert 'bye.txt' in index + assert 'hello.txt' in index + + assert index['bye.txt'].id == sha_bye + assert index['hello.txt'].id == sha_hello + + clear(testrepo) + + index.add_all(['[byehlo]*.txt']) + assert 'bye.txt' in index + assert 'hello.txt' in index + + assert index['bye.txt'].id == sha_bye + assert index['hello.txt'].id == sha_hello + + +def test_add_all_aspath(testrepo: Repository) -> None: + clear(testrepo) + + index = testrepo.index + index.add_all([Path('bye.txt'), Path('hello.txt')]) + assert 'bye.txt' in index + assert 'hello.txt' in index + + +def clear(repo: Repository) -> None: + index = repo.index + assert len(index) == 2 + index.clear() + assert len(index) == 0 + + +def test_write(testrepo: Repository) -> None: + index = testrepo.index + index.add('bye.txt') + index.write() + + index.clear() + assert 'bye.txt' not in index + index.read() + assert 'bye.txt' in index + + +def test_read_tree(testrepo: Repository) -> None: + tree_oid = '68aba62e560c0ebc3396e8ae9335232cd93a3f60' + # Test reading first tree + index = testrepo.index + assert len(index) == 2 + index.read_tree(tree_oid) + assert len(index) == 1 + # Test read-write returns the same oid + assert index.write_tree() == tree_oid + # Test the index is only modified in memory + index.read() + assert len(index) == 2 + + +def test_write_tree(testrepo: Repository) -> None: + assert testrepo.index.write_tree() == 'fd937514cb799514d4b81bb24c5fcfeb6472b245' + + +def test_iter(testrepo: Repository) -> None: + index = testrepo.index + n = len(index) + assert len(list(index)) == n + + # Compare SHAs, not IndexEntry object identity + entries = [index[x].id for x in range(n)] + assert list(x.id for x in index) == entries + + +def test_mode(testrepo: Repository) -> None: + """ + Testing that we can access an index entry mode. + """ + index = testrepo.index + + hello_mode = index['hello.txt'].mode + assert hello_mode == 33188 + + +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] + + with pytest.raises(pygit2.GitError): index.add('bye.txt') - index.write() - - index.clear() - self.assertFalse('bye.txt' in index) - index.read() - self.assertTrue('bye.txt' in index) - - - def test_read_tree(self): - tree_oid = '68aba62e560c0ebc3396e8ae9335232cd93a3f60' - # Test reading first tree - index = self.repo.index - self.assertEqual(len(index), 2) - index.read_tree(tree_oid) - self.assertEqual(len(index), 1) - # Test read-write returns the same oid - oid = index.write_tree() - self.assertEqual(oid.hex, tree_oid) - # Test the index is only modified in memory - index.read() - self.assertEqual(len(index), 2) - - - def test_write_tree(self): - oid = self.repo.index.write_tree() - self.assertEqual(oid.hex, 'fd937514cb799514d4b81bb24c5fcfeb6472b245') - - def test_iter(self): - index = self.repo.index - n = len(index) - self.assertEqual(len(list(index)), n) - - # Compare SHAs, not IndexEntry object identity - entries = [index[x].hex for x in range(n)] - self.assertEqual(list(x.hex for x in index), entries) - - def test_mode(self): - """ - Testing that we can access an index entry mode. - """ - index = self.repo.index - - hello_mode = index['hello.txt'].mode - self.assertEqual(hello_mode, 33188) - - def test_bare_index(self): - index = pygit2.Index(os.path.join(self.repo.path, 'index')) - self.assertEqual([x.hex for x in index], - [x.hex for x in self.repo.index]) - - self.assertRaises(pygit2.GitError, lambda: index.add('bye.txt')) - - def test_remove(self): - index = self.repo.index - self.assertTrue('hello.txt' in index) - index.remove('hello.txt') - self.assertFalse('hello.txt' in index) - - -if __name__ == '__main__': - unittest.main() + + +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: Repository) -> None: + index = dirtyrepo.index + assert 'subdir/current_file' in index + index.remove_directory('subdir') + assert 'subdir/current_file' not in index + + +def test_remove_all(testrepo: Repository) -> None: + index = testrepo.index + assert 'hello.txt' in index + index.remove_all(['*.txt']) + assert 'hello.txt' not in index + + index.remove_all(['not-existing']) # this doesn't error + + +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: Repository) -> None: + index = dirtyrepo.index + assert 'subdir/current_file' in index + index.remove_directory(Path('subdir')) + assert 'subdir/current_file' not in index + + +def test_remove_all_aspath(testrepo: Repository) -> None: + index = testrepo.index + assert 'hello.txt' in index + index.remove_all([Path('hello.txt')]) + assert 'hello.txt' not in index + + +def test_change_attributes(testrepo: Repository) -> None: + index = testrepo.index + entry = index['hello.txt'] + ign_entry = index['.gitignore'] + assert ign_entry.id != entry.id + assert entry.mode != FileMode.BLOB_EXECUTABLE + entry.path = 'foo.txt' + entry.id = ign_entry.id + entry.mode = FileMode.BLOB_EXECUTABLE + assert 'foo.txt' == entry.path + assert ign_entry.id == entry.id + assert FileMode.BLOB_EXECUTABLE == entry.mode + + +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) + id = testrepo.index.write_tree(nrepo) + assert nrepo[id] is not None + + +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) + index.add(entry) + assert '60e769e57ae1d6a2ab75d8d253139e6260e1f912' == index.write_tree() + + +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) + index.add(entry) + index.write_tree() + + +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) + assert hello_entry == entry + + entry = pygit2.IndexEntry('README.md', hello_entry.id, hello_entry.mode) + assert hello_entry != entry + oid = Oid(hex='0907563af06c7464d62a70cdd135a6ba7d2b41d8') + entry = pygit2.IndexEntry(hello_entry.path, oid, hello_entry.mode) + assert hello_entry != entry + entry = pygit2.IndexEntry( + hello_entry.path, hello_entry.id, FileMode.BLOB_EXECUTABLE + ) + assert hello_entry != entry + + +def test_entry_repr(testrepo: Repository) -> None: + index = testrepo.index + hello_entry = index['hello.txt'] + assert ( + repr(hello_entry) + == '' + ) + assert ( + str(hello_entry) + == '' + ) + + +def test_create_empty() -> None: + Index() + + +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') # type: ignore + + +def test_create_empty_read_tree(testrepo: Repository) -> None: + index = Index() + tree = testrepo['fd937514cb799514d4b81bb24c5fcfeb6472b245'] + assert isinstance(tree, Tree) + index.read_tree(tree) + + +@utils.fails_in_macos +def test_add_conflict(testrepo: Repository) -> None: + ancestor_blob_id = testrepo.create_blob('ancestor') + ancestor = IndexEntry('conflict.txt', ancestor_blob_id, FileMode.BLOB_EXECUTABLE) + + ours_blob_id = testrepo.create_blob('ours') + ours = IndexEntry('conflict.txt', ours_blob_id, FileMode.BLOB) + + index = Index() + assert index.conflicts is None + + index.add_conflict(ancestor, ours, None) + + assert index.conflicts is not None + assert 'conflict.txt' in index.conflicts + conflict_ancestor, conflict_ours, conflict_theirs = index.conflicts['conflict.txt'] + assert conflict_ancestor.id == ancestor_blob_id + assert conflict_ancestor.mode == FileMode.BLOB_EXECUTABLE + assert conflict_ours.id == ours_blob_id + assert conflict_ours.mode == FileMode.BLOB + assert conflict_theirs is None diff --git a/test/test_mailmap.py b/test/test_mailmap.py new file mode 100644 index 000000000..44da270f4 --- /dev/null +++ b/test/test_mailmap.py @@ -0,0 +1,90 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +"""Tests for Mailmap.""" + +from pygit2 import Mailmap + +TEST_MAILMAP = """\ +# Simple Comment line + +Some Dude nick1 +Other Author nick2 +Other Author +Phil Hill # Comment at end of line + Joseph +Santa Claus +""" + +TEST_ENTRIES = [ + (None, 'cto@company.xx', None, 'cto@coompany.xx'), + ('Some Dude', 'some@dude.xx', 'nick1', 'bugs@company.xx'), + ('Other Author', 'other@author.xx', 'nick2', 'bugs@company.xx'), + ('Other Author', 'other@author.xx', None, 'nick2@company.xx'), + ('Phil Hill', None, None, 'phil@company.xx'), + (None, 'joseph@company.xx', 'Joseph', 'bugs@company.xx'), + ('Santa Claus', 'santa.claus@northpole.xx', None, 'me@company.xx'), +] + +TEST_RESOLVE = [ + ('Brad', 'cto@company.xx', 'Brad', 'cto@coompany.xx'), + ('Brad L', 'cto@company.xx', 'Brad L', 'cto@coompany.xx'), + ('Some Dude', 'some@dude.xx', 'nick1', 'bugs@company.xx'), + ('Other Author', 'other@author.xx', 'nick2', 'bugs@company.xx'), + ('nick3', 'bugs@company.xx', 'nick3', 'bugs@company.xx'), + ('Other Author', 'other@author.xx', 'Some Garbage', 'nick2@company.xx'), + ('Phil Hill', 'phil@company.xx', 'unknown', 'phil@company.xx'), + ('Joseph', 'joseph@company.xx', 'Joseph', 'bugs@company.xx'), + ('Santa Claus', 'santa.claus@northpole.xx', 'Clause', 'me@company.xx'), + ('Charles', 'charles@charles.xx', 'Charles', 'charles@charles.xx'), +] + + +def test_empty() -> None: + mailmap = Mailmap() + + for _, _, name, email in TEST_RESOLVE: + assert mailmap.resolve(name, email) == (name, email) + + +def test_new() -> None: + mailmap = Mailmap() + + # Add entries to the mailmap + for entry in TEST_ENTRIES: + mailmap.add_entry(*entry) + + for real_name, real_email, name, email in TEST_RESOLVE: + assert mailmap.resolve(name, email) == (real_name, real_email) + + +def test_parsed() -> None: + mailmap = Mailmap.from_buffer(TEST_MAILMAP) + + for real_name, real_email, name, email in TEST_RESOLVE: + assert mailmap.resolve(name, email) == (real_name, real_email) + + +# TODO: Add a testcase which uses .mailmap in a repo diff --git a/test/test_merge.py b/test/test_merge.py new file mode 100644 index 000000000..492c00346 --- /dev/null +++ b/test/test_merge.py @@ -0,0 +1,379 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +"""Tests for merging and information about it.""" + +from pathlib import Path + +import pytest + +import pygit2 +from pygit2 import Repository +from pygit2.enums import FileStatus, MergeAnalysis, MergeFavor, MergeFileFlag, MergeFlag + + +@pytest.mark.parametrize('id', [None, 42]) +def test_merge_invalid_type(mergerepo: Repository, id: None | int) -> None: + with pytest.raises(TypeError): + mergerepo.merge(id) # type:ignore + + +# TODO: Once Repository.merge drops support for str arguments, +# add an extra parameter to test_merge_invalid_type above +# to make sure we cover legacy code. +def test_merge_string_argument_deprecated(mergerepo: Repository) -> None: + branch_head_hex = '5ebeeebb320790caf276b9fc8b24546d63316533' + + with pytest.warns(DeprecationWarning, match=r'Pass Commit.+instead'): + mergerepo.merge(branch_head_hex) + + +def test_merge_analysis_uptodate(mergerepo: Repository) -> None: + branch_head_hex = '5ebeeebb320790caf276b9fc8b24546d63316533' + branch_id = mergerepo[branch_head_hex].id + + analysis, preference = mergerepo.merge_analysis(branch_id) + assert analysis & MergeAnalysis.UP_TO_DATE + assert not analysis & MergeAnalysis.FASTFORWARD + assert {} == mergerepo.status() + + analysis, preference = mergerepo.merge_analysis(branch_id, 'refs/heads/ff-branch') + assert analysis & MergeAnalysis.UP_TO_DATE + assert not analysis & MergeAnalysis.FASTFORWARD + assert {} == mergerepo.status() + + +def test_merge_analysis_fastforward(mergerepo: Repository) -> None: + branch_head_hex = 'e97b4cfd5db0fb4ebabf4f203979ca4e5d1c7c87' + branch_id = mergerepo[branch_head_hex].id + + analysis, preference = mergerepo.merge_analysis(branch_id) + assert not analysis & MergeAnalysis.UP_TO_DATE + assert analysis & MergeAnalysis.FASTFORWARD + assert {} == mergerepo.status() + + analysis, preference = mergerepo.merge_analysis(branch_id, 'refs/heads/master') + assert not analysis & MergeAnalysis.UP_TO_DATE + assert analysis & MergeAnalysis.FASTFORWARD + assert {} == mergerepo.status() + + +def test_merge_no_fastforward_no_conflicts(mergerepo: Repository) -> None: + branch_head_hex = '03490f16b15a09913edb3a067a3dc67fbb8d41f1' + 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 + # Asking twice to assure the reference counting is correct + assert {} == mergerepo.status() + assert {} == mergerepo.status() + + +def test_merge_invalid_hex(mergerepo: Repository) -> None: + branch_head_hex = '12345678' + with ( + pytest.raises(KeyError), + pytest.warns(DeprecationWarning, match=r'Pass Commit.+instead'), + ): + mergerepo.merge(branch_head_hex) + + +def test_merge_already_something_in_index(mergerepo: Repository) -> None: + branch_head_hex = '03490f16b15a09913edb3a067a3dc67fbb8d41f1' + branch_oid = mergerepo[branch_head_hex].id + with (Path(mergerepo.workdir) / 'inindex.txt').open('w') as f: + f.write('new content') + mergerepo.index.add('inindex.txt') + with pytest.raises(pygit2.GitError): + mergerepo.merge(branch_oid) + + +def test_merge_no_fastforward_conflicts(mergerepo: Repository) -> None: + branch_head_hex = '1b2bae55ac95a4be3f8983b86cd579226d0eb247' + 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 + + mergerepo.merge(branch_id) + assert mergerepo.index.conflicts is not None + with pytest.raises(KeyError): + mergerepo.index.conflicts.__getitem__('some-file') + assert 'some-file' not in mergerepo.index.conflicts + assert '.gitignore' in mergerepo.index.conflicts + + status = FileStatus.CONFLICTED + # Asking twice to assure the reference counting is correct + assert {'.gitignore': status} == mergerepo.status() + assert {'.gitignore': status} == mergerepo.status() + + ancestor, ours, theirs = mergerepo.index.conflicts['.gitignore'] + assert ancestor is None + assert ours is not None + assert theirs is not None + assert '.gitignore' == ours.path + assert '.gitignore' == theirs.path + assert 1 == len(list(mergerepo.index.conflicts)) + + # Checking the index works as expected + mergerepo.index.add('.gitignore') + mergerepo.index.write() + assert mergerepo.index.conflicts is None + assert {'.gitignore': FileStatus.INDEX_MODIFIED} == mergerepo.status() + + +def test_merge_remove_conflicts(mergerepo: Repository) -> None: + other_branch_tip = pygit2.Oid(hex='1b2bae55ac95a4be3f8983b86cd579226d0eb247') + mergerepo.merge(other_branch_tip) + idx = mergerepo.index + conflicts = idx.conflicts + assert conflicts is not None + assert '.gitignore' in conflicts + try: + conflicts['.gitignore'] + except KeyError: + mergerepo.fail("conflicts['.gitignore'] raised KeyError unexpectedly") # type: ignore + del idx.conflicts['.gitignore'] + with pytest.raises(KeyError): + conflicts.__getitem__('.gitignore') + assert '.gitignore' not in conflicts + assert idx.conflicts is None + + +@pytest.mark.parametrize( + 'favor', + [ + MergeFavor.OURS, + MergeFavor.THEIRS, + MergeFavor.UNION, + ], +) +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: Repository) -> None: + branch_head = pygit2.Oid(hex='1b2bae55ac95a4be3f8983b86cd579226d0eb247') + + with pytest.raises(pygit2.GitError, match=r'merge conflicts exist'): + mergerepo.merge( + branch_head, flags=MergeFlag.FIND_RENAMES | MergeFlag.FAIL_ON_CONFLICT + ) + + +def test_merge_commits(mergerepo: Repository) -> None: + branch_head = pygit2.Oid(hex='03490f16b15a09913edb3a067a3dc67fbb8d41f1') + + merge_index = mergerepo.merge_commits(mergerepo.head.target, branch_head) + assert merge_index.conflicts is None + merge_commits_tree = merge_index.write_tree(mergerepo) + + mergerepo.merge(branch_head) + index = mergerepo.index + assert index.conflicts is None + merge_tree = index.write_tree() + + assert merge_tree == merge_commits_tree + + +def test_merge_commits_favor(mergerepo: Repository) -> None: + branch_head = pygit2.Oid(hex='1b2bae55ac95a4be3f8983b86cd579226d0eb247') + + merge_index = mergerepo.merge_commits( + mergerepo.head.target, branch_head, favor=MergeFavor.OURS + ) + assert merge_index.conflicts is None + + # Incorrect favor value + with pytest.raises(TypeError, match=r'favor argument must be MergeFavor'): + mergerepo.merge_commits(mergerepo.head.target, branch_head, favor='foo') # type: ignore + + +def test_merge_trees(mergerepo: Repository) -> None: + branch_id = pygit2.Oid(hex='03490f16b15a09913edb3a067a3dc67fbb8d41f1') + ancestor_id = mergerepo.merge_base(mergerepo.head.target, branch_id) + + merge_index = mergerepo.merge_trees(ancestor_id, mergerepo.head.target, branch_id) + assert merge_index.conflicts is None + merge_commits_tree = merge_index.write_tree(mergerepo) + + mergerepo.merge(branch_id) + index = mergerepo.index + assert index.conflicts is None + merge_tree = index.write_tree() + + assert merge_tree == merge_commits_tree + + +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( + ancestor_id, mergerepo.head.target, branch_head_hex, favor=MergeFavor.OURS + ) + assert merge_index.conflicts is None + + with pytest.raises(TypeError): + mergerepo.merge_trees( + ancestor_id, + mergerepo.head.target, + branch_head_hex, + favor='foo', # type: ignore + ) + + +def test_merge_options() -> None: + favor = MergeFavor.OURS + 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 + ) + assert favor == o1.file_favor + assert flags == o1.flags + assert file_flags == o1.file_flags + + favor = MergeFavor.THEIRS + flags = 0 + file_flags = 0 + o1 = pygit2.Repository._merge_options( + favor=favor, flags=flags, file_flags=file_flags + ) + assert favor == o1.file_favor + assert flags == o1.flags + assert file_flags == o1.file_flags + + favor = MergeFavor.UNION + flags = MergeFlag.FIND_RENAMES | MergeFlag.NO_RECURSIVE + file_flags = ( + MergeFileFlag.STYLE_DIFF3 + | MergeFileFlag.IGNORE_WHITESPACE + | MergeFileFlag.DIFF_PATIENCE + ) + o1 = pygit2.Repository._merge_options( + favor=favor, flags=flags, file_flags=file_flags + ) + assert favor == o1.file_favor + assert flags == o1.flags + assert file_flags == o1.file_flags + + +def test_merge_many(mergerepo: Repository) -> None: + branch_head_hex = '03490f16b15a09913edb3a067a3dc67fbb8d41f1' + branch_id = mergerepo[branch_head_hex].id + ancestor_id = mergerepo.merge_base_many([mergerepo.head.target, branch_id]) + + merge_index = mergerepo.merge_trees( + ancestor_id, mergerepo.head.target, branch_head_hex + ) + assert merge_index.conflicts is None + merge_commits_tree = merge_index.write_tree(mergerepo) + + mergerepo.merge(branch_id) + index = mergerepo.index + assert index.conflicts is None + merge_tree = index.write_tree() + + assert merge_tree == merge_commits_tree + + +def test_merge_octopus(mergerepo: Repository) -> None: + branch_head_hex = '03490f16b15a09913edb3a067a3dc67fbb8d41f1' + branch_id = mergerepo[branch_head_hex].id + ancestor_id = mergerepo.merge_base_octopus([mergerepo.head.target, branch_id]) + + merge_index = mergerepo.merge_trees( + ancestor_id, mergerepo.head.target, branch_head_hex + ) + assert merge_index.conflicts is None + merge_commits_tree = merge_index.write_tree(mergerepo) + + mergerepo.merge(branch_id) + index = mergerepo.index + assert index.conflicts is None + merge_tree = index.write_tree() + + assert merge_tree == merge_commits_tree + + +def test_merge_mergeheads(mergerepo: Repository) -> None: + assert mergerepo.listall_mergeheads() == [] + + branch_head = pygit2.Oid(hex='1b2bae55ac95a4be3f8983b86cd579226d0eb247') + mergerepo.merge(branch_head) + + assert mergerepo.listall_mergeheads() == [branch_head] + + mergerepo.state_cleanup() + assert mergerepo.listall_mergeheads() == [], ( + 'state_cleanup() should wipe the mergeheads' + ) + + +def test_merge_message(mergerepo: Repository) -> None: + assert not mergerepo.message + assert not mergerepo.raw_message + + branch_head = pygit2.Oid(hex='1b2bae55ac95a4be3f8983b86cd579226d0eb247') + mergerepo.merge(branch_head) + + assert mergerepo.message.startswith(f"Merge commit '{branch_head}'") + assert mergerepo.message.encode('utf-8') == mergerepo.raw_message + + mergerepo.state_cleanup() + assert not mergerepo.message + + +def test_merge_remove_message(mergerepo: Repository) -> None: + branch_head = pygit2.Oid(hex='1b2bae55ac95a4be3f8983b86cd579226d0eb247') + mergerepo.merge(branch_head) + + assert mergerepo.message.startswith(f"Merge commit '{branch_head}'") + mergerepo.remove_message() + assert not mergerepo.message + + +def test_merge_commit(mergerepo: Repository) -> None: + commit = mergerepo['1b2bae55ac95a4be3f8983b86cd579226d0eb247'] + assert isinstance(commit, pygit2.Commit) + mergerepo.merge(commit) + + assert mergerepo.message.startswith(f"Merge commit '{str(commit.id)}'") + assert mergerepo.listall_mergeheads() == [commit.id] + + +def test_merge_reference(mergerepo: Repository) -> None: + branch = mergerepo.branches.local['branch-conflicts'] + branch_head_hex = '1b2bae55ac95a4be3f8983b86cd579226d0eb247' + mergerepo.merge(branch) + + assert mergerepo.message.startswith("Merge branch 'branch-conflicts'") + assert mergerepo.listall_mergeheads() == [pygit2.Oid(hex=branch_head_hex)] diff --git a/test/test_nonunicode.py b/test/test_nonunicode.py new file mode 100644 index 000000000..cdf1ce167 --- /dev/null +++ b/test/test_nonunicode.py @@ -0,0 +1,59 @@ +# Copyright 2010-2024 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +"""Tests for non unicode byte strings""" + +import os +import shutil +import sys + +import pytest + +import pygit2 +from pygit2 import Repository + +from . import utils + +# FIXME Detect the filesystem rather than the operating system +works_in_linux = pytest.mark.xfail( + sys.platform != 'linux', + reason='fails in macOS/Windows, and also in Linux with the FAT filesystem', +) + + +@utils.requires_network +@works_in_linux +def test_nonunicode_branchname(testrepo: Repository) -> None: + folderpath = 'temp_repo_nonutf' + if os.path.exists(folderpath): + shutil.rmtree(folderpath) + newrepo = pygit2.clone_repository( + path=folderpath, url='https://github.com/pygit2/test_branch_notutf.git' + ) + bstring = b'\xc3master' + assert bstring in [ + (ref.split('/')[-1]).encode('utf8', 'surrogateescape') + for ref in newrepo.listall_references() + ] # Remote branch among references: 'refs/remotes/origin/\udcc3master' diff --git a/test/test_note.py b/test/test_note.py index a0ef44385..6a1846c07 100644 --- a/test/test_note.py +++ b/test/test_note.py @@ -1,6 +1,4 @@ -# -*- coding: UTF-8 -*- -# -# Copyright 2010-2013 The pygit2 contributors +# Copyright 2010-2025 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -27,55 +25,62 @@ """Tests for note objects.""" -from __future__ import absolute_import -from __future__ import unicode_literals -import unittest +import pytest -from pygit2 import Signature -from . import utils +from pygit2 import Blob, Repository, Signature NOTE = ('6c8980ba963cad8b25a9bcaf68d4023ee57370d8', 'note message') NOTES = [ - ('ab533997b80705767be3dae8cbb06a0740809f79', 'First Note - HEAD\n', - '784855caf26449a1914d2cf62d12b9374d76ae78'), - ('d879714d880671ed84f8aaed8b27fca23ba01f27', 'Second Note - HEAD~1\n', - 'f5e5aa4e36ab0fe62ee1ccc6eb8f79b866863b87')] + ( + 'ab533997b80705767be3dae8cbb06a0740809f79', + 'First Note - HEAD\n', + '784855caf26449a1914d2cf62d12b9374d76ae78', + ), + ( + 'd879714d880671ed84f8aaed8b27fca23ba01f27', + 'Second Note - HEAD~1\n', + 'f5e5aa4e36ab0fe62ee1ccc6eb8f79b866863b87', + ), +] + +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 -class NotesTest(utils.BareRepoTestCase): + note = barerepo[note_id] + assert isinstance(note, Blob) + # check the note blob + assert NOTE[1].encode() == note.data - def test_create_note(self): - annotated_id = self.repo.revparse_single('HEAD~3').hex - author = committer = Signature('Foo bar', 'foo@bar.com', 12346, 0) - note_id = self.repo.create_note(NOTE[1], author, committer, - annotated_id) - self.assertEqual(NOTE[0], note_id.hex) - # check the note blob - self.assertEqual(NOTE[1].encode(), self.repo[note_id].data) +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_lookup_note(self): - annotated_id = self.repo.head.target.hex - note = self.repo.lookup_note(annotated_id) - self.assertEqual(NOTES[0][0], note.oid.hex) - self.assertEqual(NOTES[0][1], note.message) - def test_remove_note(self): - head = self.repo.head - note = self.repo.lookup_note(head.target.hex) - author = committer = Signature('Foo bar', 'foo@bar.com', 12346, 0) - note.remove(author, committer) - self.assertRaises(KeyError, self.repo.lookup_note, head.target.hex) +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) + note.remove(author, committer) + with pytest.raises(KeyError): + barerepo.lookup_note(str(head.target)) - def test_iterate_notes(self): - for i, note in enumerate(self.repo.notes()): - entry = (note.oid.hex, note.message, note.annotated_id) - self.assertEqual(NOTES[i], entry) - def test_iterate_non_existing_ref(self): - self.assertRaises(KeyError, self.repo.notes, "refs/notes/bad_ref") +def test_iterate_notes(barerepo: Repository) -> None: + for i, note in enumerate(barerepo.notes()): + note_id, message, annotated_id = NOTES[i] + assert note_id == note.id + assert message == note.message + assert annotated_id == note.annotated_id -if __name__ == '__main__': - unittest.main() +def test_iterate_non_existing_ref(barerepo: Repository) -> None: + with pytest.raises(KeyError): + barerepo.notes('refs/notes/bad_ref') # type: ignore diff --git a/test/test_object.py b/test/test_object.py new file mode 100644 index 000000000..fa1a83119 --- /dev/null +++ b/test/test_object.py @@ -0,0 +1,144 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +"""Tests for Object objects.""" + +import pytest + +from pygit2 import Commit, Object, Oid, Repository, Tag, Tree +from pygit2.enums import ObjectType + +BLOB_SHA = 'a520c24d85fbfc815d385957eed41406ca5a860b' +BLOB_CONTENT = """hello world +hola mundo +bonjour le monde +""".encode() +BLOB_NEW_CONTENT = b'foo bar\n' +BLOB_FILE_CONTENT = b'bye world\n' + + +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] + commit_b = testrepo[commit_id] + + assert commit_a is not commit_b + assert commit_a == commit_b + assert not (commit_a != commit_b) + + +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] + commit_b = testrepo[commit_id] + + assert hash(commit_a) + + assert commit_a is not commit_b + assert commit_a == commit_b + # if the commits are equal then their hash *must* be equal + # but different objects can have the same commit + assert hash(commit_a) == hash(commit_b) + + # sanity check that python container types work as expected + s = set() + s.add(commit_a) + s.add(commit_b) + assert len(s) == 1 + assert commit_a in s + assert commit_b in s + + d = {} + d[commit_a] = True + assert commit_b in d + assert d[commit_b] + + assert commit_b == commit_a + + +def test_peel_commit(testrepo: Repository) -> None: + # start by looking up the commit + commit_id = testrepo.lookup_reference('refs/heads/master').target + commit = testrepo[commit_id] + # and peel to the tree + tree = commit.peel(ObjectType.TREE) + + assert type(tree) is Tree + assert tree.id == 'fd937514cb799514d4b81bb24c5fcfeb6472b245' + + +def test_peel_commit_type(testrepo: Repository) -> None: + commit_id = testrepo.lookup_reference('refs/heads/master').target + commit = testrepo[commit_id] + tree = commit.peel(Tree) + + assert type(tree) is Tree + assert tree.id == 'fd937514cb799514d4b81bb24c5fcfeb6472b245' + + +def test_invalid(testrepo: Repository) -> None: + commit_id = testrepo.lookup_reference('refs/heads/master').target + commit = testrepo[commit_id] + + with pytest.raises(ValueError): + commit.peel(ObjectType.TAG) + + +def test_invalid_type(testrepo: Repository) -> None: + commit_id = testrepo.lookup_reference('refs/heads/master').target + commit = testrepo[commit_id] + + with pytest.raises(ValueError): + commit.peel(Tag) + + +def test_short_id(testrepo: Repository) -> None: + seen: dict[str, Oid] = {} # from short_id to full hex id + + 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) + if already: + assert already == obj.id + else: + seen[short_id] = obj.id + lookup = testrepo[short_id] + assert obj.id == lookup.id + + for commit in testrepo.walk(testrepo.head.target): + test_obj(commit, f'commit#{commit.id}') + tree = commit.tree + test_obj(tree, f'tree#{tree.id}') + for entry in tree: + test_obj(testrepo[entry.id], f'entry={entry.name}#{entry.id}') + + +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 new file mode 100644 index 000000000..4d023135f --- /dev/null +++ b/test/test_odb.py @@ -0,0 +1,95 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +"""Tests for Odb objects.""" + +# Standard Library +import binascii +from collections.abc import Generator +from pathlib import Path + +import pytest + +# pygit2 +from pygit2 import Odb, Oid, Repository +from pygit2.enums import ObjectType + +from . import utils + +BLOB_HEX = 'af431f20fc541ed6d5afede3e2dc7160f6f01f16' +BLOB_RAW = binascii.unhexlify(BLOB_HEX.encode('ascii')) +BLOB_OID = Oid(raw=BLOB_RAW) + + +def test_emptyodb(barerepo: Repository) -> None: + odb = Odb() + + assert len(list(odb)) == 0 + assert BLOB_HEX not in odb + path = Path(barerepo.path) / 'objects' + odb.add_disk_alternate(path) + assert BLOB_HEX in odb + + +@pytest.fixture +def odb(barerepo: Repository) -> Generator[Odb, None, None]: + odb = barerepo.odb + yield odb + + +def test_iterable(odb: Odb) -> None: + assert BLOB_HEX in odb + + +def test_contains(odb: Odb) -> None: + assert BLOB_HEX in odb + + +def test_read(odb: Odb) -> None: + with pytest.raises(TypeError): + 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 + + a2 = odb.read('7f129fd57e31e935c6d60a0c794efe4e6927664b') + assert (ObjectType.BLOB, b'a contents 2\n') == a2 + + a_hex_prefix = BLOB_HEX[:4] + a3 = odb.read(a_hex_prefix) + assert (ObjectType.BLOB, b'a contents\n') == a3 + + +def test_write(odb: Odb) -> None: + data = b'hello world' + # invalid object type + with pytest.raises(ValueError): + odb.write(ObjectType.ANY, data) + + oid = odb.write(ObjectType.BLOB, data) + assert type(oid) is Oid diff --git a/test/test_odb_backend.py b/test/test_odb_backend.py new file mode 100644 index 000000000..89c215726 --- /dev/null +++ b/test/test_odb_backend.py @@ -0,0 +1,171 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +"""Tests for Odb backends.""" + +# 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 + +BLOB_HEX = 'af431f20fc541ed6d5afede3e2dc7160f6f01f16' +BLOB_RAW = binascii.unhexlify(BLOB_HEX.encode('ascii')) +BLOB_OID = pygit2.Oid(raw=BLOB_RAW) + + +@pytest.fixture +def odb_path(barerepo: Repository) -> Generator[tuple[Odb, Path], None, None]: + yield barerepo.odb, Path(barerepo.path) / 'objects' + + +def test_pack(odb_path: tuple[Odb, Path]) -> None: + odb, path = odb_path + + pack = pygit2.OdbBackendPack(path) + assert len(list(pack)) > 0 + for obj in pack: + assert obj in 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 + for obj in pack: + assert obj in odb + + +class ProxyBackend(pygit2.OdbBackend): + def __init__(self, source: pygit2.OdbBackend | pygit2.OdbBackendPack) -> None: + super().__init__() + self.source = source + + def read_cb(self, oid: Oid | str) -> tuple[int, bytes]: + return self.source.read(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: Oid | str) -> tuple[int, int]: + typ, data = self.source.read(oid) + return typ, len(data) + + def exists_cb(self, oid: Oid | str) -> bool: + return self.source.exists(oid) + + def exists_prefix_cb(self, oid: Oid | str) -> Oid: + return self.source.exists_prefix(oid) + + def refresh_cb(self) -> None: + self.source.refresh() + + def __iter__(self) -> Iterator[Oid]: + return iter(self.source) + + +# +# Test a custom object backend alone (without adding it to an ODB) +# This doesn't make much sense, but it's possible. +# + + +@pytest.fixture +def proxy(barerepo: Repository) -> Generator[ProxyBackend, None, None]: + path = Path(barerepo.path) / 'objects' + yield ProxyBackend(pygit2.OdbBackendPack(path)) + + +def test_iterable(proxy: ProxyBackend) -> None: + assert BLOB_HEX in [o for o in proxy] + + +def test_read(proxy: ProxyBackend) -> None: + with pytest.raises(TypeError): + proxy.read(123) # type: ignore + utils.assertRaisesWithArg(KeyError, '1' * 40, proxy.read, '1' * 40) + + ab = proxy.read(BLOB_OID) + a = proxy.read(BLOB_HEX) + assert ab == a + assert (ObjectType.BLOB, b'a contents\n') == a + + +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: ProxyBackend) -> None: + with pytest.raises(TypeError): + proxy.exists(123) # type: ignore + + assert not proxy.exists('1' * 40) + assert proxy.exists(BLOB_HEX) + + +def test_exists_prefix(proxy: ProxyBackend) -> None: + a_hex_prefix = BLOB_HEX[:4] + assert BLOB_HEX == proxy.exists_prefix(a_hex_prefix) + + +# +# Test a custom object backend, through a Repository. +# + + +@pytest.fixture +def repo(barerepo: Repository) -> Generator[Repository, None, None]: + odb = pygit2.Odb() + + path = Path(barerepo.path) / 'objects' + backend_org = pygit2.OdbBackendPack(path) + backend = ProxyBackend(backend_org) + odb.add_backend(backend, 1) + + repo = pygit2.Repository() + repo.set_odb(odb) + yield repo + + +def test_repo_read(repo: Repository) -> None: + with pytest.raises(TypeError): + repo[123] # type: ignore + + utils.assertRaisesWithArg(KeyError, '1' * 40, repo.__getitem__, '1' * 40) + + ab = repo[BLOB_OID] + a = repo[BLOB_HEX] + assert ab == a diff --git a/test/test_oid.py b/test/test_oid.py index e631399b2..d57432bff 100644 --- a/test/test_oid.py +++ b/test/test_oid.py @@ -1,6 +1,4 @@ -# -*- coding: UTF-8 -*- -# -# Copyright 2010-2013 The pygit2 contributors +# Copyright 2010-2025 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -27,84 +25,84 @@ """Tests for Object ids.""" -# Import from the future -from __future__ import absolute_import -from __future__ import unicode_literals - -# Import from the Standard Library +# Standard Library from binascii import unhexlify -from sys import version_info -import unittest -# Import from pygit2 -from pygit2 import Oid -from . import utils +import pytest +from pygit2 import Oid -HEX = "15b648aec6ed045b5ca6f57f8b7831a8b4757298" +HEX = '15b648aec6ed045b5ca6f57f8b7831a8b4757298' RAW = unhexlify(HEX.encode('ascii')) -class OidTest(utils.BareRepoTestCase): +def test_raw() -> None: + oid = Oid(raw=RAW) + assert oid.raw == RAW + assert oid == HEX + + +def test_hex() -> None: + oid = Oid(hex=HEX) + assert oid.raw == RAW + assert oid == HEX + + +def test_hex_bytes() -> None: + hex = bytes(HEX, 'ascii') + with pytest.raises(TypeError): + Oid(hex=hex) # type: ignore + + +def test_none() -> None: + with pytest.raises(ValueError): + Oid() - def test_raw(self): - oid = Oid(raw=RAW) - self.assertEqual(oid.raw, RAW) - self.assertEqual(oid.hex, HEX) - def test_hex(self): - oid = Oid(hex=HEX) - self.assertEqual(oid.raw, RAW) - self.assertEqual(oid.hex, HEX) +def test_both() -> None: + with pytest.raises(ValueError): + Oid(raw=RAW, hex=HEX) - def test_hex_bytes(self): - if version_info[0] == 2: - hex = bytes(HEX) - oid = Oid(hex=hex) - self.assertEqual(oid.raw, RAW) - self.assertEqual(oid.hex, HEX) - else: - hex = bytes(HEX, "ascii") - self.assertRaises(TypeError, Oid, hex=hex) - def test_none(self): - self.assertRaises(ValueError, Oid) +def test_long() -> None: + with pytest.raises(ValueError): + Oid(raw=RAW + b'a') + with pytest.raises(ValueError): + Oid(hex=HEX + 'a') - def test_both(self): - self.assertRaises(ValueError, Oid, raw=RAW, hex=HEX) - def test_long(self): - self.assertRaises(ValueError, Oid, raw=RAW + b'a') - self.assertRaises(ValueError, Oid, hex=HEX + 'a') +def test_cmp() -> None: + oid1 = Oid(raw=RAW) - def test_cmp(self): - oid1 = Oid(raw=RAW) + # Equal + oid2 = Oid(hex=HEX) + assert oid1 == oid2 - # Equal - oid2 = Oid(hex=HEX) - self.assertEqual(oid1, oid2) + # Not equal + oid2 = Oid(hex='15b648aec6ed045b5ca6f57f8b7831a8b4757299') + assert oid1 != oid2 - # Not equal - oid2 = Oid(hex="15b648aec6ed045b5ca6f57f8b7831a8b4757299") - self.assertNotEqual(oid1, oid2) + # Other + assert oid1 < oid2 + assert oid1 <= oid2 + assert not oid1 == oid2 + assert not oid1 > oid2 + assert not oid1 >= oid2 - # Other - self.assertTrue(oid1 < oid2) - self.assertTrue(oid1 <= oid2) - self.assertFalse(oid1 == oid2) - self.assertFalse(oid1 > oid2) - self.assertFalse(oid1 >= oid2) - def test_hash(self): - s = set() - s.add(Oid(raw=RAW)) - s.add(Oid(hex=HEX)) - self.assertEqual(len(s), 1) +def test_hash() -> None: + s = set() + s.add(Oid(raw=RAW)) + s.add(Oid(hex=HEX)) + assert len(s) == 1 - s.add(Oid(hex="0000000000000000000000000000000000000000")) - s.add(Oid(hex="0000000000000000000000000000000000000001")) - self.assertEqual(len(s), 3) + s.add(Oid(hex='0000000000000000000000000000000000000000')) + s.add(Oid(hex='0000000000000000000000000000000000000001')) + assert len(s) == 3 -if __name__ == '__main__': - unittest.main() +def test_bool() -> None: + assert Oid(raw=RAW) + assert Oid(hex=HEX) + assert not Oid(raw=b'') + assert not Oid(hex='0000000000000000000000000000000000000000') diff --git a/test/test_options.py b/test/test_options.py new file mode 100644 index 000000000..6a1c1ac08 --- /dev/null +++ b/test/test_options.py @@ -0,0 +1,262 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +import sys + +import pytest + +import pygit2 +from pygit2 import option +from pygit2.enums import ConfigLevel, ObjectType, Option + + +def __option(getter: Option, setter: Option, value: object) -> None: + old_value = option(getter) + option(setter, value) + assert value == option(getter) + # Reset to avoid side effects in later tests + option(setter, old_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) + # Reset to avoid side effects in later tests + setattr(pygit2.settings, name, old_value) + + +def test_mwindow_size() -> None: + __option(Option.GET_MWINDOW_SIZE, Option.SET_MWINDOW_SIZE, 200 * 1024) + + +def test_mwindow_size_proxy() -> None: + __proxy('mwindow_size', 300 * 1024) + + +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() -> None: + __proxy('mwindow_mapped_limit', 300 * 1024) + + +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() -> None: + new_limit = 4 * 1024 + pygit2.settings.cache_object_limit(ObjectType.BLOB, new_limit) + + +def test_cached_memory() -> None: + value = option(Option.GET_CACHED_MEMORY) + assert value[1] == 256 * 1024**2 + + +def test_cached_memory_proxy() -> None: + assert pygit2.settings.cached_memory[1] == 256 * 1024**2 + + +def test_enable_caching() -> None: + pygit2.settings.enable_caching(False) + pygit2.settings.enable_caching(True) + # Lower level API + option(Option.ENABLE_CACHING, False) + option(Option.ENABLE_CACHING, True) + + +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 + option(Option.DISABLE_PACK_KEEP_FILE_CHECKS, False) + option(Option.DISABLE_PACK_KEEP_FILE_CHECKS, True) + + +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() -> None: + paths = [ + (ConfigLevel.GLOBAL, '/tmp/global'), + (ConfigLevel.XDG, '/tmp/xdg'), + (ConfigLevel.SYSTEM, '/tmp/etc'), + ] + + for level, path in paths: + option(Option.SET_SEARCH_PATH, level, path) + assert path == option(Option.GET_SEARCH_PATH, level) + + +def test_search_path_proxy() -> None: + paths = [ + (ConfigLevel.GLOBAL, '/tmp2/global'), + (ConfigLevel.XDG, '/tmp2/xdg'), + (ConfigLevel.SYSTEM, '/tmp2/etc'), + ] + + for level, path in paths: + pygit2.settings.search_path[level] = path + assert path == pygit2.settings.search_path[level] + + +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 new file mode 100644 index 000000000..5309f3f13 --- /dev/null +++ b/test/test_packbuilder.py @@ -0,0 +1,118 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +"""Tests for Index files.""" + +from collections.abc import Callable +from pathlib import Path + +import pygit2 +from pygit2 import Oid, PackBuilder, Repository + +from . import utils + + +def test_create_packbuilder(testrepo: Repository) -> None: + # simple test of PackBuilder creation + packbuilder = PackBuilder(testrepo) + assert len(packbuilder) == 0 + + +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] + packbuilder.add(objects_to_add[0]) + assert len(packbuilder) == 1 + packbuilder.add(objects_to_add[1]) + assert len(packbuilder) == 2 + + +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: + # Commit + # Tree + # Blob: hello.txt + # Blob: .gitignore + + assert len(packbuilder) == 4 + + +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: Repository, tmp_path: Path) -> None: + # loop through all branches and add each commit to the packbuilder + def pack_delegate(pb: PackBuilder) -> None: + for branch in pb._repo.branches: + br = pb._repo.branches.get(branch) + for commit in br.log(): + pb.add_recur(commit.oid_new) + + confirm_same_repo_after_packing(testrepo, tmp_path, pack_delegate) + + +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: + testrepo = pygit2.Repository(path) + return testrepo + + +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) + + objects_dir = pack_repo_path / 'objects' + utils.rmtree(objects_dir) + pack_path = objects_dir / 'pack' + pack_path.mkdir(parents=True) + + # assert that the number of written objects is the same as the number of objects in the repo + written_objects = testrepo.pack(pack_path, pack_delegate=pack_delegate) + assert written_objects == len([obj for obj in testrepo]) + + # assert that the number of objects in the pack repo is the same as the original repo + orig_objects = [obj for obj in testrepo.odb] + packed_objects = [obj for obj in pack_repo.odb] + assert len(packed_objects) == len(orig_objects) + + # assert that the objects in the packed repo are the same objects as the original repo + for i, obj in enumerate(orig_objects): + assert pack_repo[obj].type == testrepo[obj].type + assert pack_repo[obj].read_raw() == testrepo[obj].read_raw() diff --git a/test/test_patch.py b/test/test_patch.py new file mode 100644 index 000000000..4b74dd572 --- /dev/null +++ b/test/test_patch.py @@ -0,0 +1,287 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +import pytest + +import pygit2 +from pygit2 import Blob, Repository + +BLOB_OLD_SHA = 'a520c24d85fbfc815d385957eed41406ca5a860b' +BLOB_NEW_SHA = '3b18e512dba79e4c8300dd08aeb37f8e728b8dad' +BLOB_OLD_CONTENT = b"""hello world +hola mundo +bonjour le monde +""" +BLOB_NEW_CONTENT = b'foo bar\n' + +BLOB_OLD_PATH = 'a/file' +BLOB_NEW_PATH = 'b/file' + +BLOB_PATCH2 = """diff --git a/a/file b/b/file +index a520c24..3b18e51 100644 +--- a/a/file ++++ b/b/file +@@ -1,3 +1 @@ + hello world +-hola mundo +-bonjour le monde +""" + +BLOB_PATCH = """diff --git a/a/file b/b/file +index a520c24..d675fa4 100644 +--- a/a/file ++++ b/b/file +@@ -1,3 +1 @@ +-hello world +-hola mundo +-bonjour le monde ++foo bar +""" + +BLOB_PATCH_ADDED = """diff --git a/a/file b/b/file +new file mode 100644 +index 0000000..d675fa4 +--- /dev/null ++++ b/b/file +@@ -0,0 +1 @@ ++foo bar +""" + +BLOB_PATCH_DELETED = """diff --git a/a/file b/b/file +deleted file mode 100644 +index a520c24..0000000 +--- a/a/file ++++ /dev/null +@@ -1,3 +0,0 @@ +-hello world +-hola mundo +-bonjour le monde +""" + + +def test_patch_create_from_buffers() -> None: + patch = pygit2.Patch.create_from( + BLOB_OLD_CONTENT, + BLOB_NEW_CONTENT, + old_as_path=BLOB_OLD_PATH, + new_as_path=BLOB_NEW_PATH, + ) + + assert patch.text == BLOB_PATCH + + +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, + new_blob, + old_as_path=BLOB_OLD_PATH, + new_as_path=BLOB_NEW_PATH, + ) + + assert patch.text == BLOB_PATCH2 + + +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, + old_as_path=BLOB_OLD_PATH, + new_as_path=BLOB_NEW_PATH, + ) + + assert patch.text == BLOB_PATCH + + +def test_patch_create_from_blob_buffer_add(testrepo: Repository) -> None: + patch = pygit2.Patch.create_from( + None, + BLOB_NEW_CONTENT, + old_as_path=BLOB_OLD_PATH, + new_as_path=BLOB_NEW_PATH, + ) + + assert patch.text == BLOB_PATCH_ADDED + + +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, + None, + old_as_path=BLOB_OLD_PATH, + new_as_path=BLOB_NEW_PATH, + ) + + assert patch.text == BLOB_PATCH_DELETED + + +def test_patch_create_from_bad_old_type_arg(testrepo: Repository) -> None: + with pytest.raises(TypeError): + pygit2.Patch.create_from(testrepo, BLOB_NEW_CONTENT) # type: ignore + + +def test_patch_create_from_bad_new_type_arg(testrepo: Repository) -> None: + with pytest.raises(TypeError): + pygit2.Patch.create_from(None, testrepo) # type: ignore + + +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, + new_blob, + old_as_path=BLOB_OLD_PATH, + 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(' ')] + ) + + assert context_count != 0 + + +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, + new_blob, + old_as_path=BLOB_OLD_PATH, + new_as_path=BLOB_NEW_PATH, + context_lines=0, + ) + + assert patch.text is not None + context_count = len( + [line for line in patch.text.splitlines() if line.startswith(' ')] + ) + + assert context_count == 0 + + +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, + new_blob, + old_as_path=BLOB_OLD_PATH, + new_as_path=BLOB_NEW_PATH, + ) + + assert patch.text == BLOB_PATCH + + +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, + old_as_path=BLOB_OLD_PATH, + new_as_path=BLOB_NEW_PATH, + ) + + assert patch.text == BLOB_PATCH + + +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, + old_as_path=BLOB_OLD_PATH, + new_as_path=BLOB_NEW_PATH, + ) + + assert patch.text == BLOB_PATCH_DELETED + + +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, + old_as_path=BLOB_OLD_PATH, + new_as_path=BLOB_NEW_PATH, + ) + + assert patch.text == BLOB_PATCH_ADDED + + +def test_patch_delete_blob(testrepo: Repository) -> None: + blob = testrepo[BLOB_OLD_SHA] + assert isinstance(blob, Blob) + patch = pygit2.Patch.create_from( + blob, + None, + old_as_path=BLOB_OLD_PATH, + new_as_path=BLOB_NEW_PATH, + ) + + # Make sure that even after deleting the blob the patch still has the + # necessary references to generate its patch + del blob + assert patch.text == BLOB_PATCH_DELETED + + +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 + + assert patch_text == patch_text2 + assert patch_text == patch.text + assert patch_text2 == patch2.text + assert patch.text == patch2.text diff --git a/test/test_patch_encoding.py b/test/test_patch_encoding.py new file mode 100644 index 000000000..4a151a70d --- /dev/null +++ b/test/test_patch_encoding.py @@ -0,0 +1,75 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +import 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 +--- a/iso-8859-1.txt ++++ b/iso-8859-1.txt +@@ -1 +1,2 @@ + Kristian H\xf8gsberg ++foo +""" + + +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' + patch = pygit2.Patch.create_from( + old_content, + new_content, + old_as_path='iso-8859-1.txt', + new_as_path='iso-8859-1.txt', + ) + + assert patch.data == expected_diff + assert patch.text == expected_diff.decode('utf-8', errors='replace') + + # `patch.text` corrupted the ISO-8859-1 content as it forced UTF-8 + # decoding, so assert that we cannot get the original content back: + assert patch.text.encode('utf-8') != expected_diff + + +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( + old_content, + new_content, + old_as_path='iso-8859-1.txt', + new_as_path='iso-8859-1.txt', + ) + + assert patch.data == expected_diff + assert patch.text == expected_diff.decode('utf-8', errors='replace') + + # `patch.text` corrupted the ISO-8859-1 content as it forced UTF-8 + # decoding, so assert that we cannot get the original content back: + assert patch.text.encode('utf-8') != expected_diff diff --git a/test/test_refdb_backend.py b/test/test_refdb_backend.py new file mode 100644 index 000000000..bfc375515 --- /dev/null +++ b/test/test_refdb_backend.py @@ -0,0 +1,140 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +"""Tests for Refdb objects.""" + +from collections.abc import Generator, Iterator +from pathlib import Path + +import pytest + +import pygit2 +from pygit2 import Commit, Oid, Reference, Repository, Signature + + +# Note: the refdb abstraction from libgit2 is meant to provide information +# which libgit2 transforms into something more useful, and in general YMMV by +# using the backend directly. So some of these tests are a bit vague or +# incomplete, to avoid hitting the semi-valid states that refdbs produce by +# design. +class ProxyRefdbBackend(pygit2.RefdbBackend): + def __init__(self, source: pygit2.RefdbBackend) -> None: + self.source = source + + def exists(self, ref: str) -> bool: + return self.source.exists(ref) + + def lookup(self, ref: str) -> Reference: + return self.source.lookup(ref) + + def write( + self, + ref: Reference, + force: bool, + who: Signature, + message: str, + old: None | str | Oid, + old_target: None | str, + ) -> None: + return self.source.write(ref, force, who, message, old, old_target) + + def rename( + self, old_name: str, new_name: str, force: bool, who: Signature, message: str + ) -> Reference: + return self.source.rename(old_name, new_name, force, who, message) + + def delete(self, ref_name: str, old_id: Oid | str, old_target: str | None) -> None: + return self.source.delete(ref_name, old_id, old_target) + + def compress(self) -> None: + return self.source.compress() + + def has_log(self, ref_name: str) -> bool: + return self.source.has_log(ref_name) + + def ensure_log(self, ref_name: str) -> bool: + return self.source.ensure_log(ref_name) + + def __iter__(self) -> Iterator[Reference]: + return iter(self.source) + + +@pytest.fixture +def repo(testrepo: Repository) -> Generator[Repository, None, None]: + testrepo.backend = ProxyRefdbBackend(pygit2.RefdbFsBackend(testrepo)) + yield testrepo + + +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: 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: Repository) -> None: + master = repo.backend.lookup('refs/heads/master') + 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: 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: 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: Repository) -> None: + repo = repo + packed_refs_file = Path(repo.path) / 'packed-refs' + assert not packed_refs_file.exists() + repo.backend.compress() + assert packed_refs_file.exists() + + +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: 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 461218527..9ad830b78 100644 --- a/test/test_refs.py +++ b/test/test_refs.py @@ -1,6 +1,4 @@ -# -*- coding: UTF-8 -*- -# -# Copyright 2010-2013 The pygit2 contributors +# Copyright 2010-2025 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -27,189 +25,765 @@ """Tests for reference objects.""" -from __future__ import absolute_import -from __future__ import unicode_literals -import unittest +from pathlib import Path -from pygit2 import GitError, GIT_REF_OID, GIT_REF_SYMBOLIC -from . import utils +import pytest +from pygit2 import ( + AlreadyExistsError, + Commit, + GitError, + InvalidSpecError, + Oid, + Reference, + Repository, + Signature, + Tree, + reference_is_valid_name, +) +from pygit2.enums import ReferenceFilter, ReferenceType LAST_COMMIT = '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98' +def test_refs_list_objects(testrepo: Repository) -> None: + refs = [(ref.name, ref.target) for ref in testrepo.references.objects] + assert sorted(refs) == [ + ('refs/heads/i18n', '5470a671a80ac3789f1a6a8cefbcf43ce7af0563'), + ('refs/heads/master', '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98'), + ] -class ReferencesTest(utils.RepoTestCase): - def test_list_all_references(self): - repo = self.repo +def test_refs_list(testrepo: Repository) -> None: + # Without argument + assert sorted(testrepo.references) == ['refs/heads/i18n', 'refs/heads/master'] - # Without argument - self.assertEqual(sorted(repo.listall_references()), - ['refs/heads/i18n', 'refs/heads/master']) + # We add a symbolic reference + testrepo.create_reference('refs/tags/version1', 'refs/heads/master') + assert sorted(testrepo.references) == [ + 'refs/heads/i18n', + 'refs/heads/master', + 'refs/tags/version1', + ] - # We add a symbolic reference - repo.create_reference('refs/tags/version1', 'refs/heads/master') - self.assertEqual(sorted(repo.listall_references()), - ['refs/heads/i18n', 'refs/heads/master', - 'refs/tags/version1']) - def test_head(self): - head = self.repo.head - self.assertEqual(LAST_COMMIT, self.repo[head.target].hex) +def test_head(testrepo: Repository) -> None: + head = testrepo.head + assert LAST_COMMIT == testrepo[head.target].id + assert not isinstance(head.raw_target, bytes) + assert LAST_COMMIT == testrepo[head.raw_target].id + + +def test_refs_getitem(testrepo: Repository) -> None: + refname = 'refs/foo' + # Raise KeyError ? + with pytest.raises(KeyError): + testrepo.references[refname] + + # Return None ? + assert testrepo.references.get(refname) is 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 + + +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') + assert reference.target == 'refs/heads/i18n' + assert reference.raw_target == b'refs/heads/i18n' + + +def test_refs_get_shorthand(testrepo: Repository) -> None: + reference = testrepo.references.get('refs/heads/master') + assert reference is not None + assert reference.shorthand == 'master' + reference = testrepo.references.create('refs/remotes/origin/master', LAST_COMMIT) + assert reference.shorthand == 'origin/master' + + +def test_refs_set_target_with_message(testrepo: 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') + testrepo.set_ident('foo', 'bar') + msg = 'Hello log' + reference.set_target('refs/heads/i18n', message=msg) + assert reference.target == 'refs/heads/i18n' + assert reference.raw_target == b'refs/heads/i18n' + first = list(reference.log())[0] + assert first.message == msg + assert first.committer == sig + + +def test_refs_delete(testrepo: Repository) -> None: + # We add a tag as a new reference that points to "origin/master" + reference = testrepo.references.create('refs/tags/version1', LAST_COMMIT) + assert 'refs/tags/version1' in testrepo.references + + # And we delete it + reference.delete() + assert 'refs/tags/version1' not in testrepo.references + + # Access the deleted reference + with pytest.raises(GitError): + getattr(reference, 'name') + with pytest.raises(GitError): + getattr(reference, 'type') + with pytest.raises(GitError): + getattr(reference, 'target') + with pytest.raises(GitError): + reference.delete() + with pytest.raises(GitError): + reference.resolve() + with pytest.raises(GitError): + reference.rename('refs/tags/version2') - def test_lookup_reference(self): - repo = self.repo - # Raise KeyError ? - self.assertRaises(KeyError, repo.lookup_reference, 'refs/foo') +def test_refs_rename(testrepo: Repository) -> None: + # We add a tag as a new reference that points to "origin/master" + reference = testrepo.references.create('refs/tags/version1', LAST_COMMIT) + assert reference.name == 'refs/tags/version1' + reference.rename('refs/tags/version2') + assert reference.name == 'refs/tags/version2' - # Test a lookup - reference = repo.lookup_reference('refs/heads/master') - self.assertEqual(reference.name, 'refs/heads/master') + with pytest.raises(AlreadyExistsError): + reference.rename('refs/tags/version2') + with pytest.raises(InvalidSpecError): + reference.rename('b1') - def test_reference_get_sha(self): - reference = self.repo.lookup_reference('refs/heads/master') - self.assertEqual(reference.target.hex, LAST_COMMIT) +# def test_reload(testrepo: Repository) -> None: +# name = 'refs/tags/version1' +# ref = testrepo.create_reference(name, "refs/heads/master", symbolic=True) +# ref2 = testrepo.lookup_reference(name) +# ref.delete() +# assert ref2.name == name +# with pytest.raises(KeyError): ref2.reload() +# with pytest.raises(GitError): getattr(ref2, 'name') - def test_reference_set_sha(self): - NEW_COMMIT = '5ebeeebb320790caf276b9fc8b24546d63316533' - reference = self.repo.lookup_reference('refs/heads/master') - reference.target = NEW_COMMIT - self.assertEqual(reference.target.hex, NEW_COMMIT) - def test_reference_set_sha_prefix(self): - NEW_COMMIT = '5ebeeebb320790caf276b9fc8b24546d63316533' - reference = self.repo.lookup_reference('refs/heads/master') - reference.target = NEW_COMMIT[0:6] - self.assertEqual(reference.target.hex, NEW_COMMIT) +def test_refs_resolve(testrepo: Repository) -> None: + reference = testrepo.references.get('HEAD') + assert reference is not None + assert reference.type == ReferenceType.SYMBOLIC + reference = reference.resolve() + assert reference.type == ReferenceType.DIRECT + assert reference.target == LAST_COMMIT - def test_reference_get_type(self): - reference = self.repo.lookup_reference('refs/heads/master') - self.assertEqual(reference.type, GIT_REF_OID) +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_get_target(self): - reference = self.repo.lookup_reference('HEAD') - self.assertEqual(reference.target, 'refs/heads/master') +def test_refs_create(testrepo: Repository) -> None: + # We add a tag as a new reference that points to "origin/master" + 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 + with pytest.raises(ValueError): + testrepo.references.create('refs/tags/version1', LAST_COMMIT) - def test_set_target(self): - reference = self.repo.lookup_reference('HEAD') - self.assertEqual(reference.target, 'refs/heads/master') - reference.target = 'refs/heads/i18n' - self.assertEqual(reference.target, 'refs/heads/i18n') + # try to create existing reference with force + reference = testrepo.references.create( + 'refs/tags/version1', LAST_COMMIT, force=True + ) + assert reference.target == LAST_COMMIT - def test_delete(self): - repo = self.repo +def test_refs_create_symbolic(testrepo: Repository) -> None: + # We add a tag as a new symbolic reference that always points to + # "refs/heads/master" + reference = testrepo.references.create('refs/tags/beta', 'refs/heads/master') + assert reference.type == ReferenceType.SYMBOLIC + assert reference.target == 'refs/heads/master' + assert reference.raw_target == b'refs/heads/master' - # We add a tag as a new reference that points to "origin/master" - reference = repo.create_reference('refs/tags/version1', LAST_COMMIT) - self.assertTrue('refs/tags/version1' in repo.listall_references()) + # try to create existing symbolic reference + with pytest.raises(ValueError): + testrepo.references.create('refs/tags/beta', 'refs/heads/master') - # And we delete it - reference.delete() - self.assertFalse('refs/tags/version1' in repo.listall_references()) - - # Access the deleted reference - self.assertRaises(GitError, getattr, reference, 'name') - self.assertRaises(GitError, getattr, reference, 'type') - self.assertRaises(GitError, getattr, reference, 'target') - self.assertRaises(GitError, setattr, reference, 'target', LAST_COMMIT) - self.assertRaises(GitError, setattr, reference, 'target', "a/b/c") - self.assertRaises(GitError, reference.delete) - self.assertRaises(GitError, reference.resolve) - self.assertRaises(GitError, reference.rename, "refs/tags/version2") - - - def test_rename(self): - # We add a tag as a new reference that points to "origin/master" - reference = self.repo.create_reference('refs/tags/version1', - LAST_COMMIT) - self.assertEqual(reference.name, 'refs/tags/version1') - reference.rename('refs/tags/version2') - self.assertEqual(reference.name, 'refs/tags/version2') + # try to create existing symbolic reference with force + reference = testrepo.references.create( + 'refs/tags/beta', 'refs/heads/master', force=True + ) + assert reference.type == ReferenceType.SYMBOLIC + assert reference.target == 'refs/heads/master' + assert reference.raw_target == b'refs/heads/master' -# def test_reload(self): -# name = 'refs/tags/version1' +# def test_packall_references(testrepo: Repository) -> None: +# testrepo.packall_references() -# repo = self.repo -# ref = repo.create_reference(name, "refs/heads/master", symbolic=True) -# ref2 = repo.lookup_reference(name) -# ref.delete() -# self.assertEqual(ref2.name, name) -# self.assertRaises(KeyError, ref2.reload) -# self.assertRaises(GitError, getattr, ref2, 'name') +def test_refs_peel(testrepo: Repository) -> None: + ref = testrepo.references.get('refs/heads/master') + assert ref is not None + assert testrepo[ref.target].id == ref.peel().id + assert not isinstance(ref.raw_target, bytes) + assert testrepo[ref.raw_target].id == ref.peel().id + + commit = ref.peel(Commit) + assert commit.tree.id == ref.peel(Tree).id + + +def test_refs_equality(testrepo: Repository) -> None: + ref1 = testrepo.references.get('refs/heads/master') + ref2 = testrepo.references.get('refs/heads/master') + ref3 = testrepo.references.get('refs/heads/i18n') + + assert ref1 is not ref2 + assert ref1 == ref2 + assert not ref1 != ref2 + + assert ref1 != ref3 + assert not ref1 == ref3 + + +def test_refs_compress(testrepo: Repository) -> None: + packed_refs_file = Path(testrepo.path) / 'packed-refs' + assert not packed_refs_file.exists() + old_refs = [(ref.name, ref.target) for ref in testrepo.references.objects] + + testrepo.references.compress() + assert packed_refs_file.exists() + new_refs = [(x.name, x.target) for x in testrepo.references.objects] + assert old_refs == new_refs + + +# +# Low level API written in C, repo.references call these. +# + + +def test_list_all_reference_objects(testrepo: Repository) -> None: + repo = testrepo + refs = [(ref.name, ref.target) for ref in repo.listall_reference_objects()] + + assert sorted(refs) == [ + ('refs/heads/i18n', '5470a671a80ac3789f1a6a8cefbcf43ce7af0563'), + ('refs/heads/master', '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98'), + ] + + +def test_list_all_references(testrepo: Repository) -> None: + repo = testrepo + + # Without argument + assert sorted(repo.listall_references()) == ['refs/heads/i18n', 'refs/heads/master'] + assert sorted(repo.raw_listall_references()) == [ + b'refs/heads/i18n', + b'refs/heads/master', + ] + + # We add a symbolic reference + repo.create_reference('refs/tags/version1', 'refs/heads/master') + assert sorted(repo.listall_references()) == [ + 'refs/heads/i18n', + 'refs/heads/master', + 'refs/tags/version1', + ] + assert sorted(repo.raw_listall_references()) == [ + b'refs/heads/i18n', + b'refs/heads/master', + b'refs/tags/version1', + ] + + +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: Repository) -> None: + repo = testrepo + repo.create_reference( + 'refs/tags/version1', '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98' + ) + repo.create_reference( + 'refs/tags/version2', '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98' + ) + + iter_all = repo.references_iterator_init() + all_refs = [] + for _ in range(4): + curr_ref = repo.references_iterator_next(iter_all) + if curr_ref: + all_refs.append((curr_ref.name, curr_ref.target)) + + assert sorted(all_refs) == [ + ('refs/heads/i18n', '5470a671a80ac3789f1a6a8cefbcf43ce7af0563'), + ('refs/heads/master', '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98'), + ('refs/tags/version1', '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98'), + ('refs/tags/version2', '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98'), + ] + + iter_branches = repo.references_iterator_init() + all_branches = [] + for _ in range(4): + curr_ref = repo.references_iterator_next( + iter_branches, ReferenceFilter.BRANCHES + ) + if curr_ref: + all_branches.append((curr_ref.name, curr_ref.target)) + + assert sorted(all_branches) == [ + ('refs/heads/i18n', '5470a671a80ac3789f1a6a8cefbcf43ce7af0563'), + ('refs/heads/master', '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98'), + ] + + iter_tags = repo.references_iterator_init() + all_tags = [] + for _ in range(4): + curr_ref = repo.references_iterator_next(iter_tags, ReferenceFilter.TAGS) + if curr_ref: + all_tags.append((curr_ref.name, curr_ref.target)) + + assert sorted(all_tags) == [ + ('refs/tags/version1', '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98'), + ('refs/tags/version2', '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98'), + ] + + +def test_references_iterator_next_python(testrepo: Repository) -> None: + repo = testrepo + repo.create_reference( + 'refs/tags/version1', '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98' + ) + repo.create_reference( + 'refs/tags/version2', '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98' + ) + + refs = [(x.name, x.target) for x in repo.references.iterator()] + assert sorted(refs) == [ + ('refs/heads/i18n', '5470a671a80ac3789f1a6a8cefbcf43ce7af0563'), + ('refs/heads/master', '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98'), + ('refs/tags/version1', '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98'), + ('refs/tags/version2', '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98'), + ] + + 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(ReferenceFilter.TAGS)] + assert sorted(tags) == [ + ('refs/tags/version1', '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98'), + ('refs/tags/version2', '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98'), + ] + + +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) # 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: Repository) -> None: + repo = testrepo + refs = [] + with pytest.raises(ValueError): + for ref in repo.references.iterator(5): # type: ignore + refs.append((ref.name, ref.target)) + + +def test_lookup_reference(testrepo: Repository) -> None: + repo = testrepo + + # Raise KeyError ? + with pytest.raises(KeyError): + repo.lookup_reference('refs/foo') + + # Test a lookup + reference = repo.lookup_reference('refs/heads/master') + assert reference.name == 'refs/heads/master' + + +def test_lookup_reference_dwim(testrepo: Repository) -> None: + repo = testrepo + + # remote ref + reference = testrepo.create_reference('refs/remotes/origin/master', LAST_COMMIT) + assert reference.shorthand == 'origin/master' + # tag + repo.create_reference('refs/tags/version1', LAST_COMMIT) + + # Test dwim lookups + + # Raise KeyError ? + with pytest.raises(KeyError): + repo.lookup_reference_dwim('foo') + with pytest.raises(KeyError): + repo.lookup_reference_dwim('refs/foo') + + reference = repo.lookup_reference_dwim('refs/heads/master') + assert reference.name == 'refs/heads/master' - def test_reference_resolve(self): - reference = self.repo.lookup_reference('HEAD') - self.assertEqual(reference.type, GIT_REF_SYMBOLIC) - reference = reference.resolve() - self.assertEqual(reference.type, GIT_REF_OID) - self.assertEqual(reference.target.hex, LAST_COMMIT) + reference = repo.lookup_reference_dwim('master') + assert reference.name == 'refs/heads/master' + reference = repo.lookup_reference_dwim('origin/master') + assert reference.name == 'refs/remotes/origin/master' - def test_reference_resolve_identity(self): - head = self.repo.lookup_reference('HEAD') - ref = head.resolve() - self.assertTrue(ref.resolve() is ref) + reference = repo.lookup_reference_dwim('version1') + assert reference.name == 'refs/tags/version1' - def test_create_reference(self): - # We add a tag as a new reference that points to "origin/master" - reference = self.repo.create_reference('refs/tags/version1', - LAST_COMMIT) - refs = self.repo.listall_references() - self.assertTrue('refs/tags/version1' in refs) - reference = self.repo.lookup_reference('refs/tags/version1') - self.assertEqual(reference.target.hex, LAST_COMMIT) +def test_resolve_refish(testrepo: Repository) -> None: + repo = testrepo - # try to create existing reference - self.assertRaises(ValueError, self.repo.create_reference, - 'refs/tags/version1', LAST_COMMIT) + # remote ref + reference = testrepo.create_reference('refs/remotes/origin/master', LAST_COMMIT) + assert reference.shorthand == 'origin/master' + # tag + repo.create_reference('refs/tags/version1', LAST_COMMIT) - # try to create existing reference with force - reference = self.repo.create_reference('refs/tags/version1', - LAST_COMMIT, force=True) - self.assertEqual(reference.target.hex, LAST_COMMIT) + # Test dwim lookups + # Raise KeyError ? + with pytest.raises(KeyError): + repo.resolve_refish('foo') + with pytest.raises(KeyError): + repo.resolve_refish('refs/foo') - def test_create_symbolic_reference(self): - repo = self.repo - # We add a tag as a new symbolic reference that always points to - # "refs/heads/master" - reference = repo.create_reference('refs/tags/beta', - 'refs/heads/master') - self.assertEqual(reference.type, GIT_REF_SYMBOLIC) - self.assertEqual(reference.target, 'refs/heads/master') + commit, ref = repo.resolve_refish('refs/heads/i18n') + assert ref.name == 'refs/heads/i18n' + assert commit.id == '5470a671a80ac3789f1a6a8cefbcf43ce7af0563' + commit, ref = repo.resolve_refish('master') + assert ref.name == 'refs/heads/master' + assert commit.id == LAST_COMMIT - # try to create existing symbolic reference - self.assertRaises(ValueError, repo.create_reference, - 'refs/tags/beta', 'refs/heads/master') + commit, ref = repo.resolve_refish('origin/master') + assert ref.name == 'refs/remotes/origin/master' + assert commit.id == LAST_COMMIT - # try to create existing symbolic reference with force - reference = repo.create_reference('refs/tags/beta', - 'refs/heads/master', force=True) - self.assertEqual(reference.type, GIT_REF_SYMBOLIC) - self.assertEqual(reference.target, 'refs/heads/master') + commit, ref = repo.resolve_refish('version1') + assert ref.name == 'refs/tags/version1' + assert commit.id == LAST_COMMIT + commit, ref = repo.resolve_refish(LAST_COMMIT) + assert ref is None + assert commit.id == LAST_COMMIT -# def test_packall_references(self): -# self.repo.packall_references() + commit, ref = repo.resolve_refish('HEAD~1') + assert ref is None + assert commit.id == '5ebeeebb320790caf276b9fc8b24546d63316533' - def test_get_object(self): - repo = self.repo - ref = repo.lookup_reference('refs/heads/master') - self.assertEqual(repo[ref.target].oid, ref.get_object().oid) +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: 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: 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: Repository) -> None: + reference = testrepo.lookup_reference('refs/heads/master') + assert reference.type == ReferenceType.DIRECT + + +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: Repository) -> None: + reference = testrepo.lookup_reference('HEAD') + assert reference.target == 'refs/heads/master' + assert reference.raw_target == b'refs/heads/master' + reference.set_target('refs/heads/i18n') + assert reference.target == 'refs/heads/i18n' + assert reference.raw_target == b'refs/heads/i18n' + + +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: Repository) -> None: + reference = testrepo.lookup_reference('HEAD') + assert reference.target == 'refs/heads/master' + assert reference.raw_target == b'refs/heads/master' + sig = Signature('foo', 'bar') + testrepo.set_ident('foo', 'bar') + msg = 'Hello log' + reference.set_target('refs/heads/i18n', message=msg) + assert reference.target == 'refs/heads/i18n' + assert reference.raw_target == b'refs/heads/i18n' + first = list(reference.log())[0] + assert first.message == msg + assert first.committer == sig + + +def test_delete(testrepo: Repository) -> None: + repo = testrepo + + # We add a tag as a new reference that points to "origin/master" + reference = repo.create_reference('refs/tags/version1', LAST_COMMIT) + assert 'refs/tags/version1' in repo.listall_references() + assert b'refs/tags/version1' in repo.raw_listall_references() + + # And we delete it + reference.delete() + assert 'refs/tags/version1' not in repo.listall_references() + assert b'refs/tags/version1' not in repo.raw_listall_references() + + # Access the deleted reference + with pytest.raises(GitError): + getattr(reference, 'name') + with pytest.raises(GitError): + getattr(reference, 'type') + with pytest.raises(GitError): + getattr(reference, 'target') + with pytest.raises(GitError): + reference.delete() + with pytest.raises(GitError): + reference.resolve() + with pytest.raises(GitError): + reference.rename('refs/tags/version2') + + +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' + reference.rename('refs/tags/version2') + assert reference.name == 'refs/tags/version2' + + +# def test_reload(testrepo: Repository) -> None: +# name = 'refs/tags/version1' + +# repo = testrepo +# ref = repo.create_reference(name, "refs/heads/master", symbolic=True) +# ref2 = repo.lookup_reference(name) +# ref.delete() +# assert ref2.name == name +# with pytest.raises(KeyError): ref2.reload() +# with pytest.raises(GitError): getattr(ref2, 'name') + + +def test_reference_resolve(testrepo: Repository) -> None: + reference = testrepo.lookup_reference('HEAD') + assert reference.type == ReferenceType.SYMBOLIC + reference = reference.resolve() + assert reference.type == ReferenceType.DIRECT + assert reference.target == LAST_COMMIT + + +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: 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() + assert b'refs/tags/version1' in testrepo.raw_listall_references() + reference = testrepo.lookup_reference('refs/tags/version1') + assert reference.target == LAST_COMMIT -if __name__ == '__main__': - unittest.main() + # try to create existing reference + with pytest.raises(AlreadyExistsError) as error: + testrepo.create_reference('refs/tags/version1', LAST_COMMIT) + assert isinstance(error.value, ValueError) + + # Clear error + del error + + # try to create existing reference with force + reference = testrepo.create_reference('refs/tags/version1', LAST_COMMIT, force=True) + assert reference.target == LAST_COMMIT + + +def test_create_reference_with_message(testrepo: Repository) -> None: + sig = Signature('foo', 'bar') + testrepo.set_ident('foo', 'bar') + msg = 'Hello log' + reference = testrepo.create_reference( + 'refs/heads/feature', LAST_COMMIT, message=msg + ) + first = list(reference.log())[0] + assert first.message == msg + assert first.committer == sig + + +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" + reference = repo.create_reference('refs/tags/beta', 'refs/heads/master') + assert reference.type == ReferenceType.SYMBOLIC + assert reference.target == 'refs/heads/master' + assert reference.raw_target == b'refs/heads/master' + + # try to create existing symbolic reference + with pytest.raises(AlreadyExistsError) as error: + repo.create_reference('refs/tags/beta', 'refs/heads/master') + assert isinstance(error.value, ValueError) + + # try to create existing symbolic reference with force + reference = repo.create_reference('refs/tags/beta', 'refs/heads/master', force=True) + assert reference.type == ReferenceType.SYMBOLIC + assert reference.target == 'refs/heads/master' + assert reference.raw_target == b'refs/heads/master' + + +def test_create_symbolic_reference_with_message(testrepo: Repository) -> None: + sig = Signature('foo', 'bar') + testrepo.set_ident('foo', 'bar') + msg = 'Hello log' + reference = testrepo.create_reference( + 'HEAD', 'refs/heads/i18n', force=True, message=msg + ) + first = list(reference.log())[0] + assert first.message == msg + assert first.committer == sig + + +def test_create_invalid_reference(testrepo: Repository) -> None: + repo = testrepo + + # try to create a reference with an invalid name + with pytest.raises(InvalidSpecError) as error: + repo.create_reference('refs/tags/in..valid', 'refs/heads/master') + assert isinstance(error.value, ValueError) + + +# def test_packall_references(testrepo: Repository) -> None: +# testrepo.packall_references() + + +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() -> None: + assert reference_is_valid_name('HEAD') + assert reference_is_valid_name('refs/heads/master') + assert reference_is_valid_name('refs/heads/perfectly/valid') + assert reference_is_valid_name('refs/tags/v1') + assert reference_is_valid_name('refs/special/ref') + + +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() -> 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') + assert not reference_is_valid_name('refs/heads/invalid~') + assert not reference_is_valid_name('refs/heads/invalid^') + assert not reference_is_valid_name('refs/heads/invalid:') + assert not reference_is_valid_name('refs/heads/invalid\\') + assert not reference_is_valid_name('refs/heads/invalid?') + assert not reference_is_valid_name('refs/heads/invalid[') + assert not reference_is_valid_name('refs/heads/invalid*') + assert not reference_is_valid_name('refs/heads/@{no}') + assert not reference_is_valid_name('refs/heads/foo//bar') + + +def test_invalid_arguments() -> None: + with pytest.raises(TypeError): + reference_is_valid_name() # type: ignore + with pytest.raises(TypeError): + reference_is_valid_name(None) # type: ignore + with pytest.raises(TypeError): + reference_is_valid_name(1) # type: ignore + with pytest.raises(TypeError): + reference_is_valid_name('too', 'many') # type: ignore diff --git a/test/test_remote.py b/test/test_remote.py index a4a125966..d4c3bea68 100644 --- a/test/test_remote.py +++ b/test/test_remote.py @@ -1,6 +1,4 @@ -# -*- coding: UTF-8 -*- -# -# Copyright 2010-2013 The pygit2 contributors +# Copyright 2010-2025 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -25,104 +23,567 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. -"""Tests for Remote objects.""" +import sys +from collections.abc import Generator +from pathlib import Path +import pytest -import unittest import pygit2 +from pygit2 import Remote, Repository +from pygit2.remotes import PushUpdate, TransferProgress + from . import utils REMOTE_NAME = 'origin' -REMOTE_URL = 'git://github.com/libgit2/pygit2.git' +REMOTE_URL = 'https://github.com/libgit2/pygit2.git' REMOTE_FETCHSPEC_SRC = 'refs/heads/*' REMOTE_FETCHSPEC_DST = 'refs/remotes/origin/*' REMOTE_REPO_OBJECTS = 30 -REMOTE_REPO_BYTES = 2758 +REMOTE_FETCHTEST_FETCHSPECS = ['refs/tags/v1.13.2'] +REMOTE_REPO_FETCH_ALL_OBJECTS = 13276 +REMOTE_REPO_FETCH_HEAD_COMMIT_OBJECTS = 238 + +ORIGIN_REFSPEC = '+refs/heads/*:refs/remotes/origin/*' + + +def test_remote_create(testrepo: Repository) -> None: + name = 'upstream' + url = 'https://github.com/libgit2/pygit2.git' + + remote = testrepo.remotes.create(name, url) + + assert type(remote) is pygit2.Remote + assert name == remote.name + assert url == remote.url + assert remote.push_url is None + + with pytest.raises(ValueError): + testrepo.remotes.create(*(name, url)) + + +def test_remote_create_with_refspec(testrepo: Repository) -> None: + name = 'upstream' + url = 'https://github.com/libgit2/pygit2.git' + fetch = '+refs/*:refs/*' + + remote = testrepo.remotes.create(name, url, fetch) + + assert type(remote) is pygit2.Remote + assert name == remote.name + assert url == remote.url + assert [fetch] == remote.fetch_refspecs + assert remote.push_url is None + + +def test_remote_create_anonymous(testrepo: Repository) -> None: + url = 'https://github.com/libgit2/pygit2.git' + + remote = testrepo.remotes.create_anonymous(url) + assert remote.name is None + assert url == remote.url + assert remote.push_url is None + assert [] == remote.fetch_refspecs + assert [] == remote.push_refspecs + + +def test_remote_delete(testrepo: Repository) -> None: + name = 'upstream' + url = 'https://github.com/libgit2/pygit2.git' + + testrepo.remotes.create(name, url) + assert 2 == len(testrepo.remotes) + remote = testrepo.remotes[1] + + assert name == remote.name + testrepo.remotes.delete(remote.name) + assert 1 == len(testrepo.remotes) + + +def test_remote_rename(testrepo: Repository) -> None: + remote = testrepo.remotes[0] + + assert REMOTE_NAME == remote.name + problems = testrepo.remotes.rename(remote.name, 'new') + assert [] == problems + assert 'new' != remote.name + + with pytest.raises(ValueError): + testrepo.remotes.rename('', '') + with pytest.raises(ValueError): + testrepo.remotes.rename(None, None) # type: ignore + + +def test_remote_set_url(testrepo: Repository) -> None: + remote = testrepo.remotes['origin'] + assert REMOTE_URL == remote.url + + new_url = 'https://github.com/cholin/pygit2.git' + testrepo.remotes.set_url('origin', new_url) + remote = testrepo.remotes['origin'] + assert new_url == remote.url + + with pytest.raises(ValueError): + testrepo.remotes.set_url('origin', '') + + testrepo.remotes.set_push_url('origin', new_url) + remote = testrepo.remotes['origin'] + assert new_url == remote.push_url + with pytest.raises(ValueError): + testrepo.remotes.set_push_url('origin', '') + + +def test_refspec(testrepo: Repository) -> None: + remote = testrepo.remotes['origin'] + + assert remote.refspec_count == 1 + refspec = remote.get_refspec(0) + assert refspec.src == REMOTE_FETCHSPEC_SRC + assert refspec.dst == REMOTE_FETCHSPEC_DST + assert refspec.force is True + assert ORIGIN_REFSPEC == refspec.string + + assert list is type(remote.fetch_refspecs) + assert 1 == len(remote.fetch_refspecs) + assert ORIGIN_REFSPEC == remote.fetch_refspecs[0] + + assert refspec.src_matches('refs/heads/master') + assert refspec.dst_matches('refs/remotes/origin/master') + assert 'refs/remotes/origin/master' == refspec.transform('refs/heads/master') + assert 'refs/heads/master' == refspec.rtransform('refs/remotes/origin/master') + + assert list is type(remote.push_refspecs) + assert 0 == len(remote.push_refspecs) + + push_specs = remote.push_refspecs + assert list is type(push_specs) + assert 0 == len(push_specs) + + testrepo.remotes.add_fetch('origin', '+refs/test/*:refs/test/remotes/*') + remote = testrepo.remotes['origin'] + + fetch_specs = remote.fetch_refspecs + assert list is type(fetch_specs) + assert 2 == len(fetch_specs) + assert [ + '+refs/heads/*:refs/remotes/origin/*', + '+refs/test/*:refs/test/remotes/*', + ] == fetch_specs + + testrepo.remotes.add_push('origin', '+refs/test/*:refs/test/remotes/*') + + with pytest.raises(TypeError): + testrepo.remotes.add_fetch(['+refs/*:refs/*', 5]) # type: ignore + + remote = testrepo.remotes['origin'] + assert ['+refs/test/*:refs/test/remotes/*'] == remote.push_refspecs + +def test_remote_list(testrepo: Repository) -> None: + assert 1 == len(testrepo.remotes) + remote = testrepo.remotes[0] + assert REMOTE_NAME == remote.name + assert REMOTE_URL == remote.url -class RepositoryTest(utils.RepoTestCase): - def test_remote_create(self): - name = 'upstream' - url = 'git://github.com/libgit2/pygit2.git' + name = 'upstream' + url = 'https://github.com/libgit2/pygit2.git' + remote = testrepo.remotes.create(name, url) + assert remote.name in testrepo.remotes.names() + assert remote.name in [x.name for x in testrepo.remotes] - remote = self.repo.create_remote(name, url) - self.assertEqual(type(remote), pygit2.Remote) - self.assertEqual(name, remote.name) - self.assertEqual(url, remote.url) +@utils.requires_network +def test_list_heads(testrepo: Repository) -> None: + assert 1 == len(testrepo.remotes) + remote = testrepo.remotes[0] - self.assertRaises(ValueError, self.repo.create_remote, *(name, url)) + 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')) - def test_remote_rename(self): - remote = self.repo.remotes[0] - self.assertEqual(REMOTE_NAME, remote.name) - remote.name = 'new' - self.assertEqual('new', remote.name) +@utils.requires_network +def test_ls_remotes_deprecated(testrepo: Repository) -> None: + assert 1 == len(testrepo.remotes) + remote = testrepo.remotes[0] - self.assertRaisesAssign(ValueError, remote, 'name', '') + new_refs = remote.list_heads() + with pytest.warns(DeprecationWarning, match='Use list_heads'): + old_refs = remote.ls_remotes() - def test_remote_set_url(self): - remote = self.repo.remotes[0] + assert new_refs + assert old_refs + + for new, old in zip(new_refs, old_refs, strict=True): + assert new.name == old['name'] + assert new.oid == old['oid'] + assert new.local == old['local'] + assert new.symref_target == old['symref_target'] + if new.local: + assert new.loid == old['loid'] + else: + assert new.loid == pygit2.Oid(b'') + assert old['loid'] is None - self.assertEqual(REMOTE_URL, remote.url) - new_url = 'git://github.com/cholin/pygit2.git' - remote.url = new_url - self.assertEqual(new_url, remote.url) - self.assertRaisesAssign(ValueError, remote, 'url', '') +@utils.requires_network +def test_list_heads_without_implicit_connect(testrepo: Repository) -> None: + assert 1 == len(testrepo.remotes) + remote = testrepo.remotes[0] + + with pytest.raises(pygit2.GitError, match='this remote has never connected'): + remote.list_heads(connect=False) + + remote.connect() + refs = remote.list_heads(connect=False) + assert refs + # Check that a known ref is returned. + assert next(iter(r for r in refs if r.name == 'refs/tags/v0.28.2')) - def test_refspec(self): - remote = self.repo.remotes[0] - self.assertEqual(remote.refspec_count, 1) - refspec = remote.get_refspec(0) - self.assertEqual(refspec[0], REMOTE_FETCHSPEC_SRC) - self.assertEqual(refspec[1], REMOTE_FETCHSPEC_DST) +def test_remote_collection(testrepo: Repository) -> None: + remote = testrepo.remotes['origin'] + assert REMOTE_NAME == remote.name + assert REMOTE_URL == remote.url -# new_fetchspec = ('refs/foo/*', 'refs/remotes/foo/*') -# remote.fetchspec = new_fetchspec -# refspec = remote.get_refspec(0) -# self.assertEqual(new_fetchspec[0], refspec[0]) -# self.assertEqual(new_fetchspec[1], refspec[1]) + with pytest.raises(KeyError): + testrepo.remotes['upstream'] + name = 'upstream' + url = 'https://github.com/libgit2/pygit2.git' + remote = testrepo.remotes.create(name, url) + assert remote.name in testrepo.remotes.names() + assert remote.name in [x.name for x in testrepo.remotes] - def test_remote_list(self): - self.assertEqual(1, len(self.repo.remotes)) - remote = self.repo.remotes[0] - self.assertEqual(REMOTE_NAME, remote.name) - self.assertEqual(REMOTE_URL, remote.url) - name = 'upstream' - url = 'git://github.com/libgit2/pygit2.git' - remote = self.repo.create_remote(name, url) - self.assertTrue(remote.name in [x.name for x in self.repo.remotes]) +@utils.requires_refcount +def test_remote_refcount(testrepo: Repository) -> None: + start = sys.getrefcount(testrepo) + remote = testrepo.remotes[0] + del remote + end = sys.getrefcount(testrepo) + assert start == end - def test_remote_save(self): - remote = self.repo.remotes[0] +def test_fetch(emptyrepo: Repository) -> None: + remote = emptyrepo.remotes[0] + stats = remote.fetch() + assert stats.received_bytes > 2700 + assert stats.received_bytes < 3100 + assert stats.indexed_objects == REMOTE_REPO_OBJECTS + assert stats.received_objects == REMOTE_REPO_OBJECTS - remote.name = 'new-name' - remote.url = 'http://example.com/test.git' - remote.save() +@utils.requires_network +def test_fetch_depth_zero(testrepo: Repository) -> None: + remote = testrepo.remotes[0] + stats = remote.fetch(REMOTE_FETCHTEST_FETCHSPECS, depth=0) + assert stats.indexed_objects == REMOTE_REPO_FETCH_ALL_OBJECTS + assert stats.received_objects == REMOTE_REPO_FETCH_ALL_OBJECTS - self.assertEqual('new-name', self.repo.remotes[0].name) - self.assertEqual('http://example.com/test.git', - self.repo.remotes[0].url) +@utils.requires_network +def test_fetch_depth_one(testrepo: Repository) -> None: + remote = testrepo.remotes[0] + stats = remote.fetch(REMOTE_FETCHTEST_FETCHSPECS, depth=1) + assert stats.indexed_objects == REMOTE_REPO_FETCH_HEAD_COMMIT_OBJECTS + assert stats.received_objects == REMOTE_REPO_FETCH_HEAD_COMMIT_OBJECTS -class EmptyRepositoryTest(utils.EmptyRepoTestCase): - def test_fetch(self): - remote = self.repo.remotes[0] - stats = remote.fetch() - self.assertEqual(stats['received_bytes'], REMOTE_REPO_BYTES) - self.assertEqual(stats['indexed_objects'], REMOTE_REPO_OBJECTS) - self.assertEqual(stats['received_objects'], REMOTE_REPO_OBJECTS) +def test_transfer_progress(emptyrepo: Repository) -> None: + class MyCallbacks(pygit2.RemoteCallbacks): + def transfer_progress(self, stats: TransferProgress) -> None: + self.tp = stats -if __name__ == '__main__': - unittest.main() + callbacks = MyCallbacks() + remote = emptyrepo.remotes[0] + stats = remote.fetch(callbacks=callbacks) + assert stats.received_bytes == callbacks.tp.received_bytes + assert stats.indexed_objects == callbacks.tp.indexed_objects + assert stats.received_objects == callbacks.tp.received_objects + + +def test_update_tips(emptyrepo: Repository) -> None: + remote = emptyrepo.remotes[0] + tips = [ + ( + 'refs/remotes/origin/master', + pygit2.Oid(hex='0' * 40), + pygit2.Oid(hex='784855caf26449a1914d2cf62d12b9374d76ae78'), + ), + ( + 'refs/tags/root', + pygit2.Oid(hex='0' * 40), + pygit2.Oid(hex='3d2962987c695a29f1f80b6c3aa4ec046ef44369'), + ), + ] + + class MyCallbacks(pygit2.RemoteCallbacks): + 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: str, old: pygit2.Oid, new: pygit2.Oid) -> None: + assert self.tips[self.i] == (name, old, new) + self.i += 1 + + callbacks = MyCallbacks(tips) + remote.fetch(callbacks=callbacks) + assert callbacks.i > 0 + + +@utils.requires_network +def test_list_heads_certificate_check() -> None: + url = 'https://github.com/pygit2/empty.git' + + class MyCallbacks(pygit2.RemoteCallbacks): + def __init__(self) -> None: + self.i = 0 + + def certificate_check( + self, certificate: None, valid: bool, host: str | bytes + ) -> bool: + self.i += 1 + + assert certificate is None + assert valid is True + assert host == b'github.com' + return True + + # We create an in-memory repository + git = pygit2.Repository() + remote = git.remotes.create_anonymous(url) + + callbacks = MyCallbacks() + refs = remote.list_heads(callbacks=callbacks) + + # Sanity check that we indeed got some refs. + assert len(refs) > 0 + + # Make sure our certificate_check callback triggered. + assert callbacks.i > 0 + + +@pytest.fixture +def origin(tmp_path: Path) -> Generator[Repository, None, None]: + with utils.TemporaryRepository('barerepo.zip', tmp_path) as path: + yield pygit2.Repository(path) + + +@pytest.fixture +def clone(tmp_path: Path) -> Generator[Repository, None, None]: + clone = tmp_path / 'clone' + clone.mkdir() + with utils.TemporaryRepository('barerepo.zip', clone) as path: + yield pygit2.Repository(path) + + +@pytest.fixture +def remote(origin: Repository, clone: Repository) -> Generator[Remote, None, None]: + yield clone.remotes.create('origin', origin.path) + + +def test_push_fast_forward_commits_to_remote_succeeds( + origin: Repository, clone: Repository, remote: Remote +) -> None: + tip = clone[clone.head.target] + oid = clone.create_commit( + 'refs/heads/master', + tip.author, + tip.author, + 'empty commit', + tip.tree.id, + [tip.id], + ) + remote.push(['refs/heads/master']) + assert origin[origin.head.target].id == oid + + +def test_push_when_up_to_date_succeeds( + origin: Repository, clone: Repository, remote: Remote +) -> None: + remote.push(['refs/heads/master']) + origin_tip = origin[origin.head.target].id + clone_tip = clone[clone.head.target].id + assert origin_tip == clone_tip + + +def test_push_transfer_progress( + origin: Repository, clone: Repository, remote: Remote +) -> None: + tip = clone[clone.head.target] + new_tip_id = clone.create_commit( + 'refs/heads/master', + tip.author, + tip.author, + 'empty commit', + tip.tree.id, + [tip.id], + ) + + # NOTE: We're currently not testing bytes_pushed due to a bug in libgit2 + # 1.9.0: it passes a junk value for bytes_pushed when pushing to a remote + # on the local filesystem, as is the case in this unit test. (When pushing + # to a remote over the network, the value is correct.) + class MyCallbacks(pygit2.RemoteCallbacks): + def push_transfer_progress( + self, objects_pushed: int, total_objects: int, bytes_pushed: int + ) -> None: + self.objects_pushed = objects_pushed + self.total_objects = total_objects + + assert origin.branches['master'].target == tip.id + + callbacks = MyCallbacks() + remote.push(['refs/heads/master'], callbacks=callbacks) + assert callbacks.objects_pushed == 1 + assert callbacks.total_objects == 1 + assert origin.branches['master'].target == new_tip_id + + +@pytest.mark.parametrize('reject_from', ['push_transfer_progress', 'push_negotiation']) +def test_push_interrupted_from_callbacks( + origin: Repository, clone: Repository, remote: Remote, reject_from: str +) -> None: + reject_message = 'retreat! retreat!' + + tip = clone[clone.head.target] + clone.create_commit( + 'refs/heads/master', + tip.author, + tip.author, + 'empty commit', + tip.tree.id, + [tip.id], + ) + + class MyCallbacks(pygit2.RemoteCallbacks): + def push_negotiation(self, updates: list[PushUpdate]) -> None: + if reject_from == 'push_negotiation': + raise InterruptedError(reject_message) + + def push_transfer_progress( + self, objects_pushed: int, total_objects: int, bytes_pushed: int + ) -> None: + if reject_from == 'push_transfer_progress': + raise InterruptedError(reject_message) + + assert origin.branches['master'].target == tip.id + + callbacks = MyCallbacks() + with pytest.raises(InterruptedError, match='retreat! retreat!'): + remote.push(['refs/heads/master'], callbacks=callbacks) + + assert origin.branches['master'].target == tip.id + + +def test_push_non_fast_forward_commits_to_remote_fails( + origin: Repository, clone: Repository, remote: Remote +) -> None: + tip = origin[origin.head.target] + origin.create_commit( + 'refs/heads/master', + tip.author, + tip.author, + 'some commit', + tip.tree.id, + [tip.id], + ) + tip = clone[clone.head.target] + clone.create_commit( + 'refs/heads/master', + tip.author, + tip.author, + 'other commit', + tip.tree.id, + [tip.id], + ) + + with pytest.raises(pygit2.GitError): + remote.push(['refs/heads/master']) + + +def test_push_options(origin: Repository, clone: Repository, remote: Remote) -> None: + from pygit2 import RemoteCallbacks + + callbacks = RemoteCallbacks() + remote.push(['refs/heads/master'], callbacks) + remote_push_options = callbacks.push_options.remote_push_options + assert remote_push_options.count == 0 + + callbacks = RemoteCallbacks() + remote.push(['refs/heads/master'], callbacks, push_options=[]) + remote_push_options = callbacks.push_options.remote_push_options + assert remote_push_options.count == 0 + + callbacks = RemoteCallbacks() + # Local remotes don't support push_options, so pushing will raise an error. + # However, push_options should still be set in RemoteCallbacks. + with pytest.raises(pygit2.GitError, match='push-options not supported by remote'): + remote.push(['refs/heads/master'], callbacks, push_options=['foo']) + remote_push_options = callbacks.push_options.remote_push_options + assert remote_push_options.count == 1 + # strings pointed to by remote_push_options.strings[] are already freed + + callbacks = RemoteCallbacks() + with pytest.raises(pygit2.GitError, match='push-options not supported by remote'): + remote.push(['refs/heads/master'], callbacks, push_options=['Opt A', 'Opt B']) + remote_push_options = callbacks.push_options.remote_push_options + assert remote_push_options.count == 2 + # strings pointed to by remote_push_options.strings[] are already freed + + +def test_push_threads(origin: Repository, clone: Repository, remote: Remote) -> None: + from pygit2 import RemoteCallbacks + + callbacks = RemoteCallbacks() + remote.push(['refs/heads/master'], callbacks) + assert callbacks.push_options.pb_parallelism == 1 + + callbacks = RemoteCallbacks() + remote.push(['refs/heads/master'], callbacks, threads=0) + assert callbacks.push_options.pb_parallelism == 0 + + callbacks = RemoteCallbacks() + remote.push(['refs/heads/master'], callbacks, threads=1) + assert callbacks.push_options.pb_parallelism == 1 + + +def test_push_negotiation( + origin: Repository, clone: Repository, remote: Remote +) -> None: + old_tip = clone[clone.head.target] + new_tip_id = clone.create_commit( + 'refs/heads/master', + old_tip.author, + old_tip.author, + 'empty commit', + old_tip.tree.id, + [old_tip.id], + ) + + the_updates: list[PushUpdate] = [] + + class MyCallbacks(pygit2.RemoteCallbacks): + def push_negotiation(self, updates: list[PushUpdate]) -> None: + the_updates.extend(updates) + + assert origin.branches['master'].target == old_tip.id + assert 'new_branch' not in origin.branches + + callbacks = MyCallbacks() + remote.push(['refs/heads/master'], callbacks=callbacks) + + assert len(the_updates) == 1 + assert the_updates[0].src_refname == 'refs/heads/master' + assert the_updates[0].dst_refname == 'refs/heads/master' + assert the_updates[0].src == old_tip.id + assert the_updates[0].dst == new_tip_id + + assert origin.branches['master'].target == new_tip_id diff --git a/test/test_remote_prune.py b/test/test_remote_prune.py new file mode 100644 index 000000000..2fdaab0dd --- /dev/null +++ b/test/test_remote_prune.py @@ -0,0 +1,78 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +from 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: Repository, tmp_path: Path +) -> Generator[Repository, None, None]: + cloned_repo_path = tmp_path / 'test_remote_prune' + + pygit2.clone_repository(testrepo.workdir, cloned_repo_path) + clonerepo = pygit2.Repository(cloned_repo_path) + testrepo.branches.delete('i18n') + yield 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: Repository) -> None: + clonerepo.remotes[0].fetch(prune=FetchPrune.PRUNE) + assert 'origin/i18n' not in clonerepo.branches + + +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: Repository) -> None: + pruned = [] + + class MyCallbacks(pygit2.RemoteCallbacks): + def update_tips(self, name: str, old: Oid, new: Oid) -> None: + pruned.append(name) + + callbacks = MyCallbacks() + remote = clonerepo.remotes['origin'] + # We do a fetch in order to establish the connection to the remote. + # Prune operation requires an active connection. + remote.fetch(prune=FetchPrune.NO_PRUNE) + assert 'origin/i18n' in clonerepo.branches + remote.prune(callbacks) + assert pruned == ['refs/remotes/origin/i18n'] + assert 'origin/i18n' not in clonerepo.branches diff --git a/test/test_remote_utf8.py b/test/test_remote_utf8.py new file mode 100644 index 000000000..03edd6b4c --- /dev/null +++ b/test/test_remote_utf8.py @@ -0,0 +1,44 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +from collections.abc import Generator +from pathlib import Path + +import pytest + +import pygit2 + +from . import utils + + +@pytest.fixture +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: 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 87c9fc704..87bd4fbdd 100644 --- a/test/test_repository.py +++ b/test/test_repository.py @@ -1,6 +1,4 @@ -# -*- coding: UTF-8 -*- -# -# Copyright 2010-2013 The pygit2 contributors +# Copyright 2010-2025 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -25,354 +23,1173 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. -"""Tests for Repository objects.""" - -# Import from the future -from __future__ import absolute_import -from __future__ import unicode_literals - -# Import from the Standard Library -import binascii -import unittest +import shutil import tempfile -import os -from os.path import join, realpath +from pathlib import Path +from typing import Optional + +import pytest -# Import from pygit2 -from pygit2 import GIT_OBJ_ANY, GIT_OBJ_BLOB, GIT_OBJ_COMMIT -from pygit2 import init_repository, clone_repository, discover_repository -from pygit2 import Oid, Reference, hashfile +# pygit2 import pygit2 +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, +) +from pygit2.index import MergeFileResult + from . import utils -HEAD_SHA = '784855caf26449a1914d2cf62d12b9374d76ae78' -PARENT_SHA = 'f5e5aa4e36ab0fe62ee1ccc6eb8f79b866863b87' # HEAD^ -BLOB_HEX = 'af431f20fc541ed6d5afede3e2dc7160f6f01f16' -BLOB_RAW = binascii.unhexlify(BLOB_HEX.encode('ascii')) -BLOB_OID = Oid(raw=BLOB_RAW) - - -class RepositoryTest(utils.BareRepoTestCase): - - def test_is_empty(self): - self.assertFalse(self.repo.is_empty) - - def test_is_bare(self): - self.assertTrue(self.repo.is_bare) - - def test_head(self): - head = self.repo.head - self.assertEqual(HEAD_SHA, head.target.hex) - self.assertEqual(type(head), Reference) - self.assertFalse(self.repo.head_is_orphaned) - self.assertFalse(self.repo.head_is_detached) - - def test_read(self): - self.assertRaises(TypeError, self.repo.read, 123) - self.assertRaisesWithArg(KeyError, '1' * 40, self.repo.read, '1' * 40) - - ab = self.repo.read(BLOB_OID) - a = self.repo.read(BLOB_HEX) - self.assertEqual(ab, a) - self.assertEqual((GIT_OBJ_BLOB, b'a contents\n'), a) - - a2 = self.repo.read('7f129fd57e31e935c6d60a0c794efe4e6927664b') - self.assertEqual((GIT_OBJ_BLOB, b'a contents 2\n'), a2) - - a_hex_prefix = BLOB_HEX[:4] - a3 = self.repo.read(a_hex_prefix) - self.assertEqual((GIT_OBJ_BLOB, b'a contents\n'), a3) - - def test_write(self): - data = b"hello world" - # invalid object type - self.assertRaises(ValueError, self.repo.write, GIT_OBJ_ANY, data) - - oid = self.repo.write(GIT_OBJ_BLOB, data) - self.assertEqual(type(oid), Oid) - - def test_contains(self): - self.assertRaises(TypeError, lambda: 123 in self.repo) - self.assertTrue(BLOB_OID in self.repo) - self.assertTrue(BLOB_HEX in self.repo) - self.assertTrue(BLOB_HEX[:10] in self.repo) - self.assertFalse('a' * 40 in self.repo) - self.assertFalse('a' * 20 in self.repo) - - def test_iterable(self): - l = [obj for obj in self.repo] - oid = Oid(hex=BLOB_HEX) - self.assertTrue(oid in l) - - def test_lookup_blob(self): - self.assertRaises(TypeError, lambda: self.repo[123]) - self.assertEqual(self.repo[BLOB_OID].hex, BLOB_HEX) - a = self.repo[BLOB_HEX] - self.assertEqual(b'a contents\n', a.read_raw()) - self.assertEqual(BLOB_HEX, a.hex) - self.assertEqual(GIT_OBJ_BLOB, a.type) - - def test_lookup_blob_prefix(self): - a = self.repo[BLOB_HEX[:5]] - self.assertEqual(b'a contents\n', a.read_raw()) - self.assertEqual(BLOB_HEX, a.hex) - self.assertEqual(GIT_OBJ_BLOB, a.type) - - def test_lookup_commit(self): - commit_sha = '5fe808e8953c12735680c257f56600cb0de44b10' - commit = self.repo[commit_sha] - self.assertEqual(commit_sha, commit.hex) - self.assertEqual(GIT_OBJ_COMMIT, commit.type) - self.assertEqual(('Second test data commit.\n\n' - 'This commit has some additional text.\n'), - commit.message) - - def test_lookup_commit_prefix(self): - commit_sha = '5fe808e8953c12735680c257f56600cb0de44b10' - commit_sha_prefix = commit_sha[:7] - too_short_prefix = commit_sha[:3] - commit = self.repo[commit_sha_prefix] - self.assertEqual(commit_sha, commit.hex) - self.assertEqual(GIT_OBJ_COMMIT, commit.type) - self.assertEqual( - ('Second test data commit.\n\n' - 'This commit has some additional text.\n'), - commit.message) - self.assertRaises(ValueError, self.repo.__getitem__, too_short_prefix) - - def test_get_path(self): - directory = realpath(self.repo.path) - expected = realpath(join(self._temp_dir, 'testrepo.git')) - self.assertEqual(directory, expected) - - def test_get_workdir(self): - self.assertEqual(self.repo.workdir, None) - - def test_revparse_single(self): - parent = self.repo.revparse_single('HEAD^') - self.assertEqual(parent.hex, PARENT_SHA) - - def test_hash(self): - data = "foobarbaz" - hashed_sha1 = pygit2.hash(data) - written_sha1 = self.repo.create_blob(data) - self.assertEqual(hashed_sha1, written_sha1) - - def test_hashfile(self): - data = "bazbarfoo" - tempfile_path = tempfile.mkstemp()[1] - with open(tempfile_path, 'w') as fh: - fh.write(data) - hashed_sha1 = hashfile(tempfile_path) - written_sha1 = self.repo.create_blob(data) - self.assertEqual(hashed_sha1, written_sha1) - - -class RepositoryTest_II(utils.RepoTestCase): - - def test_is_empty(self): - self.assertFalse(self.repo.is_empty) - - def test_is_bare(self): - self.assertFalse(self.repo.is_bare) - - def test_get_path(self): - directory = realpath(self.repo.path) - expected = realpath(join(self._temp_dir, 'testrepo', '.git')) - self.assertEqual(directory, expected) - - def test_get_workdir(self): - directory = realpath(self.repo.workdir) - expected = realpath(join(self._temp_dir, 'testrepo')) - self.assertEqual(directory, expected) - - def test_checkout_ref(self): - ref_i18n = self.repo.lookup_reference('refs/heads/i18n') - - # checkout i18n with conflicts and default strategy should - # not be possible - self.assertRaises(pygit2.GitError, self.repo.checkout, ref_i18n) - - # checkout i18n with GIT_CHECKOUT_FORCE - head = self.repo.head - head = self.repo[head.target] - self.assertTrue('new' not in head.tree) - self.repo.checkout(ref_i18n, pygit2.GIT_CHECKOUT_FORCE) - - head = self.repo.head - head = self.repo[head.target] - self.assertEqual(head.hex, ref_i18n.target.hex) - self.assertTrue('new' in head.tree) - self.assertTrue('bye.txt' not in self.repo.status()) - - def test_checkout_index(self): - # some changes to working dir - with open(os.path.join(self.repo.workdir, 'hello.txt'), 'w') as f: - f.write('new content') - - # checkout index - self.assertTrue('hello.txt' in self.repo.status()) - self.repo.checkout(strategy=pygit2.GIT_CHECKOUT_FORCE) - self.assertTrue('hello.txt' not in self.repo.status()) - - def test_checkout_head(self): - # some changes to the index - with open(os.path.join(self.repo.workdir, 'bye.txt'), 'w') as f: - f.write('new content') - self.repo.index.add('bye.txt') - - # checkout from index should not change anything - self.assertTrue('bye.txt' in self.repo.status()) - self.repo.checkout(strategy=pygit2.GIT_CHECKOUT_FORCE) - self.assertTrue('bye.txt' in self.repo.status()) - - # checkout from head will reset index as well - self.repo.checkout('HEAD', pygit2.GIT_CHECKOUT_FORCE) - self.assertTrue('bye.txt' not in self.repo.status()) - - def test_merge_base(self): - commit = self.repo.merge_base( +def test_is_empty(testrepo: Repository) -> None: + assert not testrepo.is_empty + + +def test_is_bare(testrepo: Repository) -> None: + assert not testrepo.is_bare + + +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: tuple[Repository, Path]) -> None: + testrepo, path = testrepo_path + assert Path(testrepo.workdir).resolve() == path.resolve() + + +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: Repository) -> None: + ref_i18n = testrepo.lookup_reference('refs/heads/i18n') + + # checkout i18n with conflicts and default strategy should + # not be possible + with pytest.raises(pygit2.GitError): + testrepo.checkout(ref_i18n) + + # checkout i18n with GIT_CHECKOUT_FORCE + head_object = testrepo.head + head = testrepo[head_object.target] + assert 'new' not in head.tree + testrepo.checkout(ref_i18n, strategy=CheckoutStrategy.FORCE) + + 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: Repository) -> None: + ref_i18n = testrepo.lookup_reference('refs/heads/i18n') + + class MyCheckoutCallbacks(pygit2.CheckoutCallbacks): + def __init__(self) -> None: + super().__init__() + 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: CheckoutNotify, + path: str, + baseline: Optional[DiffFile], + target: Optional[DiffFile], + workdir: Optional[DiffFile], + ) -> None: + if why == CheckoutNotify.CONFLICT: + self.conflicting_paths.add(path) + elif why == CheckoutNotify.UPDATED: + self.updated_paths.add(path) + + def checkout_progress( + self, path: str, completed_steps: int, total_steps: int + ) -> None: + self.completed_steps = completed_steps + self.total_steps = total_steps + + # checkout i18n with conflicts and default strategy should not be possible + callbacks = MyCheckoutCallbacks() + with pytest.raises(pygit2.GitError): + testrepo.checkout(ref_i18n, callbacks=callbacks) + # make sure the callbacks caught that + assert {'bye.txt'} == callbacks.conflicting_paths + assert -1 == callbacks.completed_steps # shouldn't have done anything + + # checkout i18n with GIT_CHECKOUT_FORCE + head_object = testrepo.head + head = testrepo[head_object.target] + assert 'new' not in head.tree + callbacks = MyCheckoutCallbacks() + testrepo.checkout(ref_i18n, strategy=CheckoutStrategy.FORCE, callbacks=callbacks) + # make sure the callbacks caught the files affected by the checkout + assert set() == callbacks.conflicting_paths + assert {'bye.txt', 'new'} == callbacks.updated_paths + assert callbacks.completed_steps > 0 + assert callbacks.completed_steps == callbacks.total_steps + + +def test_checkout_aborted_from_callbacks(testrepo: Repository) -> None: + ref_i18n = testrepo.lookup_reference('refs/heads/i18n') + + 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) -> None: + super().__init__() + self.invoked_times = 0 + + 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_object = testrepo.head + head = testrepo[head_object.target] + assert 'new' not in head.tree + assert b'bye world\n' == read_bye_txt() + callbacks = MyCheckoutCallbacks() + + # checkout i18n with GIT_CHECKOUT_FORCE - callbacks should prevent checkout from completing + with pytest.raises(InterruptedError): + testrepo.checkout( + ref_i18n, strategy=CheckoutStrategy.FORCE, callbacks=callbacks + ) + + assert callbacks.invoked_times == 2 + assert 'new' not in head.tree + assert b'bye world\n' == read_bye_txt() + + +def test_checkout_branch(testrepo: Repository) -> None: + branch_i18n = testrepo.lookup_branch('i18n') + + # checkout i18n with conflicts and default strategy should + # not be possible + with pytest.raises(pygit2.GitError): + testrepo.checkout(branch_i18n) + + # checkout i18n with GIT_CHECKOUT_FORCE + head_object = testrepo.head + head = testrepo[head_object.target] + assert 'new' not in head.tree + testrepo.checkout(branch_i18n, strategy=CheckoutStrategy.FORCE) + + 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: Repository) -> None: + # some changes to working dir + with (Path(testrepo.workdir) / 'hello.txt').open('w') as f: + f.write('new content') + + # checkout index + assert 'hello.txt' in testrepo.status() + testrepo.checkout(strategy=CheckoutStrategy.FORCE) + assert 'hello.txt' not in testrepo.status() + + +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') + testrepo.index.add('bye.txt') + + # checkout from index should not change anything + assert 'bye.txt' in testrepo.status() + testrepo.checkout(strategy=CheckoutStrategy.FORCE) + assert 'bye.txt' in testrepo.status() + + # checkout from head will reset index as well + testrepo.checkout('HEAD', strategy=CheckoutStrategy.FORCE) + assert 'bye.txt' not in testrepo.status() + + +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() + assert len(list(extra_dir.iterdir())) == 0 + testrepo.checkout(ref_i18n, directory=extra_dir) + assert not len(list(extra_dir.iterdir())) == 0 + + +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) + testrepo.checkout(ref_i18n, paths=['new']) + status = testrepo.status() + assert status['new'] == FileStatus.INDEX_NEW + + +def test_merge_base(testrepo: Repository) -> None: + commit = testrepo.merge_base( + '5ebeeebb320790caf276b9fc8b24546d63316533', + '4ec4389a8068641da2d6578db0419484972284c8', + ) + assert commit == 'acecd5ea2924a4b900e7e149496e1f4b57976e51' + + # Create a commit without any merge base to any other + sig = pygit2.Signature('me', 'me@example.com') + indep = testrepo.create_commit( + None, sig, sig, 'a new root commit', testrepo[commit].peel(pygit2.Tree).id, [] + ) + + assert testrepo.merge_base(indep, commit) is None + + +def test_descendent_of(testrepo: Repository) -> None: + assert not testrepo.descendant_of( + '5ebeeebb320790caf276b9fc8b24546d63316533', + '4ec4389a8068641da2d6578db0419484972284c8', + ) + assert not testrepo.descendant_of( + '5ebeeebb320790caf276b9fc8b24546d63316533', + '5ebeeebb320790caf276b9fc8b24546d63316533', + ) + assert testrepo.descendant_of( + '5ebeeebb320790caf276b9fc8b24546d63316533', + 'acecd5ea2924a4b900e7e149496e1f4b57976e51', + ) + assert not testrepo.descendant_of( + 'acecd5ea2924a4b900e7e149496e1f4b57976e51', + '5ebeeebb320790caf276b9fc8b24546d63316533', + ) + + with pytest.raises(pygit2.GitError): + testrepo.descendant_of( + '2' * 40, # a valid but inexistent SHA '5ebeeebb320790caf276b9fc8b24546d63316533', - '4ec4389a8068641da2d6578db0419484972284c8') - self.assertEqual(commit.hex, - 'acecd5ea2924a4b900e7e149496e1f4b57976e51') + ) -class NewRepositoryTest(utils.NoRepoTestCase): +def test_ahead_behind(testrepo: Repository) -> None: + ahead, behind = testrepo.ahead_behind( + '5ebeeebb320790caf276b9fc8b24546d63316533', + '4ec4389a8068641da2d6578db0419484972284c8', + ) + assert 1 == ahead + assert 2 == behind + + ahead, behind = testrepo.ahead_behind( + '4ec4389a8068641da2d6578db0419484972284c8', + '5ebeeebb320790caf276b9fc8b24546d63316533', + ) + assert 2 == ahead + assert 1 == behind + + +def test_reset_hard(testrepo: Repository) -> None: + ref = '5ebeeebb320790caf276b9fc8b24546d63316533' + with (Path(testrepo.workdir) / 'hello.txt').open() as f: + lines = f.readlines() + assert 'hola mundo\n' in lines + assert 'bonjour le monde\n' in lines + + testrepo.reset(ref, ResetMode.HARD) + assert testrepo.head.target == ref + + with (Path(testrepo.workdir) / 'hello.txt').open() as f: + lines = f.readlines() + # Hard reset will reset the working copy too + assert 'hola mundo\n' not in lines + assert 'bonjour le monde\n' not in lines + + +def test_reset_soft(testrepo: Repository) -> None: + ref = '5ebeeebb320790caf276b9fc8b24546d63316533' + with (Path(testrepo.workdir) / 'hello.txt').open() as f: + lines = f.readlines() + assert 'hola mundo\n' in lines + assert 'bonjour le monde\n' in lines + + testrepo.reset(ref, ResetMode.SOFT) + assert testrepo.head.target == ref + with (Path(testrepo.workdir) / 'hello.txt').open() as f: + lines = f.readlines() + # Soft reset will not reset the working copy + assert 'hola mundo\n' in lines + assert 'bonjour le monde\n' in lines + + # soft reset will keep changes in the index + diff = testrepo.diff(cached=True) + with pytest.raises(KeyError): + diff[0] + + +def test_reset_mixed(testrepo: Repository) -> None: + ref = '5ebeeebb320790caf276b9fc8b24546d63316533' + with (Path(testrepo.workdir) / 'hello.txt').open() as f: + lines = f.readlines() + assert 'hola mundo\n' in lines + assert 'bonjour le monde\n' in lines + + testrepo.reset(ref, ResetMode.MIXED) + + assert testrepo.head.target == ref + + with (Path(testrepo.workdir) / 'hello.txt').open() as f: + lines = f.readlines() + # mixed reset will not reset the working copy + assert 'hola mundo\n' in lines + assert 'bonjour le monde\n' in lines + + # 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: Repository) -> None: + stash_hash = '6aab5192f88018cb98a7ede99c242f43add5a2fd' + stash_message = 'custom stash message' + sig = pygit2.Signature( + name='Stasher', + email='stasher@example.com', + time=1641000000, # fixed time so the oid is stable + offset=0, + ) + + # make sure we're starting with no stashes + assert [] == testrepo.listall_stashes() + + # some changes to working dir + with (Path(testrepo.workdir) / 'hello.txt').open('w') as f: + f.write('new content') + + testrepo.stash(sig, include_untracked=True, message=stash_message) + assert 'hello.txt' not in testrepo.status() + + repo_stashes = testrepo.listall_stashes() + assert 1 == len(repo_stashes) + assert repr(repo_stashes[0]) == f'' + assert repo_stashes[0].commit_id == stash_hash + assert repo_stashes[0].message == 'On master: ' + stash_message + + testrepo.stash_apply() + assert 'hello.txt' in testrepo.status() + assert repo_stashes == testrepo.listall_stashes() # still the same stashes + + testrepo.stash_drop() + assert [] == testrepo.listall_stashes() + + with pytest.raises(KeyError): + testrepo.stash_pop() + + +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 + ) + + # make sure we're starting with no stashes + assert [] == testrepo.listall_stashes() + + # some changes to working dir + with (Path(testrepo.workdir) / 'hello.txt').open('w') as f: + f.write('stash me') + with (Path(testrepo.workdir) / 'untracked2.txt').open('w') as f: + f.write('do not stash me') + + assert testrepo.status()['hello.txt'] == FileStatus.WT_MODIFIED + assert testrepo.status()['bye.txt'] == FileStatus.WT_NEW + assert testrepo.status()['untracked2.txt'] == FileStatus.WT_NEW + + 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) + return stash_files == set(paths) - def test_new_repo(self): - repo = init_repository(self._temp_dir, False) + # Stash a modified file + assert stash_pathspecs(['hello.txt']) + + # Stash one of several untracked files + assert stash_pathspecs(['bye.txt']) + + # Stash a modified file and an untracked file + assert stash_pathspecs(['hello.txt', 'bye.txt']) + + +def test_stash_progress_callback(testrepo: Repository) -> None: + sig = pygit2.Signature( + name='Stasher', email='stasher@example.com', time=1641000000, offset=0 + ) + + # some changes to working dir + with (Path(testrepo.workdir) / 'hello.txt').open('w') as f: + f.write('new content') + + # create the stash + testrepo.stash(sig, include_untracked=True, message='custom stash message') + + progress_sequence = [] + + class MyStashApplyCallbacks(pygit2.StashApplyCallbacks): + def stash_apply_progress(self, progress: StashApplyProgress) -> None: + progress_sequence.append(progress) + + # apply the stash + testrepo.stash_apply(callbacks=MyStashApplyCallbacks()) + + # make sure the callbacks were notified of all the steps + assert progress_sequence == [ + StashApplyProgress.LOADING_STASH, + StashApplyProgress.ANALYZE_INDEX, + StashApplyProgress.ANALYZE_MODIFIED, + StashApplyProgress.ANALYZE_UNTRACKED, + StashApplyProgress.CHECKOUT_UNTRACKED, + StashApplyProgress.CHECKOUT_MODIFIED, + StashApplyProgress.DONE, + ] + + +def test_stash_aborted_from_callbacks(testrepo: Repository) -> None: + sig = pygit2.Signature( + name='Stasher', email='stasher@example.com', time=1641000000, offset=0 + ) + + # some changes to working dir + with (Path(testrepo.workdir) / 'hello.txt').open('w') as f: + f.write('new content') + with (Path(testrepo.workdir) / 'untracked.txt').open('w') as f: + f.write('yo') + + # create the stash + testrepo.stash(sig, include_untracked=True, message='custom stash message') + + # 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) -> None: + if progress == StashApplyProgress.CHECKOUT_UNTRACKED: + raise InterruptedError('Stop applying the stash!') + + # attempt to apply and delete the stash; the callbacks will interrupt that + with pytest.raises(InterruptedError): + testrepo.stash_pop(callbacks=MyStashApplyCallbacks()) + + # we interrupted right before the checkout part of the unstashing process, + # so the untracked file shouldn't be here + assert not (Path(testrepo.workdir) / 'untracked.txt').exists() + + # and hello.txt should be as it is on master + with (Path(testrepo.workdir) / 'hello.txt').open('r') as f: + assert f.read() == 'hello world\nhola mundo\nbonjour le monde\n' + + # and since we didn't let stash_pop run to completion, the stash itself should still be here + repo_stashes = testrepo.listall_stashes() + assert 1 == len(repo_stashes) + assert repo_stashes[0].message == 'On master: custom stash message' + + +def test_stash_apply_checkout_options(testrepo: Repository) -> None: + sig = pygit2.Signature( + name='Stasher', email='stasher@example.com', time=1641000000, offset=0 + ) + + hello_txt = Path(testrepo.workdir) / 'hello.txt' + + # some changes to working dir + with hello_txt.open('w') as f: + f.write('stashed content') + + # create the stash + testrepo.stash(sig, include_untracked=True, message='custom stash message') + + # define callbacks that raise an InterruptedError when checkout detects a conflict + class MyStashApplyCallbacks(pygit2.StashApplyCallbacks): + 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') + + # overwrite hello.txt so that applying the stash would create a conflict + with hello_txt.open('w') as f: + f.write('conflicting content') + + # apply the stash with the default (safe) strategy; + # the callbacks should detect a conflict on checkout + with pytest.raises(InterruptedError): + testrepo.stash_apply( + strategy=CheckoutStrategy.SAFE, callbacks=MyStashApplyCallbacks() + ) - oid = repo.write(GIT_OBJ_BLOB, "Test") - self.assertEqual(type(oid), Oid) + # hello.txt should be intact + with hello_txt.open('r') as f: + assert f.read() == 'conflicting content' - assert os.path.exists(os.path.join(self._temp_dir, '.git')) + # force apply the stash; this should work + testrepo.stash_apply( + strategy=CheckoutStrategy.FORCE, callbacks=MyStashApplyCallbacks() + ) + with hello_txt.open('r') as f: + assert f.read() == 'stashed content' -class InitRepositoryTest(utils.NoRepoTestCase): - # under the assumption that repo.is_bare works +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) - def test_no_arg(self): - repo = init_repository(self._temp_dir) - self.assertFalse(repo.is_bare) + parent = commit_to_revert.parents[0] + commit_diff_stats = parent.tree.diff_to_tree(commit_to_revert.tree).stats - def test_pos_arg_false(self): - repo = init_repository(self._temp_dir, False) - self.assertFalse(repo.is_bare) + revert_index = testrepo.revert_commit(commit_to_revert, master) + revert_diff_stats = revert_index.diff_to_tree(master.tree).stats - def test_pos_arg_true(self): - repo = init_repository(self._temp_dir, True) - self.assertTrue(repo.is_bare) + assert revert_diff_stats.insertions == commit_diff_stats.deletions + assert revert_diff_stats.deletions == commit_diff_stats.insertions + assert revert_diff_stats.files_changed == commit_diff_stats.files_changed - def test_keyword_arg_false(self): - repo = init_repository(self._temp_dir, bare=False) - self.assertFalse(repo.is_bare) - def test_keyword_arg_true(self): - repo = init_repository(self._temp_dir, bare=True) - self.assertTrue(repo.is_bare) +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 + assert 'bonjour le monde' in hello_txt.read_text() + # Revert addition of French line in hello.txt + testrepo.revert(commit_to_revert) -class DiscoverRepositoryTest(utils.NoRepoTestCase): + assert 'bonjour le monde' not in hello_txt.read_text() + assert testrepo.status()['hello.txt'] == FileStatus.INDEX_MODIFIED + assert testrepo.state() == RepositoryState.REVERT + assert ( + testrepo.message + == f'Revert "Say hello in French"\n\nThis reverts commit {commit_to_revert.id}.\n' + ) - def test_discover_repo(self): - repo = init_repository(self._temp_dir, False) - subdir = os.path.join(self._temp_dir, "test1", "test2") - os.makedirs(subdir) - self.assertEqual(repo.path, discover_repository(subdir)) +def test_default_signature(testrepo: Repository) -> None: + config = testrepo.config + config['user.name'] = 'Random J Hacker' + config['user.email'] = 'rjh@example.com' + sig = testrepo.default_signature + assert 'Random J Hacker' == sig.name + assert 'rjh@example.com' == sig.email -class EmptyRepositoryTest(utils.EmptyRepoTestCase): - def test_is_empty(self): - self.assertTrue(self.repo.is_empty) +def test_new_repo(tmp_path: Path) -> None: + repo = init_repository(tmp_path, False) - def test_is_base(self): - self.assertFalse(self.repo.is_bare) + oid = repo.write(ObjectType.BLOB, 'Test') + assert type(oid) is Oid - def test_head(self): - self.assertTrue(self.repo.head_is_orphaned) - self.assertFalse(self.repo.head_is_detached) + assert (tmp_path / '.git').exists() +def test_no_arg(tmp_path: Path) -> None: + repo = init_repository(tmp_path) + assert not repo.is_bare -class CloneRepositoryTest(utils.NoRepoTestCase): - def test_clone_repository(self): - repo_path = "./test/data/testrepo.git/" - repo = clone_repository(repo_path, self._temp_dir) - self.assertFalse(repo.is_empty) - self.assertFalse(repo.is_bare) +def test_no_arg_aspath(tmp_path: Path) -> None: + repo = init_repository(Path(tmp_path)) + assert not repo.is_bare - def test_clone_bare_repository(self): - repo_path = "./test/data/testrepo.git/" - repo = clone_repository(repo_path, self._temp_dir, bare=True) - self.assertFalse(repo.is_empty) - self.assertTrue(repo.is_bare) - def test_clone_remote_name(self): - repo_path = "./test/data/testrepo.git/" - repo = clone_repository( - repo_path, self._temp_dir, remote_name="custom_remote" - ) - self.assertFalse(repo.is_empty) - self.assertEqual(repo.remotes[0].name, "custom_remote") - - - # FIXME The tests below are commented because they are broken: - # - # - test_clone_push_url: Passes, but does nothing useful. - # - # - test_clone_fetch_spec: Segfaults because of a bug in libgit2 0.19, - # this has been fixed already, so wait for 0.20 - # - # - test_clone_push_spec: Passes, but does nothing useful. - # - # - test_clone_checkout_branch: Fails, because the test fixture does not - # have any branch named "test" - -# def test_clone_push_url(self): -# repo_path = "./test/data/testrepo.git/" -# repo = clone_repository( -# repo_path, self._temp_dir, push_url="custom_push_url" -# ) -# self.assertFalse(repo.is_empty) -# # FIXME: When pygit2 supports retrieving the pushurl parameter, -# # enable this test -# # self.assertEqual(repo.remotes[0].pushurl, "custom_push_url") - -# def test_clone_fetch_spec(self): -# repo_path = "./test/data/testrepo.git/" -# repo = clone_repository(repo_path, self._temp_dir, -# fetch_spec="refs/heads/test") -# self.assertFalse(repo.is_empty) -# # FIXME: When pygit2 retrieve the fetchspec we passed to git clone. -# # fetchspec seems to be going through, but the Repository class is -# # not getting it. -# # self.assertEqual(repo.remotes[0].fetchspec, "refs/heads/test") - -# def test_clone_push_spec(self): -# repo_path = "./test/data/testrepo.git/" -# repo = clone_repository(repo_path, self._temp_dir, -# push_spec="refs/heads/test") -# self.assertFalse(repo.is_empty) -# # FIXME: When pygit2 supports retrieving the pushspec parameter, -# # enable this test -# # not sure how to test this either... couldn't find pushspec -# # self.assertEqual(repo.remotes[0].fetchspec, "refs/heads/test") - -# def test_clone_checkout_branch(self): -# repo_path = "./test/data/testrepo.git/" -# repo = clone_repository(repo_path, self._temp_dir, -# checkout_branch="test") -# self.assertFalse(repo.is_empty) -# # FIXME: When pygit2 supports retrieving the current branch, -# # enable this test -# # self.assertEqual(repo.remotes[0].current_branch, "test") - - -if __name__ == '__main__': - unittest.main() +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: Path) -> None: + repo = init_repository(tmp_path, True) + assert repo.is_bare + + +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: Path) -> None: + repo = init_repository(tmp_path, bare=True) + assert repo.is_bare + + +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: 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() -> None: + tempdir = tempfile.tempdir + assert tempdir is not None + assert discover_repository(tempdir) is None + + +def test_repository_init(barerepo_path: tuple[Repository, Path]) -> None: + barerepo, path = barerepo_path + assert isinstance(path, Path) + pygit2.Repository(path) + pygit2.Repository(str(path)) + pygit2.Repository(bytes(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 + assert not repo.is_bare + repo = clone_repository(str(barerepo.path), str(tmp_path / 'clonestr')) + assert not repo.is_empty + assert not repo.is_bare + + +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: 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) + assert not repo.is_empty + assert repo.is_shallow + + +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: Path, bare: bool) -> Repository: + return init_repository(path, bare) + + # here we override the name + def create_remote(repo: Repository, name: str, url: str) -> Remote: + return repo.remotes.create('custom_remote', url) + + repo = clone_repository( + url, repo_path, repository=create_repository, remote=create_remote + ) + assert not repo.is_empty + assert 'refs/remotes/custom_remote/master' in repo.listall_references() + assert b'refs/remotes/custom_remote/master' in repo.raw_listall_references() + assert repo.remotes['custom_remote'] is not None + + +@utils.requires_network +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) + repo = clone_repository(url, tmp_path, callbacks=callbacks) + + assert not repo.is_empty + + +@utils.requires_network +def test_clone_bad_credentials(tmp_path: Path) -> None: + class MyCallbacks(pygit2.RemoteCallbacks): + 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' + with pytest.raises(RuntimeError) as exc: + clone_repository(url, tmp_path, callbacks=MyCallbacks()) + assert str(exc.value) == 'Unexpected error' + + +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 + ) + commit = test_repo[test_repo.head.target] + assert isinstance(commit, Commit) + test_repo.create_branch('test', commit) + repo = clone_repository( + test_repo.path, tmp_path / 'testrepo.git', checkout_branch='test', bare=True + ) + assert repo.lookup_reference('HEAD').target == 'refs/heads/test' + + +@utils.requires_proxy +@utils.requires_network +def test_clone_with_proxy(tmp_path: Path) -> None: + url = 'https://github.com/libgit2/TestGitRepository' + repo = clone_repository( + url, + tmp_path / 'testrepo-orig.git', + proxy=True, + ) + assert not repo.is_empty + + +# FIXME The tests below are commented because they are broken: +# +# - test_clone_push_url: Passes, but does nothing useful. +# +# - test_clone_fetch_spec: Segfaults because of a bug in libgit2 0.19, +# this has been fixed already, so wait for 0.20 +# +# - test_clone_push_spec: Passes, but does nothing useful. +# + +# def test_clone_push_url(): +# repo_path = "./test/data/testrepo.git/" +# repo = clone_repository( +# repo_path, tmp_path, push_url="custom_push_url" +# ) +# assert not repo.is_empty +# # FIXME: When pygit2 supports retrieving the pushurl parameter, +# # enable this test +# # assert repo.remotes[0].pushurl == "custom_push_url" +# +# def test_clone_fetch_spec(): +# repo_path = "./test/data/testrepo.git/" +# repo = clone_repository(repo_path, tmp_path, +# fetch_spec="refs/heads/test") +# assert not repo.is_empty +# # FIXME: When pygit2 retrieve the fetchspec we passed to git clone. +# # fetchspec seems to be going through, but the Repository class is +# # not getting it. +# # assert repo.remotes[0].fetchspec == "refs/heads/test" +# +# def test_clone_push_spec(): +# repo_path = "./test/data/testrepo.git/" +# repo = clone_repository(repo_path, tmp_path, +# push_spec="refs/heads/test") +# assert not repo.is_empty +# # FIXME: When pygit2 supports retrieving the pushspec parameter, +# # enable this test +# # not sure how to test this either... couldn't find pushspec +# # assert repo.remotes[0].fetchspec == "refs/heads/test" + + +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: Worktree) -> None: + # Confirm the name attribute matches the specified name + assert worktree.name == worktree_name + # Confirm the path attribute points to the correct path + assert Path(worktree.path).resolve() == worktree_dir.resolve() + # The "gitdir" in a worktree should be a file with a reference to + # the actual gitdir. Let's make sure that the path exists and is a + # file. + assert (worktree_dir / '.git').is_file() + + # We should have zero worktrees + assert testrepo.list_worktrees() == [] + # Add a worktree + worktree = testrepo.add_worktree(worktree_name, str(worktree_dir)) + # Check that the worktree was added properly + _check_worktree(worktree) + # We should have one worktree now + assert testrepo.list_worktrees() == [worktree_name] + # We should also have a branch of the same name + assert worktree_name in testrepo.listall_branches() + # Test that lookup_worktree() returns a properly-instantiated + # pygit2._Worktree object + _check_worktree(testrepo.lookup_worktree(worktree_name)) + # Remove the worktree dir + shutil.rmtree(worktree_dir) + # Prune the worktree. For some reason, libgit2 treats a worktree as + # valid unless both the worktree directory and data dir under + # $GIT_DIR/worktrees are gone. This doesn't make much sense since the + # normal usage involves removing the worktree directory and then + # pruning. So, for now we have to force the prune. This may be + # something to take up with libgit2. + worktree.prune(True) + assert testrepo.list_worktrees() == [] + + +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 + # worktree later + worktree_dir.rmdir() + testrepo.add_worktree(worktree_name, worktree_dir) + assert testrepo.list_worktrees() == [worktree_name] + + +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 + worktree_dir.rmdir() + + # Add a worktree for the given ref + worktree = testrepo.add_worktree(worktree_name, str(worktree_dir), worktree_ref) + # We should have one worktree now + assert testrepo.list_worktrees() == [worktree_name] + # We should not have a branch of the same name + assert worktree_name not in testrepo.listall_branches() + + # The given ref is checked out in the "worktree repository" + assert worktree_ref.is_checked_out() + + # Remove the worktree dir and prune the worktree + shutil.rmtree(worktree_dir) + worktree.prune(True) + assert testrepo.list_worktrees() == [] + + # The ref is no longer checked out + assert worktree_ref.is_checked_out() is False + + # The branch still exists + assert branch_name in testrepo.branches + + +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 + assert orig_repo.path + assert orig_repo.workdir + + # GIT_REPOSITORY_OPEN_NO_SEARCH + subdir_path = path / 'subdir' + repo = pygit2.Repository(subdir_path) + assert not repo.is_bare + assert repo.path == orig_repo.path + assert repo.workdir == orig_repo.workdir + + with pytest.raises(pygit2.GitError): + repo = pygit2.Repository(subdir_path, RepositoryOpenFlag.NO_SEARCH) + + # GIT_REPOSITORY_OPEN_NO_DOTGIT + gitdir_path = path / '.git' + with pytest.raises(pygit2.GitError): + repo = pygit2.Repository(path, RepositoryOpenFlag.NO_DOTGIT) + + repo = pygit2.Repository(gitdir_path, RepositoryOpenFlag.NO_DOTGIT) + assert not repo.is_bare + assert repo.path == orig_repo.path + assert repo.workdir == orig_repo.workdir + + # GIT_REPOSITORY_OPEN_BARE + repo = pygit2.Repository(gitdir_path, RepositoryOpenFlag.BARE) + assert repo.is_bare + assert repo.path == orig_repo.path + assert not repo.workdir + + +def test_is_shallow(testrepo: Repository) -> None: + assert not testrepo.is_shallow + + # create a dummy shallow file + with (Path(testrepo.path) / 'shallow').open('wt') as f: + f.write('abcdef0123456789abcdef0123456789abcdef00\n') + + assert testrepo.is_shallow + + +def test_repository_hashfile(testrepo: Repository) -> None: + original_hash = testrepo.index['hello.txt'].id + + # Test simple use + h = testrepo.hashfile('hello.txt') + assert h == original_hash + + # 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_str = absolute_path.as_posix() # Windows compatibility + h = testrepo.hashfile(str(absolute_path_str)) + assert h == original_hash + + # Test missing path + with pytest.raises(KeyError): + testrepo.hashfile('missing-file') + + # Test invalid object type + with pytest.raises(pygit2.GitError): + testrepo.hashfile('hello.txt', ObjectType.OFS_DELTA) + + +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: + original_text = f.read() + + crlf_data = original_text.replace(b'\n', b'\r\n') + crlf_hash = utils.gen_blob_sha1(crlf_data) + assert crlf_hash != original_hash + + # Write hellocrlf.txt as a copy of hello.txt with CRLF line endings + with open(Path(testrepo.workdir, 'hellocrlf.txt'), 'wb') as f: + f.write(crlf_data) + + # Set up a CRLF filter + testrepo.config['core.autocrlf'] = True + with open(Path(testrepo.workdir, '.gitattributes'), 'wt') as f: + f.write('*.txt text\n*.bin binary\n\n') + + # By default, hellocrlf.txt should have the same hash as the original, + # due to core.autocrlf=True + h = testrepo.hashfile('hellocrlf.txt') + assert h == original_hash + + # 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_str = absolute_path.as_posix() # Windows compatibility + h = testrepo.hashfile(str(absolute_path_str)) + assert h == original_hash + + # Bypass filters + h = testrepo.hashfile('hellocrlf.txt', as_path='') + assert h == crlf_hash + + # Bypass filters via .gitattributes + h = testrepo.hashfile('hellocrlf.txt', as_path='foobar.bin') + assert h == crlf_hash + + # If core.safecrlf=fail, hashing a non-CRLF file will fail + testrepo.config['core.safecrlf'] = 'fail' + with pytest.raises(pygit2.GitError): + h = testrepo.hashfile('hello.txt') + + +def test_merge_file_from_index_deprecated(testrepo: Repository) -> None: + hello_txt = testrepo.index['hello.txt'] + hello_txt_executable = IndexEntry( + hello_txt.path, hello_txt.id, FileMode.BLOB_EXECUTABLE + ) + hello_world = IndexEntry('hello_world.txt', hello_txt.id, hello_txt.mode) + + def get_hello_txt_from_repo() -> str: + blob = testrepo.get(hello_txt.id) + assert isinstance(blob, Blob) + return blob.data.decode() + + # no change + res = testrepo.merge_file_from_index(hello_txt, hello_txt, hello_txt) + assert res == get_hello_txt_from_repo() + + # executable switch on ours + res = testrepo.merge_file_from_index(hello_txt, hello_txt_executable, hello_txt) + assert res == get_hello_txt_from_repo() + + # executable switch on theirs + res = testrepo.merge_file_from_index(hello_txt, hello_txt, hello_txt_executable) + assert res == get_hello_txt_from_repo() + + # executable switch on both + res = testrepo.merge_file_from_index( + hello_txt, hello_txt_executable, hello_txt_executable + ) + assert res == get_hello_txt_from_repo() + + # path switch on ours + res = testrepo.merge_file_from_index(hello_txt, hello_world, hello_txt) + assert res == get_hello_txt_from_repo() + + # path switch on theirs + res = testrepo.merge_file_from_index(hello_txt, hello_txt, hello_world) + assert res == get_hello_txt_from_repo() + + # path switch on both + res = testrepo.merge_file_from_index(hello_txt, hello_world, hello_world) + assert res == get_hello_txt_from_repo() + + # path switch on ours, executable flag switch on theirs + res = testrepo.merge_file_from_index(hello_txt, hello_world, hello_txt_executable) + assert res == get_hello_txt_from_repo() + + # path switch on theirs, executable flag switch on ours + res = testrepo.merge_file_from_index(hello_txt, hello_txt_executable, hello_world) + assert res == get_hello_txt_from_repo() + + +def test_merge_file_from_index_non_deprecated(testrepo: Repository) -> None: + hello_txt = testrepo.index['hello.txt'] + hello_txt_executable = IndexEntry( + hello_txt.path, hello_txt.id, FileMode.BLOB_EXECUTABLE + ) + hello_world = IndexEntry('hello_world.txt', hello_txt.id, hello_txt.mode) + + def get_hello_txt_from_repo() -> str: + blob = testrepo.get(hello_txt.id) + assert isinstance(blob, Blob) + return blob.data.decode() + + # no change + res = testrepo.merge_file_from_index( + hello_txt, hello_txt, hello_txt, use_deprecated=False + ) + assert res == MergeFileResult( + True, hello_txt.path, hello_txt.mode, get_hello_txt_from_repo() + ) + + # executable switch on ours + res = testrepo.merge_file_from_index( + hello_txt, hello_txt_executable, hello_txt, use_deprecated=False + ) + assert res == MergeFileResult( + True, + hello_txt.path, + hello_txt_executable.mode, + get_hello_txt_from_repo(), + ) + + # executable switch on theirs + res = testrepo.merge_file_from_index( + hello_txt, hello_txt, hello_txt_executable, use_deprecated=False + ) + assert res == MergeFileResult( + True, + hello_txt.path, + hello_txt_executable.mode, + get_hello_txt_from_repo(), + ) + + # executable switch on both + res = testrepo.merge_file_from_index( + hello_txt, hello_txt_executable, hello_txt_executable, use_deprecated=False + ) + assert res == MergeFileResult( + True, + hello_txt.path, + hello_txt_executable.mode, + get_hello_txt_from_repo(), + ) + + # path switch on ours + res = testrepo.merge_file_from_index( + hello_txt, hello_world, hello_txt, use_deprecated=False + ) + assert res == MergeFileResult( + True, hello_world.path, hello_txt.mode, get_hello_txt_from_repo() + ) + + # path switch on theirs + res = testrepo.merge_file_from_index( + hello_txt, hello_txt, hello_world, use_deprecated=False + ) + assert res == MergeFileResult( + True, hello_world.path, hello_txt.mode, get_hello_txt_from_repo() + ) + + # path switch on both + res = testrepo.merge_file_from_index( + hello_txt, hello_world, hello_world, use_deprecated=False + ) + assert res == MergeFileResult(True, None, hello_txt.mode, get_hello_txt_from_repo()) + + # path switch on ours, executable flag switch on theirs + res = testrepo.merge_file_from_index( + hello_txt, hello_world, hello_txt_executable, use_deprecated=False + ) + assert res == MergeFileResult( + True, + hello_world.path, + hello_txt_executable.mode, + get_hello_txt_from_repo(), + ) + + # path switch on theirs, executable flag switch on ours + res = testrepo.merge_file_from_index( + hello_txt, hello_txt_executable, hello_world, use_deprecated=False + ) + assert res == MergeFileResult( + True, + hello_world.path, + hello_txt_executable.mode, + get_hello_txt_from_repo(), + ) diff --git a/test/test_repository_bare.py b/test/test_repository_bare.py new file mode 100644 index 000000000..4021fc8b3 --- /dev/null +++ b/test/test_repository_bare.py @@ -0,0 +1,251 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +import binascii +import os +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 + +HEAD_SHA = '784855caf26449a1914d2cf62d12b9374d76ae78' +PARENT_SHA = 'f5e5aa4e36ab0fe62ee1ccc6eb8f79b866863b87' # HEAD^ +BLOB_HEX = 'af431f20fc541ed6d5afede3e2dc7160f6f01f16' +BLOB_RAW = binascii.unhexlify(BLOB_HEX.encode('ascii')) +BLOB_OID = pygit2.Oid(raw=BLOB_RAW) + + +def test_is_empty(barerepo: Repository) -> None: + assert not barerepo.is_empty + + +def test_is_bare(barerepo: Repository) -> None: + assert barerepo.is_bare + + +def test_head(barerepo: Repository) -> None: + head = barerepo.head + assert HEAD_SHA == head.target + assert type(head) is pygit2.Reference + assert not barerepo.head_is_unborn + assert not barerepo.head_is_detached + + +def test_set_head(barerepo: Repository) -> None: + # Test setting a detached HEAD. + barerepo.set_head(pygit2.Oid(hex=PARENT_SHA)) + assert barerepo.head.target == PARENT_SHA + # And test setting a normal HEAD. + barerepo.set_head('refs/heads/master') + assert barerepo.head.name == 'refs/heads/master' + assert barerepo.head.target == HEAD_SHA + + +def test_read(barerepo: Repository) -> None: + with pytest.raises(TypeError): + barerepo.read(123) # type: ignore + utils.assertRaisesWithArg(KeyError, '1' * 40, barerepo.read, '1' * 40) + + ab = barerepo.read(BLOB_OID) + a = barerepo.read(BLOB_HEX) + assert ab == a + assert (ObjectType.BLOB, b'a contents\n') == a + + a2 = barerepo.read('7f129fd57e31e935c6d60a0c794efe4e6927664b') + assert (ObjectType.BLOB, b'a contents 2\n') == a2 + + a_hex_prefix = BLOB_HEX[:4] + a3 = barerepo.read(a_hex_prefix) + assert (ObjectType.BLOB, b'a contents\n') == a3 + + +def test_write(barerepo: Repository) -> None: + data = b'hello world' + # invalid object type + with pytest.raises(ValueError): + barerepo.write(ObjectType.ANY, data) + + oid = barerepo.write(ObjectType.BLOB, data) + assert type(oid) is pygit2.Oid + + +def test_contains(barerepo: Repository) -> None: + with pytest.raises(TypeError): + 123 in barerepo # type: ignore + assert BLOB_OID in barerepo + assert BLOB_HEX in barerepo + assert BLOB_HEX[:10] in barerepo + assert ('a' * 40) not in barerepo + assert ('a' * 20) not in 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: Repository) -> None: + with pytest.raises(TypeError): + 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 int(ObjectType.BLOB) == a.type + + +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 int(ObjectType.BLOB) == a.type + + +def test_lookup_commit(barerepo: Repository) -> None: + commit_sha = '5fe808e8953c12735680c257f56600cb0de44b10' + commit = barerepo[commit_sha] + assert commit_sha == commit.id + 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: 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 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 + ) + with pytest.raises(ValueError): + barerepo.__getitem__(too_short_prefix) + + +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: Repository) -> None: + start = sys.getrefcount(barerepo) + commit_sha = '5fe808e8953c12735680c257f56600cb0de44b10' + commit = barerepo[commit_sha] + del commit + end = sys.getrefcount(barerepo) + assert start == end + + +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: Repository) -> None: + assert barerepo.workdir is None + + +def test_revparse_single(barerepo: Repository) -> None: + parent = barerepo.revparse_single('HEAD^') + assert parent.id == PARENT_SHA + + +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: Repository) -> None: + data = 'bazbarfoo' + handle, tempfile_path = tempfile.mkstemp() + with os.fdopen(handle, 'w') as fh: + fh.write(data) + hashed_sha1 = pygit2.hashfile(tempfile_path) + pathlib.Path(tempfile_path).unlink() + written_sha1 = barerepo.create_blob(data) + assert hashed_sha1 == written_sha1 + + +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) + tree = tb.write() + + sig = pygit2.Signature('Author', 'author@example.com') + commit = repo.create_commit( + branch.name, sig, sig, 'Conflict', tree, [branch.target] + ) + assert commit is not None + return commit + + 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') + 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) + assert index.conflicts is not None + + # ConflictCollection does not allow calling len(...) on it directly so + # we have to calculate length by iterating over its entries + assert sum(1 for _ in index.conflicts) == 1 + + (a, t, o) = index.conflicts['conflict'] + diff = barerepo.merge_file_from_index(a, t, o) + assert ( + diff + == """<<<<<<< conflict +ASCII - abc +======= +Unicode - äüö +>>>>>>> conflict +""" + ) diff --git a/test/test_repository_custom.py b/test/test_repository_custom.py new file mode 100644 index 000000000..40698df05 --- /dev/null +++ b/test/test_repository_custom.py @@ -0,0 +1,64 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +from 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: Repository) -> Generator[Repository, None, None]: + testrepo = testrepopacked + + odb = pygit2.Odb() + object_path = Path(testrepo.path) / 'objects' + odb.add_backend(pygit2.OdbBackendPack(object_path), 1) + odb.add_backend(pygit2.OdbBackendLoose(object_path, 0, False), 1) + + refdb = pygit2.Refdb.new(testrepo) + refdb.set_backend(pygit2.RefdbFsBackend(testrepo)) + + repo = pygit2.Repository() + repo.set_odb(odb) + repo.set_refdb(refdb) + yield 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'), + ('refs/heads/master', '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98'), + ] + + +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 new file mode 100644 index 000000000..be8a8d342 --- /dev/null +++ b/test/test_repository_empty.py @@ -0,0 +1,39 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +from pygit2 import Repository + + +def test_is_empty(emptyrepo: Repository) -> None: + assert emptyrepo.is_empty + + +def test_is_base(emptyrepo: Repository) -> None: + assert not emptyrepo.is_bare + + +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 new file mode 100644 index 000000000..d61df77d2 --- /dev/null +++ b/test/test_revparse.py @@ -0,0 +1,93 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +"""Tests for revision parsing.""" + +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: 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: Repository) -> None: + o, r = testrepo.revparse_ext('master') + assert o.id == HEAD_SHA + assert r == testrepo.references['refs/heads/master'] + + o, r = testrepo.revparse_ext('HEAD^') + assert o.id == PARENT_SHA + assert r is None + + o, r = testrepo.revparse_ext('i18n') + assert str(o.id).startswith('5470a67') + assert r == testrepo.references['refs/heads/i18n'] + + +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: 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: Repository) -> None: + s = testrepo.revparse('HEAD...i18n') + assert str(s.from_object.id).startswith('2be5719') + assert str(s.to_object.id).startswith('5470a67') + assert s.flags == RevSpecFlag.RANGE | RevSpecFlag.MERGE_BASE + assert testrepo.merge_base(s.from_object.id, s.to_object.id) is not None + + +def test_revparse_range_errors(testrepo: Repository) -> None: + with raises(KeyError): + testrepo.revparse('nope..2be571915') + + with raises(InvalidSpecError): + testrepo.revparse('master............2be571915') + + +def test_revparse_repr(testrepo: Repository) -> None: + s = testrepo.revparse('HEAD...i18n') + assert ( + repr(s) + == ',to=}>' + ) diff --git a/test/test_revwalk.py b/test/test_revwalk.py index 119f22253..28cfc4061 100644 --- a/test/test_revwalk.py +++ b/test/test_revwalk.py @@ -1,6 +1,4 @@ -# -*- coding: UTF-8 -*- -# -# Copyright 2010-2013 The pygit2 contributors +# Copyright 2010-2025 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -27,13 +25,8 @@ """Tests for revision walk.""" -from __future__ import absolute_import -from __future__ import unicode_literals -import unittest - -from pygit2 import GIT_SORT_TIME, GIT_SORT_REVERSE -from . import utils - +from pygit2 import Repository +from pygit2.enums import SortMode # In the order given by git log log = [ @@ -41,7 +34,8 @@ '5ebeeebb320790caf276b9fc8b24546d63316533', '4ec4389a8068641da2d6578db0419484972284c8', '6aaa262e655dd54252e5813c8e5acd7780ed097d', - 'acecd5ea2924a4b900e7e149496e1f4b57976e51'] + 'acecd5ea2924a4b900e7e149496e1f4b57976e51', +] REVLOGS = [ ('Nico von Geyso', 'checkout: moving from i18n to master'), @@ -53,55 +47,69 @@ ('J. David Ibañez', 'commit: Say hello in French'), ('J. David Ibañez', 'commit: Say hello in Spanish'), ('J. David Ibañez', 'checkout: moving from master to i18n'), - ('J. David Ibañez', 'commit (initial): First commit') + ('J. David Ibañez', 'commit (initial): First commit'), ] -class RevlogTestTest(utils.RepoTestCase): - def test_log(self): - ref = self.repo.lookup_reference('HEAD') - for i, entry in enumerate(ref.log()): - self.assertEqual(entry.committer.name, REVLOGS[i][0]) - self.assertEqual(entry.message, REVLOGS[i][1]) +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: Repository) -> None: + walker = testrepo.walk(log[0], SortMode.TIME) + assert [x.id for x in walker] == log + + +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: Repository) -> None: + walker = testrepo.walk(log[0], SortMode.TIME) + walker.hide('4ec4389a8068641da2d6578db0419484972284c8') + assert len(list(walker)) == 2 + + +def test_hide_prefix(testrepo: Repository) -> None: + walker = testrepo.walk(log[0], SortMode.TIME) + walker.hide('4ec4389a') + assert len(list(walker)) == 2 -class WalkerTest(utils.RepoTestCase): +def test_reset(testrepo: Repository) -> None: + walker = testrepo.walk(log[0], SortMode.TIME) + walker.reset() + assert list(walker) == [] - def test_walk(self): - walker = self.repo.walk(log[0], GIT_SORT_TIME) - self.assertEqual([x.hex for x in walker], log) - def test_reverse(self): - walker = self.repo.walk(log[0], GIT_SORT_TIME | GIT_SORT_REVERSE) - self.assertEqual([x.hex for x in walker], list(reversed(log))) +def test_push(testrepo: Repository) -> None: + walker = testrepo.walk(log[-1], SortMode.TIME) + assert [x.id for x in walker] == log[-1:] + walker.reset() + walker.push(log[0]) + assert [x.id for x in walker] == log - def test_hide(self): - walker = self.repo.walk(log[0], GIT_SORT_TIME) - walker.hide('4ec4389a8068641da2d6578db0419484972284c8') - self.assertEqual(len(list(walker)), 2) - def test_hide_prefix(self): - walker = self.repo.walk(log[0], GIT_SORT_TIME) - walker.hide('4ec4389a') - self.assertEqual(len(list(walker)), 2) +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_reset(self): - walker = self.repo.walk(log[0], GIT_SORT_TIME) - walker.reset() - self.assertEqual([x.hex for x in walker], []) - def test_push(self): - walker = self.repo.walk(log[-1], GIT_SORT_TIME) - self.assertEqual([x.hex for x in walker], log[-1:]) - walker.reset() - walker.push(log[0]) - self.assertEqual([x.hex for x in walker], log) +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_sort(self): - walker = self.repo.walk(log[0], GIT_SORT_TIME) - walker.sort(GIT_SORT_TIME | GIT_SORT_REVERSE) - self.assertEqual([x.hex for x in walker], list(reversed(log))) +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]) + list2 = list([x.id for x in walker]) -if __name__ == '__main__': - unittest.main() + assert list1 == list2 diff --git a/test/test_settings.py b/test/test_settings.py new file mode 100644 index 000000000..5c5211019 --- /dev/null +++ b/test/test_settings.py @@ -0,0 +1,284 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +"""Test the Settings class.""" + +import sys + +import pytest + +import pygit2 +from pygit2.enums import ConfigLevel, ObjectType + + +def test_mwindow_size() -> None: + original = pygit2.settings.mwindow_size + try: + test_size = 200 * 1024 + pygit2.settings.mwindow_size = test_size + assert pygit2.settings.mwindow_size == test_size + finally: + pygit2.settings.mwindow_size = original + + +def test_mwindow_mapped_limit() -> None: + original = pygit2.settings.mwindow_mapped_limit + try: + test_limit = 300 * 1024 + pygit2.settings.mwindow_mapped_limit = test_limit + assert pygit2.settings.mwindow_mapped_limit == test_limit + finally: + pygit2.settings.mwindow_mapped_limit = original + + +def test_cached_memory() -> None: + cached = pygit2.settings.cached_memory + assert isinstance(cached, tuple) + assert len(cached) == 2 + assert isinstance(cached[0], int) + assert isinstance(cached[1], int) + + +def test_enable_caching() -> None: + assert hasattr(pygit2.settings, 'enable_caching') + assert callable(pygit2.settings.enable_caching) + + # Should not raise exceptions + pygit2.settings.enable_caching(False) + pygit2.settings.enable_caching(True) + + +def test_disable_pack_keep_file_checks() -> None: + assert hasattr(pygit2.settings, 'disable_pack_keep_file_checks') + assert callable(pygit2.settings.disable_pack_keep_file_checks) + + # Should not raise exceptions + pygit2.settings.disable_pack_keep_file_checks(False) + pygit2.settings.disable_pack_keep_file_checks(True) + pygit2.settings.disable_pack_keep_file_checks(False) + + +def test_cache_max_size() -> None: + original_max_size = pygit2.settings.cached_memory[1] + try: + pygit2.settings.cache_max_size(128 * 1024**2) + assert pygit2.settings.cached_memory[1] == 128 * 1024**2 + pygit2.settings.cache_max_size(256 * 1024**2) + assert pygit2.settings.cached_memory[1] == 256 * 1024**2 + finally: + pygit2.settings.cache_max_size(original_max_size) + + +@pytest.mark.parametrize( + 'object_type,test_size,default_size', + [ + (ObjectType.BLOB, 2 * 1024, 0), + (ObjectType.COMMIT, 8 * 1024, 4096), + (ObjectType.TREE, 8 * 1024, 4096), + (ObjectType.TAG, 8 * 1024, 4096), + (ObjectType.BLOB, 0, 0), + ], +) +def test_cache_object_limit( + object_type: ObjectType, test_size: int, default_size: int +) -> None: + assert callable(pygit2.settings.cache_object_limit) + + pygit2.settings.cache_object_limit(object_type, test_size) + pygit2.settings.cache_object_limit(object_type, default_size) + + +@pytest.mark.parametrize( + 'level,test_path', + [ + (ConfigLevel.GLOBAL, '/tmp/test_global'), + (ConfigLevel.XDG, '/tmp/test_xdg'), + (ConfigLevel.SYSTEM, '/tmp/test_system'), + ], +) +def test_search_path(level: ConfigLevel, test_path: str) -> None: + original = pygit2.settings.search_path[level] + try: + pygit2.settings.search_path[level] = test_path + assert pygit2.settings.search_path[level] == test_path + finally: + pygit2.settings.search_path[level] = original + + +def test_template_path() -> None: + original = pygit2.settings.template_path + try: + pygit2.settings.template_path = '/tmp/test_templates' + assert pygit2.settings.template_path == '/tmp/test_templates' + finally: + if original: + pygit2.settings.template_path = original + + +def test_user_agent() -> None: + original = pygit2.settings.user_agent + try: + pygit2.settings.user_agent = 'test-agent/1.0' + assert pygit2.settings.user_agent == 'test-agent/1.0' + finally: + if original: + pygit2.settings.user_agent = original + + +def test_user_agent_product() -> None: + original = pygit2.settings.user_agent_product + try: + pygit2.settings.user_agent_product = 'test-product' + assert pygit2.settings.user_agent_product == 'test-product' + finally: + if original: + pygit2.settings.user_agent_product = original + + +def test_pack_max_objects() -> None: + original = pygit2.settings.pack_max_objects + try: + pygit2.settings.pack_max_objects = 100000 + assert pygit2.settings.pack_max_objects == 100000 + finally: + pygit2.settings.pack_max_objects = original + + +def test_owner_validation() -> None: + original = pygit2.settings.owner_validation + try: + pygit2.settings.owner_validation = False + assert pygit2.settings.owner_validation == False # noqa: E712 + pygit2.settings.owner_validation = True + assert pygit2.settings.owner_validation == True # noqa: E712 + finally: + pygit2.settings.owner_validation = original + + +def test_mwindow_file_limit() -> None: + original = pygit2.settings.mwindow_file_limit + try: + pygit2.settings.mwindow_file_limit = 100 + assert pygit2.settings.mwindow_file_limit == 100 + finally: + pygit2.settings.mwindow_file_limit = original + + +def test_homedir() -> None: + original = pygit2.settings.homedir + try: + pygit2.settings.homedir = '/tmp/test_home' + assert pygit2.settings.homedir == '/tmp/test_home' + finally: + if original: + pygit2.settings.homedir = original + + +def test_server_timeouts() -> None: + original_connect = pygit2.settings.server_connect_timeout + original_timeout = pygit2.settings.server_timeout + try: + pygit2.settings.server_connect_timeout = 5000 + assert pygit2.settings.server_connect_timeout == 5000 + + pygit2.settings.server_timeout = 10000 + assert pygit2.settings.server_timeout == 10000 + finally: + pygit2.settings.server_connect_timeout = original_connect + pygit2.settings.server_timeout = original_timeout + + +def test_extensions() -> None: + original = pygit2.settings.extensions + try: + test_extensions = ['objectformat', 'worktreeconfig'] + pygit2.settings.set_extensions(test_extensions) + + new_extensions = pygit2.settings.extensions + for ext in test_extensions: + assert ext in new_extensions + finally: + if original: + pygit2.settings.set_extensions(original) + + +@pytest.mark.parametrize( + 'method_name,default_value', + [ + ('enable_strict_object_creation', True), + ('enable_strict_symbolic_ref_creation', True), + ('enable_ofs_delta', True), + ('enable_fsync_gitdir', False), + ('enable_strict_hash_verification', True), + ('enable_unsaved_index_safety', False), + ('enable_http_expect_continue', False), + ], +) +def test_enable_methods(method_name: str, default_value: bool) -> None: + assert hasattr(pygit2.settings, method_name) + method = getattr(pygit2.settings, method_name) + assert callable(method) + + method(True) + method(False) + method(default_value) + + +@pytest.mark.parametrize('priority', [1, 5, 10, 0, -1, -2]) +def test_odb_priorities(priority: int) -> None: + """Test setting ODB priorities""" + assert hasattr(pygit2.settings, 'set_odb_packed_priority') + assert hasattr(pygit2.settings, 'set_odb_loose_priority') + assert callable(pygit2.settings.set_odb_packed_priority) + assert callable(pygit2.settings.set_odb_loose_priority) + + pygit2.settings.set_odb_packed_priority(priority) + pygit2.settings.set_odb_loose_priority(priority) + + pygit2.settings.set_odb_packed_priority(1) + pygit2.settings.set_odb_loose_priority(2) + + +def test_ssl_ciphers() -> None: + assert callable(pygit2.settings.set_ssl_ciphers) + + try: + pygit2.settings.set_ssl_ciphers('DEFAULT') + except pygit2.GitError as e: + if "TLS backend doesn't support" in str(e): + pytest.skip(str(e)) + raise + + +@pytest.mark.skipif(sys.platform != 'win32', reason='Windows-specific feature') +def test_windows_sharemode() -> None: + original = pygit2.settings.windows_sharemode + try: + pygit2.settings.windows_sharemode = 1 + assert pygit2.settings.windows_sharemode == 1 + pygit2.settings.windows_sharemode = 2 + assert pygit2.settings.windows_sharemode == 2 + finally: + pygit2.settings.windows_sharemode = original diff --git a/test/test_signature.py b/test/test_signature.py index cf21b33f1..e90f55392 100644 --- a/test/test_signature.py +++ b/test/test_signature.py @@ -1,6 +1,4 @@ -# -*- coding: UTF-8 -*- -# -# Copyright 2010-2013 The pygit2 contributors +# Copyright 2010-2025 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -25,46 +23,84 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. -from __future__ import absolute_import -from __future__ import unicode_literals -import unittest +import re import time -from pygit2 import Signature -from .utils import NoRepoTestCase +import pytest + +import pygit2 +from pygit2 import Repository, Signature + + +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) + assert signature.name.encode(encoding) == signature.raw_name + assert signature.email == signature.raw_email.decode(encoding) + assert signature.email.encode(encoding) == signature.raw_email + + +@pytest.mark.parametrize('encoding', [None, 'utf-8', 'iso-8859-1']) +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() -> None: + signature = pygit2.Signature('Foo Ibáñez', 'foo@example.com', 1322174594, 60) + __assert(signature, 'utf-8') + + +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: str | None) -> None: + signature = pygit2.Signature( + 'Foo Ibáñez', 'foo@bar.com', 1322174594, 60, encoding=encoding + ) + expected = f"pygit2.Signature('Foo Ibáñez', 'foo@bar.com', 1322174594, 60, {repr(encoding)})" + assert repr(signature) == expected + assert signature == eval(expected) + +def test_repr_from_commit(barerepo: Repository) -> None: + repo = barerepo + signature = pygit2.Signature('Foo Ibáñez', 'foo@example.com', encoding=None) + tree = '967fce8df97cc71722d3c2a5930ef3e6f1d27b12' + parents = ['5fe808e8953c12735680c257f56600cb0de44b10'] + sha = repo.create_commit(None, signature, signature, 'New commit.', tree, parents) + commit = repo[sha] -class SignatureTest(NoRepoTestCase): + assert repr(signature) == repr(commit.author) + assert repr(signature) == repr(commit.committer) - def test_default(self): - signature = Signature( - 'Foo', 'foo@example.com', 1322174594, 60) - encoding = signature._encoding - self.assertEqual(encoding, 'ascii') - self.assertEqual(signature.name, signature._name.decode(encoding)) - self.assertEqual(signature.name.encode(encoding), signature._name) - def test_ascii(self): - self.assertRaises(UnicodeEncodeError, - Signature, 'Foo Ibáñez', 'foo@example.com') +def test_incorrect_encoding() -> None: + gbk_bytes = 'Café'.encode('GBK') - def test_latin1(self): - encoding = 'iso-8859-1' - signature = Signature( - 'Foo Ibáñez', 'foo@example.com', encoding=encoding) - self.assertEqual(encoding, signature._encoding) - self.assertEqual(signature.name, signature._name.decode(encoding)) - self.assertEqual(signature.name.encode(encoding), signature._name) + # deliberately specifying a mismatching encoding (mojibake) + signature = pygit2.Signature(gbk_bytes, 'foo@example.com', 999, 0, encoding='utf-8') - def test_now(self): - encoding = 'utf-8' - signature = Signature( - 'Foo Ibáñez', 'foo@example.com', encoding=encoding) - self.assertEqual(encoding, signature._encoding) - self.assertEqual(signature.name, signature._name.decode(encoding)) - self.assertEqual(signature.name.encode(encoding), signature._name) - self.assertTrue(abs(signature.time - time.time()) < 5) + # repr() and str() may display junk, but they must not crash + assert re.match( + r"pygit2.Signature\('Caf.+', 'foo@example.com', 999, 0, 'utf-8'\)", + repr(signature), + ) + assert re.match(r'Caf.+ ', str(signature)) + # deliberately specifying an unsupported encoding + signature = pygit2.Signature( + gbk_bytes, 'foo@example.com', 999, 0, encoding='this-encoding-does-not-exist' + ) -if __name__ == '__main__': - unittest.main() + # repr() and str() may display junk, but they must not crash + assert "pygit2.Signature('(error)', '(error)', 999, 0, '(error)')" == repr( + signature + ) + assert '(error) <(error)>' == str(signature) diff --git a/test/test_status.py b/test/test_status.py index 98a7a7099..653ed93d3 100644 --- a/test/test_status.py +++ b/test/test_status.py @@ -1,6 +1,4 @@ -# -*- coding: UTF-8 -*- -# -# Copyright 2010-2013 The pygit2 contributors +# Copyright 2010-2025 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -25,27 +23,65 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. -"""Tests for revision walk.""" +import pytest + +from pygit2 import Repository +from pygit2.enums import FileStatus + -from __future__ import absolute_import -from __future__ import unicode_literals -import unittest +def test_status(dirtyrepo: Repository) -> None: + """ + For every file in the status, check that the flags are correct. + """ + git_status = dirtyrepo.status() + for filepath, status in git_status.items(): + assert filepath in git_status + assert status == git_status[filepath] -import pygit2 -from . import utils +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()) -class StatusTest(utils.DirtyRepoTestCase): - def test_status(self): - """ - For every file in the status, check that the flags are correct. - """ - git_status = self.repo.status() - for filepath, status in git_status.items(): - self.assertTrue(filepath in git_status) - self.assertEqual(status, git_status[filepath]) +@pytest.mark.parametrize( + 'untracked_files,expected', + [ + ('no', set()), + ( + 'normal', + { + 'untracked_dir/', + 'staged_delete_file_modified', + 'subdir/new_file', + 'new_file', + }, + ), + ( + 'all', + { + 'new_file', + 'subdir/new_file', + 'staged_delete_file_modified', + 'untracked_dir/untracked_file', + }, + ), + ], +) +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 + } == expected -if __name__ == '__main__': - unittest.main() +@pytest.mark.parametrize('ignored,expected', [(True, {'ignored'}), (False, set())]) +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 diff --git a/test/test_submodule.py b/test/test_submodule.py new file mode 100644 index 000000000..90bdc7586 --- /dev/null +++ b/test/test_submodule.py @@ -0,0 +1,349 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +"""Tests for Submodule objects.""" + +from collections.abc import Generator +from pathlib import Path + +import pytest + +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' +SUBM_URL = 'https://github.com/libgit2/TestGitRepository' +SUBM_HEAD_SHA = '49322bb17d3acc9146f98c97d078513228bbf3c0' +SUBM_BOTTOM_SHA = '6c8b137b1c652731597c89668f417b8695f28dd7' + + +@pytest.fixture +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: 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: Repository) -> None: + s = repo.submodules[Path(SUBM_PATH)] + assert s is not None + + +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: Repository) -> None: + submodules = repo.listall_submodules() + assert len(submodules) == 1 + assert submodules[0] == SUBM_PATH + + +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: 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: Repository) -> None: + s = repo.submodules[SUBM_PATH] + repo.submodules.init() + repo.submodules.update() + r = s.open() + assert r is not None + assert r.head.target == SUBM_HEAD_SHA + + +@utils.requires_network +def test_submodule_open_from_repository_subclass(repo: Repository) -> None: + class CustomRepoClass(pygit2.Repository): + pass + + custom_repo = CustomRepoClass(repo.workdir) + s = custom_repo.submodules[SUBM_PATH] + custom_repo.submodules.init() + custom_repo.submodules.update() + r = s.open() + assert isinstance(r, CustomRepoClass) + assert r.head.target == SUBM_HEAD_SHA + + +def test_name(repo: Repository) -> None: + s = repo.submodules[SUBM_PATH] + assert SUBM_NAME == s.name + + +def test_path(repo: Repository) -> None: + s = repo.submodules[SUBM_PATH] + assert SUBM_PATH == s.path + + +def test_url(repo: Repository) -> None: + s = repo.submodules[SUBM_PATH] + assert SUBM_URL == s.url + + +def test_set_url(repo: Repository) -> None: + new_url = 'ssh://git@127.0.0.1:2222/my_repo' + s = repo.submodules[SUBM_PATH] + s.url = new_url + assert new_url == repo.submodules[SUBM_PATH].url + # Ensure .gitmodules has been correctly altered + with open(Path(repo.workdir, '.gitmodules'), 'r') as fd: + modules = fd.read() + assert new_url in modules + + +def test_missing_url(repo: Repository) -> None: + # Remove "url" from .gitmodules + with open(Path(repo.workdir, '.gitmodules'), 'wt') as f: + f.write('[submodule "TestGitRepository"]\n') + f.write('\tpath = TestGitRepository\n') + s = repo.submodules[SUBM_PATH] + assert not s.url + + +@utils.requires_network +def test_init_and_update(repo: Repository) -> None: + subrepo_file_path = Path(repo.workdir) / SUBM_PATH / 'master.txt' + assert not subrepo_file_path.exists() + + status = repo.submodules.status(SUBM_NAME) + assert status == (SS.IN_HEAD | SS.IN_INDEX | SS.IN_CONFIG | SS.WD_UNINITIALIZED) + + repo.submodules.init() + repo.submodules.update() + + assert subrepo_file_path.exists() + + status = repo.submodules.status(SUBM_NAME) + assert status == (SS.IN_HEAD | SS.IN_INDEX | SS.IN_CONFIG | SS.IN_WD) + + +@utils.requires_network +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']) + repo.submodules.update(submodules=['TestGitRepository']) + assert subrepo_file_path.exists() + + +@utils.requires_network +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'] + sm.init() + sm.update() + assert subrepo_file_path.exists() + + +@utils.requires_network +@pytest.mark.parametrize('depth', [0, 1]) +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) + + subrepo_file_path = Path(repo.workdir) / SUBM_PATH / 'master.txt' + assert not subrepo_file_path.exists() + repo.submodules.update(init=True, depth=depth) + assert subrepo_file_path.exists() + + status = repo.submodules.status(SUBM_NAME) + assert status == (SS.IN_HEAD | SS.IN_INDEX | SS.IN_CONFIG | SS.IN_WD) + + sm_repo = repo.submodules[SUBM_NAME].open() + if depth == 0: + sm_repo[SUBM_BOTTOM_SHA] # full history must be available + else: + with pytest.raises(KeyError): + sm_repo[SUBM_BOTTOM_SHA] # shallow clone + + +@utils.requires_network +@pytest.mark.parametrize('depth', [0, 1]) +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] + sm.update(init=True, depth=depth) + assert subrepo_file_path.exists() + + sm_repo = sm.open() + if depth == 0: + sm_repo[SUBM_BOTTOM_SHA] # full history must be available + else: + with pytest.raises(KeyError): + sm_repo[SUBM_BOTTOM_SHA] # shallow clone + + +@utils.requires_network +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: Repository) -> None: + gitmodules_newlines = ( + '\n' + '[submodule "uncommitted_submodule"]\n' + ' path = pygit2\n' + ' url = https://github.com/libgit2/pygit2\n' + '\n' + ) + with open(Path(repo.workdir, '.gitmodules'), 'a') as f: + f.write(gitmodules_newlines) + + subm = repo.submodules['uncommitted_submodule'] + + # The submodule isn't in the HEAD yet, so head_id should be None + assert subm.head_id is None + + +@utils.requires_network +@pytest.mark.parametrize('depth', [0, 1]) +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) + + status = repo.submodules.status(sm_repo_path) + assert status == (SS.IN_INDEX | SS.IN_CONFIG | SS.IN_WD | SS.INDEX_ADDED) + + sm_repo = sm.open() + assert sm_repo_path == sm.path + assert SUBM_URL == sm.url + assert not sm_repo.is_empty + + if depth == 0: + sm_repo[SUBM_BOTTOM_SHA] # full history must be available + else: + with pytest.raises(KeyError): + sm_repo[SUBM_BOTTOM_SHA] # shallow clone + + +@utils.requires_network +def test_submodule_status(repo: Repository) -> None: + common_status = SS.IN_HEAD | SS.IN_INDEX | SS.IN_CONFIG + + # Submodule needs initializing + assert repo.submodules.status(SUBM_PATH) == common_status | SS.WD_UNINITIALIZED + + # If ignoring ALL, don't look at WD + assert repo.submodules.status(SUBM_PATH, ignore=SI.ALL) == common_status + + # Update the submodule + repo.submodules.update(init=True) + + # It's in our WD now + assert repo.submodules.status(SUBM_PATH) == common_status | SS.IN_WD + + # Open submodule repo + sm_repo: pygit2.Repository = repo.submodules[SUBM_PATH].open() + + # Move HEAD in the submodule (WD_MODIFIED) + sm_repo.checkout('refs/tags/annotated_tag') + assert ( + repo.submodules.status(SUBM_PATH) == common_status | SS.IN_WD | SS.WD_MODIFIED + ) + + # Move HEAD back to master + sm_repo.checkout('refs/heads/master') + + # Touch some file in the submodule's workdir (WD_WD_MODIFIED) + with open(Path(repo.workdir, SUBM_PATH, 'master.txt'), 'wt') as f: + f.write('modifying master.txt') + assert ( + repo.submodules.status(SUBM_PATH) + == common_status | SS.IN_WD | SS.WD_WD_MODIFIED + ) + + # Add an untracked file in the submodule's workdir (WD_UNTRACKED) + with open(Path(repo.workdir, SUBM_PATH, 'some_untracked_file.txt'), 'wt') as f: + f.write('hi') + assert ( + repo.submodules.status(SUBM_PATH) + == common_status | SS.IN_WD | SS.WD_WD_MODIFIED | SS.WD_UNTRACKED + ) + + # Add modified files to the submodule's index (WD_INDEX_MODIFIED) + sm_repo.index.add_all() + sm_repo.index.write() + assert ( + repo.submodules.status(SUBM_PATH) + == common_status | SS.IN_WD | SS.WD_INDEX_MODIFIED + ) + + +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] + sm2 = repo.submodules[SUBM_NAME] + assert sm1._subm == sm2._subm + + # After turning off the cache, each lookup must return a new git_submodule object + repo.submodules.cache_clear() + sm3 = repo.submodules[SUBM_NAME] + sm4 = repo.submodules[SUBM_NAME] + assert sm1._subm != sm3._subm + assert sm3._subm != sm4._subm + + +def test_submodule_reload(repo: Repository) -> None: + sm = repo.submodules[SUBM_NAME] + assert sm.url == 'https://github.com/libgit2/TestGitRepository' + + # Doctor the config file outside of libgit2 + with open(Path(repo.workdir, '.gitmodules'), 'wt') as f: + f.write('[submodule "TestGitRepository"]\n') + f.write('\tpath = TestGitRepository\n') + f.write('\turl = https://github.com/libgit2/pygit2\n') + + # Submodule object is oblivious to the change + assert sm.url == 'https://github.com/libgit2/TestGitRepository' + + # Tell it to refresh its cache + sm.reload() + assert sm.url == 'https://github.com/libgit2/pygit2' diff --git a/test/test_tag.py b/test/test_tag.py index 12d89f055..a3be72c28 100644 --- a/test/test_tag.py +++ b/test/test_tag.py @@ -1,6 +1,4 @@ -# -*- coding: UTF-8 -*- -# -# Copyright 2010-2013 The pygit2 contributors +# Copyright 2010-2025 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -27,68 +25,73 @@ """Tests for Tag objects.""" -from __future__ import absolute_import -from __future__ import unicode_literals -import unittest +import pytest import pygit2 -from . import utils - +from pygit2 import Repository +from pygit2.enums import ObjectType TAG_SHA = '3d2962987c695a29f1f80b6c3aa4ec046ef44369' -class TagTest(utils.BareRepoTestCase): - - def test_read_tag(self): - repo = self.repo - tag = repo[TAG_SHA] - target = repo[tag.target] - self.assertTrue(isinstance(tag, pygit2.Tag)) - self.assertEqual(pygit2.GIT_OBJ_TAG, tag.type) - self.assertEqual(pygit2.GIT_OBJ_COMMIT, target.type) - self.assertEqual('root', tag.name) - self.assertEqual('Tagged root commit.\n', tag.message) - self.assertEqual('Initial test data commit.\n', target.message) - self.assertEqualSignature( - tag.tagger, - pygit2.Signature('Dave Borowitz', 'dborowitz@google.com', - 1288724692, -420)) - - def test_new_tag(self): - name = 'thetag' - target = 'af431f20fc541ed6d5afede3e2dc7160f6f01f16' - message = 'Tag a blob.\n' - tagger = pygit2.Signature('John Doe', 'jdoe@example.com', 12347, 0) - - target_prefix = target[:5] - too_short_prefix = target[:3] - self.assertRaises(ValueError, self.repo.create_tag, name, - too_short_prefix, pygit2.GIT_OBJ_BLOB, tagger, - message) - sha = self.repo.create_tag(name, target_prefix, pygit2.GIT_OBJ_BLOB, - tagger, message) - tag = self.repo[sha] - - self.assertEqual('3ee44658fd11660e828dfc96b9b5c5f38d5b49bb', tag.hex) - self.assertEqual(name, tag.name) - self.assertEqual(target, tag.target.hex) - self.assertEqualSignature(tagger, tag.tagger) - self.assertEqual(message, tag.message) - self.assertEqual(name, self.repo[tag.hex].name) - - def test_modify_tag(self): - name = 'thetag' - target = 'af431f20fc541ed6d5afede3e2dc7160f6f01f16' - message = 'Tag a blob.\n' - tagger = ('John Doe', 'jdoe@example.com', 12347) - - tag = self.repo[TAG_SHA] - self.assertRaises(AttributeError, setattr, tag, 'name', name) - self.assertRaises(AttributeError, setattr, tag, 'target', target) - self.assertRaises(AttributeError, setattr, tag, 'tagger', tagger) - self.assertRaises(AttributeError, setattr, tag, 'message', message) - - -if __name__ == '__main__': - unittest.main() +def test_read_tag(barerepo: Repository) -> None: + repo = barerepo + tag = repo[TAG_SHA] + assert isinstance(tag, pygit2.Tag) + 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 + assert tag.tagger == pygit2.Signature( + 'Dave Borowitz', 'dborowitz@google.com', 1288724692, -420 + ) + + +def test_new_tag(barerepo: Repository) -> None: + name = 'thetag' + target = 'af431f20fc541ed6d5afede3e2dc7160f6f01f16' + message = 'Tag a blob.\n' + tagger = pygit2.Signature('John Doe', 'jdoe@example.com', 12347, 0) + + target_prefix = target[:5] + too_short_prefix = target[:3] + with pytest.raises(ValueError): + barerepo.create_tag(name, too_short_prefix, ObjectType.BLOB, tagger, message) + + sha = barerepo.create_tag(name, target_prefix, ObjectType.BLOB, tagger, message) + tag = barerepo[sha] + assert isinstance(tag, pygit2.Tag) + + assert '3ee44658fd11660e828dfc96b9b5c5f38d5b49bb' == tag.id + assert name == tag.name + assert target == tag.target + assert tagger == tag.tagger + assert message == tag.message + assert name == barerepo[tag.id].name + + +def test_modify_tag(barerepo: Repository) -> None: + name = 'thetag' + target = 'af431f20fc541ed6d5afede3e2dc7160f6f01f16' + message = 'Tag a blob.\n' + tagger = ('John Doe', 'jdoe@example.com', 12347) + + tag = barerepo[TAG_SHA] + with pytest.raises(AttributeError): + setattr(tag, 'name', name) + with pytest.raises(AttributeError): + setattr(tag, 'target', target) + with pytest.raises(AttributeError): + setattr(tag, 'tagger', tagger) + with pytest.raises(AttributeError): + setattr(tag, 'message', message) + + +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..5a6e97ed5 --- /dev/null +++ b/test/test_transaction.py @@ -0,0 +1,327 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +import threading + +import pytest + +from pygit2 import GitError, Oid, Repository +from pygit2.transaction import ReferenceTransaction + + +def test_transaction_context_manager(testrepo: Repository) -> None: + """Test basic transaction with context manager.""" + master_ref = testrepo.lookup_reference('refs/heads/master') + assert str(master_ref.target) == '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98' + + # Create a transaction and update a ref + new_target = Oid(hex='5ebeeebb320790caf276b9fc8b24546d63316533') + + with testrepo.transaction() as txn: + txn.lock_ref('refs/heads/master') + txn.set_target('refs/heads/master', new_target, message='Test update') + + # Verify the update was applied + master_ref = testrepo.lookup_reference('refs/heads/master') + assert master_ref.target == new_target + + +def test_transaction_rollback_on_exception(testrepo: Repository) -> None: + """Test that transaction rolls back when exception is raised.""" + master_ref = testrepo.lookup_reference('refs/heads/master') + original_target = master_ref.target + + new_target = Oid(hex='5ebeeebb320790caf276b9fc8b24546d63316533') + + # Transaction should not commit if exception is raised + with pytest.raises(RuntimeError): + with testrepo.transaction() as txn: + txn.lock_ref('refs/heads/master') + txn.set_target('refs/heads/master', new_target, message='Test update') + raise RuntimeError('Abort transaction') + + # Verify the update was NOT applied + master_ref = testrepo.lookup_reference('refs/heads/master') + assert master_ref.target == original_target + + +def test_transaction_multiple_refs(testrepo: Repository) -> None: + """Test updating multiple refs in a single transaction.""" + master_ref = testrepo.lookup_reference('refs/heads/master') + i18n_ref = testrepo.lookup_reference('refs/heads/i18n') + + new_master = Oid(hex='5ebeeebb320790caf276b9fc8b24546d63316533') + new_i18n = Oid(hex='2be5719152d4f82c7302b1c0932d8e5f0a4a0e98') + + with testrepo.transaction() as txn: + txn.lock_ref('refs/heads/master') + txn.lock_ref('refs/heads/i18n') + txn.set_target('refs/heads/master', new_master, message='Update master') + txn.set_target('refs/heads/i18n', new_i18n, message='Update i18n') + + # Verify both updates were applied + master_ref = testrepo.lookup_reference('refs/heads/master') + i18n_ref = testrepo.lookup_reference('refs/heads/i18n') + assert master_ref.target == new_master + assert i18n_ref.target == new_i18n + + +def test_transaction_symbolic_ref(testrepo: Repository) -> None: + """Test updating symbolic reference in transaction.""" + with testrepo.transaction() as txn: + txn.lock_ref('HEAD') + txn.set_symbolic_target('HEAD', 'refs/heads/i18n', message='Switch HEAD') + + head = testrepo.lookup_reference('HEAD') + assert head.target == 'refs/heads/i18n' + + # Restore HEAD to master + with testrepo.transaction() as txn: + txn.lock_ref('HEAD') + txn.set_symbolic_target('HEAD', 'refs/heads/master', message='Restore HEAD') + + +def test_transaction_remove_ref(testrepo: Repository) -> None: + """Test removing a reference in a transaction.""" + # Create a test ref + test_ref_name = 'refs/heads/test-transaction-delete' + target = Oid(hex='5ebeeebb320790caf276b9fc8b24546d63316533') + testrepo.create_reference(test_ref_name, target) + + # Verify it exists + assert test_ref_name in testrepo.references + + # Remove it in a transaction + with testrepo.transaction() as txn: + txn.lock_ref(test_ref_name) + txn.remove(test_ref_name) + + # Verify it's gone + assert test_ref_name not in testrepo.references + + +def test_transaction_error_without_lock(testrepo: Repository) -> None: + """Test that setting target without lock raises error.""" + new_target = Oid(hex='5ebeeebb320790caf276b9fc8b24546d63316533') + + with pytest.raises(KeyError, match='not locked'): + with testrepo.transaction() as txn: + # Try to set target without locking first + txn.set_target('refs/heads/master', new_target, message='Should fail') + + +def test_transaction_isolated_across_threads(testrepo: Repository) -> None: + """Test that transactions from different threads are isolated.""" + # Create two test refs + ref1_name = 'refs/heads/thread-test-1' + ref2_name = 'refs/heads/thread-test-2' + target1 = Oid(hex='5ebeeebb320790caf276b9fc8b24546d63316533') + target2 = Oid(hex='2be5719152d4f82c7302b1c0932d8e5f0a4a0e98') + testrepo.create_reference(ref1_name, target1) + testrepo.create_reference(ref2_name, target2) + + results = [] + errors = [] + thread1_ref1_locked = threading.Event() + thread2_ref2_locked = threading.Event() + + def update_ref1() -> None: + try: + with testrepo.transaction() as txn: + txn.lock_ref(ref1_name) + thread1_ref1_locked.set() + thread2_ref2_locked.wait(timeout=5) + txn.set_target(ref1_name, target2, message='Thread 1 update') + results.append('thread1_success') + except Exception as e: + errors.append(('thread1', str(e))) + + def update_ref2() -> None: + try: + with testrepo.transaction() as txn: + txn.lock_ref(ref2_name) + thread2_ref2_locked.set() + thread1_ref1_locked.wait(timeout=5) + txn.set_target(ref2_name, target1, message='Thread 2 update') + results.append('thread2_success') + except Exception as e: + errors.append(('thread2', str(e))) + + thread1 = threading.Thread(target=update_ref1) + thread2 = threading.Thread(target=update_ref2) + + thread1.start() + thread2.start() + thread1.join() + thread2.join() + + # Both threads should succeed - transactions are isolated + assert len(errors) == 0, f'Errors: {errors}' + assert 'thread1_success' in results + assert 'thread2_success' in results + + # Verify both updates were applied + ref1 = testrepo.lookup_reference(ref1_name) + ref2 = testrepo.lookup_reference(ref2_name) + assert str(ref1.target) == '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98' + assert str(ref2.target) == '5ebeeebb320790caf276b9fc8b24546d63316533' + + +def test_transaction_deadlock_prevention(testrepo: Repository) -> None: + """Test that acquiring locks in different order raises error instead of deadlock.""" + # Create two test refs + ref1_name = 'refs/heads/deadlock-test-1' + ref2_name = 'refs/heads/deadlock-test-2' + target = Oid(hex='5ebeeebb320790caf276b9fc8b24546d63316533') + testrepo.create_reference(ref1_name, target) + testrepo.create_reference(ref2_name, target) + + thread1_ref1_locked = threading.Event() + thread2_ref2_locked = threading.Event() + errors = [] + successes = [] + + def thread1_task() -> None: + try: + with testrepo.transaction() as txn: + txn.lock_ref(ref1_name) + thread1_ref1_locked.set() + thread2_ref2_locked.wait(timeout=5) + # this would cause a deadlock, so will throw (GitError) + txn.lock_ref(ref2_name) + # shouldn't get here + successes.append('thread1') + except Exception as e: + errors.append(('thread1', type(e).__name__, str(e))) + + def thread2_task() -> None: + try: + with testrepo.transaction() as txn: + txn.lock_ref(ref2_name) + thread2_ref2_locked.set() + thread1_ref1_locked.wait(timeout=5) + # this would cause a deadlock, so will throw (GitError) + txn.lock_ref(ref2_name) + # shouldn't get here + successes.append('thread2') + except Exception as e: + errors.append(('thread2', type(e).__name__, str(e))) + + thread1 = threading.Thread(target=thread1_task) + thread2 = threading.Thread(target=thread2_task) + + thread1.start() + thread2.start() + thread1.join(timeout=5) + thread2.join(timeout=5) + + # At least one thread should fail with an error (not deadlock) + # If both threads are still alive, we have a deadlock + assert not thread1.is_alive(), 'Thread 1 deadlocked' + assert not thread2.is_alive(), 'Thread 2 deadlocked' + + # Both can't succeed. + # libgit2 doesn't *wait* for locks, so it's possible for neither to succeed + # if they both try to take the second lock at basically the same time. + # The other possibility is that one thread throws, exits its transaction, + # and the other thread is able to acquire the second lock. + assert len(successes) <= 1 and len(errors) >= 1, ( + f'Successes: {successes}; errors: {errors}' + ) + + +def test_transaction_commit_from_wrong_thread(testrepo: Repository) -> None: + """Test that committing a transaction from wrong thread raises error.""" + txn: ReferenceTransaction | None = None + + def create_transaction() -> None: + nonlocal txn + txn = testrepo.transaction().__enter__() + ref_name = 'refs/heads/wrong-thread-test' + target = Oid(hex='5ebeeebb320790caf276b9fc8b24546d63316533') + testrepo.create_reference(ref_name, target) + txn.lock_ref(ref_name) + + # Create transaction in thread 1 + thread = threading.Thread(target=create_transaction) + thread.start() + thread.join() + + assert txn is not None + with pytest.raises(RuntimeError): + # Try to commit from main thread (different from creator) doesn't cause libgit2 to crash, + # it raises an exception instead + txn.commit() + + +def test_transaction_nested_same_thread(testrepo: Repository) -> None: + """Test that two concurrent transactions from same thread work with different refs.""" + # Create test refs + ref1_name = 'refs/heads/nested-test-1' + ref2_name = 'refs/heads/nested-test-2' + target1 = Oid(hex='5ebeeebb320790caf276b9fc8b24546d63316533') + target2 = Oid(hex='2be5719152d4f82c7302b1c0932d8e5f0a4a0e98') + testrepo.create_reference(ref1_name, target1) + testrepo.create_reference(ref2_name, target2) + + # Nested transactions should work as long as they don't conflict + with testrepo.transaction() as txn1: + txn1.lock_ref(ref1_name) + + with testrepo.transaction() as txn2: + txn2.lock_ref(ref2_name) + txn2.set_target(ref2_name, target1, message='Inner transaction') + + # Inner transaction committed, now update outer + txn1.set_target(ref1_name, target2, message='Outer transaction') + + # Both updates should have been applied + ref1 = testrepo.lookup_reference(ref1_name) + ref2 = testrepo.lookup_reference(ref2_name) + assert str(ref1.target) == '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98' + assert str(ref2.target) == '5ebeeebb320790caf276b9fc8b24546d63316533' + + +def test_transaction_nested_same_ref_conflict(testrepo: Repository) -> None: + """Test that nested transactions fail when trying to lock the same ref.""" + ref_name = 'refs/heads/nested-conflict-test' + target = Oid(hex='5ebeeebb320790caf276b9fc8b24546d63316533') + new_target = Oid(hex='2be5719152d4f82c7302b1c0932d8e5f0a4a0e98') + testrepo.create_reference(ref_name, target) + + with testrepo.transaction() as txn1: + txn1.lock_ref(ref_name) + + # Inner transaction should fail to lock the same ref + with pytest.raises(GitError): + with testrepo.transaction() as txn2: + txn2.lock_ref(ref_name) + + # Outer transaction should still be able to complete + txn1.set_target(ref_name, new_target, message='Outer transaction') + + # Outer transaction's update should have been applied + ref = testrepo.lookup_reference(ref_name) + assert ref.target == new_target diff --git a/test/test_tree.py b/test/test_tree.py index 16674ec32..556c3751a 100644 --- a/test/test_tree.py +++ b/test/test_tree.py @@ -1,6 +1,4 @@ -# -*- coding: utf-8 -*- -# -# Copyright 2010-2013 The pygit2 contributors +# Copyright 2010-2025 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -25,101 +23,191 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. -"""Tests for Commit objects.""" - -from __future__ import absolute_import -from __future__ import unicode_literals import operator -import unittest -from . import utils +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' -class TreeTest(utils.BareRepoTestCase): - - def assertTreeEntryEqual(self, entry, sha, name, filemode): - self.assertEqual(entry.hex, sha) - self.assertEqual(entry.name, name) - self.assertEqual(entry.filemode, filemode, - '0%o != 0%o' % (entry.filemode, filemode)) - - def test_read_tree(self): - tree = self.repo[TREE_SHA] - self.assertRaises(TypeError, lambda: tree[()]) - self.assertRaisesWithArg(KeyError, 'abcd', lambda: tree['abcd']) - self.assertRaisesWithArg(IndexError, -4, lambda: tree[-4]) - self.assertRaisesWithArg(IndexError, 3, lambda: tree[3]) - - self.assertEqual(3, len(tree)) - sha = '7f129fd57e31e935c6d60a0c794efe4e6927664b' - self.assertTrue('a' in tree) - self.assertTreeEntryEqual(tree[0], sha, 'a', 0o0100644) - self.assertTreeEntryEqual(tree[-3], sha, 'a', 0o0100644) - self.assertTreeEntryEqual(tree['a'], sha, 'a', 0o0100644) - - sha = '85f120ee4dac60d0719fd51731e4199aa5a37df6' - self.assertTrue('b' in tree) - self.assertTreeEntryEqual(tree[1], sha, 'b', 0o0100644) - self.assertTreeEntryEqual(tree[-2], sha, 'b', 0o0100644) - self.assertTreeEntryEqual(tree['b'], sha, 'b', 0o0100644) - - sha = '297efb891a47de80be0cfe9c639e4b8c9b450989' - self.assertTreeEntryEqual(tree['c/d'], sha, 'd', 0o0100644) - self.assertRaisesWithArg(KeyError, 'ab/cd', lambda: tree['ab/cd']) - - - def test_read_subtree(self): - tree = self.repo[TREE_SHA] - subtree_entry = tree['c'] - self.assertTreeEntryEqual(subtree_entry, SUBTREE_SHA, 'c', 0o0040000) - - subtree = self.repo[subtree_entry.oid] - self.assertEqual(1, len(subtree)) - sha = '297efb891a47de80be0cfe9c639e4b8c9b450989' - self.assertTreeEntryEqual(subtree[0], sha, 'd', 0o0100644) - - - def test_new_tree(self): - repo = self.repo - b0 = repo.create_blob('1') - b1 = repo.create_blob('2') - t = repo.TreeBuilder() - t.insert('x', b0, 0o0100644) - t.insert('y', b1, 0o0100755) - tree = repo[t.write()] - - self.assertTrue('x' in tree) - self.assertTrue('y' in tree) - - x = tree['x'] - y = tree['y'] - self.assertEqual(x.filemode, 0o0100644) - self.assertEqual(y.filemode, 0o0100755) - - self.assertEqual(repo[x.oid].oid, b0) - self.assertEqual(repo[y.oid].oid, b1) - - - def test_modify_tree(self): - tree = self.repo[TREE_SHA] - self.assertRaises(TypeError, operator.setitem, 'c', tree['a']) - self.assertRaises(TypeError, operator.delitem, 'c') - - - def test_iterate_tree(self): - """ - Testing that we're able to iterate of a Tree object and that the - resulting sha strings are consitent with the sha strings we could - get with other Tree access methods. - """ - tree = self.repo[TREE_SHA] - for tree_entry in tree: - self.assertEqual(tree_entry.hex, tree[tree_entry.name].hex) - - -if __name__ == '__main__': - unittest.main() +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: Repository) -> None: + tree = barerepo[TREE_SHA] + assert isinstance(tree, Tree) + with pytest.raises(TypeError): + tree[()] # type: ignore + with pytest.raises(TypeError): + 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]) + utils.assertRaisesWithArg(KeyError, 'abcd', lambda: tree / 'abcd') + + assert 3 == len(tree) + sha = '7f129fd57e31e935c6d60a0c794efe4e6927664b' + assert 'a' in tree + assertTreeEntryEqual(tree[0], sha, 'a', 0o0100644) + assertTreeEntryEqual(tree[-3], sha, 'a', 0o0100644) + assertTreeEntryEqual(tree['a'], sha, 'a', 0o0100644) + assertTreeEntryEqual(tree / 'a', sha, 'a', 0o0100644) + + sha = '85f120ee4dac60d0719fd51731e4199aa5a37df6' + assert 'b' in tree + assertTreeEntryEqual(tree[1], sha, 'b', 0o0100644) + assertTreeEntryEqual(tree[-2], sha, 'b', 0o0100644) + assertTreeEntryEqual(tree['b'], sha, 'b', 0o0100644) + assertTreeEntryEqual(tree / 'b', sha, 'b', 0o0100644) + + sha = '297efb891a47de80be0cfe9c639e4b8c9b450989' + 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') # type: ignore[operator] + with pytest.raises(TypeError): + tree / 'a' / 'cd' # type: ignore + + +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: 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: 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 == int(ObjectType.TREE) + assert subtree_entry.type_str == 'tree' + + subtree_entry = tree / 'c' + assertTreeEntryEqual(subtree_entry, SUBTREE_SHA, 'c', 0o0040000) + 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) + + subtree_entry = tree / 'c' + assert subtree_entry == barerepo[subtree_entry.id] + + +def test_new_tree(barerepo: Repository) -> None: + repo = barerepo + b0 = repo.create_blob('1') + b1 = repo.create_blob('2') + st = repo.TreeBuilder() + st.insert('a', b0, 0o0100644) + subtree = repo[st.write()] + + t = repo.TreeBuilder() + t.insert('x', b0, 0o0100644) + t.insert('y', b1, 0o0100755) + t.insert('z', subtree.id, FileMode.TREE) + tree = repo[t.write()] + + for name, oid, cls, filemode, type, type_str in [ + ('x', b0, pygit2.Blob, FileMode.BLOB, ObjectType.BLOB, 'blob'), + ('y', b1, pygit2.Blob, FileMode.BLOB_EXECUTABLE, ObjectType.BLOB, 'blob'), + ('z', subtree.id, pygit2.Tree, FileMode.TREE, ObjectType.TREE, 'tree'), + ]: + 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 + assert obj.type == type + assert obj.type_str == type_str + assert repo[obj.id].id == oid + assert obj == repo[obj.id] + + obj = tree / name # type: ignore[operator] + assert isinstance(obj, cls) + assert obj.name == name + assert obj.filemode == filemode + assert obj.type == type + assert obj.type_str == type_str + assert repo[obj.id].id == oid + assert obj == repo[obj.id] + + +def test_modify_tree(barerepo: Repository) -> None: + tree = barerepo[TREE_SHA] + with pytest.raises(TypeError): + operator.setitem('c', tree['a']) # type: ignore + with pytest.raises(TypeError): + operator.delitem('c') # type: ignore + + +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: 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: 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 6b2d99adc..99e4d6b3b 100644 --- a/test/test_treebuilder.py +++ b/test/test_treebuilder.py @@ -1,6 +1,4 @@ -# -*- coding: utf-8 -*- -# -# Copyright 2010-2013 The pygit2 contributors +# Copyright 2010-2025 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -25,55 +23,46 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. -"""Tests for Index files.""" - -from __future__ import absolute_import -from __future__ import unicode_literals -import unittest - -from . import utils - +from pygit2 import Repository, Tree TREE_SHA = '967fce8df97cc71722d3c2a5930ef3e6f1d27b12' -class TreeBuilderTest(utils.BareRepoTestCase): - - def test_new_empty_treebuilder(self): - self.repo.TreeBuilder() - - - def test_noop_treebuilder(self): - tree = self.repo[TREE_SHA] - bld = self.repo.TreeBuilder(TREE_SHA) - result = bld.write() +def test_new_empty_treebuilder(barerepo: Repository) -> None: + barerepo.TreeBuilder() - self.assertEqual(len(bld), len(tree)) - self.assertEqual(tree.oid, result) +def test_noop_treebuilder(barerepo: Repository) -> None: + tree = barerepo[TREE_SHA] + assert isinstance(tree, Tree) + bld = barerepo.TreeBuilder(TREE_SHA) + result = bld.write() - def test_noop_treebuilder_from_tree(self): - tree = self.repo[TREE_SHA] - bld = self.repo.TreeBuilder(tree) - result = bld.write() + assert len(bld) == len(tree) + assert tree.id == result - self.assertEqual(len(bld), len(tree)) - self.assertEqual(tree.oid, result) +def test_noop_treebuilder_from_tree(barerepo: Repository) -> None: + tree = barerepo[TREE_SHA] + assert isinstance(tree, Tree) + bld = barerepo.TreeBuilder(tree) + result = bld.write() - def test_rebuild_treebuilder(self): - tree = self.repo[TREE_SHA] - bld = self.repo.TreeBuilder() - for entry in tree: - name = entry.name - self.assertTrue(bld.get(name) is None) - bld.insert(name, entry.hex, entry.filemode) - self.assertEqual(bld.get(name).oid, entry.oid) - result = bld.write() + assert len(bld) == len(tree) + assert tree.id == result - self.assertEqual(len(bld), len(tree)) - self.assertEqual(tree.oid, result) +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 + result = bld.write() -if __name__ == '__main__': - unittest.main() + assert len(bld) == len(tree) + assert tree.id == result diff --git a/test/utils.py b/test/utils.py index 10a4e1719..a3b14cfdb 100644 --- a/test/utils.py +++ b/test/utils.py @@ -1,6 +1,4 @@ -# -*- coding: UTF-8 -*- -# -# Copyright 2010-2013 The pygit2 contributors +# Copyright 2010-2025 The pygit2 contributors # # This file is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2, @@ -25,123 +23,132 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. -"""Test utilities for libgit2.""" - -import sys -import os +# Standard library +import hashlib import shutil +import socket import stat -import tarfile -import tempfile -import unittest -import hashlib - -import pygit2 - - -def force_rm_handle(remove_path, path, excinfo): - os.chmod( - path, - os.stat(path).st_mode | stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH - ) - remove_path(path) - - -def gen_blob_sha1(data): - # http://stackoverflow.com/questions/552659/assigning-git-sha1s-without-git - m = hashlib.sha1() - m.update(('blob %d\0' % len(data)).encode()) - m.update(data) - - return m.hexdigest() - - -def rmtree(path): - """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. - """ - if os.path.exists(path): - onerror = lambda func, path, e: force_rm_handle(func, path, e) - shutil.rmtree(path, onerror=onerror) - - -class NoRepoTestCase(unittest.TestCase): - - def setUp(self): - self._temp_dir = tempfile.mkdtemp() - self.repo = None - - def tearDown(self): - del self.repo - rmtree(self._temp_dir) - - def assertRaisesAssign(self, exc_class, instance, name, value): - try: - setattr(instance, name, value) - except: - self.assertEqual(exc_class, sys.exc_info()[0]) - - def assertAll(self, func, entries): - return self.assertTrue(all(func(x) for x in entries)) - - def assertAny(self, func, entries): - return self.assertTrue(any(func(x) for x in entries)) - - def assertRaisesWithArg(self, exc_class, arg, func, *args, **kwargs): - try: - func(*args, **kwargs) - except exc_class as exc_value: - self.assertEqual((arg,), exc_value.args) - else: - self.fail('%s(%r) not raised' % (exc_class.__name__, arg)) - - def assertEqualSignature(self, a, b): - # XXX Remove this once equality test is supported by Signature - self.assertEqual(a.name, b.name) - self.assertEqual(a.email, b.email) - self.assertEqual(a.time, b.time) - self.assertEqual(a.offset, b.offset) - +import sys +import zipfile +from collections.abc import Callable +from pathlib import Path +from types import TracebackType +from typing import Any, Optional, ParamSpec, TypeVar -class BareRepoTestCase(NoRepoTestCase): +# Requirements +import pytest - repo_dir = 'testrepo.git' +# Pygit2 +import pygit2 - def setUp(self): - super(BareRepoTestCase, self).setUp() +T = TypeVar('T') +P = ParamSpec('P') - repo_dir = self.repo_dir - repo_path = os.path.join(os.path.dirname(__file__), 'data', repo_dir) - temp_repo_path = os.path.join(self._temp_dir, repo_dir) +requires_future_libgit2 = pytest.mark.xfail( + pygit2.LIBGIT2_VER < (2, 0, 0), + reason='This test may work with a future version of libgit2', +) - shutil.copytree(repo_path, temp_repo_path) +try: + socket.gethostbyname('github.com') + has_network = True +except socket.gaierror: + has_network = False - self.repo = pygit2.Repository(temp_repo_path) +requires_network = pytest.mark.skipif(not has_network, reason='Requires network') +with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + has_proxy = sock.connect_ex(('', 8888)) == 0 -class RepoTestCase(NoRepoTestCase): +requires_proxy = pytest.mark.skipif(not has_proxy, reason='Requires proxy at port 8888') - repo_dir = 'testrepo' +requires_ssh = pytest.mark.skipif( + pygit2.enums.Feature.SSH not in pygit2.features, reason='Requires SSH' +) - def setUp(self): - super(RepoTestCase, self).setUp() - repo_dir = self.repo_dir - repo_path = os.path.join(os.path.dirname(__file__), 'data', repo_dir) - temp_repo_path = os.path.join(self._temp_dir, repo_dir, '.git') +is_pypy = '__pypy__' in sys.builtin_module_names - tar = tarfile.open(repo_path + '.tar') - tar.extractall(self._temp_dir) - tar.close() +requires_refcount = pytest.mark.skipif(is_pypy, reason='skip refcounts checks in pypy') - self.repo = pygit2.Repository(temp_repo_path) +fails_in_macos = pytest.mark.xfail( + sys.platform == 'darwin', reason='fails in macOS for an unknown reason' +) -class DirtyRepoTestCase(RepoTestCase): +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()) + m.update(data) + return m.hexdigest() - repo_dir = 'dirtyrepo' +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) -class EmptyRepoTestCase(RepoTestCase): - repo_dir = 'emptyrepo' +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. + """ + if Path(path).exists(): + shutil.rmtree(path, onerror=force_rm_handle) + + +class TemporaryRepository: + def __init__(self, name: str, tmp_path: Path) -> None: + self.name = name + self.tmp_path = tmp_path + + def __enter__(self) -> Path: + path = Path(__file__).parent / 'data' / self.name + temp_repo_path = Path(self.tmp_path) / path.stem + if path.suffix == '.zip': + with zipfile.ZipFile(path) as zipf: + zipf.extractall(self.tmp_path) + elif path.suffix == '.git': + shutil.copytree(path, temp_repo_path) + else: + raise ValueError(f'Unexpected {path.suffix} extension') + + return temp_repo_path + + def __exit__( + self, + exc_type: Optional[type[BaseException]], + exc_value: Optional[BaseException], + traceback: Optional[TracebackType], + ) -> None: + pass + + +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,) + + # Explicitly clear the Exception Info. Citing + # https://docs.pytest.org/en/latest/reference.html#pytest-raises: + # + # Clearing those references breaks a reference cycle + # (ExceptionInfo –> caught exception –> frame stack raising the exception + # –> current frame stack –> local variables –> ExceptionInfo) which makes + # Python keep all objects referenced from that cycle (including all local + # variables in the current frame) alive until the next cyclic garbage + # collection run. See the official Python try statement documentation for + # more detailed information. + del excinfo