diff --git a/.basedpyright/baseline.json b/.basedpyright/baseline.json new file mode 100644 index 000000000..79d35235e --- /dev/null +++ b/.basedpyright/baseline.json @@ -0,0 +1,502 @@ +{ + "files": { + "./git/config.py": [ + { + "code": "reportGeneralTypeIssues", + "range": { + "startColumn": 11, + "endColumn": 26, + "lineCount": 1 + } + }, + { + "code": "reportInvalidTypeVarUse", + "range": { + "startColumn": 43, + "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": "reportAssignmentType", + "range": { + "startColumn": 38, + "endColumn": 76, + "lineCount": 1 + } + }, + { + "code": "reportArgumentType", + "range": { + "startColumn": 51, + "endColumn": 57, + "lineCount": 1 + } + }, + { + "code": "reportArgumentType", + "range": { + "startColumn": 55, + "endColumn": 61, + "lineCount": 1 + } + }, + { + "code": "reportIncompatibleMethodOverride", + "range": { + "startColumn": 8, + "endColumn": 12, + "lineCount": 1 + } + }, + { + "code": "reportAssignmentType", + "range": { + "startColumn": 20, + "endColumn": 46, + "lineCount": 1 + } + } + ], + "./git/objects/blob.py": [ + { + "code": "reportIncompatibleVariableOverride", + "range": { + "startColumn": 4, + "endColumn": 8, + "lineCount": 1 + } + } + ], + "./git/objects/commit.py": [ + { + "code": "reportIncompatibleVariableOverride", + "range": { + "startColumn": 4, + "endColumn": 8, + "lineCount": 1 + } + }, + { + "code": "reportIncompatibleMethodOverride", + "range": { + "startColumn": 8, + "endColumn": 31, + "lineCount": 1 + } + }, + { + "code": "reportArgumentType", + "range": { + "startColumn": 20, + "endColumn": 33, + "lineCount": 1 + } + }, + { + "code": "reportArgumentType", + "range": { + "startColumn": 20, + "endColumn": 30, + "lineCount": 1 + } + } + ], + "./git/objects/submodule/base.py": [ + { + "code": "reportAttributeAccessIssue", + "range": { + "startColumn": 19, + "endColumn": 24, + "lineCount": 1 + } + }, + { + "code": "reportAttributeAccessIssue", + "range": { + "startColumn": 19, + "endColumn": 24, + "lineCount": 1 + } + }, + { + "code": "reportReturnType", + "range": { + "startColumn": 23, + "endColumn": 25, + "lineCount": 1 + } + }, + { + "code": "reportReturnType", + "range": { + "startColumn": 23, + "endColumn": 25, + "lineCount": 1 + } + }, + { + "code": "reportArgumentType", + "range": { + "startColumn": 52, + "endColumn": 84, + "lineCount": 1 + } + } + ], + "./git/objects/tag.py": [ + { + "code": "reportIncompatibleVariableOverride", + "range": { + "startColumn": 4, + "endColumn": 8, + "lineCount": 1 + } + } + ], + "./git/objects/tree.py": [ + { + "code": "reportIncompatibleVariableOverride", + "range": { + "startColumn": 4, + "endColumn": 8, + "lineCount": 1 + } + }, + { + "code": "reportIncompatibleMethodOverride", + "range": { + "startColumn": 8, + "endColumn": 31, + "lineCount": 1 + } + }, + { + "code": "reportReturnType", + "range": { + "startColumn": 19, + "endColumn": 83, + "lineCount": 1 + } + }, + { + "code": "reportReturnType", + "range": { + "startColumn": 15, + "endColumn": 54, + "lineCount": 1 + } + } + ], + "./git/objects/util.py": [ + { + "code": "reportAssignmentType", + "range": { + "startColumn": 23, + "endColumn": 34, + "lineCount": 1 + } + }, + { + "code": "reportReturnType", + "range": { + "startColumn": 22, + "endColumn": 26, + "lineCount": 1 + } + }, + { + "code": "reportArgumentType", + "range": { + "startColumn": 30, + "endColumn": 34, + "lineCount": 1 + } + }, + { + "code": "reportReturnType", + "range": { + "startColumn": 15, + "endColumn": 54, + "lineCount": 1 + } + } + ], + "./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", + "range": { + "startColumn": 13, + "endColumn": 17, + "lineCount": 1 + } + } + ], + "./git/refs/symbolic.py": [ + { + "code": "reportAttributeAccessIssue", + "range": { + "startColumn": 15, + "endColumn": 20, + "lineCount": 1 + } + } + ], + "./git/refs/tag.py": [ + { + "code": "reportIncompatibleMethodOverride", + "range": { + "startColumn": 8, + "endColumn": 14, + "lineCount": 1 + } + }, + { + "code": "reportIncompatibleMethodOverride", + "range": { + "startColumn": 8, + "endColumn": 14, + "lineCount": 1 + } + } + ], + "./git/remote.py": [ + { + "code": "reportAttributeAccessIssue", + "range": { + "startColumn": 26, + "endColumn": 38, + "lineCount": 1 + } + }, + { + "code": "reportAttributeAccessIssue", + "range": { + "startColumn": 26, + "endColumn": 38, + "lineCount": 1 + } + }, + { + "code": "reportAttributeAccessIssue", + "range": { + "startColumn": 26, + "endColumn": 38, + "lineCount": 1 + } + }, + { + "code": "reportAttributeAccessIssue", + "range": { + "startColumn": 26, + "endColumn": 38, + "lineCount": 1 + } + } + ], + "./git/repo/base.py": [ + { + "code": "reportReturnType", + "range": { + "startColumn": 15, + "endColumn": 46, + "lineCount": 1 + } + }, + { + "code": "reportReturnType", + "range": { + "startColumn": 15, + "endColumn": 51, + "lineCount": 1 + } + }, + { + "code": "reportReturnType", + "range": { + "startColumn": 15, + "endColumn": 28, + "lineCount": 1 + } + }, + { + "code": "reportTypedDictNotRequiredAccess", + "range": { + "startColumn": 21, + "endColumn": 31, + "lineCount": 1 + } + }, + { + "code": "reportTypedDictNotRequiredAccess", + "range": { + "startColumn": 34, + "endColumn": 44, + "lineCount": 1 + } + }, + { + "code": "reportTypedDictNotRequiredAccess", + "range": { + "startColumn": 65, + "endColumn": 79, + "lineCount": 1 + } + }, + { + "code": "reportTypedDictNotRequiredAccess", + "range": { + "startColumn": 82, + "endColumn": 102, + "lineCount": 1 + } + }, + { + "code": "reportTypedDictNotRequiredAccess", + "range": { + "startColumn": 50, + "endColumn": 69, + "lineCount": 1 + } + }, + { + "code": "reportTypedDictNotRequiredAccess", + "range": { + "startColumn": 68, + "endColumn": 85, + "lineCount": 1 + } + }, + { + "code": "reportTypedDictNotRequiredAccess", + "range": { + "startColumn": 88, + "endColumn": 111, + "lineCount": 1 + } + }, + { + "code": "reportTypedDictNotRequiredAccess", + "range": { + "startColumn": 51, + "endColumn": 73, + "lineCount": 1 + } + } + ], + "./git/repo/fun.py": [ + { + "code": "reportReturnType", + "range": { + "startColumn": 11, + "endColumn": 20, + "lineCount": 1 + } + } + ], + "./test/deprecation/test_basic.py": [ + { + "code": "reportUnusedExpression", + "range": { + "startColumn": 12, + "endColumn": 62, + "lineCount": 1 + } + } + ] + } +} diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 16d5f11bc..7f803ee43 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,11 +6,6 @@ updates: schedule: interval: weekly -- package-ecosystem: gitsubmodule - directory: "/" - schedule: - interval: weekly - - package-ecosystem: pre-commit directory: "/" schedule: diff --git a/.github/workflows/alpine-test.yml b/.github/workflows/alpine-test.yml index a5b63f423..b10336a15 100644 --- a/.github/workflows/alpine-test.yml +++ b/.github/workflows/alpine-test.yml @@ -63,17 +63,13 @@ jobs: - name: Install project and test dependencies run: | . .venv/bin/activate - pip install '.[test]' + pip install ./smmap ./gitdb '.[test]' - name: Show POSIX file ownership run: | ls -ld -- \ "$(pwd)" \ "$(pwd)/.git" \ - "$(pwd)/git/ext/gitdb" \ - "$(pwd)/git/ext/gitdb/.git" \ - "$(pwd)/git/ext/gitdb/gitdb/ext/smmap" \ - "$(pwd)/git/ext/gitdb/gitdb/ext/smmap/.git" \ "${HOME:?HOME is not set}/.gitconfig" \ 2>&1 || true diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 1ad1a612c..eeb68cd0c 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -62,6 +62,10 @@ jobs: with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} + config: | + paths-ignore: + - gitdb/gitdb/test/** + - smmap/smmap/test/** # If you wish to specify custom queries, you can do so here or in a config file. # By default, queries listed here will override any specified in a config file. # Prefix the list here with "+" to use these queries and those in the config file. diff --git a/.github/workflows/cygwin-test.yml b/.github/workflows/cygwin-test.yml index 6f9f347a9..e14856e7e 100644 --- a/.github/workflows/cygwin-test.yml +++ b/.github/workflows/cygwin-test.yml @@ -45,7 +45,7 @@ jobs: - name: Install Cygwin uses: cygwin/cygwin-install-action@v6 with: - packages: git python39 python-pip-wheel python-setuptools-wheel python-wheel-wheel + packages: curl git python39 python-setuptools-wheel add-to-path: false # No need to change $PATH outside the Cygwin environment. - name: Arrange for verbose output @@ -57,8 +57,6 @@ jobs: run: | git config --global --add safe.directory "$(pwd)" git config --global --add safe.directory "$(pwd)/.git" - git config --global --add safe.directory "$(pwd)/git/ext/gitdb" - git config --global --add safe.directory "$(pwd)/git/ext/gitdb/gitdb/ext/smmap" git config --global core.autocrlf false - name: Prepare this repo for tests @@ -75,16 +73,19 @@ jobs: - name: Set up virtual environment run: | + pip_wheel=/usr/share/python-wheels/pip-26.0.1-py3-none-any.whl + curl -fsSLo "$pip_wheel" https://files.pythonhosted.org/packages/de/f0/c81e05b613866b76d2d1066490adf1a3dbc4ee9d9c839961c3fc8a6997af/pip-26.0.1-py3-none-any.whl + python3.9 -c 'import hashlib, pathlib, sys; assert hashlib.sha256(pathlib.Path(sys.argv[1]).read_bytes()).hexdigest() == sys.argv[2]' "$pip_wheel" bdb1b08f4274833d62c1aa29e20907365a2ceb950410df15fc9521bad440122b python3.9 -m venv .venv echo 'BASH_ENV=.venv/bin/activate' >>"$GITHUB_ENV" - name: Update PyPA packages run: | - python -m pip install -U pip 'setuptools; python_version<"3.12"' wheel + python -m pip install -U 'setuptools; python_version<"3.12"' wheel - name: Install project and test dependencies run: | - pip install '.[test]' + pip install ./smmap ./gitdb '.[test]' - name: Show POSIX file ownership # Cygwin's `ls -ld` reports the NTFS Owner SID via Cygwin's SID-to-uid @@ -96,12 +97,6 @@ jobs: ls -ld -- \ "$(pwd)" \ "$(pwd)/.git" \ - "$(pwd)/git/ext/gitdb" \ - "$(pwd)/git/ext/gitdb/.git" \ - "$(pwd)/.git/modules/gitdb" \ - "$(pwd)/git/ext/gitdb/gitdb/ext/smmap" \ - "$(pwd)/git/ext/gitdb/gitdb/ext/smmap/.git" \ - "$(pwd)/.git/modules/gitdb/modules/smmap" \ "${HOME:?HOME is not set}/.gitconfig" \ 2>&1 || true @@ -114,12 +109,6 @@ jobs: $paths = @( "$pwd", "$pwd\.git", - "$pwd\git\ext\gitdb", - "$pwd\git\ext\gitdb\.git", - "$pwd\.git\modules\gitdb", - "$pwd\git\ext\gitdb\gitdb\ext\smmap", - "$pwd\git\ext\gitdb\gitdb\ext\smmap\.git", - "$pwd\.git\modules\gitdb\modules\smmap", "$env:USERPROFILE\.gitconfig" ) foreach ($p in $paths) { diff --git a/.github/workflows/dependencies.yml b/.github/workflows/dependencies.yml new file mode 100644 index 000000000..aa6f91531 --- /dev/null +++ b/.github/workflows/dependencies.yml @@ -0,0 +1,57 @@ +name: Dependency packages + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + runs-on: ${{ matrix.python-version == '3.7' && 'ubuntu-22.04' || 'ubuntu-latest' }} + strategy: + fail-fast: false + matrix: + project: [smmap, gitdb] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.13t"] + + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - uses: actions/setup-python@v7 + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: true + - name: Install test dependencies + run: | + python -m pip install --upgrade pip + python -m pip install pytest flake8 ./smmap + if test "${{ matrix.project }}" = gitdb; then python -m pip install ./gitdb; fi + - name: Lint + run: flake8 "${{ matrix.project }}/${{ matrix.project }}" --count --select=E9,F63,F7,F82 --show-source --statistics + - name: Test + run: | + if test "${{ matrix.project }}" = gitdb; then + GITDB_TEST_GIT_REPO_BASE="$(git rev-parse --git-common-dir)" pytest -o addopts= -v gitdb/gitdb/test + else + pytest -o addopts= -v smmap/smmap/test + fi + uv-sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + with: + enable-cache: false + version: "0.12.13" # Released on 2026-09-10 + + # Ensure that dependencies and dependency groups are consistent and resolvable. + - run: uv sync --all-extras --all-groups --dry-run diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 79d786669..d22640951 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -16,7 +16,7 @@ jobs: steps: - uses: actions/checkout@v7 - - uses: actions/setup-python@v6 + - uses: actions/setup-python@v7 with: python-version: "3.x" diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index fe3e26236..cb9a9f285 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -17,22 +17,14 @@ jobs: strategy: matrix: os-type: [ubuntu, macos, windows] - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.13t", "3.14", "3.14t"] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "3.14t", "3.15", "3.15t"] exclude: - os-type: macos python-version: "3.7" # Not available for the ARM-based macOS runners. - os-type: macos - python-version: "3.13t" - - os-type: macos - python-version: "3.14t" - - os-type: windows - python-version: "3.13" # FIXME: Fix and enable Python 3.13 and 3.14 on Windows (#1955). - - os-type: windows - python-version: "3.13t" - - os-type: windows - python-version: "3.14" - - os-type: windows python-version: "3.14t" + - os-type: macos + python-version: "3.15t" include: - os-ver: latest - os-type: ubuntu @@ -44,6 +36,10 @@ jobs: - python-version: "3.8" build-docs: false - experimental: false + - python-version: "3.15" + experimental: true + - python-version: "3.15t" + experimental: true fail-fast: false @@ -59,17 +55,16 @@ jobs: fetch-depth: 0 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} allow-prereleases: ${{ matrix.experimental }} - name: Set up WSL (Windows) if: matrix.os-type == 'windows' - uses: Vampire/setup-wsl@v6.0.0 + uses: Vampire/setup-wsl@v7.0.0 with: wsl-version: 1 - distribution: Debian - name: Prepare this repo for tests run: | @@ -89,7 +84,7 @@ jobs: - name: Install project and test dependencies run: | - pip install '.[test]' + pip install ./smmap ./gitdb '.[test]' - name: Show POSIX file ownership # Linux and macOS only. On Windows, Git Bash's `ls -ld` reports a @@ -101,10 +96,6 @@ jobs: ls -ld -- \ "$(pwd)" \ "$(pwd)/.git" \ - "$(pwd)/git/ext/gitdb" \ - "$(pwd)/git/ext/gitdb/.git" \ - "$(pwd)/git/ext/gitdb/gitdb/ext/smmap" \ - "$(pwd)/git/ext/gitdb/gitdb/ext/smmap/.git" \ "${HOME:?HOME is not set}/.gitconfig" \ 2>&1 || true @@ -118,10 +109,6 @@ jobs: $paths = @( "$pwd", "$pwd\.git", - "$pwd\git\ext\gitdb", - "$pwd\git\ext\gitdb\.git", - "$pwd\git\ext\gitdb\gitdb\ext\smmap", - "$pwd\git\ext\gitdb\gitdb\ext\smmap\.git", "$env:USERPROFILE\.gitconfig" ) foreach ($p in $paths) { @@ -173,6 +160,11 @@ jobs: TERM: "xterm-256color" # For color: https://github.com/python/mypy/issues/13817 PYTHON_VERSION: ${{ matrix.python-version }} + - name: Check types with basedpyright + if: matrix.os-type == 'ubuntu' && matrix.python-version == '3.12' + run: | + basedpyright --warnings + - name: Test with pytest run: | pytest --color=yes -p no:sugar --instafail -vv @@ -181,5 +173,5 @@ jobs: - name: Documentation if: matrix.build-docs run: | - pip install '.[doc]' + pip install --group doc make -C doc html diff --git a/.gitignore b/.gitignore index eab294a65..79e543121 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,6 @@ output.txt # Files created by OSS-Fuzz when running locally fuzz_*.pkg.spec + +# Lock files created by uv, pixi, pipenv and others +*.lock diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 251eeeec4..000000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "gitdb"] - url = https://github.com/gitpython-developers/gitdb.git - path = git/ext/gitdb diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a13e85d40..dfa5fc91e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,13 +1,15 @@ +exclude: ^(?:gitdb|smmap)/ + repos: - repo: https://github.com/codespell-project/codespell - rev: v2.4.2 + rev: v2.4.3 hooks: - id: codespell additional_dependencies: [tomli] exclude: ^test/fixtures/ - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.20 + rev: v0.16.5 hooks: - id: ruff-check args: ["--fix"] diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 9bce80fd2..206d3f492 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -13,6 +13,10 @@ build: # nodejs: "20" # rust: "1.70" # golang: "1.20" + jobs: + install: + - python -m pip install --upgrade --no-cache-dir pip setuptools + - python -m pip install --upgrade --upgrade-strategy only-if-needed --no-cache-dir --group doc . # Build documentation in the "doc/" directory with Sphinx. sphinx: @@ -24,13 +28,3 @@ sphinx: # Optionally build your docs in additional formats such as PDF and ePub. formats: all - -# Optional but recommended, declare the Python requirements required -# to build your documentation. -# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html -python: - install: - - method: pip - path: . - extra_requirements: - - doc diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..6b2963cf4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,49 @@ +# Contribution guidelines + +Before starting work, read and follow [CONTRIBUTING.md](CONTRIBUTING.md), +including the [Prevent agent impersonation](CONTRIBUTING.md#prevent-agent-impersonation) +section governing identification when communicating through a person's account. + +# Commit messages + +Follow Conventional Commits for every commit. Every commit must have a +descriptive title and a substantive body. Title-only commit messages are not +acceptable. + +## Formatting + +Write commit messages in Markdown and assume readers view them with syntax +highlighting. Enclose code identifiers, package and module names, file paths, +and shell commands in backticks. Use Markdown whenever it helps readers +understand or navigate the prose. + +## Titles + +- Every title must use the form `type: description` or + `type(scope): description`. +- Use `feat:` for user-visible features and `fix:` for user-visible fixes. +- Use appropriate prefixes for other changes, such as `docs:`, `test:`, `ci:`, + `build:`, `refactor:`, `perf:`, `style:`, or `chore:`. +- Breaking changes must use `!` immediately before the colon, for example + `feat!:`, `refactor!:`, or a scoped form such as `fix(repo)!:`. +- Optionally scope a commit to the affected component, for example `fix(repo):`. + +Example titles: + +- `feat: add support for a new Git option` +- `fix(repo): handle bare repositories correctly` +- `build!: drop support for an older Python version` +- `ci: add an independent documentation build` +- `refactor(repo): simplify repository initialization` + +## Body + +The body must explain the problem or motivation, what changed, and why the +chosen approach addresses it. Include relevant behavior before and after the +change, design decisions, limitations, and validation results. Scale the detail +to the change; do not add boilerplate or claim checks that were not run. + +Commit messages must stand on their own. Put the information needed to understand +and review the change in the commit body, even when it also appears in a pull +request description. PR and issue links may provide additional context, but must +not substitute for that explanation. diff --git a/README.md b/README.md index 412d38205..c5a069bfe 100644 --- a/README.md +++ b/README.md @@ -101,15 +101,13 @@ In the less common case that you do not want to install test dependencies, `pip #### With editable *dependencies* (not preferred, and rarely needed) -In rare cases, you may want to work on GitPython and one or both of its [gitdb](https://github.com/gitpython-developers/gitdb) and [smmap](https://github.com/gitpython-developers/smmap) dependencies at the same time, with changes in your local working copy of gitdb or smmap immediately reflected in the behavior of your local working copy of GitPython. This can be done by making editable installations of those dependencies in the same virtual environment where you install GitPython. - -If you want to do that *and* you want the versions in GitPython's git submodules to be used, then pass `-e git/ext/gitdb` and/or `-e git/ext/gitdb/gitdb/ext/smmap` to `pip install`. This can be done in any order, and in separate `pip install` commands or the same one, so long as `-e` appears before *each* path. For example, you can install GitPython, gitdb, and smmap editably in the currently active virtual environment this way: +GitPython, [gitdb](gitdb), and [smmap](smmap) live in this repository as independently packaged projects. To work on all three at once, install each one editably in the same virtual environment: ```sh -pip install -e ".[test]" -e git/ext/gitdb -e git/ext/gitdb/gitdb/ext/smmap +pip install -e smmap -e gitdb -e ".[test]" ``` -The submodules must have been cloned for that to work, but that will already be the case if you have run `./init-tests-after-clone.sh`. You can use `pip list` to check which packages are installed editably and which are installed normally. +You can use `pip list` to check which packages are installed editably and which are installed normally. To reiterate, this approach should only rarely be used. For most development it is preferable to allow the gitdb and smmap dependencices to be retrieved automatically from PyPI in their latest stable packaged versions. diff --git a/VERSION b/VERSION index 2c5a9fd4a..4eb2ee669 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.1.52 +3.1.62 diff --git a/doc/requirements.txt b/doc/requirements.txt deleted file mode 100644 index 24472ba39..000000000 --- a/doc/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -sphinx >= 7.4.7, < 8 -sphinx_rtd_theme -sphinx-autodoc-typehints diff --git a/doc/source/changes.rst b/doc/source/changes.rst index 2bfe6dd17..df003c7d3 100644 --- a/doc/source/changes.rst +++ b/doc/source/changes.rst @@ -2,6 +2,162 @@ Changelog ========= +3.1.63 +====== + +Security fixes for + +* https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-gq48-pqfc-9p58 +* https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-23mf-xhv8-69c2 + +If you can, also try and provide feedback on the upcoming v4 branch +https://github.com/gitpython-developers/GitPython/pull/2177 - patches welcome. + +See the following for all changes. +https://github.com/gitpython-developers/GitPython/releases/tag/3.1.63 + +3.1.62 +====== + +Security fixes for + +* https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-59cr-6r3x-644w + +If you can, also try and provide feedback on the upcoming v4 branch +https://github.com/gitpython-developers/GitPython/pull/2177 - patches welcome. + +See the following for all changes. +https://github.com/gitpython-developers/GitPython/releases/tag/3.1.62 + +3.1.61 +====== + +A fixup release to avoid accidental removal of public class regex on Actor. +It's now deprecated instead. + +If you can, also try and provide feedback on the upcoming v4 branch +https://github.com/gitpython-developers/GitPython/pull/2177 - patches welcome. + +See the following for all changes. +https://github.com/gitpython-developers/GitPython/releases/tag/3.1.61 + +3.1.60 +====== + +Security fixes for + +* https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-g5vv-9gxw-82hx +* https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-whh4-5q6c-9v3x +* https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-239g-whfq-7xj9 + +If you can, also try and provide feedback on the upcoming v4 branch +https://github.com/gitpython-developers/GitPython/pull/2177 - patches welcome. + +See the following for all changes. +https://github.com/gitpython-developers/GitPython/releases/tag/3.1.60 + +3.1.59 +====== + +Security fixes for + +* https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-5xxx-qhh7-9287 +* https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-3wxw-xv34-2frg +* https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-8mcc-hrx5-hvxc +* https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-284h-m62q-gf8w +* https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-7833-fr7j-v32q + +If you can, also try and provide feedback on the upcoming v4 branch +https://github.com/gitpython-developers/GitPython/pull/2177 - patches welcome. + +See the following for all changes. +https://github.com/gitpython-developers/GitPython/releases/tag/3.1.59 + +3.1.58 +====== + +Security fixes for + +* https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-hh9p-6wh2-4mfc +* https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-9rj7-rf2p-w77r +* https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-4gmw-gg2m-w46p +* https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-wvpp-8hx9-p66j +* https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-jm78-9fvv-mhgr +* https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-hmq2-w58f-27jc + +If you can, also try and provide feedback on the upcoming v4 branch +https://github.com/gitpython-developers/GitPython/pull/2177 - patches welcome. + +See the following for all changes. +https://github.com/gitpython-developers/GitPython/releases/tag/3.1.58 + +3.1.57 +====== + +A security fix for + +* https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-3f7w-8rr8-f37f +* https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-539m-9xh6-q6rr + +If you can, also try and provide feedback on the upcoming v4 branch +https://github.com/gitpython-developers/GitPython/pull/2177 - patches welcome. + +See the following for all changes. +https://github.com/gitpython-developers/GitPython/releases/tag/3.1.57 + +3.1.56 +====== + +A security fix for + +* https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-p538-c434-8v24 + +If you can, also try and provide feedback on the upcoming v4 branch +https://github.com/gitpython-developers/GitPython/pull/2177 - patches welcome. + +See the following for all changes. +https://github.com/gitpython-developers/GitPython/releases/tag/3.1.56 + +3.1.55 +====== + +A security fix for + +* https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-94p4-4cq8-9g67 + +If you can, also try and provide feedback on the upcoming v4 branch +https://github.com/gitpython-developers/GitPython/pull/2177 - patches welcome. + +See the following for all changes. +https://github.com/gitpython-developers/GitPython/releases/tag/3.1.55 + +3.1.54 +====== + +A security fix for + +* https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-r9mr-m37c-5fr3 +* https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-6p8h-3wgx-97gf +* https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-fjr4-x663-mwxc + +If you can, also try and provide feedback on the upcoming v4 branch +https://github.com/gitpython-developers/GitPython/pull/2177 - patches welcome. + +See the following for all changes. +https://github.com/gitpython-developers/GitPython/releases/tag/3.1.54 + +3.1.53 +====== + +A security fix for +https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-3rp5-jjmw-4wv2 + +If you can, also try and provide feedback on the upcoming v4 branch +https://github.com/gitpython-developers/GitPython/pull/2177 - patches welcome. + +See the following for all changes. +https://github.com/gitpython-developers/GitPython/releases/tag/3.1.53 + 3.1.52 ====== diff --git a/doc/source/intro.rst b/doc/source/intro.rst index d053bd117..e1075b1c9 100644 --- a/doc/source/intro.rst +++ b/doc/source/intro.rst @@ -97,11 +97,6 @@ and cloned using:: $ git clone https://github.com/gitpython-developers/GitPython git-python -Initialize all submodules to obtain the required dependencies with:: - - $ cd git-python - $ git submodule update --init --recursive - Finally verify the installation by running unit tests:: $ python -m unittest diff --git a/doc/source/reference.rst b/doc/source/reference.rst index 13dd38d02..49d732ca2 100644 --- a/doc/source/reference.rst +++ b/doc/source/reference.rst @@ -91,6 +91,7 @@ Objects.Util .. automodule:: git.objects.util :members: :undoc-members: + :private-members: _get_intermediate_items, _list_traverse, _traverse :special-members: Index.Base diff --git a/doc/source/tutorial.rst b/doc/source/tutorial.rst index d095d3be3..1edfa4e11 100644 --- a/doc/source/tutorial.rst +++ b/doc/source/tutorial.rst @@ -78,6 +78,12 @@ Query relevant repository paths ... :class:`Heads ` Heads are branches in git-speak. :class:`References ` are pointers to a specific commit or to other references. Heads and :class:`Tags ` are a kind of references. GitPython allows you to query them rather intuitively. +To obtain the current commit ID, use ``repo.head.commit.hexsha``. This works both +on a branch and with a detached HEAD, provided HEAD resolves to an existing commit. +When ``repo.head.is_detached`` is true, HEAD points directly to a commit and there +is no active branch: reading ``repo.head.reference`` or ``repo.active_branch`` +raises :exc:`TypeError`. The branch examples below assume an attached HEAD. + .. literalinclude:: ../../test/test_docs.py :language: python :dedent: 8 @@ -152,7 +158,7 @@ Examining References :start-after: # [2-test_references_and_objects] :end-before: # ![2-test_references_and_objects] -A :class:`symbolic reference ` is a special case of a reference as it points to another reference instead of a commit. +A :class:`symbolic reference ` can point to another reference. When detached, it points directly to a commit instead. Reading its ``commit`` property resolves the commit in either state. Assigning a commit to ``reference`` detaches it; reading ``reference`` then raises :exc:`TypeError`. .. literalinclude:: ../../test/test_docs.py :language: python diff --git a/git/cmd.py b/git/cmd.py index 1868862d3..3d7446906 100644 --- a/git/cmd.py +++ b/git/cmd.py @@ -100,7 +100,7 @@ def handle_process_output( - process: "Git.AutoInterrupt" | Popen, + process: Union["Git.AutoInterrupt", Popen], stdout_handler: Union[ None, Callable[[AnyStr], None], @@ -395,9 +395,7 @@ def wait(self, stderr: Union[None, str, bytes] = b"") -> int: :raise git.exc.GitCommandError: If the return status is not 0. """ - if stderr is None: - stderr_b = b"" - stderr_b = force_bytes(data=stderr, encoding="utf-8") + stderr_b = force_bytes(data=stderr, encoding="utf-8") or b"" status: Union[int, None] if self.proc is not None: status = self.proc.wait() @@ -652,6 +650,13 @@ class Git(metaclass=_GitMeta): unsafe_git_ls_remote_options = [ # This option allows arbitrary command execution in git-ls-remote. "--upload-pack", + "--exec", + ] + + unsafe_git_pathspec_from_file_options = [ + # Reads pathspecs from a caller-controlled file. Some commands include an + # unmatched pathspec in their error output, which can disclose the file. + "--pathspec-from-file", ] def __getstate__(self) -> Dict[str, Any]: @@ -970,7 +975,9 @@ def _canonicalize_option_name(cls, option: str) -> str: return dashify(option_tokens[0]) @classmethod - def check_unsafe_options(cls, options: List[str], unsafe_options: List[str]) -> None: + def check_unsafe_options( + cls, options: List[str], unsafe_options: List[str], clusterable_short_options: str = "46flnqsv" + ) -> None: """Raise :class:`~git.exc.UnsafeOptionError` for blocked option spellings. In addition to exact matches, this rejects abbreviated long options accepted @@ -1005,7 +1012,7 @@ def check_unsafe_options(cls, options: List[str], unsafe_options: List[str]) -> # These value-less Git flags can be clustered before another short option # (for example, ``-fuVALUE``). Stop at any other character because it may # begin an attached value, as ``o`` does in the safe option ``-oupstream``. - clusterable_short_options = frozenset("46flnqsv") + clusterable_short_options_set = frozenset(clusterable_short_options) options_are_kwargs = all(not option.startswith("-") for option in options) for option in options: candidate = cls._canonicalize_option_name(option) @@ -1022,7 +1029,7 @@ def check_unsafe_options(cls, options: List[str], unsafe_options: List[str]) -> raise UnsafeOptionError( f"{unsafe_option} is not allowed, use `allow_unsafe_options=True` to allow it." ) - if option_char not in clusterable_short_options: + if option_char not in clusterable_short_options_set: break if not (option.startswith("--") or (options_are_kwargs and len(candidate) > 1)): continue @@ -1039,11 +1046,26 @@ def _option_candidates(cls, args: Sequence[Any] = (), kwargs: Optional[Mapping[s option for option in cls._unpack_args([arg for arg in args if arg is not None]) if option.startswith("-") ] if kwargs: + split_single_char_options = kwargs.get("split_single_char_options", True) for key, value in kwargs.items(): values = value if isinstance(value, (list, tuple)) else (value,) if any(value is True or (value is not False and value is not None) for value in values): key = str(key) - options.append(f"-{key}" if len(key) == 1 else f"--{dashify(key)}") + if len(key) != 1: + options.append(f"--{dashify(key)}") + elif split_single_char_options: + options.append(f"-{key}") + options.extend( + str(value) + for value in values + if value is not True and value not in (False, None) and str(value).startswith("-") + ) + else: + options.extend( + f"-{key}" if value is True else f"-{key}{value}" + for value in values + if value is True or (value is not False and value is not None) + ) return options AutoInterrupt: TypeAlias = _AutoInterrupt @@ -1112,7 +1134,7 @@ def ls_remote( """List references in a remote repository. :param allow_unsafe_options: - Allow unsafe options, like ``--upload-pack``. + Allow unsafe options, like ``--upload-pack`` or ``--exec``. """ if not allow_unsafe_options: candidate_options = self._option_candidates(args, kwargs) @@ -1156,52 +1178,93 @@ def version_info(self) -> Tuple[int, ...]: def execute( self, command: Union[str, Sequence[Any]], + istream: Union[None, int, BinaryIO] = None, *, as_process: Literal[True], + **subprocess_kwargs: Any, ) -> "AutoInterrupt": ... @overload def execute( self, command: Union[str, Sequence[Any]], + istream: Union[None, int, BinaryIO] = None, *, as_process: Literal[False] = False, - stdout_as_string: Literal[True], - ) -> Union[str, Tuple[int, str, str]]: ... + with_extended_output: Literal[False] = False, + stdout_as_string: Literal[True] = True, + with_stdout: Literal[True] = True, + **subprocess_kwargs: Any, + ) -> str: ... @overload def execute( self, command: Union[str, Sequence[Any]], + istream: Union[None, int, BinaryIO] = None, *, as_process: Literal[False] = False, - stdout_as_string: Literal[False] = False, - ) -> Union[bytes, Tuple[int, bytes, str]]: ... + with_extended_output: Literal[False] = False, + stdout_as_string: Literal[False], + universal_newlines: Literal[False] = False, + with_stdout: Literal[True] = True, + **subprocess_kwargs: Any, + ) -> bytes: ... @overload def execute( self, command: Union[str, Sequence[Any]], + istream: Union[None, int, BinaryIO] = None, *, - with_extended_output: Literal[False], - as_process: Literal[False], - stdout_as_string: Literal[True], - ) -> str: ... + as_process: Literal[False] = False, + with_extended_output: Literal[True], + stdout_as_string: Literal[True] = True, + with_stdout: Literal[True] = True, + **subprocess_kwargs: Any, + ) -> Tuple[int, str, str]: ... @overload def execute( self, command: Union[str, Sequence[Any]], + istream: Union[None, int, BinaryIO] = None, *, - with_extended_output: Literal[False], - as_process: Literal[False], + as_process: Literal[False] = False, + with_extended_output: Literal[True], stdout_as_string: Literal[False], - ) -> bytes: ... + universal_newlines: Literal[False] = False, + with_stdout: Literal[True] = True, + **subprocess_kwargs: Any, + ) -> Tuple[int, bytes, str]: ... + + @overload + def execute( + self, + command: Union[str, Sequence[Any]], + istream: Union[None, int, BinaryIO] = None, + *, + as_process: Literal[False] = False, + with_extended_output: Literal[True], + **subprocess_kwargs: Any, + ) -> Tuple[int, Union[str, bytes, None], str]: ... + + @overload + def execute( + self, + command: Union[str, Sequence[Any]], + istream: Union[None, int, BinaryIO] = None, + *, + as_process: Literal[False] = False, + with_extended_output: Literal[False] = False, + **subprocess_kwargs: Any, + ) -> Union[str, bytes, None]: ... + @overload def execute( self, command: Union[str, Sequence[Any]], - istream: Union[None, BinaryIO] = None, + istream: Union[None, int, BinaryIO] = None, with_extended_output: bool = False, with_exceptions: bool = True, as_process: bool = False, @@ -1215,7 +1278,26 @@ def execute( max_chunk_size: int = io.DEFAULT_BUFFER_SIZE, strip_newline_in_stdout: bool = True, **subprocess_kwargs: Any, - ) -> Union[str, bytes, Tuple[int, Union[str, bytes], str], AutoInterrupt]: + ) -> Union[None, str, bytes, Tuple[int, Union[str, bytes, None], str], AutoInterrupt]: ... + + def execute( + self, + command: Union[str, Sequence[Any]], + istream: Union[None, int, BinaryIO] = None, + with_extended_output: bool = False, + with_exceptions: bool = True, + as_process: bool = False, + output_stream: Union[None, BinaryIO] = None, + stdout_as_string: bool = True, + kill_after_timeout: Union[None, float] = None, + with_stdout: bool = True, + universal_newlines: bool = False, + shell: Union[None, bool] = None, + env: Union[None, Mapping[str, str]] = None, + max_chunk_size: int = io.DEFAULT_BUFFER_SIZE, + strip_newline_in_stdout: bool = True, + **subprocess_kwargs: Any, + ) -> Union[None, str, bytes, Tuple[int, Union[str, bytes, None], str], AutoInterrupt]: R"""Handle executing the command, and consume and return the returned information (stdout). @@ -1279,9 +1361,9 @@ def execute( carefully considered, due to the following limitations: 1. This feature is not supported at all on Windows. - 2. Effectiveness may vary by operating system. ``ps --ppid`` is used to - enumerate child processes, which is available on most GNU/Linux systems - but not most others. + 2. Enumerating child processes requires ``pgrep -P``, or a ``ps`` command + supporting the POSIX ``-A`` and ``-o`` options if ``pgrep`` is not + installed. Effectiveness may vary on systems without these commands. 3. Deeper descendants do not receive signals, though they may sometimes terminate as a consequence of their parent processes being killed. 4. `kill_after_timeout` uses ``SIGKILL``, which can have negative side @@ -1428,6 +1510,10 @@ def execute( if as_process: return self.AutoInterrupt(proc, command) + watchdog: Optional[threading.Timer] = None + kill_check: Optional[threading.Event] = None + timeout_error: Optional[Callable[[], Union[str, bytes]]] = None + if sys.platform != "win32" and kill_after_timeout is not None: # Help mypy figure out this is not None even when used inside communicate(). timeout = kill_after_timeout @@ -1437,14 +1523,24 @@ def kill_process(pid: int) -> None: This callback implementation would be ineffective and unsafe on Windows. """ - p = Popen(["ps", "--ppid", str(pid)], stdout=PIPE) child_pids = [] - if p.stdout is not None: - for line in p.stdout: - if len(line.split()) > 0: - local_pid = (line.split())[0] - if local_pid.isdigit(): - child_pids.append(int(local_pid)) + try: + p = Popen(["pgrep", "-P", str(pid)], stdout=PIPE) + except FileNotFoundError: + # POSIX ps does not support selecting by parent PID. + with Popen(["ps", "-A", "-o", "pid=", "-o", "ppid="], stdout=PIPE) as p: + if p.stdout is not None: + for line in p.stdout: + fields = line.split() + if len(fields) == 2 and all(field.isdigit() for field in fields): + if int(fields[1]) == pid: + child_pids.append(int(fields[0])) + else: + with p: + if p.stdout is not None: + for line in p.stdout: + if line.strip().isdigit(): + child_pids.append(int(line)) try: os.kill(pid, signal.SIGKILL) for child_pid in child_pids: @@ -1453,6 +1549,7 @@ def kill_process(pid: int) -> None: except OSError: pass # Tell the main routine that the process was killed. + assert kill_check is not None kill_check.set() except OSError: # It is possible that the process gets completed in the duration @@ -1460,30 +1557,32 @@ def kill_process(pid: int) -> None: pass return - def communicate() -> Tuple[AnyStr, AnyStr]: + def make_timeout_error() -> Union[str, bytes]: + err = f'Timeout: the command "{" ".join(redacted_command)}" did not complete in {timeout:g} secs.' + return err if universal_newlines else err.encode(defenc) + + def communicate() -> Tuple[Union[str, bytes, None], Union[str, bytes, None]]: + assert watchdog is not None + assert kill_check is not None watchdog.start() out, err = proc.communicate() watchdog.cancel() if kill_check.is_set(): - err = 'Timeout: the command "%s" did not complete in %d secs.' % ( - " ".join(redacted_command), - timeout, - ) - if not universal_newlines: - err = err.encode(defenc) + err = make_timeout_error() return out, err # END helpers kill_check = threading.Event() watchdog = threading.Timer(timeout, kill_process, args=(proc.pid,)) + timeout_error = make_timeout_error else: communicate = proc.communicate # Wait for the process to return. status = 0 - stdout_value: Union[str, bytes] = b"" - stderr_value: Union[str, bytes] = b"" + stdout_value: Union[str, bytes, None] = b"" + stderr_value: Union[str, bytes, None] = b"" newline = "\n" if universal_newlines else b"\n" try: if output_stream is None: @@ -1497,15 +1596,24 @@ def communicate() -> Tuple[AnyStr, AnyStr]: status = proc.returncode else: max_chunk_size = max_chunk_size if max_chunk_size and max_chunk_size > 0 else io.DEFAULT_BUFFER_SIZE - if proc.stdout is not None: - stream_copy(proc.stdout, output_stream, max_chunk_size) - stdout_value = proc.stdout.read() - if proc.stderr is not None: - stderr_value = proc.stderr.read() + if watchdog is not None: + watchdog.start() + try: + if proc.stdout is not None: + stream_copy(proc.stdout, output_stream, max_chunk_size) + stdout_value = proc.stdout.read() + if proc.stderr is not None: + stderr_value = proc.stderr.read() + status = proc.wait() + finally: + if watchdog is not None: + watchdog.cancel() # Strip trailing "\n". if stderr_value is not None and stderr_value.endswith(newline): # type: ignore[arg-type] stderr_value = stderr_value[:-1] - status = proc.wait() + if kill_check is not None and kill_check.is_set(): + assert timeout_error is not None + stderr_value = timeout_error() # END stdout handling finally: if proc.stdout is not None: @@ -1516,7 +1624,7 @@ def communicate() -> Tuple[AnyStr, AnyStr]: if self.GIT_PYTHON_TRACE == "full": cmdstr = " ".join(redacted_command) - def as_text(stdout_value: Union[bytes, str]) -> str: + def as_text(stdout_value: Union[bytes, str, None]) -> str: return not output_stream and safe_decode(stdout_value) or "" # END as_text @@ -1541,6 +1649,8 @@ def as_text(stdout_value: Union[bytes, str]) -> str: if isinstance(stdout_value, bytes) and stdout_as_string: # Could also be output_stream. stdout_value = safe_decode(stdout_value) + # stderr is always captured through PIPE. + assert stderr_value is not None # Allow access to the command's status code. if with_extended_output: return (status, stdout_value, safe_decode(stderr_value)) @@ -1779,7 +1889,7 @@ def _parse_object_header(self, header_line: str) -> Tuple[str, str, int]: raise ValueError("Failed to parse header: %r" % header_line) return (tokens[0], tokens[1], int(tokens[2])) - def _prepare_ref(self, ref: AnyStr) -> bytes: + def _prepare_ref(self, ref: object) -> bytes: # Required for command to separate refs on stdin, as bytes. if isinstance(ref, bytes): # Assume 40 bytes hexsha - bin-to-ascii for some reason returns bytes, not text. @@ -1806,7 +1916,7 @@ def _get_persistent_cmd(self, attr_name: str, cmd_name: str, *args: Any, **kwarg cmd = cast("Git.AutoInterrupt", cmd) return cmd - def __get_object_header(self, cmd: "Git.AutoInterrupt", ref: AnyStr) -> Tuple[str, str, int]: + def __get_object_header(self, cmd: "Git.AutoInterrupt", ref: Union[str, bytes]) -> Tuple[str, str, int]: if cmd.stdin and cmd.stdout: cmd.stdin.write(self._prepare_ref(ref)) cmd.stdin.flush() @@ -1814,7 +1924,7 @@ def __get_object_header(self, cmd: "Git.AutoInterrupt", ref: AnyStr) -> Tuple[st else: raise ValueError("cmd stdin was empty") - def get_object_header(self, ref: str) -> Tuple[str, str, int]: + def get_object_header(self, ref: Union[str, bytes]) -> Tuple[str, str, int]: """Use this method to quickly examine the type and size of the object behind the given ref. @@ -1828,7 +1938,7 @@ def get_object_header(self, ref: str) -> Tuple[str, str, int]: cmd = self._get_persistent_cmd("cat_file_header", "cat_file", batch_check=True) return self.__get_object_header(cmd, ref) - def get_object_data(self, ref: str) -> Tuple[str, str, int, bytes]: + 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. :return: @@ -1842,7 +1952,7 @@ def get_object_data(self, ref: str) -> Tuple[str, str, int, bytes]: del stream return (hexsha, typename, size, data) - def stream_object_data(self, ref: str) -> Tuple[str, str, int, "Git.CatFileContentStream"]: + def stream_object_data(self, ref: Union[str, bytes]) -> Tuple[str, str, int, "Git.CatFileContentStream"]: """Similar to :meth:`get_object_data`, but returns the data as a stream. :return: diff --git a/git/config.py b/git/config.py index 64f501424..f54b4b97e 100644 --- a/git/config.py +++ b/git/config.py @@ -10,36 +10,34 @@ import abc import configparser as cp import fnmatch -from functools import wraps import inspect -from io import BufferedReader, IOBase import logging import os import os.path as osp import re import sys - -from git.compat import defenc, force_text -from git.util import LockFile +from functools import wraps +from io import BufferedReader, IOBase # typing------------------------------------------------------- - from typing import ( + IO, + TYPE_CHECKING, Any, Callable, + Dict, Generic, - IO, List, - Dict, Sequence, - TYPE_CHECKING, Tuple, TypeVar, Union, cast, ) -from git.types import Lit_config_levels, ConfigLevels_Tup, PathLike, assert_never, _T +from git.compat import defenc, force_text +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 @@ -47,7 +45,7 @@ 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) +T_OMD_value = TypeVar("T_OMD_value", str, bytes, int, float, bool, None) if sys.version_info[:3] < (3, 7, 2): # typing.Ordereddict not added until Python 3.7.2. @@ -66,7 +64,7 @@ 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):(.+)\"") +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 @@ -75,6 +73,9 @@ 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 @@ -202,41 +203,67 @@ def __exit__(self, exception_type: str, exception_value: str, traceback: str) -> self._config.__exit__(exception_type, exception_value, traceback) +def _normalize_name(name: str) -> str: + """Fold section and option names, leaving quoted subsections unchanged.""" + prefix, separator, subsection = name.partition('"') + return prefix.lower() + separator + subsection + + class _OMD(OrderedDict_OMD): - """Ordered multi-dict.""" + """Ordered multi-dict matching config names while retaining their first spelling.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + self._keymap: Dict[str, str] = {} + super().__init__(*args, **kwargs) + + def _key(self, key: str) -> str: + stored = self._keymap.get(_normalize_name(key), key) + return stored if super().__contains__(stored) else key + + def __contains__(self, key: object) -> bool: + return isinstance(key, str) and super().__contains__(self._key(key)) + + def __delitem__(self, key: str) -> None: + super().__delitem__(self._key(key)) + del self._keymap[_normalize_name(key)] def __setitem__(self, key: str, value: _T) -> None: - super().__setitem__(key, [value]) + self.setall(key, [value]) + + def clear(self) -> None: + super().clear() + self._keymap.clear() def add(self, key: str, value: Any) -> None: if key not in self: - super().__setitem__(key, [value]) + self[key] = value return - super().__getitem__(key).append(value) + self.getall(key).append(value) def setall(self, key: str, values: List[_T]) -> None: + key = self._key(key) super().__setitem__(key, values) + self._keymap[_normalize_name(key)] = key def __getitem__(self, key: str) -> Any: - return super().__getitem__(key)[-1] + return super().__getitem__(self._key(key))[-1] def getlast(self, key: str) -> Any: - return super().__getitem__(key)[-1] + return self[key] def setlast(self, key: str, value: Any) -> None: if key not in self: - super().__setitem__(key, [value]) + self[key] = value return - prior = super().__getitem__(key) - prior[-1] = value + self.getall(key)[-1] = value def get(self, key: str, default: Union[_T, None] = None) -> Union[_T, None]: - return super().get(key, [default])[-1] + return super().get(self._key(key), [default])[-1] def getall(self, key: str) -> List[_T]: - return super().__getitem__(key) + return super().__getitem__(self._key(key)) def items(self) -> List[Tuple[str, _T]]: # type: ignore[override] """List of (key, last value for key).""" @@ -285,11 +312,18 @@ class GitConfigParser(cp.RawConfigParser, metaclass=MetaParserBuilder): other instances to write concurrently. :note: - The config is case-sensitive even when queried, hence section and option names - must match perfectly. + 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. :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`. """ # { Configuration @@ -303,7 +337,7 @@ class GitConfigParser(cp.RawConfigParser, metaclass=MetaParserBuilder): re_comment = re.compile(r"^\s*[#;]") # } END configuration - optvalueonly_source = r"\s*(?P