From 73d6632490e0d57873d62048b67864b2b4db1e3e Mon Sep 17 00:00:00 2001 From: Codex GPT-5 Date: Thu, 1 Oct 2026 14:45:37 +0200 Subject: [PATCH 01/14] feat!: use guarded Git commands for repository storage GitPython's Python implementations of repository storage couple its behavior to on-disk formats and object-ID widths. Delegate repository discovery, configuration, references, reflogs, object storage, tree construction, index operations, and revision parsing to `git.cmd.Git` so the default backend works with SHA-1/SHA-256 objects and files/reftable references. Require Git 2.52 or newer, retain the deprecated `GitDB` as an explicit choice, and preserve raw `repo.git` access. Use existing unsafe option/protocol primitives together with operand validation, NUL-framed records, and protected option ordering. Suppress implicit hooks, filesystem monitors, maintenance, lazy fetching, external diffs, and default text conversion in managed plumbing. Explicit commit hooks remain supported; signing and custom archive commands require opt-in. Preserve the established clean/smudge-filter behavior of existing worktree operations. Keep common workflows and map recognizable native failures to established exceptions. Remove or restrict low-level binary index/tree, raw reflog, precompressed object, and direct storage mutation APIs that cannot be exposed faithfully through Git. Preserve semantic index edits through private native indexes, discover worktree storage through Git, and reconnect retained submodule metadata without assuming its object or reference backend. Document the API and CLI changes in `doc/source/changes.rst`, add format and injection coverage, update minimum-Git CI and fuzz tooling, and bound the existing throughput benchmarks for subprocess-based operations. This work is planned for GitPython 3.4 some weeks before Git 3's official release, allowing users to test this branch and report compatibility feedback beforehand. Validation: the full pytest run produced 1,328 passes, 79 skips, one expected failure, and three submodule failures that were resolved and passed reruns. A fresh submodule/offline run had 224 passes; its two metadata-alias failures were fixed and retested, followed by eight passing retained-metadata checks. The Git 2.52 matrix passed 127 tests, plus four later quoted-branch checks. Ruff, mypy, basedpyright, Sphinx with warnings as errors, Python 3.8 package smoke tests, and deterministic fuzz-harness/version-guard checks pass. The updated fuzzing Docker image was not built and no long fuzz campaign was run. --- .basedpyright/baseline.json | 144 -- .github/workflows/git-cli.yml | 42 + .github/workflows/pythonpackage.yml | 13 + README.md | 4 +- doc/source/changes.rst | 92 ++ doc/source/intro.rst | 8 +- doc/source/tutorial.rst | 12 +- fuzzing/README.md | 6 + fuzzing/fuzz-targets/fuzz_blob.py | 9 +- fuzzing/fuzz-targets/fuzz_config.py | 4 + fuzzing/fuzz-targets/fuzz_diff.py | 11 +- fuzzing/fuzz-targets/fuzz_repo.py | 20 +- fuzzing/local-dev-helpers/Dockerfile | 5 +- fuzzing/oss-fuzz-scripts/build.sh | 14 +- .../container-environment-bootstrap.sh | 13 +- git/__init__.py | 2 +- git/cmd.py | 152 +- git/config.py | 1186 +++----------- git/db.py | 130 +- git/diff.py | 25 +- git/index/base.py | 430 +++-- git/index/fun.py | 626 +------- git/index/typ.py | 82 +- git/index/util.py | 6 - git/objects/base.py | 29 +- git/objects/commit.py | 181 ++- git/objects/fun.py | 266 +-- git/objects/submodule/base.py | 333 ++-- git/objects/submodule/root.py | 13 +- git/objects/submodule/util.py | 7 +- git/objects/tag.py | 2 +- git/objects/tree.py | 60 +- git/refs/head.py | 31 +- git/refs/log.py | 450 +----- git/refs/reference.py | 32 +- git/refs/remote.py | 18 +- git/refs/symbolic.py | 641 +++----- git/refs/tag.py | 31 +- git/remote.py | 354 ++-- git/repo/base.py | 474 +++--- git/repo/fun.py | 700 +------- git/util.py | 59 +- test/lib/helper.py | 7 +- test/performance/test_commit.py | 4 +- test/performance/test_odb.py | 5 +- test/test_blob_filter.py | 5 +- test/test_cli_objects_index.py | 177 ++ test/test_cli_safety.py | 144 ++ test/test_clone.py | 25 +- test/test_command_guards.py | 7 +- test/test_commit.py | 31 +- test/test_config.py | 1430 ++--------------- test/test_fun.py | 180 +-- test/test_git.py | 7 + test/test_index.py | 461 +----- test/test_positional_args.py | 34 +- test/test_reflog.py | 242 +-- test/test_refs.py | 39 +- test/test_remote.py | 103 +- test/test_remote_cli.py | 64 + test/test_repo.py | 141 +- test/test_rev_parse.py | 63 +- test/test_submodule.py | 206 ++- test/test_submodule_no_fetch.py | 3 + test/test_tree.py | 23 +- test/test_typing.py | 9 +- 66 files changed, 3332 insertions(+), 6795 deletions(-) create mode 100644 .github/workflows/git-cli.yml create mode 100644 test/test_cli_objects_index.py create mode 100644 test/test_cli_safety.py create mode 100644 test/test_remote_cli.py diff --git a/.basedpyright/baseline.json b/.basedpyright/baseline.json index f51f7ce13..0616936cd 100644 --- a/.basedpyright/baseline.json +++ b/.basedpyright/baseline.json @@ -8,75 +8,9 @@ "endColumn": 45, "lineCount": 1 } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 41, - "endColumn": 48, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 32, - "endColumn": 39, - "lineCount": 1 - } - }, - { - "code": "reportCallIssue", - "range": { - "startColumn": 25, - "endColumn": 46, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 30, - "endColumn": 39, - "lineCount": 1 - } - } - ], - "./git/db.py": [ - { - "code": "reportIncompatibleMethodOverride", - "range": { - "startColumn": 8, - "endColumn": 12, - "lineCount": 1 - } - }, - { - "code": "reportIncompatibleMethodOverride", - "range": { - "startColumn": 8, - "endColumn": 14, - "lineCount": 1 - } } ], "./git/index/base.py": [ - { - "code": "reportArgumentType", - "range": { - "startColumn": 30, - "endColumn": 36, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 28, - "endColumn": 34, - "lineCount": 1 - } - }, { "code": "reportArgumentType", "range": { @@ -186,14 +120,6 @@ "endColumn": 25, "lineCount": 1 } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 52, - "endColumn": 84, - "lineCount": 1 - } } ], "./git/objects/tag.py": [ @@ -274,32 +200,6 @@ } } ], - "./git/refs/log.py": [ - { - "code": "reportArgumentType", - "range": { - "startColumn": 30, - "endColumn": 34, - "lineCount": 1 - } - }, - { - "code": "reportAttributeAccessIssue", - "range": { - "startColumn": 17, - "endColumn": 22, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 28, - "endColumn": 30, - "lineCount": 1 - } - } - ], "./git/refs/reference.py": [ { "code": "reportIncompatibleVariableOverride", @@ -310,16 +210,6 @@ } } ], - "./git/refs/symbolic.py": [ - { - "code": "reportAttributeAccessIssue", - "range": { - "startColumn": 15, - "endColumn": 20, - "lineCount": 1 - } - } - ], "./git/refs/tag.py": [ { "code": "reportIncompatibleMethodOverride", @@ -339,22 +229,6 @@ } ], "./git/remote.py": [ - { - "code": "reportAttributeAccessIssue", - "range": { - "startColumn": 26, - "endColumn": 38, - "lineCount": 1 - } - }, - { - "code": "reportAttributeAccessIssue", - "range": { - "startColumn": 26, - "endColumn": 38, - "lineCount": 1 - } - }, { "code": "reportAttributeAccessIssue", "range": { @@ -373,14 +247,6 @@ } ], "./git/repo/base.py": [ - { - "code": "reportReturnType", - "range": { - "startColumn": 15, - "endColumn": 28, - "lineCount": 1 - } - }, { "code": "reportTypedDictNotRequiredAccess", "range": { @@ -446,16 +312,6 @@ } } ], - "./git/repo/fun.py": [ - { - "code": "reportReturnType", - "range": { - "startColumn": 11, - "endColumn": 20, - "lineCount": 1 - } - } - ], "./test/deprecation/test_basic.py": [ { "code": "reportUnusedExpression", diff --git a/.github/workflows/git-cli.yml b/.github/workflows/git-cli.yml new file mode 100644 index 000000000..5f94293dd --- /dev/null +++ b/.github/workflows/git-cli.yml @@ -0,0 +1,42 @@ +name: Minimum Git CLI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + git-2-52: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: git/git + ref: v2.52.0 + path: .git-source + - uses: actions/setup-python@v7 + with: + python-version: '3.12' + - name: Build minimum supported Git + working-directory: .git-source + run: | + make -j2 prefix="$RUNNER_TEMP/git" NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PERL=YesPlease NO_CURL=YesPlease install + echo "$RUNNER_TEMP/git/bin" >> "$GITHUB_PATH" + - name: Install GitPython and test dependencies + run: python -m pip install ./smmap ./gitdb '.[test]' + - name: Verify native format and safety contracts + env: + GIT_CONFIG_NOSYSTEM: '1' + GIT_CONFIG_GLOBAL: /dev/null + GIT_AUTHOR_NAME: GitPython Tests + GIT_AUTHOR_EMAIL: tests@example.invalid + GIT_COMMITTER_NAME: GitPython Tests + GIT_COMMITTER_EMAIL: tests@example.invalid + run: | + git version + python -m pytest --no-cov --tb=short test/test_cli_objects_index.py test/test_cli_safety.py test/test_reflog.py test/test_config.py test/test_remote_cli.py test/test_submodule.py::test_submodule_cli_lifecycle diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 3fbbe2ec6..1a152b033 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -58,6 +58,19 @@ jobs: with: wsl-version: 1 + - name: Install current Git (Linux) + if: matrix.os-type == 'ubuntu' + run: | + sudo add-apt-repository --yes ppa:git-core/ppa + sudo apt-get update + sudo apt-get install --yes git + + - name: Install current Git (macOS) + if: matrix.os-type == 'macos' + run: | + brew install git + echo "$(brew --prefix git)/bin" >> "$GITHUB_PATH" + - name: Prepare this repo for tests run: | ./init-tests-after-clone.sh diff --git a/README.md b/README.md index 029b4fa00..97c331b97 100644 --- a/README.md +++ b/README.md @@ -40,10 +40,10 @@ The project is open to contributions of all kinds, as well as new maintainers. ### REQUIREMENTS GitPython needs the `git` executable to be installed on the system and available in your -`PATH` for most operations. If it is not in your `PATH`, you can help GitPython find it +`PATH` for repository operations. If it is not in your `PATH`, you can help GitPython find it by setting the `GIT_PYTHON_GIT_EXECUTABLE=` environment variable. -- Git (1.7.x or newer) +- Git (2.52 or newer) - Python >= 3.8 The list of dependencies are listed in [`./requirements.txt`](https://github.com/gitpython-developers/GitPython/blob/main/requirements.txt) and [`./test-requirements.txt`](https://github.com/gitpython-developers/GitPython/blob/main/test-requirements.txt). diff --git a/doc/source/changes.rst b/doc/source/changes.rst index 2f537c99d..ea972682d 100644 --- a/doc/source/changes.rst +++ b/doc/source/changes.rst @@ -2,6 +2,98 @@ Changelog ========= +Unreleased: Git CLI migration +============================= + +GitPython now delegates repository discovery, references, configuration, object +storage, tree construction, index operations, and revision parsing to Git. The +default backend supports SHA-1 and SHA-256 repositories with either files or +reftable reference storage. Repository object IDs must no longer be assumed to +contain 20 binary bytes or 40 hexadecimal characters. +Legacy class-level ``NULL_BIN_SHA`` and ``NULL_HEX_SHA`` constants retain their +SHA-1 values; do not use their width to interpret repository object IDs. + +Git executable and safety +------------------------- + +* Git **2.52 or newer** is required for library-managed operations. Older versions + raise ``UnsupportedOperation`` before repository mutation; there is no Python + implementation fallback. ``GIT_PYTHON_GIT_EXECUTABLE`` still selects Git. +* Every managed command uses argument sequences without a shell. Existing unsafe + option/protocol checks remain, and operands and stdin records receive additional + validation. NUL-delimited ``cat-file`` requests prevent newline injection. + Previously accepted option-like revision/ref arguments can now be rejected. +* New plumbing calls suppress implicit hooks, filesystem monitors, automatic + maintenance, and lazy network fetches. Index staging continues to store raw + content rather than introduce clean filters. Diffs disable external diff and + text conversion programs; blame disables text conversion unless unsafe options + are explicitly enabled. Explicit commit hooks use ``git hook run`` and honor + ``skip_hooks``; Git combines their output, so ``HookExecutionError`` may carry + former stdout text in stderr. Trailer additions reject executable trailer + configuration instead of invoking it. +* Existing working-tree conversions retain Git's behavior: status and working-tree + diffs can run configured clean filters; checkout, clone, and archive can run + smudge filters. Archive defaults to Git's built-in formats and internal gzip; + custom format commands require ``allow_unsafe_options=True``. Tag creation + suppresses configured signing by default; signing, verification, and editor + options require the same opt-in. +* The raw ``repo.git`` interface remains available for direct Git commands. + Existing explicit unsafe-option and unsafe-protocol opt-ins remain independent. + They do not permit argument or stdin-record injection, or reordering command + options past the library's safety flags. +* Known command outcomes retain established GitPython/configparser exceptions + where distinguishable. Other failures raise ``GitCommandError`` with Git's + exit status and diagnostic output; exact error text may differ. + +API changes +----------- + +* ``GitCmdObjectDB`` no longer inherits ``LooseObjectDB``. Its object reads, + writes, existence checks, and enumeration use Git, including packed objects. + Precompressed input streams and custom object-output writers are unsupported. + The deprecated ``GitDB`` remains explicitly selectable with its existing + warning and limitations; ``gitdb`` remains a dependency for shared types. +* ``Repo.object_format`` and ``Repo.ref_format`` report Git's storage formats. + ``Repo.alternates`` is read-only and reports effective absolute alternate + directories, including environment and transitive alternates. Direct editing + of the alternates file through this property is removed. +* Index entries retain their mode, object ID, path, and stage. Raw stat fields, + arbitrary cache flags/extensions/checksums, ``IndexFileSHA1Writer``, and the + standalone binary index read/write/merge helpers are removed. Use + ``IndexFile.from_tree()``, ``IndexFile.new()``, ``write()`` and ``write_tree()``. + Git preserves untouched metadata when an existing index is edited. Temporary + indexes isolate tree/merge operations from the real index and working tree. + ``version`` is read-only. ``from_tree()`` accepts ``trivial``, ``aggressive``, + and ``verbose`` options; arbitrary ``read-tree`` keyword forwarding is removed. +* Standalone binary tree parsers, serializers, and multi-tree traversal helpers + are removed. Use ``Tree`` traversal/cache operations and the index interface. + Tree/commit serialization adapters that remain write their objects to the + repository to obtain Git-produced bytes. + Commit creation preserves message bytes through plain stdin. Injecting or + reusing arbitrary ``gpgsig`` headers is unsupported; modified signed commits + become unsigned. +* ``RefLog`` is associated with a reference, not a filesystem path. Keep using + ``ref.log()``, ``ref.log_entry()`` and ``ref.log_append()``. ``RefLogEntry`` now + contains ``newhexsha``, ``actor``, ``time`` and ``message``; ``oldhexsha`` and + the old tuple layout are removed. Reads follow Git's commit-reflog view, which + omits entries targeting non-commit or unavailable objects. Old IDs are never + inferred from adjacent entries. Raw reflog file/stream read, write, and rewrite + APIs are removed. Reference updates follow Git's reflog creation/update rules, + including updates when ``logmsg`` is omitted. +* ``GitConfigParser`` uses Git's configuration syntax and canonical key spelling, + and no longer subclasses ``configparser.RawConfigParser``. + Common getters, typed values, duplicate values, file/stream inputs and mutation + methods remain. Invalid Git syntax previously accepted by Python is rejected. + Empty-section creation and valueless writes are removed; valueless reads remain. + Relative includes from streams are rejected by Git. Locks cover each native + mutation rather than the writer object's lifetime. +* ``Submodule.rename()`` is removed. Moving a submodule preserves its logical name, + matching Git. Normal removal retains Git's recoverable submodule metadata. + Re-adding with ``no_checkout=True`` can reuse that metadata without changing refs; + incompatible URLs, branches, and clone-only options are rejected before mutation. + Fetch/pull results are derived from command output rather than ``FETCH_HEAD`` + file parsing. Revision strings follow Git's native revision grammar. + 3.2.0 ===== diff --git a/doc/source/intro.rst b/doc/source/intro.rst index dec2ec0fa..abf2d517a 100644 --- a/doc/source/intro.rst +++ b/doc/source/intro.rst @@ -6,7 +6,7 @@ Overview / Install GitPython is a python library used to interact with git repositories, high-level like git-porcelain, or low-level like git-plumbing. -It provides abstractions of git objects for easy access of repository data, and additionally allows you to access the git repository more directly using either a pure python implementation, or the faster, but more resource intensive git command implementation. +It provides Python objects for repository data and delegates Git operations to the Git executable. The default backend supports both SHA-1 and SHA-256 object IDs and both files and reftable reference storage. The legacy pure-Python GitDB backend remains available but is deprecated. The object database implementation is optimized for handling large quantities of objects and large datasets, which is achieved by using low-level structures and data streaming. @@ -14,10 +14,8 @@ Requirements ============ * `Python`_ >= 3.8 -* `Git`_ 1.7.0 or newer - It should also work with older versions, but it may be that some operations - involving remotes will not work as expected. -* `GitDB`_ - a pure python git database implementation +* `Git`_ 2.52 or newer +* `GitDB`_ - shared data types and the deprecated legacy object database * `typing_extensions`_ >= 3.7.3.4 (if python < 3.10) .. _Python: https://www.python.org diff --git a/doc/source/tutorial.rst b/doc/source/tutorial.rst index a1ac1eace..34f6692b5 100644 --- a/doc/source/tutorial.rst +++ b/doc/source/tutorial.rst @@ -122,7 +122,7 @@ You can traverse down to :class:`git objects ` through :start-after: # [12-test_init_repo_object] :end-before: # ![12-test_init_repo_object] -The :class:`index ` is also called stage in git-speak. It is used to prepare new commits, and can be used to keep results of merge operations. Our index implementation allows to stream date into the index, which is useful for bare repositories that do not have a working tree. +The :class:`index ` is also called stage in git-speak. It is used to prepare new commits, and can be used to keep results of merge operations. Our index implementation allows to stream data into the index, which is useful for bare repositories that do not have a working tree. .. literalinclude:: ../../test/test_docs.py :language: python @@ -166,7 +166,7 @@ A :class:`symbolic reference ` can point to :start-after: # [3-test_references_and_objects] :end-before: # ![3-test_references_and_objects] -Access the :class:`reflog ` easily. +Access the :class:`reflog ` through a reference. Entries expose the new object ID, reflog actor, timestamp, and message in oldest-first order. They follow Git's commit-reflog view; exact old IDs and raw reflog-file access are unavailable. .. literalinclude:: ../../test/test_docs.py :language: python @@ -202,7 +202,7 @@ Change the :class:`symbolic reference ` to Understanding Objects ********************* -An Object is anything storable in git's object database. Objects contain information about their type, their uncompressed size as well as the actual data. Each object is uniquely identified by a binary SHA1 hash, being 20 bytes in size, or 40 bytes in hexadecimal notation. +An Object is anything storable in git's object database. Objects contain information about their type, their uncompressed size as well as the actual data. Each object is identified by an object ID in the repository's hash format, reported by ``repo.object_format``. SHA-1 IDs contain 20 binary bytes (40 hexadecimal characters); SHA-256 IDs contain 32 binary bytes (64 hexadecimal characters). Treat IDs returned by Git as opaque values instead of assuming a fixed width. Git only knows 4 distinct object types being :class:`Blobs `, :class:`Trees `, :class:`Commits ` and :class:`Tags `. @@ -341,7 +341,7 @@ As trees allow direct access to their intermediate child entries only, use the t The Index Object **************** -The git index is the stage containing changes to be written with the next commit or where merges finally have to take place. You may freely access and manipulate this information using the :class:`IndexFile ` object. +The git index is the stage containing changes to be written with the next commit or where merges finally have to take place. You may access and manipulate semantic entries (mode, object ID, path, and stage) using the :class:`IndexFile ` object. Git reads and writes the underlying index; raw stat fields and binary index extensions are not exposed. Modify the index with ease .. literalinclude:: ../../test/test_docs.py @@ -377,13 +377,13 @@ You can easily access configuration information for a remote by accessing option :start-after: # [26-test_references_and_objects] :end-before: # ![26-test_references_and_objects] -You can also specify per-call custom environments using a new context manager on the Git command, e.g. for using a specific SSH key. The following example works with `git` starting at *v2.3*:: +You can also specify per-call custom environments using a context manager on the Git command, e.g. for using a specific SSH key:: ssh_cmd = 'ssh -i id_deployment_key' with repo.git.custom_environment(GIT_SSH_COMMAND=ssh_cmd): repo.remotes.origin.fetch() -This one sets a custom script to be executed in place of `ssh`, and can be used in `git` prior to *v2.3*:: +Alternatively, set a custom script to be executed in place of `ssh`:: ssh_executable = os.path.join(rw_dir, 'my_ssh_executable.sh') with repo.git.custom_environment(GIT_SSH=ssh_executable): diff --git a/fuzzing/README.md b/fuzzing/README.md index 286f529eb..0d557d658 100644 --- a/fuzzing/README.md +++ b/fuzzing/README.md @@ -46,6 +46,10 @@ capabilities, jump into the "Getting Started" section below. Before contributing to fuzzing efforts, ensure Python and Docker are installed on your machine. Docker is required for running fuzzers in containers provided by OSS-Fuzz and for safely executing test files directly. [Install Docker](https://docs.docker.com/get-docker/) following the official guide if you do not already have it. +The fuzz targets require **Git 2.52 or newer**, including the Git executable bundled into OSS-Fuzz artifacts. +The local development image builds pinned Git 2.52.0. The OSS-Fuzz bootstrap and build scripts reject older selected +Git executables before preparing artifacts; update the external OSS-Fuzz image when its installed Git is too old. + ### Understanding Existing Fuzz Targets Review the `fuzz-targets/` directory to familiarize yourself with how existing tests are implemented. See @@ -67,6 +71,8 @@ Contains Python files for each fuzz test. **Things to Know**: - Each fuzz test targets a specific part of GitPython's functionality. +- Repository and object targets exercise Git-backed workflows. Removed Python index/tree binary parsers are no longer + fuzz targets; malformed configuration and object IDs can now be rejected by Git or by Python input validation. - Test files adhere to the naming convention: `fuzz_.py`, where `` indicates the functionality targeted by the test. - Any functionality that involves performing operations on input data is a possible candidate for fuzz testing, but diff --git a/fuzzing/fuzz-targets/fuzz_blob.py b/fuzzing/fuzz-targets/fuzz_blob.py index ce888e85f..afbdf7780 100644 --- a/fuzzing/fuzz-targets/fuzz_blob.py +++ b/fuzzing/fuzz-targets/fuzz_blob.py @@ -16,17 +16,16 @@ def TestOneInput(data): with tempfile.TemporaryDirectory() as temp_dir: repo = git.Repo.init(path=temp_dir) - binsha = fdp.ConsumeBytes(20) + binsha = fdp.ConsumeBytes(repo._oid_size) mode = fdp.ConsumeInt(fdp.ConsumeIntInRange(0, fdp.remaining_bytes())) path = fdp.ConsumeUnicodeNoSurrogates(fdp.remaining_bytes()) try: blob = git.Blob(repo, binsha, mode, path) - except AssertionError as e: - if "Require 20 byte binary sha, got" in str(e): + except ValueError: + if len(binsha) != repo._oid_size: return -1 - else: - raise e + raise _ = blob.mime_type diff --git a/fuzzing/fuzz-targets/fuzz_config.py b/fuzzing/fuzz-targets/fuzz_config.py index 4eddc32ff..87ef6e846 100644 --- a/fuzzing/fuzz-targets/fuzz_config.py +++ b/fuzzing/fuzz-targets/fuzz_config.py @@ -39,6 +39,10 @@ def TestOneInput(data): git_config.read() except (MissingSectionHeaderError, ParsingError, UnicodeDecodeError): return -1 # Reject inputs raising expected exceptions + except git.GitCommandError as e: + if e.status == 128: + return -1 # Git rejects invalid includes and other configuration input. + raise # Preserve crashes and unexpected command failures. except ValueError as e: if "embedded null byte" in str(e): # The `os.path.expanduser` function, which does not accept strings diff --git a/fuzzing/fuzz-targets/fuzz_diff.py b/fuzzing/fuzz-targets/fuzz_diff.py index d4bd68b57..3e15a3b7b 100644 --- a/fuzzing/fuzz-targets/fuzz_diff.py +++ b/fuzzing/fuzz-targets/fuzz_diff.py @@ -40,8 +40,8 @@ def TestOneInput(data): repo, a_rawpath=fdp.ConsumeBytes(fdp.ConsumeIntInRange(0, fdp.remaining_bytes())), b_rawpath=fdp.ConsumeBytes(fdp.ConsumeIntInRange(0, fdp.remaining_bytes())), - a_blob_id=fdp.ConsumeBytes(20), - b_blob_id=fdp.ConsumeBytes(20), + a_blob_id=fdp.ConsumeBytes(repo._oid_size * 2), + b_blob_id=fdp.ConsumeBytes(repo._oid_size * 2), a_mode=fdp.ConsumeBytes(fdp.ConsumeIntInRange(0, fdp.remaining_bytes())), b_mode=fdp.ConsumeBytes(fdp.ConsumeIntInRange(0, fdp.remaining_bytes())), new_file=fdp.ConsumeBool(), @@ -55,11 +55,10 @@ def TestOneInput(data): ) except BinasciiError: return -1 - except AssertionError as e: - if "Require 20 byte binary sha, got" in str(e): + except ValueError as e: + if "Object ID does not match the repository object format" in str(e): return -1 - else: - raise e + raise _ = diff.__str__() _ = diff.a_path diff --git a/fuzzing/fuzz-targets/fuzz_repo.py b/fuzzing/fuzz-targets/fuzz_repo.py index 7bd82c120..9045b7fee 100644 --- a/fuzzing/fuzz-targets/fuzz_repo.py +++ b/fuzzing/fuzz-targets/fuzz_repo.py @@ -1,5 +1,4 @@ import atheris -import io import sys import os import tempfile @@ -15,9 +14,7 @@ def TestOneInput(data): fdp = atheris.FuzzedDataProvider(data) - with tempfile.TemporaryDirectory() as temp_dir: - repo = git.Repo.init(path=temp_dir) - + with tempfile.TemporaryDirectory() as temp_dir, git.Repo.init(path=temp_dir) as repo: # Generate a minimal set of files based on fuzz data to minimize I/O operations. file_paths = [os.path.join(temp_dir, f"File{i}") for i in range(min(3, fdp.ConsumeIntInRange(1, 3)))] for file_path in file_paths: @@ -27,15 +24,14 @@ def TestOneInput(data): # fuzzer coverage plateaus. f.write(fdp.ConsumeBytes(fdp.ConsumeIntInRange(1, 512))) - repo.index.add(file_paths) - repo.index.commit(fdp.ConsumeUnicodeNoSurrogates(fdp.ConsumeIntInRange(1, 80))) - - fuzz_tree = git.Tree(repo, git.Tree.NULL_BIN_SHA, 0, "") - - try: - fuzz_tree._deserialize(io.BytesIO(data)) - except IndexError: + message = fdp.ConsumeUnicodeNoSurrogates(fdp.ConsumeIntInRange(1, 80)) + if "\0" in message: return -1 + repo.index.add(file_paths) + actor = git.Actor("Fuzzing", "fuzzing@example.invalid") + commit = repo.index.commit(message, author=actor, committer=actor, skip_hooks=True) + for blob in commit.tree.blobs: + blob.data_stream.read() def main(): diff --git a/fuzzing/local-dev-helpers/Dockerfile b/fuzzing/local-dev-helpers/Dockerfile index 426de05dd..a947ecfeb 100644 --- a/fuzzing/local-dev-helpers/Dockerfile +++ b/fuzzing/local-dev-helpers/Dockerfile @@ -11,7 +11,10 @@ COPY . . # Update package managers, install necessary packages, and cleanup unnecessary files in a single RUN to keep the image smaller. RUN apt-get update && \ - apt-get install -y git clang && \ + apt-get install -y git clang build-essential libssl-dev zlib1g-dev libexpat1-dev libcurl4-openssl-dev && \ + git clone --depth 1 --branch v2.52.0 -- https://github.com/git/git.git /tmp/git-source && \ + make -C /tmp/git-source -j2 prefix=/usr/local NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PERL=YesPlease install && \ + rm -rf /tmp/git-source && \ python -m pip install --upgrade pip && \ python -m pip install atheris && \ python -m pip install -e . && \ diff --git a/fuzzing/oss-fuzz-scripts/build.sh b/fuzzing/oss-fuzz-scripts/build.sh index c156e872d..4b3356319 100644 --- a/fuzzing/oss-fuzz-scripts/build.sh +++ b/fuzzing/oss-fuzz-scripts/build.sh @@ -5,6 +5,18 @@ set -euo pipefail +git_binary="$(command -v git)" || { + printf 'GitPython fuzzing requires Git 2.52 or newer; git was not found on PATH.\n' >&2 + exit 1 +} +git_version="$("$git_binary" --version)" +if [[ ! "$git_version" =~ ^git\ version\ ([0-9]+)\.([0-9]+)(\.|[[:space:]]|$) ]] || + ((10#${BASH_REMATCH[1]} < 2 || (10#${BASH_REMATCH[1]} == 2 && 10#${BASH_REMATCH[2]} < 52))); then + printf 'GitPython fuzzing requires Git 2.52 or newer; %s reports %s. Update the container Git installation.\n' \ + "$git_binary" "$git_version" >&2 + exit 1 +fi + python3 -m pip install . find "$SRC" -maxdepth 1 \ @@ -15,5 +27,5 @@ find "$SRC" -maxdepth 1 \ # Build fuzzers in $OUT. find "$SRC/gitpython/fuzzing" -name 'fuzz_*.py' -print0 | while IFS= read -r -d '' fuzz_harness; do - compile_python_fuzzer "$fuzz_harness" --add-binary="$(command -v git):." --add-data="$SRC/explicit-exceptions-list.txt:." + compile_python_fuzzer "$fuzz_harness" --add-binary="$git_binary:." --add-data="$SRC/explicit-exceptions-list.txt:." done diff --git a/fuzzing/oss-fuzz-scripts/container-environment-bootstrap.sh b/fuzzing/oss-fuzz-scripts/container-environment-bootstrap.sh index 924a3cbf3..9d878d7c1 100755 --- a/fuzzing/oss-fuzz-scripts/container-environment-bootstrap.sh +++ b/fuzzing/oss-fuzz-scripts/container-environment-bootstrap.sh @@ -16,6 +16,15 @@ for cmd in python3 git wget zip; do } done +git_binary="$(command -v git)" +git_version="$("$git_binary" --version)" +if [[ ! "$git_version" =~ ^git\ version\ ([0-9]+)\.([0-9]+)(\.|[[:space:]]|$) ]] || + ((10#${BASH_REMATCH[1]} < 2 || (10#${BASH_REMATCH[1]} == 2 && 10#${BASH_REMATCH[2]} < 52))); then + printf 'GitPython fuzzing requires Git 2.52 or newer; %s reports %s. Update the container Git installation.\n' \ + "$git_binary" "$git_version" >&2 + exit 1 +fi + ############# # Functions # ############# @@ -85,7 +94,7 @@ prepare_dictionaries_for_fuzz_targets() { ######################## # Seed corpora and dictionaries are hosted in a separate repository to avoid additional bloat in this repo. # We clone into the $WORK directory because OSS-Fuzz cleans it up after building the image, keeping the image small. -git clone --depth 1 https://github.com/gitpython-developers/qa-assets.git "$WORK/qa-assets" +"$git_binary" clone --depth 1 -- https://github.com/gitpython-developers/qa-assets.git "$WORK/qa-assets" create_seed_corpora_zips "$WORK/qa-assets/gitpython/corpora" @@ -97,7 +106,7 @@ pushd "$SRC/gitpython/" # This file can then be used by fuzz harnesses to check exception tracebacks and filter out explicitly raised or otherwise # anticipated exceptions to reduce false positive test failures. -git grep -n --recurse-submodules -e '\braise\b' -e '\bassert\b' -- '*.py' -- ':!setup.py' -- ':!test/**' -- ':!fuzzing/**' > "$SRC/explicit-exceptions-list.txt" +"$git_binary" grep -n --recurse-submodules -e '\braise\b' -e '\bassert\b' -- '*.py' -- ':!setup.py' -- ':!test/**' -- ':!fuzzing/**' > "$SRC/explicit-exceptions-list.txt" popd diff --git a/git/__init__.py b/git/__init__.py index ecc6cd94e..8a8e71311 100644 --- a/git/__init__.py +++ b/git/__init__.py @@ -95,7 +95,7 @@ import warnings -from gitdb.util import to_hex_sha +from git.util import to_hex_sha from git.exc import ( AmbiguousObjectName, diff --git a/git/cmd.py b/git/cmd.py index cb88e7c24..1e0b70c8c 100644 --- a/git/cmd.py +++ b/git/cmd.py @@ -28,6 +28,7 @@ GitCommandNotFound, UnsafeOptionError, UnsafeProtocolError, + UnsupportedOperation, ) from git.util import ( cygpath, @@ -1079,6 +1080,82 @@ def _option_candidates(cls, args: Sequence[Any] = (), kwargs: Optional[Mapping[s ) return options + @staticmethod + def _check_operand(value: Any, label: str = "operand") -> str: + """Validate a single name/revision, before Git can interpret it as an option. + + Paths and free-form payloads need their own validation and framing instead. + """ + value = safe_decode(value) if isinstance(value, bytes) else str(value) + if value.startswith("-") or any(char in value for char in "\0\r\n"): + raise UnsafeOptionError(f"Invalid {label}: {value!r}") + return value + + def _require_version(self) -> None: + if self.version_info < (2, 52): + raise UnsupportedOperation("GitPython requires Git 2.52 or newer for repository operations") + + def _call_process_safe( + self, + method: str, + *args: Any, + _allow_hooks: bool = False, + _allow_network: bool = False, + _config: Sequence[str] = (), + **kwargs: Any, + ) -> Any: + """Run library-owned plumbing without introducing executable configuration. + + Callers validate their operands and any forwarded options using the existing + command-specific guards. This does not restrict the public raw Git interface. + """ + self._check_operand(method, "command") + if kwargs.get("shell"): + raise UnsafeOptionError("Library operations cannot run through a shell") + if _allow_hooks and method != "hook": + raise UnsafeOptionError("Only explicit hook operations may enable hooks") + insertion = kwargs.get("insert_kwargs_after") + if insertion is not None and not ( + method == "remote" and args and args[0] == insertion and insertion in ("add", "set-url", "update") + ): + raise UnsafeOptionError("Library command options cannot be reordered past safety flags") + for setting in _config: + key, separator, value = setting.partition("=") + if not separator or ( + key.lower() not in ("i18n.commitencoding", "diff.mnemonicprefix", "fetch.output", "core.abbrev") + and not (key == "protocol.file.allow" and value in ("always", "never", "user")) + and not (key in ("tar.tgz.command", "tar.tar.gz.command") and value == "git archive gzip") + ): + raise UnsafeOptionError(f"Unsupported internal Git configuration: {key!r}") + for arg in self._unpack_args([arg for arg in args if arg is not None]) + self.transform_kwargs( + **{key: value for key, value in kwargs.items() if key not in execute_kwargs} + ): + if "\0" in arg: + raise UnsafeOptionError("Git arguments cannot contain NUL bytes") + self._require_version() + options = ["--no-pager", "--no-optional-locks"] + # These settings would become visible as user configuration in `config`. + if method != "config": + config = ["core.fsmonitor=false", "gc.auto=0", "maintenance.auto=false"] + if not _allow_hooks: + config.append(f"core.hooksPath={os.devnull}") + for setting in [*config, *_config]: + options.extend(("-c", setting)) + elif _config: + raise ValueError("Configuration queries must not include synthetic settings") + env = dict(kwargs.pop("env", {}) or {}) + env.update(LC_ALL="C", LANGUAGE="C") + if not _allow_network: + env.update(GIT_NO_LAZY_FETCH="1", GIT_TERMINAL_PROMPT="0") + return self._call_process( + method, + *args, + _safe_git_options=options, + shell=False, + env=env, + **{key: value for key, value in kwargs.items() if key != "shell"}, + ) + AutoInterrupt: TypeAlias = _AutoInterrupt CatFileContentStream: TypeAlias = _CatFileContentStream @@ -1098,7 +1175,7 @@ def __init__(self, working_dir: Union[None, PathLike] = None) -> None: self._persistent_git_options: List[str] = [] # Extra environment variables to pass to git commands - self._environment: Dict[str, str] = {} + self._environment: Dict[str, Optional[str]] = {} # Cached version slots self._version_info: Union[Tuple[int, ...], None] = None @@ -1182,7 +1259,7 @@ def version_info(self) -> Tuple[int, ...]: return self._version_info # Run "git version" and parse it. - process_version = self._call_process("version") + process_version = cast(str, self._call_process("version", shell=False)) version_string = process_version.split(" ")[2] version_fields = version_string.split(".")[:4] leading_numeric_fields = itertools.takewhile(str.isdigit, version_fields) @@ -1466,16 +1543,18 @@ def execute( # Start the process. inline_env = env - env = os.environ.copy() + environment: Dict[str, Optional[str]] = dict(os.environ) # Attempt to force all output to plain ASCII English, which is what some parsing # code may expect. # According to https://askubuntu.com/a/311796, we are setting LANGUAGE as well # just to be sure. - env["LANGUAGE"] = "C" - env["LC_ALL"] = "C" - env.update(self._environment) + environment["LANGUAGE"] = "C" + environment["LC_ALL"] = "C" + environment.update(self._environment) if inline_env is not None: - env.update(inline_env) + environment.update(inline_env) + # Internal or per-call None overrides remove inherited variables. + env = {key: value for key, value in environment.items() if value is not None} if sys.platform == "win32": if kill_after_timeout is not None: @@ -1675,7 +1754,7 @@ def as_text(stdout_value: Union[bytes, str, None]) -> str: else: return stdout_value - def environment(self) -> Dict[str, str]: + def environment(self) -> Dict[str, Optional[str]]: return self._environment def update_environment(self, **kwargs: Any) -> Dict[str, Union[str, None]]: @@ -1759,7 +1838,7 @@ def _unpack_args(cls, arg_list: Sequence[Any]) -> List[str]: for arg in arg_list: outlist.extend(cls._unpack_args(arg)) else: - outlist.append(str(arg_list)) + outlist.append(os.fsdecode(arg_list) if isinstance(arg_list, os.PathLike) else str(arg_list)) return outlist @@ -1844,6 +1923,7 @@ def _call_process( """ # Handle optional arguments prior to calling transform_kwargs. # Otherwise these'll end up in args, which is bad. + safe_git_options = kwargs.pop("_safe_git_options", ()) exec_kwargs = {k: v for k, v in kwargs.items() if k in execute_kwargs} opts_kwargs = {k: v for k, v in kwargs.items() if k not in execute_kwargs} @@ -1877,12 +1957,14 @@ def _call_process( call.extend(self._git_options) self._git_options = () + call.extend(safe_git_options) + call.append(dashify(method)) call.extend(args_list) return self.execute(call, **exec_kwargs) - def _parse_object_header(self, header_line: str) -> Tuple[str, str, int]: + def _parse_object_header(self, header_line: Union[str, bytes]) -> Tuple[str, str, int]: """ :param header_line: A line of the form:: @@ -1895,6 +1977,8 @@ def _parse_object_header(self, header_line: str) -> Tuple[str, str, int]: :raise ValueError: If the header contains indication for an error due to incorrect input sha. """ + if isinstance(header_line, bytes): + header_line = header_line.decode("ascii", "replace") tokens = header_line.split() if len(tokens) != 3: if not tokens: @@ -1909,42 +1993,56 @@ def _parse_object_header(self, header_line: str) -> Tuple[str, str, int]: # END handle actual return value # END error handling - if len(tokens[0]) != 40: + if ( + not re.fullmatch(r"[0-9a-fA-F]+", tokens[0]) + or len(tokens[0]) % 2 + or tokens[1] not in ("blob", "tree", "commit", "tag") + or not tokens[2].isdigit() + ): raise ValueError("Failed to parse header: %r" % header_line) return (tokens[0], tokens[1], int(tokens[2])) def _prepare_ref(self, ref: object) -> bytes: - # Required for command to separate refs on stdin, as bytes. + # `cat-file -Z` separates both requests and responses with NUL, so paths + # containing newlines cannot inject requests or desynchronize the process. if isinstance(ref, bytes): - # Assume 40 bytes hexsha - bin-to-ascii for some reason returns bytes, not text. - refstr: str = ref.decode("ascii") + refstr: str = ref.decode(defenc, "surrogateescape") elif not isinstance(ref, str): refstr = str(ref) # Could be ref-object. else: refstr = ref - if not refstr.endswith("\n"): - refstr += "\n" - return refstr.encode(defenc) + if "\0" in refstr or refstr.startswith("-"): + raise UnsafeOptionError("Object queries cannot contain NUL or start with '-'") + return refstr.encode(defenc, "surrogateescape") + b"\0" def _get_persistent_cmd(self, attr_name: str, cmd_name: str, *args: Any, **kwargs: Any) -> "Git.AutoInterrupt": cur_val = getattr(self, attr_name) if cur_val is not None: return cur_val - options = {"istream": PIPE, "as_process": True} + options: Dict[str, Any] = {"istream": PIPE, "as_process": True} options.update(kwargs) - cmd = self._call_process(cmd_name, *args, **options) + cmd = self._call_process_safe(cmd_name, *args, **options) setattr(self, attr_name, cmd) cmd = cast("Git.AutoInterrupt", cmd) return cmd - def __get_object_header(self, cmd: "Git.AutoInterrupt", ref: Union[str, bytes]) -> Tuple[str, str, int]: + def __get_object_header(self, cmd: "Git.AutoInterrupt", request: bytes) -> Tuple[str, str, int]: if cmd.stdin and cmd.stdout: - cmd.stdin.write(self._prepare_ref(ref)) + cmd.stdin.write(request) cmd.stdin.flush() - return self._parse_object_header(cmd.stdout.readline()) + header = bytearray() + while True: + char = cmd.stdout.read(1) + if not char: + cmd.wait() + raise ValueError("Git closed the object stream before its response") + if char == b"\0": + break + header.extend(char) + return self._parse_object_header(bytes(header)) else: raise ValueError("cmd stdin was empty") @@ -1959,8 +2057,9 @@ def get_object_header(self, ref: Union[str, bytes]) -> Tuple[str, str, int]: :return: (hexsha, type_string, size_as_int) """ - cmd = self._get_persistent_cmd("cat_file_header", "cat_file", batch_check=True) - return self.__get_object_header(cmd, ref) + request = self._prepare_ref(ref) + cmd = self._get_persistent_cmd("cat_file_header", "cat_file", batch_check=True, Z=True) + return self.__get_object_header(cmd, request) def get_object_data(self, ref: Union[str, bytes]) -> Tuple[str, str, int, bytes]: """Similar to :meth:`get_object_header`, but returns object data as well. @@ -1986,8 +2085,9 @@ def stream_object_data(self, ref: Union[str, bytes]) -> Tuple[str, str, int, "Gi This method is not threadsafe. You need one independent :class:`Git` instance per thread to be safe! """ - cmd = self._get_persistent_cmd("cat_file_all", "cat_file", batch=True) - hexsha, typename, size = self.__get_object_header(cmd, ref) + request = self._prepare_ref(ref) + cmd = self._get_persistent_cmd("cat_file_all", "cat_file", batch=True, Z=True) + hexsha, typename, size = self.__get_object_header(cmd, request) cmd_stdout = cmd.stdout if cmd.stdout is not None else io.BytesIO() return (hexsha, typename, size, self.CatFileContentStream(size, cmd_stdout)) diff --git a/git/config.py b/git/config.py index a3437d525..ed89498b4 100644 --- a/git/config.py +++ b/git/config.py @@ -3,133 +3,31 @@ # This module is part of GitPython and is released under the # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/ -"""Parser for reading and writing configuration files.""" +"""Git configuration access through ``git config``.""" __all__ = ["GitConfigParser", "SectionConstraint"] -import abc import configparser as cp -import fnmatch -import inspect -import logging import os import os.path as osp import re import sys -from functools import wraps -from io import BufferedReader, IOBase - -# typing------------------------------------------------------- -from typing import ( - IO, - TYPE_CHECKING, - Any, - Callable, - Dict, - Generic, - List, - OrderedDict, - Sequence, - Tuple, - TypeVar, - Union, - cast, -) +import tempfile +from contextlib import contextmanager +from typing import Any, Dict, Generic, Iterator, List, OrderedDict, Sequence, Tuple, TypeVar, Union, TYPE_CHECKING from git.compat import defenc, force_text +from git.exc import GitCommandError from git.types import _T, ConfigLevels_Tup, Lit_config_levels, PathLike, assert_never -from git.util import LockFile if TYPE_CHECKING: from io import BytesIO - from git.repo.base import Repo T_ConfigParser = TypeVar("T_ConfigParser", bound="GitConfigParser") T_OMD_value = TypeVar("T_OMD_value", str, bytes, int, float, bool, None) - OrderedDict_OMD = OrderedDict[str, List[T_OMD_value]] - -# ------------------------------------------------------------- - -_logger = logging.getLogger(__name__) - CONFIG_LEVELS: ConfigLevels_Tup = ("system", "user", "global", "repository") -"""The configuration level of a configuration file.""" - -CONDITIONAL_INCLUDE_REGEXP = re.compile(r"(?<=includeif )\"(gitdir|gitdir/i|onbranch|hasconfig:remote\.\*\.url):(.+)\"") -"""Section pattern to detect conditional includes. - -See: https://git-scm.com/docs/git-config#_conditional_includes -""" - -UNSAFE_CONFIG_CHARS_RE = re.compile(r"[\r\n\x00]") -"""Characters that cannot be safely written in config names or values.""" - -VALID_CONFIG_OPTION_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]+$") -"""Pattern for option names that can be written without changing config syntax.""" - - -class MetaParserBuilder(abc.ABCMeta): # noqa: B024 - """Utility class wrapping base-class methods into decorators that assure read-only - properties.""" - - def __new__(cls, name: str, bases: Tuple, clsdict: Dict[str, Any]) -> "MetaParserBuilder": - """Equip all base-class methods with a needs_values decorator, and all non-const - methods with a :func:`set_dirty_and_flush_changes` decorator in addition to - that. - """ - kmm = "_mutating_methods_" - if kmm in clsdict: - mutating_methods = clsdict[kmm] - for base in bases: - methods = (t for t in inspect.getmembers(base, inspect.isroutine) if not t[0].startswith("_")) - for method_name, method in methods: - if method_name in clsdict: - continue - method_with_values = needs_values(method) - if method_name in mutating_methods: - method_with_values = set_dirty_and_flush_changes(method_with_values) - # END mutating methods handling - - clsdict[method_name] = method_with_values - # END for each name/method pair - # END for each base - # END if mutating methods configuration is set - - new_type = super().__new__(cls, name, bases, clsdict) - return new_type - - -def needs_values(func: Callable[..., _T]) -> Callable[..., _T]: - """Return a method for ensuring we read values (on demand) before we try to access - them.""" - - @wraps(func) - def assure_data_present(self: "GitConfigParser", *args: Any, **kwargs: Any) -> _T: - self.read() - return func(self, *args, **kwargs) - - # END wrapper method - return assure_data_present - - -def set_dirty_and_flush_changes(non_const_func: Callable[..., _T]) -> Callable[..., _T]: - """Return a method that checks whether given non constant function may be called. - - If so, the instance will be set dirty. Additionally, we flush the changes right to - disk. - """ - - def flush_changes(self: "GitConfigParser", *args: Any, **kwargs: Any) -> _T: - rval = non_const_func(self, *args, **kwargs) - self._dirty = True - self.write() - return rval - - # END wrapper method - flush_changes.__name__ = non_const_func.__name__ - return flush_changes class SectionConstraint(Generic[T_ConfigParser]): @@ -146,6 +44,10 @@ class SectionConstraint(Generic[T_ConfigParser]): _valid_attrs_ = ( "get_value", + "get_values", + "add_value", + "items", + "items_all", "set_value", "get", "set", @@ -291,60 +193,19 @@ def get_config_path(config_level: Lit_config_levels) -> str: ) -class GitConfigParser(cp.RawConfigParser, metaclass=MetaParserBuilder): - """Implements specifics required to read git style configuration files. +class GitConfigParser: + """Read and modify Git configuration using Git's parser and file locking. - This variation behaves much like the :manpage:`git-config(1)` command, such that the - configuration will be read on demand based on the filepath given during - initialization. - - The changes will automatically be written once the instance goes out of scope, but - can be triggered manually as well. - - The configuration file will be locked if you intend to change values preventing - other instances to write concurrently. - - :note: - Section and option names are case-insensitive; quoted subsection names are - case-sensitive. Names retain their first spelling when enumerated or written. - Case variants are merged, preserving all values in the order they are read. + File paths, byte streams, and lists of sources are accepted for reading. Writers + operate on one source, updating it immediately. Git locks each mutation; a writer + does not reserve a lifetime lock. Git canonicalizes enumerated section/option + names, while preserving subsection case and duplicate values. - :note: - If used as a context manager, this will release the locked file. - - :note: - Options without a value are stored as ``None`` and written without ``=``. - :meth:`get_value` and :meth:`get_values` return an empty string for them, - while :meth:`getboolean` returns ``True``. An explicit empty value is - stored as an empty string and reads as ``False`` with :meth:`getboolean`. + Empty sections, raw configuration parsing/serialization, and writing valueless + options are not supported. Existing valueless options remain readable. """ - # { Configuration - t_lock = LockFile - """The lock type determines the type of lock to use in new configuration readers. - - They must be compatible to the :class:`~git.util.LockFile` interface. - A suitable alternative would be the :class:`~git.util.BlockingLockFile`. - """ - - re_comment = re.compile(r"^\s*[#;]") - # } END configuration - - optvalueonly_source = r"\s*(?P