diff -pruN 1.18.2-2/.github/dependabot.yml 1.19.0-1/.github/dependabot.yml
--- 1.18.2-2/.github/dependabot.yml	1970-01-01 00:00:00.000000000 +0000
+++ 1.19.0-1/.github/dependabot.yml	2025-10-23 11:50:22.000000000 +0000
@@ -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 -pruN 1.18.2-2/.github/workflows/codespell.yml 1.19.0-1/.github/workflows/codespell.yml
--- 1.18.2-2/.github/workflows/codespell.yml	2025-08-16 11:34:29.000000000 +0000
+++ 1.19.0-1/.github/workflows/codespell.yml	2025-10-23 11:50:22.000000000 +0000
@@ -4,7 +4,9 @@ name: Codespell
 
 on:
   pull_request:
+    branches: [master]
   push:
+    branches: [master]
 
 permissions:
   contents: read
@@ -16,7 +18,7 @@ jobs:
 
     steps:
       - name: Checkout
-        uses: actions/checkout@v4
+        uses: actions/checkout@v5
       - name: Annotate locations with typos
         uses: codespell-project/codespell-problem-matcher@v1
       - name: Codespell
diff -pruN 1.18.2-2/.github/workflows/lint.yml 1.19.0-1/.github/workflows/lint.yml
--- 1.18.2-2/.github/workflows/lint.yml	2025-08-16 11:34:29.000000000 +0000
+++ 1.19.0-1/.github/workflows/lint.yml	2025-10-23 11:50:22.000000000 +0000
@@ -2,7 +2,9 @@ name: Lints
 
 on:
   pull_request:
+    branches: [master]
   push:
+    branches: [master]
     paths-ignore:
     - '**.rst'
 
@@ -12,10 +14,10 @@ jobs:
 
     steps:
     - name: Checkout pygit2
-      uses: actions/checkout@v4
+      uses: actions/checkout@v5
 
     - name: Set up Python
-      uses: actions/setup-python@v5
+      uses: actions/setup-python@v6
       with:
         python-version: '3.13'
 
diff -pruN 1.18.2-2/.github/workflows/parse_release_notes.py 1.19.0-1/.github/workflows/parse_release_notes.py
--- 1.18.2-2/.github/workflows/parse_release_notes.py	1970-01-01 00:00:00.000000000 +0000
+++ 1.19.0-1/.github/workflows/parse_release_notes.py	2025-10-23 11:50:22.000000000 +0000
@@ -0,0 +1,94 @@
+"""Parse the latest release notes from CHANGELOG.md.
+
+If running in GitHub Actions, set the `release_title` output
+variable for use in subsequent step(s).
+
+If running in CI, write the release notes to ReleaseNotes.md
+for upload as an artifact.
+
+Otherwise, print the release title and notes to stdout.
+"""
+
+import re
+import subprocess
+from os import environ
+from pathlib import Path
+
+
+class ChangesEntry:
+    def __init__(self, version: str, notes: str) -> None:
+        self.version = version
+        title = notes.splitlines()[0]
+        self.title = f'{version} {title}'
+        self.notes = notes[len(title) :].strip()
+
+
+H1 = re.compile(r'^# (\d+\.\d+\.\d+)', re.MULTILINE)
+
+
+def parse_changelog() -> list[ChangesEntry]:
+    changelog = Path('CHANGELOG.md').read_text(encoding='utf-8')
+    parsed = H1.split(changelog)  # may result in a blank line at index 0
+    if not parsed[0]:  # leading entry is a blank line due to re.split() implementation
+        parsed = parsed[1:]
+    assert len(parsed) % 2 == 0, (
+        'Malformed CHANGELOG.md; Entries expected to start with "# x.y.x"'
+    )
+
+    changes: list[ChangesEntry] = []
+    for i in range(0, len(parsed), 2):
+        version = parsed[i]
+        notes = parsed[i + 1].strip()
+        changes.append(ChangesEntry(version, notes))
+    return changes
+
+
+def get_version_tag() -> str | None:
+    if 'GITHUB_REF' in environ:  # for use in GitHub Actions
+        git_ref = environ['GITHUB_REF']
+    else:  # for local use
+        git_out = subprocess.run(
+            ['git', 'rev-parse', '--symbolic-full-name', 'HEAD'],
+            capture_output=True,
+            text=True,
+            check=True,
+        )
+        git_ref = git_out.stdout.strip()
+    version: str | None = None
+    if git_ref and git_ref.startswith('refs/tags/'):
+        version = git_ref[len('refs/tags/') :].lstrip('v')
+    else:
+        print(
+            f"Using latest CHANGELOG.md entry because the git ref '{git_ref}' is not a tag."
+        )
+    return version
+
+
+def get_entry(changes: list[ChangesEntry], version: str | None) -> ChangesEntry:
+    latest = changes[0]
+    if version is not None:
+        for entry in changes:
+            if entry.version == version:
+                latest = entry
+                break
+        else:
+            raise ValueError(f'No changelog entry found for version {version}')
+    return latest
+
+
+def main() -> None:
+    changes = parse_changelog()
+    version = get_version_tag()
+    latest = get_entry(changes=changes, version=version)
+    if 'GITHUB_OUTPUT' in environ:
+        with Path(environ['GITHUB_OUTPUT']).open('a') as gh_out:
+            print(f'release_title={latest.title}', file=gh_out)
+    if environ.get('CI', 'false') == 'true':
+        Path('ReleaseNotes.md').write_text(latest.notes, encoding='utf-8')
+    else:
+        print('Release notes:')
+        print(f'# {latest.title}\n{latest.notes}')
+
+
+if __name__ == '__main__':
+    main()
diff -pruN 1.18.2-2/.github/workflows/tests.yml 1.19.0-1/.github/workflows/tests.yml
--- 1.18.2-2/.github/workflows/tests.yml	2025-08-16 11:34:29.000000000 +0000
+++ 1.19.0-1/.github/workflows/tests.yml	2025-10-23 11:50:22.000000000 +0000
@@ -1,46 +1,20 @@
-name: Tests
+name: Tests (s390x)
 
 on:
   pull_request:
+    branches: [master]
   push:
+    branches: [master]
     paths-ignore:
     - '**.rst'
 
 jobs:
-  linux:
-    runs-on: ${{ matrix.os }}
-    strategy:
-      matrix:
-        include:
-        - os: ubuntu-24.04
-          python-version: '3.10'
-        - os: ubuntu-24.04
-          python-version: '3.13'
-        - os: ubuntu-24.04
-          python-version: 'pypy3.10'
-        - os: ubuntu-24.04-arm
-          python-version: '3.13'
-
-    steps:
-    - name: Checkout pygit2
-      uses: actions/checkout@v4
-
-    - name: Set up Python
-      uses: actions/setup-python@v5
-      with:
-        python-version: ${{ matrix.python-version }}
-
-    - name: Linux
-      run: |
-        sudo apt install tinyproxy
-        LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.1 /bin/sh build.sh test
-
   linux-s390x:
     runs-on: ubuntu-24.04
     if: github.ref == 'refs/heads/master'
     steps:
     - name: Checkout
-      uses: actions/checkout@v4
+      uses: actions/checkout@v5
 
     - name: Build & test
       uses: uraimo/run-on-arch-action@v3
@@ -53,19 +27,3 @@ jobs:
         run: |
           LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.1 /bin/sh build.sh test
       continue-on-error: true # Tests are expected to fail, see issue #812
-
-  macos-arm64:
-    runs-on: macos-latest
-    steps:
-    - name: Checkout pygit2
-      uses: actions/checkout@v4
-
-    - name: Set up Python
-      uses: actions/setup-python@v5
-      with:
-        python-version: '3.13'
-
-    - name: macOS
-      run: |
-        export OPENSSL_PREFIX=`brew --prefix openssl@3`
-        LIBSSH2_VERSION=1.11.1 LIBGIT2_VERSION=1.9.1 /bin/sh build.sh test
diff -pruN 1.18.2-2/.github/workflows/wheels.yml 1.19.0-1/.github/workflows/wheels.yml
--- 1.18.2-2/.github/workflows/wheels.yml	2025-08-16 11:34:29.000000000 +0000
+++ 1.19.0-1/.github/workflows/wheels.yml	2025-10-23 11:50:22.000000000 +0000
@@ -1,12 +1,18 @@
 name: Wheels
 
+concurrency:
+  group: ${{ github.workflow }}-${{ github.ref }}
+  cancel-in-progress: ${{ github.ref_name != 'master' }}
+
 on:
   push:
-    branches:
-    - master
-    - wheels-*
+    branches: [master]
     tags:
-    - 'v*'
+      - 'v*'
+  pull_request:
+    branches: [master]
+    paths-ignore:
+      - 'docs/**'
 
 jobs:
   build_wheels:
@@ -21,18 +27,30 @@ jobs:
           os: ubuntu-24.04-arm
         - name: macos
           os: macos-13
+        - name: windows-x64
+          os: windows-latest
+        - name: windows-x86
+          os: windows-latest
+        - name: windows-arm64
+          # https://github.com/actions/partner-runner-images#available-images
+          os: windows-11-arm
 
     steps:
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@v5
+        with:
+          # avoid leaking credentials in uploaded artifacts
+          persist-credentials: false
 
-      - uses: actions/setup-python@v5
+      - uses: actions/setup-python@v6
         with:
           python-version: '3.13'
 
       - name: Install cibuildwheel
-        run: python -m pip install cibuildwheel==3.1.1
+        run: python -m pip install cibuildwheel~=3.1.1
 
       - name: Build wheels
+        env:
+          CIBW_ARCHS_WINDOWS: ${{ matrix.name == 'windows-x86' && 'auto32' || 'native' }}
         run: python -m cibuildwheel --output-dir wheelhouse
 
       - uses: actions/upload-artifact@v4
@@ -45,9 +63,12 @@ jobs:
     runs-on: ubuntu-24.04
 
     steps:
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@v5
+        with:
+          # avoid leaking credentials in uploaded artifacts
+          persist-credentials: false
 
-      - uses: actions/setup-python@v5
+      - uses: actions/setup-python@v6
         with:
           python-version: '3.13'
 
@@ -56,7 +77,7 @@ jobs:
           platforms: linux/ppc64le
 
       - name: Install cibuildwheel
-        run: python -m pip install cibuildwheel==3.1.1
+        run: python -m pip install cibuildwheel~=3.1.1
 
       - name: Build wheels
         run: python -m cibuildwheel --output-dir wheelhouse
@@ -69,13 +90,65 @@ jobs:
           name: wheels-linux-ppc
           path: ./wheelhouse/*.whl
 
+  sdist:
+    runs-on: ubuntu-latest
+    outputs:
+      release_title: ${{ steps.parse_changelog.outputs.release_title }}
+    steps:
+      - uses: actions/checkout@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/*
+
+      - name: parse CHANGELOG for release notes
+        id: parse_changelog
+        run: python .github/workflows/parse_release_notes.py
+
+      - name: Upload Release Notes
+        uses: actions/upload-artifact@v4
+        with:
+          name: release-notes
+          path: ReleaseNotes.md
+
+
+  twine-check:
+    name: Twine check
+    # It is good to do this check on non-tagged commits.
+    # 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]
+    needs: [build_wheels, build_wheels_ppc, sdist]
+    permissions:
+      contents: write # to create GitHub Release
     runs-on: ubuntu-24.04
 
     steps:
-    - uses: actions/download-artifact@v4
+    - uses: actions/download-artifact@v5
       with:
         path: dist
         pattern: wheels-*
@@ -88,3 +161,21 @@ jobs:
       with:
         user: __token__
         password: ${{ secrets.PYPI_API_TOKEN }}
+        skip-existing: true
+
+    - uses: actions/download-artifact@v5
+      with:
+        name: release-notes
+    - name: Create GitHub Release
+      env:
+        GITHUB_TOKEN: ${{ github.token }}
+        TAG: ${{ github.ref_name }}
+        REPO: ${{ github.repository }}
+        TITLE: ${{ needs.sdist.outputs.release_title }}
+      # https://cli.github.com/manual/gh_release_create
+      run: >-
+        gh release create ${TAG}
+        --verify-tag
+        --repo ${REPO}
+        --title ${TITLE}
+        --notes-file ReleaseNotes.md
diff -pruN 1.18.2-2/.gitignore 1.19.0-1/.gitignore
--- 1.18.2-2/.gitignore	2025-08-16 11:34:29.000000000 +0000
+++ 1.19.0-1/.gitignore	2025-10-23 11:50:22.000000000 +0000
@@ -1,18 +1,245 @@
-/.cache/
-/.coverage
-/.eggs/
-/.envrc
-/.tox/
-/build/
-/ci/
+# Created by https://www.toptal.com/developers/gitignore/api/python,c
+# Edit at https://www.toptal.com/developers/gitignore?templates=python,c
+
+### C ###
+# Prerequisites
+*.d
+
+# Object files
+*.o
+*.ko
+*.obj
+*.elf
+
+# Linker output
+*.ilk
+*.map
+*.exp
+
+# Precompiled Headers
+*.gch
+*.pch
+
+# Libraries
+*.lib
+*.a
+*.la
+*.lo
+
+# Shared objects (inc. Windows DLLs)
+*.dll
+*.so
+*.so.*
+*.dylib
+
+# Executables
+*.exe
+*.out
+*.app
+*.i*86
+*.x86_64
+*.hex
+
+# Debug files
+*.dSYM/
+*.su
+*.idb
+*.pdb
+
+# Kernel Module Compile Results
+*.mod*
+*.cmd
+.tmp_versions/
+modules.order
+Module.symvers
+Mkfile.old
+dkms.conf
+
+### Python ###
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
 /dist/
-/docs/_build/
+downloads/
+eggs/
+/.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+wheelhouse/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
 /MANIFEST
+
+# PyInstaller
+#  Usually these files are written by a python script from a template
+#  before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+/.coverage
+.coverage.*
+/.cache/
+nosetests.xml
+coverage.xml
+lcov.info
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+cover/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+.pybuilder/
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+#   For a library or package, you might want to ignore these files since the code is
+#   intended to run in multiple environments; otherwise, check them in:
+# .python-version
+
+# pipenv
+#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+#   However, in case of collaboration, if having platform-specific dependencies or dependencies
+#   having no cross-platform support, pipenv may install dependencies that don't work, or not
+#   install all needed dependencies.
+#Pipfile.lock
+
+# poetry
+#   Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
+#   This is especially recommended for binary packages to ensure reproducibility, and is more
+#   commonly ignored for libraries.
+#   https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
+#poetry.lock
+
+# pdm
+#   Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
+#pdm.lock
+#   pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
+#   in version control.
+#   https://pdm.fming.dev/#use-with-ide
+.pdm.toml
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
+
+### Python Patch ###
+# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
+poetry.toml
+
+# ruff
+.ruff_cache/
+
+# LSP config files
+pyrightconfig.json
+
+# End of https://www.toptal.com/developers/gitignore/api/python,c
+
+# PyCharm (IntelliJ JetBrains)
+.idea/
+*.iml
+*.iws
+*.ipr
+.idea_modules/
+
+# for VSCode
+.vscode/
+
+# for Eclipse
+.settings/
+
+# custom ignore paths
+/.envrc
 /venv*
-__pycache__/
-*.egg-info
-*.pyc
-*.so
+/ci/
 *.swp
 /pygit2/_libgit2.c
 /pygit2/_libgit2.o
diff -pruN 1.18.2-2/.mailmap 1.19.0-1/.mailmap
--- 1.18.2-2/.mailmap	2025-08-16 11:34:29.000000000 +0000
+++ 1.19.0-1/.mailmap	2025-10-23 11:50:22.000000000 +0000
@@ -3,6 +3,7 @@ Alexander Linne <alexander.linne@outlook
 Anatoly Techtonik <techtonik@gmail.com>
 Bob Carroll <bob.carroll@alum.rit.edu> <bobc@qti.qualcomm.com>
 Brandon Milton <bmilton@fb.com> <brandon.milton21@gmail.com>
+Brendan Doherty <2bndy5@gmail.com>
 CJ Steiner <47841949+clintonsteiner@users.noreply.github.com>
 Carlos Martín Nieto <cmn@dwim.me> <carlos@cmartin.tk>
 Christian Boos <cboos@edgewall.org> <cboos@bct-technology.com>
@@ -26,6 +27,7 @@ Nicolas Rybowski <nicolas.rybowski@uclou
 Óscar San José <osanjose@tuenti.com>
 Petr Hosek <petrhosek@gmail.com> <p.hosek@imperial.ac.uk>
 Phil Schleihauf <uniphil@gmail.com>
+Raphael Medaer <rme@escaux.com>
 Richo Healey <richo@psych0tik.net>
 Robert Hölzl <robert.hoelzl@posteo.de>
 Saugat Pachhai <suagatchhetri@outlook.com>
diff -pruN 1.18.2-2/AUTHORS.md 1.19.0-1/AUTHORS.md
--- 1.18.2-2/AUTHORS.md	2025-08-16 11:34:29.000000000 +0000
+++ 1.19.0-1/AUTHORS.md	2025-10-23 11:50:22.000000000 +0000
@@ -15,6 +15,7 @@ Authors:
     Daniel Rodríguez Troitiño
     Peter Rowlands
     Richo Healey
+    Brendan Doherty
     Christian Boos
     Julien Miotte
     Nick Hynes
@@ -50,7 +51,7 @@ Authors:
     Mathieu Parent
     Michał Kępień
     Nicolas Dandrimont
-    Raphael Medaer (Escaux)
+    Raphael Medaer
     Yaroslav Halchenko
     Anatoly Techtonik
     Andrew Olsen
@@ -64,6 +65,7 @@ Authors:
     Santiago Perez De Rosso
     Sebastian Thiel
     Thom Wiggers
+    WANG Xuerui
     William Manley
     Alexander Linne
     Alok Singhal
@@ -89,7 +91,6 @@ Authors:
     Sukhman Bhuller
     Thomas Kluyver
     Tyler Cipriani
-    WANG Xuerui
     Alex Chamberlain
     Alexander Bayandin
     Amit Bakshi
@@ -98,6 +99,7 @@ Authors:
     Ben Davis
     CJ Steiner
     Colin Watson
+    Craig de Stigter
     Dan Yeaw
     Dustin Raimondi
     Eric Schrijver
@@ -156,7 +158,6 @@ Authors:
     Chris Rebert
     Christopher Hunt
     Claudio Jolowicz
-    Craig de Stigter
     Cristian Hotea
     Cyril Jouve
     Dan Cecile
diff -pruN 1.18.2-2/CHANGELOG.md 1.19.0-1/CHANGELOG.md
--- 1.18.2-2/CHANGELOG.md	2025-08-16 11:34:29.000000000 +0000
+++ 1.19.0-1/CHANGELOG.md	2025-10-23 11:50:22.000000000 +0000
@@ -1,3 +1,38 @@
+# 1.19.0 (2025-10-23)
+
+- Add support for Python 3.14 and drop 3.10
+
+- Support threaded builds (experimental)
+  [#1430](https://github.com/libgit2/pygit2/pull/1430)
+  [#1435](https://github.com/libgit2/pygit2/pull/1435)
+
+- Add Linux musl wheels for AArch64
+
+- Add Windows wheels for AArch64;
+  CI: build Windows wheels with cibuildwheel on GitHub
+  [#1423](https://github.com/libgit2/pygit2/pull/1423)
+
+- New `Repository.transaction()` context manager, returns new `ReferenceTransaction`
+  [#1420](https://github.com/libgit2/pygit2/pull/1420)
+
+- CI: add GitHub releases and other improvements
+  [#1433](https://github.com/libgit2/pygit2/pull/1433)
+  [#1432](https://github.com/libgit2/pygit2/pull/1432)
+  [#1425](https://github.com/libgit2/pygit2/pull/1425)
+  [#1431](https://github.com/libgit2/pygit2/pull/1431)
+
+- Documentation improvements and other changes
+  [#1426](https://github.com/libgit2/pygit2/pull/1426)
+  [#1424](https://github.com/libgit2/pygit2/pull/1424)
+
+Breaking changes:
+
+- Remove deprecated `IndexEntry.hex`, use `str(entry.id)` instead of `entry.hex`
+
+Deprecations:
+
+- Deprecate `IndexEntry.oid`, use `entry.id` instead of `entry.oid`
+
 # 1.18.2 (2025-08-16)
 
 - Add support for almost all global options
@@ -298,31 +333,39 @@ Deprecations:
 
 # 1.14.0 (2024-01-26)
 
--   Drop support for Python 3.8
--   Add Linux wheels for musl on x86\_64
-    [#1266](https://github.com/libgit2/pygit2/pull/1266)
--   New `Repository.submodules` namespace
-    [#1250](https://github.com/libgit2/pygit2/pull/1250)
--   New `Repository.listall_mergeheads()`, `Repository.message`,
-    `Repository.raw_message` and `Repository.remove_message()`
-    [#1261](https://github.com/libgit2/pygit2/pull/1261)
--   New `pygit2.enums` supersedes the `GIT_` constants
-    [#1251](https://github.com/libgit2/pygit2/pull/1251)
--   Now `Repository.status()`, `Repository.status_file()`,
-    `Repository.merge_analysis()`, `DiffFile.flags`, `DiffFile.mode`,
-    `DiffDelta.flags` and `DiffDelta.status` return enums
-    [#1263](https://github.com/libgit2/pygit2/pull/1263)
--   Now repository\'s `merge()`, `merge_commits()` and `merge_trees()`
-    take enums/flags for their `favor`, `flags` and `file_flags` arguments.
-    [#1271](https://github.com/libgit2/pygit2/pull/1271)
-    [#1272](https://github.com/libgit2/pygit2/pull/1272)
--   Fix crash in filter cleanup
-    [#1259](https://github.com/libgit2/pygit2/pull/1259)
--   Documentation fixes
-    [#1255](https://github.com/libgit2/pygit2/pull/1255)
-    [#1258](https://github.com/libgit2/pygit2/pull/1258)
-    [#1268](https://github.com/libgit2/pygit2/pull/1268)
-    [#1270](https://github.com/libgit2/pygit2/pull/1270)
+- Drop support for Python 3.8
+
+- Add Linux wheels for musl on x86\_64
+  [#1266](https://github.com/libgit2/pygit2/pull/1266)
+
+- New `Repository.submodules` namespace
+  [#1250](https://github.com/libgit2/pygit2/pull/1250)
+
+- New `Repository.listall_mergeheads()`, `Repository.message`,
+  `Repository.raw_message` and `Repository.remove_message()`
+  [#1261](https://github.com/libgit2/pygit2/pull/1261)
+
+- New `pygit2.enums` supersedes the `GIT_` constants
+  [#1251](https://github.com/libgit2/pygit2/pull/1251)
+
+- Now `Repository.status()`, `Repository.status_file()`,
+  `Repository.merge_analysis()`, `DiffFile.flags`, `DiffFile.mode`,
+  `DiffDelta.flags` and `DiffDelta.status` return enums
+  [#1263](https://github.com/libgit2/pygit2/pull/1263)
+
+- Now repository\'s `merge()`, `merge_commits()` and `merge_trees()`
+  take enums/flags for their `favor`, `flags` and `file_flags` arguments.
+  [#1271](https://github.com/libgit2/pygit2/pull/1271)
+  [#1272](https://github.com/libgit2/pygit2/pull/1272)
+
+- Fix crash in filter cleanup
+  [#1259](https://github.com/libgit2/pygit2/pull/1259)
+
+- Documentation fixes
+  [#1255](https://github.com/libgit2/pygit2/pull/1255)
+  [#1258](https://github.com/libgit2/pygit2/pull/1258)
+  [#1268](https://github.com/libgit2/pygit2/pull/1268)
+  [#1270](https://github.com/libgit2/pygit2/pull/1270)
 
 Breaking changes:
 
diff -pruN 1.18.2-2/README.md 1.19.0-1/README.md
--- 1.18.2-2/README.md	2025-08-16 11:34:29.000000000 +0000
+++ 1.19.0-1/README.md	2025-10-23 11:50:22.000000000 +0000
@@ -1,20 +1,24 @@
 # pygit2 - libgit2 bindings in Python
 
 Bindings to the libgit2 shared library, implements Git plumbing.
-Supports Python 3.10 to 3.13 and PyPy3 7.3+
+Supports Python 3.11 to 3.14 and PyPy3 7.3+
 
-[![image](https://github.com/libgit2/pygit2/actions/workflows/tests.yml/badge.svg)](https://github.com/libgit2/pygit2/actions/workflows/tests.yml)
+[![test-ci-badge][test-ci-badge]][test-ci-link]
+[![deploy-ci-badge][deploy-ci-badge]][deploy-ci-link]
 
-[![image](https://ci.appveyor.com/api/projects/status/edmwc0dctk5nacx0/branch/master?svg=true)](https://ci.appveyor.com/project/jdavid/pygit2/branch/master)
+[deploy-ci-badge]: https://github.com/libgit2/pygit2/actions/workflows/wheels.yml/badge.svg
+[deploy-ci-link]: https://github.com/libgit2/pygit2/actions/workflows/wheels.yml
+[test-ci-badge]: https://github.com/libgit2/pygit2/actions/workflows/tests.yml/badge.svg
+[test-ci-link]: https://github.com/libgit2/pygit2/actions/workflows/tests.yml
 
 ## Links
 
--   Documentation - <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>
+- 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>
 
 ## Sponsors
 
diff -pruN 1.18.2-2/appveyor.yml 1.19.0-1/appveyor.yml
--- 1.18.2-2/appveyor.yml	2025-08-16 11:34:29.000000000 +0000
+++ 1.19.0-1/appveyor.yml	1970-01-01 00:00:00.000000000 +0000
@@ -1,68 +0,0 @@
-version: 1.18.{build}
-image: Visual Studio 2019
-configuration: Release
-environment:
-  global:
-    TWINE_USERNAME: __token__
-    TWINE_PASSWORD:
-      secure: 7YD82RnQJ9rnJE/josiQ/V6VWh+tlhmJpWVM/u5jGdl8XqyhsLEKF5MNMYd4ZYxA/MGaYBCQ525d4m9RSDk9RB+uIFMZJLnl1eOjHQVyJ+ZZmJb65tqd/fR5hybhWtVhn+0wANiI4uqrojFFVy1HjfBYSrvyk+7LLDxfSVTqkhMEhbZbWBpGP/3VET1gPy+qdlWcL7quwhSBPSbKpyMi/cqvp5/yFLAM615RRABgQUDpRyXxtBTReRgWSxi9kUXXqR18ZvQlvMLnAsEnGFRenA==
-  matrix:
-  - GENERATOR: 'Visual Studio 14'
-    PYTHON: 'C:\Python310\python.exe'
-  - GENERATOR: 'Visual Studio 14 Win64'
-    PYTHON: 'C:\Python310-x64\python.exe'
-  - GENERATOR: 'Visual Studio 14'
-    PYTHON: 'C:\Python311\python.exe'
-  - GENERATOR: 'Visual Studio 14 Win64'
-    PYTHON: 'C:\Python311-x64\python.exe'
-  - GENERATOR: 'Visual Studio 14'
-    PYTHON: 'C:\Python312\python.exe'
-  - GENERATOR: 'Visual Studio 14 Win64'
-    PYTHON: 'C:\Python312-x64\python.exe'
-  - GENERATOR: 'Visual Studio 14'
-    PYTHON: 'C:\Python313\python.exe'
-  - GENERATOR: 'Visual Studio 14 Win64'
-    PYTHON: 'C:\Python313-x64\python.exe'
-
-matrix:
-  fast_finish: true
-
-init:
-- cmd: |
-    "%PYTHON%" -m pip install -U pip wheel
-
-build_script:
-# Clone, build and install libgit2
-- cmd: |
-    set LIBGIT2=%APPVEYOR_BUILD_FOLDER%\venv
-    git clone --depth=1 -b v1.9.1 https://github.com/libgit2/libgit2.git libgit2
-    cd libgit2
-    cmake . -DBUILD_TESTS=OFF -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="%LIBGIT2%" -G "%GENERATOR%"
-    cmake --build . --target install
-    cd ..
-
-# Build and install pygit2
-# Rename pygit2 folder, so when testing it picks the installed one
-- cmd: |
-    "%PYTHON%" -m pip install -r requirements-test.txt
-    "%PYTHON%" -m pip wheel --wheel-dir=dist .
-    "%PYTHON%" -m pip install --no-index --find-links=dist pygit2
-    mv pygit2 pygit2.bak
-
-test_script:
-- ps: |
-    &$env:PYTHON -m pytest test --junitxml=testresults.xml
-
-    if ($LastExitCode -ne 0) { $host.SetShouldExit($LastExitCode) }
-
-    # upload results to AppVeyor
-    $wc = New-Object 'System.Net.WebClient'
-    $wc.UploadFile("https://ci.appveyor.com/api/testresults/junit/$($env:APPVEYOR_JOB_ID)", (Resolve-Path ".\testresults.xml"))
-
-artifacts:
-- path: dist\pygit2-*.whl
-
-deploy_script:
-- ps: if ($env:APPVEYOR_REPO_TAG -eq $TRUE) { pip install twine; twine upload dist/pygit2-*.whl }
-
-deploy: on
diff -pruN 1.18.2-2/build.ps1 1.19.0-1/build.ps1
--- 1.18.2-2/build.ps1	1970-01-01 00:00:00.000000000 +0000
+++ 1.19.0-1/build.ps1	2025-10-23 11:50:22.000000000 +0000
@@ -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 -pruN 1.18.2-2/build.sh 1.19.0-1/build.sh
--- 1.18.2-2/build.sh	2025-08-16 11:34:29.000000000 +0000
+++ 1.19.0-1/build.sh	2025-10-23 11:50:22.000000000 +0000
@@ -134,7 +134,7 @@ if [ -n "$OPENSSL_VERSION" ]; then
         # Linux
         tar xf $FILENAME.tar.gz
         cd $FILENAME
-        ./Configure shared --prefix=$PREFIX --libdir=$PREFIX/lib
+        ./Configure shared no-apps no-docs no-tests --prefix=$PREFIX --libdir=$PREFIX/lib
         make
         make install
         OPENSSL_PREFIX=$(pwd)
diff -pruN 1.18.2-2/debian/changelog 1.19.0-1/debian/changelog
--- 1.18.2-2/debian/changelog	2025-09-13 17:39:59.000000000 +0000
+++ 1.19.0-1/debian/changelog	2025-10-28 11:30:02.000000000 +0000
@@ -1,3 +1,14 @@
+python-pygit2 (1.19.0-1) unstable; urgency=medium
+
+  [ Alexandre Detiste ]
+  * Drop "Python 2" from description
+
+  [ Timo Röhling ]
+  * New upstream version 1.19.0
+  * Refresh patches (no functional changes)
+
+ -- Timo Röhling <roehling@debian.org>  Tue, 28 Oct 2025 12:30:02 +0100
+
 python-pygit2 (1.18.2-2) unstable; urgency=medium
 
   * Testsuite needs ca-certificates
diff -pruN 1.18.2-2/debian/control 1.19.0-1/debian/control
--- 1.18.2-2/debian/control	2025-09-13 17:39:59.000000000 +0000
+++ 1.19.0-1/debian/control	2025-10-28 11:30:02.000000000 +0000
@@ -30,8 +30,8 @@ Testsuite: autopkgtest-pkg-pybuild
 Rules-Requires-Root: no
 Description: Python bindings for libgit2
  The Pygit2 module provides a set of Python bindings to the libgit2 shared
- library. libgit2 implements the core of Git. Pygit2 works with Python 2.7,
- 3.x and pypy.
+ library. libgit2 implements the core of Git. Pygit2 works with CPython
+ and pypy.
 
 Package: python3-pygit2
 Architecture: any
diff -pruN 1.18.2-2/debian/patches/0001-Remove-privacy-breach.patch 1.19.0-1/debian/patches/0001-Remove-privacy-breach.patch
--- 1.18.2-2/debian/patches/0001-Remove-privacy-breach.patch	2025-09-13 17:39:59.000000000 +0000
+++ 1.19.0-1/debian/patches/0001-Remove-privacy-breach.patch	2025-10-28 11:30:02.000000000 +0000
@@ -3,26 +3,23 @@ Date: Thu, 3 Aug 2017 23:32:27 +0200
 Subject: Remove privacy breach
 
 ---
- docs/development.rst | 9 ---------
- 1 file changed, 9 deletions(-)
+ docs/development.rst | 6 ------
+ 1 file changed, 6 deletions(-)
 
 diff --git a/docs/development.rst b/docs/development.rst
-index b9422bf..8585a2b 100644
+index 79d7e6b..68ba1e0 100644
 --- a/docs/development.rst
 +++ b/docs/development.rst
-@@ -2,15 +2,6 @@
+@@ -2,12 +2,6 @@
  The development version
  **********************************************************************
  
 -.. image:: https://github.com/libgit2/pygit2/actions/workflows/tests.yml/badge.svg
 -   :target: https://github.com/libgit2/pygit2/actions/workflows/tests.yml
 -
--.. image:: https://ci.appveyor.com/api/projects/status/edmwc0dctk5nacx0/branch/master?svg=true
--   :target: https://ci.appveyor.com/project/jdavid/pygit2/branch/master
+-.. image:: https://github.com/libgit2/pygit2/actions/workflows/wheels.yml/badge.svg
+-   :target: https://github.com/libgit2/pygit2/actions/workflows/wheels.yml
 -
--.. contents:: Contents
--   :local:
--
- Unit tests
- ==========
+ .. contents:: Contents
+    :local:
  
diff -pruN 1.18.2-2/debian/patches/0004-use-python3-sphinx-rtd-theme.patch 1.19.0-1/debian/patches/0004-use-python3-sphinx-rtd-theme.patch
--- 1.18.2-2/debian/patches/0004-use-python3-sphinx-rtd-theme.patch	2025-09-13 17:39:59.000000000 +0000
+++ 1.19.0-1/debian/patches/0004-use-python3-sphinx-rtd-theme.patch	2025-10-28 11:30:02.000000000 +0000
@@ -8,7 +8,7 @@ Forwarded: not-needed
  1 file changed, 1 deletion(-)
 
 diff --git a/docs/conf.py b/docs/conf.py
-index cf79808..8e44478 100644
+index 7f6bdfd..74b55a9 100644
 --- a/docs/conf.py
 +++ b/docs/conf.py
 @@ -50,7 +50,6 @@ exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
diff -pruN 1.18.2-2/debian/patches/0005-Do-not-insert-source-dir-in-sphinx.patch 1.19.0-1/debian/patches/0005-Do-not-insert-source-dir-in-sphinx.patch
--- 1.18.2-2/debian/patches/0005-Do-not-insert-source-dir-in-sphinx.patch	2025-09-13 17:39:59.000000000 +0000
+++ 1.19.0-1/debian/patches/0005-Do-not-insert-source-dir-in-sphinx.patch	2025-10-28 11:30:02.000000000 +0000
@@ -9,7 +9,7 @@ must be built in the binary package dire
  1 file changed, 1 insertion(+), 1 deletion(-)
 
 diff --git a/docs/conf.py b/docs/conf.py
-index 8e44478..86f234d 100644
+index 74b55a9..a480948 100644
 --- a/docs/conf.py
 +++ b/docs/conf.py
 @@ -13,7 +13,7 @@ import sys
diff -pruN 1.18.2-2/docs/conf.py 1.19.0-1/docs/conf.py
--- 1.18.2-2/docs/conf.py	2025-08-16 11:34:29.000000000 +0000
+++ 1.19.0-1/docs/conf.py	2025-10-23 11:50:22.000000000 +0000
@@ -23,7 +23,7 @@ copyright = '2010-2025 The pygit2 contri
 # author = ''
 
 # The full version, including alpha/beta/rc tags
-release = '1.18.2'
+release = '1.19.0'
 
 
 # -- General configuration ---------------------------------------------------
diff -pruN 1.18.2-2/docs/development.rst 1.19.0-1/docs/development.rst
--- 1.18.2-2/docs/development.rst	2025-08-16 11:34:29.000000000 +0000
+++ 1.19.0-1/docs/development.rst	2025-10-23 11:50:22.000000000 +0000
@@ -5,8 +5,8 @@ The development version
 .. image:: https://github.com/libgit2/pygit2/actions/workflows/tests.yml/badge.svg
    :target: https://github.com/libgit2/pygit2/actions/workflows/tests.yml
 
-.. image:: https://ci.appveyor.com/api/projects/status/edmwc0dctk5nacx0/branch/master?svg=true
-   :target: https://ci.appveyor.com/project/jdavid/pygit2/branch/master
+.. image:: https://github.com/libgit2/pygit2/actions/workflows/wheels.yml/badge.svg
+   :target: https://github.com/libgit2/pygit2/actions/workflows/wheels.yml
 
 .. contents:: Contents
    :local:
diff -pruN 1.18.2-2/docs/index.rst 1.19.0-1/docs/index.rst
--- 1.18.2-2/docs/index.rst	2025-08-16 11:34:29.000000000 +0000
+++ 1.19.0-1/docs/index.rst	2025-10-23 11:50:22.000000000 +0000
@@ -3,7 +3,7 @@ pygit2 - libgit2 bindings in Python
 ######################################################################
 
 Bindings to the libgit2 shared library, implements Git plumbing.
-Supports Python 3.10 to 3.13 and PyPy3 7.3+
+Supports Python 3.11 to 3.14 and PyPy3 7.3+
 
 Links
 =====================================
@@ -74,6 +74,7 @@ Table of Contents
    oid
    packing
    references
+   transactions
    remotes
    repository
    revparse
diff -pruN 1.18.2-2/docs/install.rst 1.19.0-1/docs/install.rst
--- 1.18.2-2/docs/install.rst	2025-08-16 11:34:29.000000000 +0000
+++ 1.19.0-1/docs/install.rst	2025-10-23 11:50:22.000000000 +0000
@@ -2,10 +2,6 @@
 Installation
 **********************************************************************
 
-.. |lq| unicode:: U+00AB
-.. |rq| unicode:: U+00BB
-
-
 .. contents:: Contents
    :local:
 
@@ -17,8 +13,8 @@ Install pygit2:
 
 .. code-block:: sh
 
-   $ pip install -U pip
-   $ pip install pygit2
+   pip install -U pip
+   pip install pygit2
 
 The line above will install binary wheels if available in your platform.
 
@@ -33,12 +29,12 @@ If you get the error::
     fatal error: git2.h: No such file or directory
 
 It means that pip did not find a binary wheel for your platform, so it tried to
-build from source, but it failed because it could not find the libgit2 headers.
+build from source. It failed to build because it could not find the libgit2 headers.
 Then:
 
 - Verify pip is updated
 - Verify there is a binary wheel of pygit2 for your platform
-- Otherwise install from the source distribution
+- Otherwise `install from the source distribution`_
 
 Caveats:
 
@@ -50,20 +46,20 @@ Requirements
 
 Supported versions of Python:
 
-- Python 3.10 to 3.13
+- Python 3.11 to 3.14
 - PyPy3 7.3+
 
 Python requirements (these are specified in ``setup.py``):
 
-- cffi 1.17.0 or later
+- cffi 2.0 or later
 
 Libgit2 **v1.9.x**; binary wheels already include libgit2, so you only need to
-worry about this if you install the source package.
+worry about this if you `install from the source distribution`_.
 
 Optional libgit2 dependencies to support ssh and https:
 
 - https: WinHTTP (Windows), SecureTransport (OS X) or OpenSSL.
-- ssh: libssh2 1.9.0 or later, pkg-config
+- ssh: libssh2 1.9.1 or later, pkg-config
 
 To run the tests:
 
@@ -72,8 +68,9 @@ To run the tests:
 Version numbers
 ===============
 
-The version number of pygit2 is composed of three numbers separated by dots
-|lq| *major.medium.minor* |rq|:
+The version number of pygit2 is composed of three numbers separated by dots::
+
+   <major>.<medium>.<minor>
 
 - *major* will always be 1 (until we release 2.0 in a far undefined future)
 - *medium* will increase whenever we make breaking changes, or upgrade to new
@@ -86,6 +83,8 @@ of Python and the required libgit2 versi
 +-------------+----------------+------------+
 | pygit2      | Python         | libgit2    |
 +-------------+----------------+------------+
+| 1.19        | 3.11 - 3.14(t) | 1.9        |
++-------------+----------------+------------+
 | 1.17 - 1.18 | 3.10 - 3.13    | 1.9        |
 +-------------+----------------+------------+
 | 1.16        | 3.10 - 3.13    | 1.8        |
@@ -128,6 +127,10 @@ of Python and the required libgit2 versi
    the release notes for incompatible changes before upgrading to a new
    release.
 
+.. warning::
+
+   Threaded builds are experimental, do not use them in production.
+
 History: the 0.x series
 -----------------------
 
@@ -139,33 +142,41 @@ lockstep with libgit2, e.g. pygit2 0.28.
 Advanced
 ===========================
 
+.. _install from the source distribution:
+
 Install libgit2 from source
 ---------------------------
 
+Installing from source requires
+
+* a C compiler (such as gcc)
+* the CPython API headers (typically in an ``apt`` package named ``python3-dev``)
+
 To install the latest version of libgit2 system wide, in the ``/usr/local``
 directory, do:
 
 .. code-block:: sh
+   :caption: On Linux using bash
 
-   $ wget https://github.com/libgit2/libgit2/archive/refs/tags/v1.9.0.tar.gz -O libgit2-1.9.0.tar.gz
-   $ tar xzf libgit2-1.9.0.tar.gz
-   $ cd libgit2-1.9.0/
-   $ cmake .
-   $ make
-   $ sudo make install
+   wget https://github.com/libgit2/libgit2/archive/refs/tags/v1.9.1.tar.gz -O libgit2-1.9.1.tar.gz
+   tar -xzf libgit2-1.9.1.tar.gz
+   cd libgit2-1.9.1/
+   cmake .
+   make
+   sudo make install
 
 .. seealso::
 
    For detailed instructions on building libgit2 check
-   https://libgit2.github.com/docs/guides/build-and-link/
+   https://libgit2.org/docs/guides/build-and-link/
 
 Now install pygit2, and then verify it is correctly installed:
 
 .. code-block:: sh
 
-   $ pip install pygit2
-   ...
-   $ python -c 'import pygit2'
+   pip install pygit2
+   # ...
+   python -c 'import pygit2'
 
 
 Troubleshooting
@@ -174,9 +185,9 @@ Troubleshooting
 The verification step may fail if the dynamic linker does not find the libgit2
 library:
 
-.. code-block:: sh
+.. code-block:: text
 
-   $ python -c 'import pygit2'
+   python -c 'import pygit2'
    Traceback (most recent call last):
      File "<string>", line 1, in <module>
      File "pygit2/__init__.py", line 29, in <module>
@@ -188,9 +199,10 @@ the ``/usr/local/lib`` directory, but th
 fix this call ``ldconfig``:
 
 .. code-block:: sh
+   :caption: On Linux using bash
 
-   $ sudo ldconfig
-   $ python -c 'import pygit2'
+   sudo ldconfig
+   python -c 'import pygit2'
 
 If it still does not work, please open an issue at
 https://github.com/libgit2/pygit2/issues
@@ -222,29 +234,32 @@ Create the virtualenv, activate it, and
 variable:
 
 .. code-block:: sh
+   :caption: On Linux using bash
 
-   $ virtualenv venv
-   $ source venv/bin/activate
-   $ export LIBGIT2=$VIRTUAL_ENV
+   virtualenv venv
+   source venv/bin/activate
+   export LIBGIT2=$VIRTUAL_ENV
 
 Install libgit2 (see we define the installation prefix):
 
 .. code-block:: sh
+   :caption: On Linux using bash
 
-   $ wget https://github.com/libgit2/libgit2/archive/refs/tags/v1.9.0.tar.gz -O libgit2-1.9.0.tar.gz
-   $ tar xzf libgit2-1.9.0.tar.gz
-   $ cd libgit2-1.9.0/
-   $ cmake . -DCMAKE_INSTALL_PREFIX=$LIBGIT2
-   $ cmake --build . --target install
+   wget https://github.com/libgit2/libgit2/archive/refs/tags/v1.9.1.tar.gz -O libgit2-1.9.1.tar.gz
+   tar xzf libgit2-1.9.1.tar.gz
+   cd libgit2-1.9.1/
+   cmake . -DCMAKE_INSTALL_PREFIX=$LIBGIT2
+   cmake --build . --target install
 
 Install pygit2:
 
 .. code-block:: sh
+   :caption: On Linux using bash
 
-   $ export LDFLAGS="-Wl,-rpath,'$LIBGIT2/lib',--enable-new-dtags $LDFLAGS"
+   export LDFLAGS="-Wl,-rpath,'$LIBGIT2/lib',--enable-new-dtags $LDFLAGS"
    # on OSX: export LDFLAGS="-Wl,-rpath,'$LIBGIT2/lib' $LDFLAGS"
-   $ pip install pygit2
-   $ python -c 'import pygit2'
+   pip install pygit2
+   python -c 'import pygit2'
 
 
 The run-path
@@ -258,9 +273,10 @@ this time.
 So you need to either set ``LD_LIBRARY_PATH`` before using pygit2, like:
 
 .. code-block:: sh
+   :caption: On Linux using bash
 
-   $ export LD_LIBRARY_PATH=$LIBGIT2/lib
-   $ python -c 'import pygit2'
+   export LD_LIBRARY_PATH=$LIBGIT2/lib
+   python -c 'import pygit2'
 
 Or, like we have done in the instructions above, use the `rpath
 <http://en.wikipedia.org/wiki/Rpath>`_, it hard-codes extra search paths within
@@ -268,33 +284,38 @@ the pygit2 extension modules, so you don
 every time. Verify yourself if curious:
 
 .. code-block:: sh
+   :caption: On Linux using bash
 
-   $ readelf --dynamic lib/python2.7/site-packages/pygit2-0.27.0-py2.7-linux-x86_64.egg/pygit2/_pygit2.so | grep PATH
+   readelf --dynamic lib/python2.7/site-packages/pygit2-0.27.0-py2.7-linux-x86_64.egg/pygit2/_pygit2.so | grep PATH
     0x000000000000001d (RUNPATH)            Library runpath: [/tmp/venv/lib]
 
 
 Installing on Windows
 ===================================
 
-`pygit2` for Windows is packaged into wheels and can be easily installed with
-`pip`:
+``pygit2`` for Windows is packaged into wheels and can be easily installed with
+``pip``:
 
 .. code-block:: console
 
    pip install pygit2
 
-For development it is also possible to build `pygit2` with `libgit2` from
-sources. `libgit2` location is specified by the ``LIBGIT2`` environment
-variable.  The following recipe shows you how to do it from a bash shell:
-
-.. code-block:: sh
+For development it is also possible to build ``pygit2`` with ``libgit2`` from
+sources. ``libgit2`` location is specified by the ``LIBGIT2`` environment
+variable.  The following recipe shows you how to do it:
+
+.. code-block:: pwsh
+   :caption: On Windows using PowerShell (and CMake v3.21 or newer)
+
+   git clone --depth=1 -b v1.9.1 https://github.com/libgit2/libgit2.git
+   $env:CMAKE_INSTALL_PREFIX = "C:/Dev/libgit2"
+   $env:CMAKE_GENERATOR = "Visual Studio 17 2022"
+   $env:CMAKE_GENERATOR_PLATFORM = "x64" # or "Win32" or "ARM64"
+   cmake -B libgit2/build -S libgit2
+   cmake --build libgit2/build --config release --target install
 
-   $ export LIBGIT2=C:/Dev/libgit2
-   $ git clone --depth=1 -b v1.9.0 https://github.com/libgit2/libgit2.git
-   $ cd libgit2
-   $ cmake . -DCMAKE_INSTALL_PREFIX=$LIBGIT2 -G "Visual Studio 14 Win64"
-   $ cmake --build . --config release --target install
-   $ ctest -v
+   # let pip know where to find libgit2 when building pygit2
+   $env:LIBGIT2 = "$env:CMAKE_INSTALL_PREFIX"
 
 At this point, you're ready to execute the generic `pygit2` installation steps
 described at the start of this page.
@@ -321,8 +342,8 @@ XCode and Homebrew are already installed
 
 .. code-block:: sh
 
-   $ brew update
-   $ brew install libgit2
-   $ pip3 install pygit2
+   brew update
+   brew install libgit2
+   pip3 install pygit2
 
 To build from a non-Homebrew libgit2 follow the guide in `libgit2 within a virtual environment`_.
diff -pruN 1.18.2-2/docs/references.rst 1.19.0-1/docs/references.rst
--- 1.18.2-2/docs/references.rst	2025-08-16 11:34:29.000000000 +0000
+++ 1.19.0-1/docs/references.rst	2025-10-23 11:50:22.000000000 +0000
@@ -88,6 +88,21 @@ Example::
 .. autoclass:: pygit2.RefLogEntry
    :members:
 
+Reference Transactions
+=======================
+
+For atomic updates of multiple references, use transactions. See the
+:doc:`transactions` documentation for details.
+
+Example::
+
+    # Update multiple refs atomically
+    with repo.transaction() as txn:
+        txn.lock_ref('refs/heads/master')
+        txn.lock_ref('refs/heads/develop')
+        txn.set_target('refs/heads/master', new_oid, message='Release')
+        txn.set_target('refs/heads/develop', dev_oid, message='Continue dev')
+
 Notes
 ====================
 
diff -pruN 1.18.2-2/docs/requirements.txt 1.19.0-1/docs/requirements.txt
--- 1.18.2-2/docs/requirements.txt	1970-01-01 00:00:00.000000000 +0000
+++ 1.19.0-1/docs/requirements.txt	2025-10-23 11:50:22.000000000 +0000
@@ -0,0 +1,2 @@
+sphinx
+sphinx-rtd-theme
diff -pruN 1.18.2-2/docs/transactions.rst 1.19.0-1/docs/transactions.rst
--- 1.18.2-2/docs/transactions.rst	1970-01-01 00:00:00.000000000 +0000
+++ 1.19.0-1/docs/transactions.rst	2025-10-23 11:50:22.000000000 +0000
@@ -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 -pruN 1.18.2-2/pygit2/__init__.py 1.19.0-1/pygit2/__init__.py
--- 1.18.2-2/pygit2/__init__.py	2025-08-16 11:34:29.000000000 +0000
+++ 1.19.0-1/pygit2/__init__.py	2025-10-23 11:50:22.000000000 +0000
@@ -366,6 +366,7 @@ from .remotes import Remote
 from .repository import Repository
 from .settings import Settings
 from .submodules import Submodule
+from .transaction import ReferenceTransaction
 from .utils import to_bytes, to_str
 
 # Features
@@ -971,6 +972,8 @@ __all__ = (
     'Settings',
     'submodules',
     'Submodule',
+    'transaction',
+    'ReferenceTransaction',
     'utils',
     'to_bytes',
     'to_str',
diff -pruN 1.18.2-2/pygit2/_build.py 1.19.0-1/pygit2/_build.py
--- 1.18.2-2/pygit2/_build.py	2025-08-16 11:34:29.000000000 +0000
+++ 1.19.0-1/pygit2/_build.py	2025-10-23 11:50:22.000000000 +0000
@@ -34,7 +34,7 @@ from pathlib import Path
 #
 # The version number of pygit2
 #
-__version__ = '1.18.2'
+__version__ = '1.19.0'
 
 
 #
diff -pruN 1.18.2-2/pygit2/_libgit2/ffi.pyi 1.19.0-1/pygit2/_libgit2/ffi.pyi
--- 1.18.2-2/pygit2/_libgit2/ffi.pyi	2025-08-16 11:34:29.000000000 +0000
+++ 1.19.0-1/pygit2/_libgit2/ffi.pyi	2025-10-23 11:50:22.000000000 +0000
@@ -239,12 +239,17 @@ class GitRemoteC:
 class GitReferenceC:
     pass
 
+class GitTransactionC:
+    pass
+
 def string(a: char_pointer) -> bytes: ...
 @overload
 def new(a: Literal['git_repository **']) -> _Pointer[GitRepositoryC]: ...
 @overload
 def new(a: Literal['git_remote **']) -> _Pointer[GitRemoteC]: ...
 @overload
+def new(a: Literal['git_transaction **']) -> _Pointer[GitTransactionC]: ...
+@overload
 def new(a: Literal['git_repository_init_options *']) -> GitRepositoryInitOptionsC: ...
 @overload
 def new(a: Literal['git_submodule_update_options *']) -> GitSubmoduleUpdateOptionsC: ...
diff -pruN 1.18.2-2/pygit2/_run.py 1.19.0-1/pygit2/_run.py
--- 1.18.2-2/pygit2/_run.py	2025-08-16 11:34:29.000000000 +0000
+++ 1.19.0-1/pygit2/_run.py	2025-10-23 11:50:22.000000000 +0000
@@ -81,6 +81,7 @@ h_files = [
     'revert.h',
     'stash.h',
     'submodule.h',
+    'transaction.h',
     'options.h',
     'callbacks.h',  # Bridge from libgit2 to Python
 ]
diff -pruN 1.18.2-2/pygit2/decl/transaction.h 1.19.0-1/pygit2/decl/transaction.h
--- 1.18.2-2/pygit2/decl/transaction.h	1970-01-01 00:00:00.000000000 +0000
+++ 1.19.0-1/pygit2/decl/transaction.h	2025-10-23 11:50:22.000000000 +0000
@@ -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 -pruN 1.18.2-2/pygit2/decl/types.h 1.19.0-1/pygit2/decl/types.h
--- 1.18.2-2/pygit2/decl/types.h	2025-08-16 11:34:29.000000000 +0000
+++ 1.19.0-1/pygit2/decl/types.h	2025-10-23 11:50:22.000000000 +0000
@@ -12,6 +12,8 @@ typedef struct git_submodule git_submodu
 typedef struct git_transport git_transport;
 typedef struct git_tree git_tree;
 typedef struct git_packbuilder git_packbuilder;
+typedef struct git_transaction git_transaction;
+typedef struct git_reflog git_reflog;
 
 typedef int64_t git_off_t;
 typedef int64_t git_time_t;
diff -pruN 1.18.2-2/pygit2/index.py 1.19.0-1/pygit2/index.py
--- 1.18.2-2/pygit2/index.py	2025-08-16 11:34:29.000000000 +0000
+++ 1.19.0-1/pygit2/index.py	2025-10-23 11:50:22.000000000 +0000
@@ -446,15 +446,9 @@ class IndexEntry:
 
     @property
     def oid(self):
-        # For backwards compatibility
+        warnings.warn('Use entry.id', DeprecationWarning)
         return self.id
 
-    @property
-    def hex(self):
-        """The id of the referenced object as a hex string"""
-        warnings.warn('Use str(entry.id)', DeprecationWarning)
-        return str(self.id)
-
     def __str__(self):
         return f'<path={self.path} id={self.id} mode={self.mode}>'
 
diff -pruN 1.18.2-2/pygit2/repository.py 1.19.0-1/pygit2/repository.py
--- 1.18.2-2/pygit2/repository.py	2025-08-16 11:34:29.000000000 +0000
+++ 1.19.0-1/pygit2/repository.py	2025-10-23 11:50:22.000000000 +0000
@@ -78,6 +78,7 @@ 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:
@@ -120,6 +121,7 @@ class BaseRepository(_Repository):
         self.references = References(self)
         self.remotes = RemoteCollection(self)
         self.submodules = SubmoduleCollection(self)
+        self._active_transaction = None
 
         # Get the pointer as the contents of a buffer and store it for
         # later access
@@ -359,6 +361,22 @@ class BaseRepository(_Repository):
 
         return (commit, reference)  # type: ignore
 
+    def transaction(self) -> ReferenceTransaction:
+        """Create a new reference transaction.
+
+        Returns a context manager that commits all reference updates atomically
+        when the context exits successfully, or performs no updates if an exception
+        is raised.
+
+        Example::
+
+            with repo.transaction() as txn:
+                txn.lock_ref('refs/heads/master')
+                txn.set_target('refs/heads/master', new_oid, message='Update')
+        """
+        txn = ReferenceTransaction(self)
+        return txn
+
     #
     # Checkout
     #
diff -pruN 1.18.2-2/pygit2/transaction.py 1.19.0-1/pygit2/transaction.py
--- 1.18.2-2/pygit2/transaction.py	1970-01-01 00:00:00.000000000 +0000
+++ 1.19.0-1/pygit2/transaction.py	2025-10-23 11:50:22.000000000 +0000
@@ -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 -pruN 1.18.2-2/pyproject.toml 1.19.0-1/pyproject.toml
--- 1.18.2-2/pyproject.toml	2025-08-16 11:34:29.000000000 +0000
+++ 1.19.0-1/pyproject.toml	2025-10-23 11:50:22.000000000 +0000
@@ -3,7 +3,7 @@ requires = ["setuptools", "wheel"]
 
 [tool.cibuildwheel]
 enable = ["pypy"]
-skip = "*musllinux_aarch64 *musllinux_ppc64le cp314*"
+skip = "*musllinux_ppc64le"
 
 archs = ["native"]
 build-frontend = "default"
@@ -11,6 +11,11 @@ dependency-versions = "pinned"
 environment = {LIBGIT2_VERSION="1.9.1", LIBSSH2_VERSION="1.11.1", OPENSSL_VERSION="3.3.3", LIBGIT2="/project/ci"}
 
 before-all = "sh build.sh"
+test-command = "pytest"
+test-sources = ["test", "pytest.ini"]
+before-test = "pip install -r {project}/requirements-test.txt"
+# Will avoid testing on emulated architectures (specifically ppc64le)
+test-skip = "*-*linux_ppc64le"
 
 [tool.cibuildwheel.linux]
 repair-wheel-command = "LD_LIBRARY_PATH=/project/ci/lib64 auditwheel repair -w {dest_dir} {wheel}"
@@ -24,9 +29,38 @@ 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
+target-version = "py311"  # oldest supported Python version
 
 [tool.ruff.lint]
 select = ["E4", "E7", "E9", "F", "I", "UP035", "UP007"]
diff -pruN 1.18.2-2/requirements.txt 1.19.0-1/requirements.txt
--- 1.18.2-2/requirements.txt	2025-08-16 11:34:29.000000000 +0000
+++ 1.19.0-1/requirements.txt	2025-10-23 11:50:22.000000000 +0000
@@ -1,2 +1,2 @@
-cffi>=1.16.0
+cffi>=2.0
 setuptools ; python_version >= "3.12"
diff -pruN 1.18.2-2/setup.py 1.19.0-1/setup.py
--- 1.18.2-2/setup.py	2025-08-16 11:34:29.000000000 +0000
+++ 1.19.0-1/setup.py	2025-10-23 11:50:22.000000000 +0000
@@ -57,7 +57,7 @@ class sdist_files_from_git(sdist):
             sys.exit()
 
         def exclude(line: str) -> bool:
-            for prefix in ['.', 'appveyor.yml', 'docs/', 'misc/']:
+            for prefix in ['.', 'docs/', 'misc/']:
                 if line.startswith(prefix):
                     return True
             return False
@@ -77,10 +77,10 @@ classifiers = [
     'Intended Audience :: Developers',
     'Programming Language :: Python',
     'Programming Language :: Python :: 3',
-    'Programming Language :: Python :: 3.10',
     'Programming Language :: Python :: 3.11',
     'Programming Language :: Python :: 3.12',
     'Programming Language :: Python :: 3.13',
+    'Programming Language :: Python :: 3.14',
     'Programming Language :: Python :: Implementation :: PyPy',
     'Programming Language :: Python :: Implementation :: CPython',
     'Topic :: Software Development :: Version Control',
@@ -153,9 +153,9 @@ setup(
     cffi_modules=['pygit2/_run.py:ffi'],
     ext_modules=ext_modules,
     # Requirements
-    python_requires='>=3.10',
-    setup_requires=['cffi>=1.17.0'],
-    install_requires=['cffi>=1.17.0'],
+    python_requires='>=3.11',
+    setup_requires=['cffi>=2.0'],
+    install_requires=['cffi>=2.0'],
     # URLs
     url='https://github.com/libgit2/pygit2',
     project_urls={
diff -pruN 1.18.2-2/src/pygit2.c 1.19.0-1/src/pygit2.c
--- 1.18.2-2/src/pygit2.c	2025-08-16 11:34:29.000000000 +0000
+++ 1.19.0-1/src/pygit2.c	2025-10-23 11:50:22.000000000 +0000
@@ -465,6 +465,10 @@ PyInit__pygit2(void)
     if (m == NULL)
         return NULL;
 
+#ifdef Py_GIL_DISABLED
+    PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED);
+#endif
+
     /* libgit2 version info */
     ADD_CONSTANT_INT(m, LIBGIT2_VER_MAJOR)
     ADD_CONSTANT_INT(m, LIBGIT2_VER_MINOR)
diff -pruN 1.18.2-2/src/repository.c 1.19.0-1/src/repository.c
--- 1.18.2-2/src/repository.c	2025-08-16 11:34:29.000000000 +0000
+++ 1.19.0-1/src/repository.c	2025-10-23 11:50:22.000000000 +0000
@@ -46,6 +46,17 @@
 #include <git2/odb_backend.h>
 #include <git2/sys/repository.h>
 
+// TODO: remove this function when Python 3.13 becomes the minimum supported version
+#if PY_MAJOR_VERSION == 3 && PY_MINOR_VERSION < 13
+static inline PyObject *
+PyList_GetItemRef(PyObject *op, Py_ssize_t index)
+{
+    PyObject *item = PyList_GetItem(op, index);
+    Py_XINCREF(item);
+    return item;
+}
+#endif
+
 extern PyObject *GitError;
 
 extern PyTypeObject IndexType;
@@ -599,8 +610,11 @@ merge_base_xxx(Repository *self, PyObjec
     }
 
     for (; i < commit_oid_count; i++) {
-        py_commit_oid = PyList_GET_ITEM(py_commit_oids, i);
+        py_commit_oid = PyList_GetItemRef(py_commit_oids, i);
+        if (py_commit_oid == NULL)
+            goto out;
         err = py_oid_to_git_oid_expand(self->repo, py_commit_oid, &commit_oids[i]);
+        Py_DECREF(py_commit_oid);
         if (err < 0)
             goto out;
     }
@@ -1052,8 +1066,11 @@ Repository_create_commit(Repository *sel
         goto out;
     }
     for (; i < parent_count; i++) {
-        py_parent = PyList_GET_ITEM(py_parents, i);
+        py_parent = PyList_GetItemRef(py_parents, i);
+        if (py_parent == NULL)
+            goto out;
         len = py_oid_to_git_oid(py_parent, &oid);
+        Py_DECREF(py_parent);
         if (len == 0)
             goto out;
         err = git_commit_lookup_prefix(&parents[i], self->repo, &oid, len);
@@ -1135,8 +1152,11 @@ Repository_create_commit_string(Reposito
         goto out;
     }
     for (; i < parent_count; i++) {
-        py_parent = PyList_GET_ITEM(py_parents, i);
+        py_parent = PyList_GetItemRef(py_parents, i);
+        if (py_parent == NULL)
+            goto out;
         len = py_oid_to_git_oid(py_parent, &oid);
+        Py_DECREF(py_parent);
         if (len == 0)
             goto out;
         err = git_commit_lookup_prefix(&parents[i], self->repo, &oid, len);
diff -pruN 1.18.2-2/test/__init__.py 1.19.0-1/test/__init__.py
--- 1.18.2-2/test/__init__.py	2025-08-16 11:34:29.000000000 +0000
+++ 1.19.0-1/test/__init__.py	2025-10-23 11:50:22.000000000 +0000
@@ -30,5 +30,6 @@ import os
 import sys
 
 cwd = os.getcwd()
-sys.path.remove(cwd)
-sys.path.append(cwd)
+if cwd in sys.path:
+    sys.path.remove(cwd)
+    sys.path.append(cwd)
diff -pruN 1.18.2-2/test/conftest.py 1.19.0-1/test/conftest.py
--- 1.18.2-2/test/conftest.py	2025-08-16 11:34:29.000000000 +0000
+++ 1.19.0-1/test/conftest.py	2025-10-23 11:50:22.000000000 +0000
@@ -1,4 +1,3 @@
-import platform
 from collections.abc import Generator
 from pathlib import Path
 
@@ -22,10 +21,6 @@ def global_git_config() -> None:
     for level in levels:
         pygit2.settings.search_path[level] = ''
 
-    # Fix tests running in AppVeyor
-    if platform.system() == 'Windows':
-        pygit2.option(pygit2.enums.Option.SET_OWNER_VALIDATION, False)
-
 
 @pytest.fixture
 def pygit2_empty_key() -> tuple[Path, str, str]:
diff -pruN 1.18.2-2/test/test_refs.py 1.19.0-1/test/test_refs.py
--- 1.18.2-2/test/test_refs.py	2025-08-16 11:34:29.000000000 +0000
+++ 1.19.0-1/test/test_refs.py	2025-10-23 11:50:22.000000000 +0000
@@ -595,7 +595,10 @@ def test_set_target_with_message(testrep
     assert reference.raw_target == b'refs/heads/i18n'
     first = list(reference.log())[0]
     assert first.message == msg
-    assert first.committer == sig
+    # Signature.time and Signature.encoding may not be equal.
+    # Here we only care that the name and email are correctly set.
+    assert first.committer.name == sig.name
+    assert first.committer.email == sig.email
 
 
 def test_delete(testrepo: Repository) -> None:
diff -pruN 1.18.2-2/test/test_transaction.py 1.19.0-1/test/test_transaction.py
--- 1.18.2-2/test/test_transaction.py	1970-01-01 00:00:00.000000000 +0000
+++ 1.19.0-1/test/test_transaction.py	2025-10-23 11:50:22.000000000 +0000
@@ -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
