From 0b45d122c68cd7de9e0fbcd9f6cc336bee9371fc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 5 Jan 2026 13:05:40 +0000 Subject: [PATCH 01/57] Bump git/ext/gitdb from `335c0f6` to `4c63ee6` Bumps [git/ext/gitdb](https://github.com/gitpython-developers/gitdb) from `335c0f6` to `4c63ee6`. - [Release notes](https://github.com/gitpython-developers/gitdb/releases) - [Commits](https://github.com/gitpython-developers/gitdb/compare/335c0f66173eecdc7b2597c2b6c3d1fde795df30...4c63ee6636a6a3370f58b05d0bd19fec2f16dd5a) --- updated-dependencies: - dependency-name: git/ext/gitdb dependency-version: 4c63ee6636a6a3370f58b05d0bd19fec2f16dd5a dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- git/ext/gitdb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git/ext/gitdb b/git/ext/gitdb index 335c0f661..4c63ee663 160000 --- a/git/ext/gitdb +++ b/git/ext/gitdb @@ -1 +1 @@ -Subproject commit 335c0f66173eecdc7b2597c2b6c3d1fde795df30 +Subproject commit 4c63ee6636a6a3370f58b05d0bd19fec2f16dd5a From acc6a8cc83188b107344797cdf6b88398fef0676 Mon Sep 17 00:00:00 2001 From: Ilyas Timour Date: Sat, 10 Jan 2026 11:07:29 +0100 Subject: [PATCH 02/57] DOC: README Add urls and updated a relative url --- README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 59c6f995b..412d38205 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ by setting the `GIT_PYTHON_GIT_EXECUTABLE=` environment variable. - Git (1.7.x or newer) - Python >= 3.7 -The list of dependencies are listed in `./requirements.txt` and `./test-requirements.txt`. +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). The installer takes care of installing them for you. ### INSTALL @@ -180,7 +180,7 @@ Style and formatting checks, and running tests on all the different supported Py #### Configuration files -Specific tools are all configured in the `./pyproject.toml` file: +Specific tools are all configured in the [`./pyproject.toml`](https://github.com/gitpython-developers/GitPython/blob/main/pyproject.toml) file: - `pytest` (test runner) - `coverage.py` (code coverage) @@ -189,9 +189,9 @@ Specific tools are all configured in the `./pyproject.toml` file: Orchestration tools: -- Configuration for `pre-commit` is in the `./.pre-commit-config.yaml` file. -- Configuration for `tox` is in `./tox.ini`. -- Configuration for GitHub Actions (CI) is in files inside `./.github/workflows/`. +- Configuration for `pre-commit` is in the [`./.pre-commit-config.yaml`](https://github.com/gitpython-developers/GitPython/blob/main/.pre-commit-config.yaml) file. +- Configuration for `tox` is in [`./tox.ini`](https://github.com/gitpython-developers/GitPython/blob/main/tox.ini). +- Configuration for GitHub Actions (CI) is in files inside [`./.github/workflows/`](https://github.com/gitpython-developers/GitPython/tree/main/.github/workflows). ### Contributions @@ -212,8 +212,8 @@ Please have a look at the [contributions file][contributing]. ### How to make a new release -1. Update/verify the **version** in the `VERSION` file. -2. Update/verify that the `doc/source/changes.rst` changelog file was updated. It should include a link to the forthcoming release page: `https://github.com/gitpython-developers/GitPython/releases/tag/` +1. Update/verify the **version** in the [`VERSION`](https://github.com/gitpython-developers/GitPython/blob/main/VERSION) file. +2. Update/verify that the [`doc/source/changes.rst`](https://github.com/gitpython-developers/GitPython/blob/main/doc/source/changes.rst) changelog file was updated. It should include a link to the forthcoming release page: `https://github.com/gitpython-developers/GitPython/releases/tag/` 3. Commit everything. 4. Run `git tag -s ` to tag the version in Git. 5. _Optionally_ create and activate a [virtual environment](https://packaging.python.org/en/latest/guides/installing-using-pip-and-virtual-environments/#creating-a-virtual-environment). (Then the next step can install `build` and `twine`.) @@ -240,7 +240,7 @@ Please have a look at the [contributions file][contributing]. [3-Clause BSD License](https://opensource.org/license/bsd-3-clause/), also known as the New BSD License. See the [LICENSE file][license]. -One file exclusively used for fuzz testing is subject to [a separate license, detailed here](./fuzzing/README.md#license). +One file exclusively used for fuzz testing is subject to [a separate license, detailed here](https://github.com/gitpython-developers/GitPython/blob/main/fuzzing/README.md#license). This file is not included in the wheel or sdist packages published by the maintainers of GitPython. [contributing]: https://github.com/gitpython-developers/GitPython/blob/main/CONTRIBUTING.md From 1a74bce18b5492a3cec9b839a4cb706d78eca466 Mon Sep 17 00:00:00 2001 From: danielyan Date: Mon, 9 Feb 2026 20:03:39 +0000 Subject: [PATCH 03/57] Fix GitConfigParser ignoring multiple [include] path entries When an [include] section has multiple entries with the same key (e.g. multiple 'path' values), only the last one was respected. This is because _included_paths() used self.items(section) which delegates to _OMD.items(), and _OMD.__getitem__ returns only the last value for a given key. Fix by using _OMD.items_all() to retrieve all values for each key in the include/includeIf sections, ensuring all paths are processed. Fixes gitpython-developers#2099 --- git/config.py | 18 ++++++++++++++---- test/test_config.py | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/git/config.py b/git/config.py index 769929441..c6eaf8f7b 100644 --- a/git/config.py +++ b/git/config.py @@ -549,11 +549,21 @@ def _included_paths(self) -> List[Tuple[str, str]]: :return: The list of paths, where each path is a tuple of (option, value). """ + + def _all_items(section: str) -> List[Tuple[str, str]]: + """Return all (key, value) pairs for a section, including duplicate keys.""" + return [ + (key, value) + for key, values in self._sections[section].items_all() + if key != "__name__" + for value in values + ] + paths = [] for section in self.sections(): if section == "include": - paths += self.items(section) + paths += _all_items(section) match = CONDITIONAL_INCLUDE_REGEXP.search(section) if match is None or self._repo is None: @@ -579,7 +589,7 @@ def _included_paths(self) -> List[Tuple[str, str]]: ) if self._repo.git_dir: if fnmatch.fnmatchcase(os.fspath(self._repo.git_dir), value): - paths += self.items(section) + paths += _all_items(section) elif keyword == "onbranch": try: @@ -589,11 +599,11 @@ def _included_paths(self) -> List[Tuple[str, str]]: continue if fnmatch.fnmatchcase(branch_name, value): - paths += self.items(section) + paths += _all_items(section) elif keyword == "hasconfig:remote.*.url": for remote in self._repo.remotes: if fnmatch.fnmatchcase(remote.url, value): - paths += self.items(section) + paths += _all_items(section) break return paths diff --git a/test/test_config.py b/test/test_config.py index 56ac0f304..11ea52d16 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -246,6 +246,43 @@ def check_test_value(cr, value): with GitConfigParser(fpa, read_only=True) as cr: check_test_value(cr, tv) + @with_rw_directory + def test_multiple_include_paths_with_same_key(self, rw_dir): + """Test that multiple 'path' entries under [include] are all respected. + + Regression test for https://github.com/gitpython-developers/GitPython/issues/2099. + Git config allows multiple ``path`` values under ``[include]``, e.g.:: + + [include] + path = file1 + path = file2 + + Previously only one of these was included because _OMD.items() returns + only the last value for each key. + """ + # Create two config files to be included. + fp_inc1 = osp.join(rw_dir, "inc1.cfg") + fp_inc2 = osp.join(rw_dir, "inc2.cfg") + fp_main = osp.join(rw_dir, "main.cfg") + + with GitConfigParser(fp_inc1, read_only=False) as cw: + cw.set_value("user", "name", "from-inc1") + + with GitConfigParser(fp_inc2, read_only=False) as cw: + cw.set_value("core", "bar", "from-inc2") + + # Write a config with two path entries under a single [include] section. + # We write it manually because set_value would overwrite the key. + with open(fp_main, "w") as f: + f.write("[include]\n") + f.write(f"\tpath = {fp_inc1}\n") + f.write(f"\tpath = {fp_inc2}\n") + + with GitConfigParser(fp_main, read_only=True) as cr: + # Both included files should be loaded. + assert cr.get_value("user", "name") == "from-inc1" + assert cr.get_value("core", "bar") == "from-inc2" + @pytest.mark.xfail( sys.platform == "win32", reason='Second config._has_includes() assertion fails (for "config is included if path is matching git_dir")', From 2f6e5441b323a7fbde672e3df30e564628d43371 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Mon, 9 Mar 2026 12:32:24 -0400 Subject: [PATCH 04/57] Switch back from Alpine to Debian for WSL For #2107. Note that this does not affect the container workflow `alpine-test.yml` workflow (that is, it doesn't affect actually running the test suite in Alpine Linux, which continues to work), only WSL in Windows jobs in the main workflow `pythonpackage.yml`. --- .github/workflows/pythonpackage.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index ac764d9a7..6c5cf4552 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -54,8 +54,7 @@ jobs: uses: Vampire/setup-wsl@v6.0.0 with: wsl-version: 1 - distribution: Alpine - additional-packages: bash + distribution: Debian - name: Prepare this repo for tests run: | From 9648077f1913b41498b55f253a5188b4ba4b27dc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:58:20 +0000 Subject: [PATCH 05/57] Bump git/ext/gitdb from `4c63ee6` to `5c1b303` Bumps [git/ext/gitdb](https://github.com/gitpython-developers/gitdb) from `4c63ee6` to `5c1b303`. - [Release notes](https://github.com/gitpython-developers/gitdb/releases) - [Commits](https://github.com/gitpython-developers/gitdb/compare/4c63ee6636a6a3370f58b05d0bd19fec2f16dd5a...5c1b3036a6e34782e0ab6ce85e5ae64fe777fdbe) --- updated-dependencies: - dependency-name: git/ext/gitdb dependency-version: 5c1b3036a6e34782e0ab6ce85e5ae64fe777fdbe dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- git/ext/gitdb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git/ext/gitdb b/git/ext/gitdb index 4c63ee663..5c1b3036a 160000 --- a/git/ext/gitdb +++ b/git/ext/gitdb @@ -1 +1 @@ -Subproject commit 4c63ee6636a6a3370f58b05d0bd19fec2f16dd5a +Subproject commit 5c1b3036a6e34782e0ab6ce85e5ae64fe777fdbe From 98b78d2c1558e4d2fef1d52ecfd30c15f181c38b Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Mon, 9 Mar 2026 13:27:16 -0400 Subject: [PATCH 06/57] Run `gc.collect()` twice in `test_rename` on Python 3.12 Recently, the conditional `gc.collect()` step for Python >= 3.12 in `TestSubmodule.test_rename` is often insufficient. This has mainly been seen in #2248. For example: https://github.com/gitpython-developers/GitPython/actions/runs/22864869684/job/66331124651?pr=2106#step:12:620 In principle, there can be situations with finalizers where a cycle is only collectable due to finalization that happened due to a previous collection. Therefore, there is occasionally a benefit to collecting twice. This does that, in the hope that it will help. --- test/test_submodule.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test/test_submodule.py b/test/test_submodule.py index 2bf0940c9..47647f2a1 100644 --- a/test/test_submodule.py +++ b/test/test_submodule.py @@ -1011,6 +1011,7 @@ def test_rename(self, rwdir): # garbage collector detailed in https://github.com/python/cpython/issues/97922.) if sys.platform == "win32" and sys.version_info >= (3, 12): gc.collect() + gc.collect() # Some finalizer scenarios need two collections, at least in theory. new_path = "renamed/myname" assert sm.move(new_path).name == new_path From 3cfd22938581631cc22f80ea751eecea1747a1d8 Mon Sep 17 00:00:00 2001 From: Luca Weyrich Date: Wed, 25 Feb 2026 09:04:29 +0100 Subject: [PATCH 07/57] fix: ignore AutoInterrupt terminate errors during shutdown --- git/cmd.py | 8 ++++++-- test/test_autointerrupt.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 test/test_autointerrupt.py diff --git a/git/cmd.py b/git/cmd.py index 15d7820df..78a9f4c78 100644 --- a/git/cmd.py +++ b/git/cmd.py @@ -368,8 +368,12 @@ def _terminate(self) -> None: status = proc.wait() # Ensure the process goes away. self.status = self._status_code_if_terminate or status - except OSError as ex: - _logger.info("Ignored error after process had died: %r", ex) + except (OSError, AttributeError) as ex: + # On interpreter shutdown (notably on Windows), parts of the stdlib used by + # subprocess can already be torn down (e.g. `subprocess._winapi` becomes None), + # which can cause AttributeError during terminate(). In that case, we prefer + # to silently ignore to avoid noisy "Exception ignored in: __del__" messages. + _logger.info("Ignored error while terminating process: %r", ex) # END exception handling def __del__(self) -> None: diff --git a/test/test_autointerrupt.py b/test/test_autointerrupt.py new file mode 100644 index 000000000..56e101efb --- /dev/null +++ b/test/test_autointerrupt.py @@ -0,0 +1,35 @@ +import pytest + +from git.cmd import Git + + +class _DummyProc: + """Minimal stand-in for subprocess.Popen used to exercise AutoInterrupt. + + We deliberately raise AttributeError from terminate() to simulate interpreter + shutdown on Windows where subprocess internals (e.g. subprocess._winapi) may + already be torn down. + """ + + stdin = None + stdout = None + stderr = None + + def poll(self): + return None + + def terminate(self): + raise AttributeError("TerminateProcess") + + def wait(self): # pragma: no cover - should not be reached in this test + raise AssertionError("wait() should not be called if terminate() fails") + + +def test_autointerrupt_terminate_ignores_attributeerror(): + ai = Git.AutoInterrupt(_DummyProc(), args=["git", "rev-list"]) + + # Should not raise, even if terminate() triggers AttributeError. + ai._terminate() + + # Ensure the reference is cleared to avoid repeated attempts. + assert ai.proc is None From 357aad19912087b9819844acb3b904cf3c23b29e Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Mon, 9 Mar 2026 13:16:43 -0400 Subject: [PATCH 08/57] Remove unnecessary `pytest` import in a test module --- test/test_autointerrupt.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/test_autointerrupt.py b/test/test_autointerrupt.py index 56e101efb..645ec402c 100644 --- a/test/test_autointerrupt.py +++ b/test/test_autointerrupt.py @@ -1,5 +1,3 @@ -import pytest - from git.cmd import Git From 078f3514f5b70052c0126e03713f1e543b26e8c3 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Mon, 9 Mar 2026 14:29:43 -0400 Subject: [PATCH 09/57] Run the the `pre-commit` CI job on `ubuntu-slim` The `ubuntu-slim` runner is lighter weight, being a container rather than using a whole VM, and having only one vCPU, less RAM, and a 15 minute time limit. It's not suitable for most of our CI jobs in GitPython, but it should work well for our `pre-commit` checks. (If it doesn't, that's reason to suspect they might be better removed from `pre-commit` and run in a different way.) - https://github.blog/changelog/2026-01-22-1-vcpu-linux-runner-now-generally-available-in-github-actions/ - https://github.com/actions/runner-images/blob/main/images/ubuntu-slim/ubuntu-slim-Readme.md --- .github/workflows/lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 956b38963..e32e946c8 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -7,7 +7,7 @@ permissions: jobs: lint: - runs-on: ubuntu-latest + runs-on: ubuntu-slim steps: - uses: actions/checkout@v6 From 35db8094038a817780f614a518663362aec11a4c Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Mon, 9 Mar 2026 14:38:42 -0400 Subject: [PATCH 10/57] Keep `pre-commit` hooks up to date using Dependabot - Add `pre-commit` as an ecosystem for Dependabot version updates, now that it is available as a beta ecosystem. Enable beta ecosystems to allow this. - Group the updates and use a monthly cadence to avoid getting swamped by frequent automated PRs. - It would be valuable in the future to Use a 7-day cooldown period rather than taking new versions immediately once released. (This may also be of value to developers who use `pre-commit` locally.) However, this doesn't do that, since the Dependabot ecosystem for `pre-commit` does not currently support `cooldown`. - Use a less busy style (less unnecessary quoting) than was being used in `dependabot.yml` before, since this new stanza is more elaborate than before. Apply that style to the existing stanzas for consistency. --- .github/dependabot.yml | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 2fe73ca77..16d5f11bc 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,11 +1,20 @@ version: 2 +enable-beta-ecosystems: true updates: -- package-ecosystem: "github-actions" +- package-ecosystem: github-actions directory: "/" schedule: - interval: "weekly" + interval: weekly -- package-ecosystem: "gitsubmodule" +- package-ecosystem: gitsubmodule directory: "/" schedule: - interval: "weekly" + interval: weekly + +- package-ecosystem: pre-commit + directory: "/" + schedule: + interval: monthly + groups: + pre-commit: + patterns: ["*"] From a4bc27a7ae6b74df397de3f15fd3ee97fa06d28c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 19:48:14 +0000 Subject: [PATCH 11/57] Bump the pre-commit group with 5 updates Bumps the pre-commit group with 5 updates: | Package | From | To | | --- | --- | --- | | [https://github.com/codespell-project/codespell](https://github.com/codespell-project/codespell) | `v2.4.1` | `2.4.2` | | [https://github.com/astral-sh/ruff-pre-commit](https://github.com/astral-sh/ruff-pre-commit) | `v0.11.12` | `0.15.5` | | [https://github.com/shellcheck-py/shellcheck-py](https://github.com/shellcheck-py/shellcheck-py) | `v0.10.0.1` | `0.11.0.1` | | [https://github.com/pre-commit/pre-commit-hooks](https://github.com/pre-commit/pre-commit-hooks) | `v5.0.0` | `6.0.0` | | [https://github.com/abravalheri/validate-pyproject](https://github.com/abravalheri/validate-pyproject) | `v0.24.1` | `0.25` | Updates `https://github.com/codespell-project/codespell` from v2.4.1 to 2.4.2 - [Release notes](https://github.com/codespell-project/codespell/releases) - [Commits](https://github.com/codespell-project/codespell/compare/v2.4.1...v2.4.2) Updates `https://github.com/astral-sh/ruff-pre-commit` from v0.11.12 to 0.15.5 - [Release notes](https://github.com/astral-sh/ruff-pre-commit/releases) - [Commits](https://github.com/astral-sh/ruff-pre-commit/compare/v0.11.12...v0.15.5) Updates `https://github.com/shellcheck-py/shellcheck-py` from v0.10.0.1 to 0.11.0.1 - [Commits](https://github.com/shellcheck-py/shellcheck-py/compare/v0.10.0.1...v0.11.0.1) Updates `https://github.com/pre-commit/pre-commit-hooks` from v5.0.0 to 6.0.0 - [Release notes](https://github.com/pre-commit/pre-commit-hooks/releases) - [Changelog](https://github.com/pre-commit/pre-commit-hooks/blob/main/CHANGELOG.md) - [Commits](https://github.com/pre-commit/pre-commit-hooks/compare/v5.0.0...v6.0.0) Updates `https://github.com/abravalheri/validate-pyproject` from v0.24.1 to 0.25 - [Release notes](https://github.com/abravalheri/validate-pyproject/releases) - [Changelog](https://github.com/abravalheri/validate-pyproject/blob/main/CHANGELOG.rst) - [Commits](https://github.com/abravalheri/validate-pyproject/compare/v0.24.1...v0.25) --- updated-dependencies: - dependency-name: https://github.com/codespell-project/codespell dependency-version: 2.4.2 dependency-type: direct:production dependency-group: pre-commit - dependency-name: https://github.com/astral-sh/ruff-pre-commit dependency-version: 0.15.5 dependency-type: direct:production dependency-group: pre-commit - dependency-name: https://github.com/shellcheck-py/shellcheck-py dependency-version: 0.11.0.1 dependency-type: direct:production dependency-group: pre-commit - dependency-name: https://github.com/pre-commit/pre-commit-hooks dependency-version: 6.0.0 dependency-type: direct:production dependency-group: pre-commit - dependency-name: https://github.com/abravalheri/validate-pyproject dependency-version: '0.25' dependency-type: direct:production dependency-group: pre-commit ... Signed-off-by: dependabot[bot] --- .pre-commit-config.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 737b56d45..3bd9cbce9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,13 +1,13 @@ repos: - repo: https://github.com/codespell-project/codespell - rev: v2.4.1 + rev: v2.4.2 hooks: - id: codespell additional_dependencies: [tomli] exclude: ^test/fixtures/ - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.12 + rev: v0.15.5 hooks: - id: ruff-check args: ["--fix"] @@ -16,14 +16,14 @@ repos: exclude: ^git/ext/ - repo: https://github.com/shellcheck-py/shellcheck-py - rev: v0.10.0.1 + rev: v0.11.0.1 hooks: - id: shellcheck args: [--color] exclude: ^test/fixtures/polyglot$|^git/ext/ - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: - id: end-of-file-fixer exclude: ^test/fixtures/|COPYING|LICENSE @@ -33,6 +33,6 @@ repos: - id: check-merge-conflict - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.24.1 + rev: v0.25 hooks: - id: validate-pyproject From d1ab2e40b3bd03f84dcf440ed920bf7ab512366f Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Mon, 9 Mar 2026 14:17:27 -0400 Subject: [PATCH 12/57] Test free-threaded interpreter on macOS As discussed in #2005 and #2011, we had not been doing this before. Conditions have changed in two relevant ways: - The free-threaded interpreter has been around longer and it sees more use. - The macOS runners are very fast now. The specific motivations for doing this now are: - In view of the condition described in #2109 and how the change there seems to have helped with it, there's some reason to think *patch* versions of Python sometimes affect GitPython in ways it makes possibly unfounded assumptions about the effect of garbage collection. This mainly affects Windows and it is not specific to free-threaded builds. However, in principle we could also see assumptions violated in tests we think always work on Unix-like operating systems, due to differences in how garbage collection works in free-threaded interpreters. Therefore, the assumption that this only needs to be tested occasionally is not as well founded I assumed when I suggested testing it only on GNU/Linux. - We may add 3.14 jobs to CI soon, and it's useful to be able to see how both free-threaded interpreters work on CI, as well as to confirm for at least a short while that they are continuing to work as expected. This macOS free-threaded interpreter CI jobs could be disabled once more if necessary, or if they're found to make CI complete slower in PRs by even a small amount so long as they don't seem to be surfacing anything. --- .github/workflows/pythonpackage.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 6c5cf4552..6c1f7a67a 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -17,8 +17,6 @@ jobs: 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: windows python-version: "3.13" # FIXME: Fix and enable Python 3.13 on Windows (#1955). - os-type: windows From 53c0a8800bc4297c2b50c14e8ada45bdaedfe2ab Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Mon, 9 Mar 2026 15:15:28 -0400 Subject: [PATCH 13/57] Test Python 3.14 on Ubuntu and macOS on CI The status of 3.14 is now effectively the same as the status of 3.13 when we started testing it in #1990. This doesn't enable it on Windows yet, for the same reason that we still have not yet enabled regular CI tests of 3.13 on Windows. It is hoped that 3.13 and 3.14 can be gotten fully working on Windows (rather than just mostly working, we think) soon; these exclusions are meant to be temporary. Both the usual GIL interpreter and the free-threaded (nogil) intepreters are tested. See the immediately preceding commit on the tradeoffs involved in doing so. --- .github/workflows/pythonpackage.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 6c1f7a67a..3d2cb9b63 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -13,14 +13,18 @@ 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"] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.13t", "3.14", "3.14t"] exclude: - os-type: macos python-version: "3.7" # Not available for the ARM-based macOS runners. - os-type: windows - python-version: "3.13" # FIXME: Fix and enable Python 3.13 on Windows (#1955). + 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" include: - os-ver: latest - os-type: ubuntu From d1ca2af85b96062a9d5979edf895ddf3968cfa49 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Mon, 9 Mar 2026 17:42:21 -0400 Subject: [PATCH 14/57] Upgrade Sphinx to ~7.4.7 Except on Python 3.8, where 7.1.2 is the latest compatible version. (This would also apply to versions lower than 3.8, but we don't support building docs on any such versions, even though we still support installing and using GitPython on 3.7.) The reason for this change is that, starting in Python 3.14, the `ast` module no longer has a `Str` member. String literals are instead represented by `ast.Constant` (and the type of the value can be checked to see if it's a string). But versions of `sphinx` lower than 7.2.0 rely on `ast.Str` being present. This causes our documentation not to be able to build at all starting in 3.14. The most important part of the error is: Exception occurred: File "/opt/hostedtoolcache/Python/3.14.3/x64/lib/python3.14/site-packages/sphinx/pycode/__init__.py", line 141, in analyze raise PycodeError(f'parsing {self.srcname!r} failed: {exc!r}') from exc sphinx.errors.PycodeError: parsing '/home/runner/work/GitPython/GitPython/git/index/base.py' failed: AttributeError("module 'ast' has no attribute 'Str'") An example of code in `sphinx` 7.1.2 that will cause such an error is `sphinx.pycode.parser.visit_Expr` implementation, which starts: if (isinstance(self.previous, (ast.Assign, ast.AnnAssign)) and isinstance(node.value, ast.Str)): In `sphinx` 7.2.0, `sphinx.pycode.parser.visit_Expr` instead begins: if (isinstance(self.previous, (ast.Assign, ast.AnnAssign)) and isinstance(node.value, ast.Constant) and isinstance(node.value.value, str)): This upgrades `sphinx` on all versions of Python where it *can* be installed at a version that has such changes -- rather than only on Python 3.14 -- for consistency, including consistency in possible minor variations in generated documentation that could otherwise arise from using different versions of `sphinx` unnecessarily. As for why this upgrades to 7.4.7 rather than only to 7.2.0, that's because they are both compatible with the same versions of Python, and as far as I know there's no reason to prefer an earlier version within that range. Although GitPython still supports being installed and run on Python 3.8 (and even on Python 3.7), it has been end-of-life (i.e., no longer supported by the Python Software Foundation) for quite some time now. That the version of Sphinx used to build documentation will now be different on Python 3.8 than other versions is a reason not to use Python 3.8 for this purpose, but probablly already not the most important reason. The change here is conceptually similar to, but much simpler than, the change in #1954, which upgraded Sphinx to 7.1.2 on all Python versions GitPython suppports other than Python 3.7. The subsequent change in #1956 of removing support for building the GitPython documentation on Python 3.7 may be paralleled for 3.8 shortly. --- doc/requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/doc/requirements.txt b/doc/requirements.txt index 81140d898..cbf34cc69 100644 --- a/doc/requirements.txt +++ b/doc/requirements.txt @@ -1,3 +1,4 @@ -sphinx >= 7.1.2, < 7.2 +sphinx >= 7.4.7, < 8 ; python_version >= "3.9" +sphinx >= 7.1.2, < 7.2 ; python_version < "3.9" sphinx_rtd_theme sphinx-autodoc-typehints From 8d979061850e8659a089e4249be84d906c409334 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Mon, 9 Mar 2026 18:10:49 -0400 Subject: [PATCH 15/57] Don't support building documentation on Python 3.8 This discontinues supporting building documentation on Python 3.8. It does not affect installing or running GitPython on Python 3.8 (except when the `doc` extra is used, but this is only used for building documentation). The reason is that it is no longer possible to use the same version of Sphinx on Python 3.8 as on the most recent supported versions of Python, because Python 3.14 no longer has `ast.Str` (using `str.Constant` for string literals instead), which causes the oldest version of `sphinx` that runs on Python 3.14 to be `sphinx` 7.2.0, while the newest version that is installable on Python 3.8 is `sphinx` 7.1.2. The immediately preceding commit changes the requirements for the `doc` extra to specify a newer `sphinx` version for Python 3.9 and later. This can't be done on Python 3.8. Because there can be subtle differences in documentation generated with different `sphinx` versions, and because Python 3.8 has been end-of-life for some time, it is not really worth carrying conditional dependencies for the `sphinx` version in `doc/requirements.txt`. Note that, while it is probably not a very good idea to use GitPython (or anything) on Python 3.8 since it is end-of-life, this change does not stop supporting installing GitPython on that or any other version it has been supporting. Installing and using GitPython remains supported all the way back to Python 3.7 at this time. This only affects the `doc` extra and its requirements. This change is analogous to the change made in #1956, which followed up on the change in #1964 in the same way this change follows up on the change in the immediately preceding commit. --- .github/workflows/pythonpackage.yml | 7 ++++++- doc/requirements.txt | 3 +-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 3d2cb9b63..8c84f7580 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -30,6 +30,11 @@ jobs: - os-type: ubuntu python-version: "3.7" os-ver: "22.04" + - build-docs: true # We ensure documentation builds, except on very old interpreters. + - python-version: "3.7" + build-docs: false + - python-version: "3.8" + build-docs: false - experimental: false fail-fast: false @@ -110,7 +115,7 @@ jobs: continue-on-error: false - name: Documentation - if: matrix.python-version != '3.7' + if: matrix.build-docs run: | pip install '.[doc]' make -C doc html diff --git a/doc/requirements.txt b/doc/requirements.txt index cbf34cc69..24472ba39 100644 --- a/doc/requirements.txt +++ b/doc/requirements.txt @@ -1,4 +1,3 @@ -sphinx >= 7.4.7, < 8 ; python_version >= "3.9" -sphinx >= 7.1.2, < 7.2 ; python_version < "3.9" +sphinx >= 7.4.7, < 8 sphinx_rtd_theme sphinx-autodoc-typehints From 4b25af2ae8aba951503c8c7055ffa5085bc18f3f Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Mon, 9 Mar 2026 19:16:00 -0400 Subject: [PATCH 16/57] Go back to testing free-threaded interpreters only on GNU/Linux This effectively reverts d1ab2e4. It doesn't look like any problems arose, and contrary to my guess, the additional jobs do actually make the checks that we intend to be blocking for PRs take longer, even after all non-macOS checks have completed. --- .github/workflows/pythonpackage.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 8c84f7580..874e18a8f 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -17,6 +17,10 @@ jobs: 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 From 77b1135ef9156a389d83b0d39ad0e1614350c002 Mon Sep 17 00:00:00 2001 From: "GPT 5.4" Date: Fri, 20 Mar 2026 22:12:49 +0000 Subject: [PATCH 17/57] fix: resolve active_branch correctly for reftable refs reviewed-by: Sebastian Thiel --- git/repo/base.py | 12 ++++++++++-- test/test_repo.py | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/git/repo/base.py b/git/repo/base.py index 1f543cc57..16807b9fa 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -1042,11 +1042,19 @@ def active_branch(self) -> Head: :raise TypeError: If HEAD is detached. + :raise ValueError: + If HEAD points to the ``.invalid`` ref Git uses to mark refs as + incompatible with older clients. + :return: :class:`~git.refs.head.Head` to the active branch """ - # reveal_type(self.head.reference) # => Reference - return self.head.reference + active_branch = self.head.reference + if active_branch.name == ".invalid": + raise ValueError( + "HEAD points to 'refs/heads/.invalid', which Git uses to mark refs as incompatible with older clients" + ) + return active_branch def blame_incremental(self, rev: str | HEAD | None, file: str, **kwargs: Any) -> Iterator["BlameEntry"]: """Iterator for blame information for the given file at the given revision. diff --git a/test/test_repo.py b/test/test_repo.py index 2a92c2523..544b5c561 100644 --- a/test/test_repo.py +++ b/test/test_repo.py @@ -962,6 +962,46 @@ def test_empty_repo(self, rw_dir): assert "BAD MESSAGE" not in contents, "log is corrupt" + @with_rw_directory + def test_active_branch_raises_value_error_when_head_ref_is_invalid(self, rw_dir): + repo = Repo.init(rw_dir) + with open(osp.join(rw_dir, ".git", "HEAD"), "w") as f: + f.write("ref: refs/heads/.invalid\n") + + self.assertRaisesRegex( + ValueError, + r"refs/heads/\.invalid.*older clients", + lambda: repo.active_branch, + ) + + @with_rw_directory + def test_empty_repo_reftable_active_branch(self, rw_dir): + git = Git(rw_dir) + try: + git.init(ref_format="reftable") + except GitCommandError as err: + if err.status == 129: + pytest.skip("git init --ref-format is not supported by this git version") + raise + + repo = Repo(rw_dir) + self.assertEqual(repo.head.reference.name, ".invalid") + self.assertRaisesRegex( + ValueError, + r"refs/heads/\.invalid.*older clients", + lambda: repo.active_branch, + ) + + @with_rw_directory + def test_active_branch_raises_type_error_when_head_is_detached(self, rw_dir): + repo = Repo.init(rw_dir) + with open(osp.join(rw_dir, "a.txt"), "w") as f: + f.write("a") + repo.index.add(["a.txt"]) + repo.index.commit("initial commit") + repo.git.checkout(repo.head.commit.hexsha) + self.assertRaisesRegex(TypeError, "detached symbolic reference", lambda: repo.active_branch) + def test_merge_base(self): repo = self.rorepo c1 = "f6aa8d1" From 859ea95b6d6207dab3d406f28234e6507ff1f527 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sun, 22 Mar 2026 00:11:12 -0700 Subject: [PATCH 18/57] docs: warn about GitDB performance with large commits Add a warning note in the Object Database section of the tutorial about GitDB failing or becoming extremely slow when traversing trees in repositories with very large commits (thousands of changed files). Directs users to switch to GitCmdObjectDB instead. Closes #2065 --- doc/source/tutorial.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/doc/source/tutorial.rst b/doc/source/tutorial.rst index fd3b14c57..d095d3be3 100644 --- a/doc/source/tutorial.rst +++ b/doc/source/tutorial.rst @@ -513,6 +513,12 @@ The GitDB is a pure-python implementation of the git object database. It is the repo = Repo("path/to/repo", odbt=GitDB) +.. warning:: + ``GitDB`` may fail or become extremely slow when traversing trees in + repositories with very large commits (thousands of changed files in a + single commit). If you encounter ``RecursionError`` or excessive + slowness during tree traversal, switch to ``GitCmdObjectDB`` instead. + GitCmdObjectDB ============== From 7c5fbc6a95c012e5e70625b78a2e7097c0659fa5 Mon Sep 17 00:00:00 2001 From: Krishna Chaitanya Balusu Date: Tue, 24 Mar 2026 22:36:11 -0400 Subject: [PATCH 19/57] Add trailer support for commit creation Add a `trailers` parameter to `Commit.create_from_tree()` and `IndexFile.commit()` that allows appending trailer key-value pairs (e.g. Signed-off-by, Issue) to the commit message at creation time. Trailers can be passed as either a dict or a list of (key, value) tuples, the latter being useful when duplicate keys are needed. The implementation uses `git interpret-trailers` for proper formatting, consistent with the existing trailer parsing in `Commit.trailers_list`. Closes #1998 --- git/index/base.py | 2 ++ git/objects/commit.py | 30 ++++++++++++++++++ test/test_commit.py | 74 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+) diff --git a/git/index/base.py b/git/index/base.py index 93de7933c..2276343f2 100644 --- a/git/index/base.py +++ b/git/index/base.py @@ -1133,6 +1133,7 @@ def commit( author_date: Union[datetime.datetime, str, None] = None, commit_date: Union[datetime.datetime, str, None] = None, skip_hooks: bool = False, + trailers: Union[None, "Dict[str, str]", "List[Tuple[str, str]]"] = None, ) -> Commit: """Commit the current default index file, creating a :class:`~git.objects.commit.Commit` object. @@ -1169,6 +1170,7 @@ def commit( committer=committer, author_date=author_date, commit_date=commit_date, + trailers=trailers, ) if not skip_hooks: run_commit_hook("post-commit", self) diff --git a/git/objects/commit.py b/git/objects/commit.py index 8c51254a2..3438239b0 100644 --- a/git/objects/commit.py +++ b/git/objects/commit.py @@ -570,6 +570,7 @@ def create_from_tree( committer: Union[None, Actor] = None, author_date: Union[None, str, datetime.datetime] = None, commit_date: Union[None, str, datetime.datetime] = None, + trailers: Union[None, Dict[str, str], List[Tuple[str, str]]] = None, ) -> "Commit": """Commit the given tree, creating a :class:`Commit` object. @@ -609,6 +610,14 @@ def create_from_tree( :param commit_date: The timestamp for the committer field. + :param trailers: + Optional trailer key-value pairs to append to the commit message. + Can be a dictionary mapping trailer keys to values, or a list of + ``(key, value)`` tuples (useful when the same key appears multiple + times, e.g. multiple ``Signed-off-by`` trailers). Trailers are + appended using ``git interpret-trailers``. + See :manpage:`git-interpret-trailers(1)`. + :return: :class:`Commit` object representing the new commit. @@ -678,6 +687,27 @@ def create_from_tree( tree = repo.tree(tree) # END tree conversion + # APPLY TRAILERS + if trailers: + trailer_args: List[str] = [] + if isinstance(trailers, dict): + for key, val in trailers.items(): + trailer_args.append("--trailer") + trailer_args.append(f"{key}: {val}") + else: + for key, val in trailers: + trailer_args.append("--trailer") + trailer_args.append(f"{key}: {val}") + + cmd = ["git", "interpret-trailers"] + trailer_args + proc: Git.AutoInterrupt = repo.git.execute( # type: ignore[call-overload] + cmd, + as_process=True, + istream=PIPE, + ) + message = proc.communicate(str(message).encode())[0].decode("utf8") + # END apply trailers + # CREATE NEW COMMIT new_commit = cls( repo, diff --git a/test/test_commit.py b/test/test_commit.py index 37c66e3e7..11308cbdb 100644 --- a/test/test_commit.py +++ b/test/test_commit.py @@ -566,3 +566,77 @@ def test_commit_co_authors(self): Actor("test_user_2", "another_user-email@github.com"), Actor("test_user_3", "test_user_3@github.com"), ] + + @with_rw_directory + def test_create_from_tree_with_trailers_dict(self, rw_dir): + """Test that create_from_tree supports adding trailers via a dict.""" + rw_repo = Repo.init(osp.join(rw_dir, "test_trailers_dict")) + path = osp.join(str(rw_repo.working_tree_dir), "hello.txt") + touch(path) + rw_repo.index.add([path]) + tree = rw_repo.index.write_tree() + + trailers = {"Issue": "123", "Signed-off-by": "Test User "} + commit = Commit.create_from_tree( + rw_repo, + tree, + "Test commit with trailers", + head=True, + trailers=trailers, + ) + + assert "Issue: 123" in commit.message + assert "Signed-off-by: Test User " in commit.message + assert commit.trailers_dict == { + "Issue": ["123"], + "Signed-off-by": ["Test User "], + } + + @with_rw_directory + def test_create_from_tree_with_trailers_list(self, rw_dir): + """Test that create_from_tree supports adding trailers via a list of tuples.""" + rw_repo = Repo.init(osp.join(rw_dir, "test_trailers_list")) + path = osp.join(str(rw_repo.working_tree_dir), "hello.txt") + touch(path) + rw_repo.index.add([path]) + tree = rw_repo.index.write_tree() + + trailers = [ + ("Signed-off-by", "Alice "), + ("Signed-off-by", "Bob "), + ("Issue", "456"), + ] + commit = Commit.create_from_tree( + rw_repo, + tree, + "Test commit with multiple trailers", + head=True, + trailers=trailers, + ) + + assert "Signed-off-by: Alice " in commit.message + assert "Signed-off-by: Bob " in commit.message + assert "Issue: 456" in commit.message + assert commit.trailers_dict == { + "Signed-off-by": ["Alice ", "Bob "], + "Issue": ["456"], + } + + @with_rw_directory + def test_index_commit_with_trailers(self, rw_dir): + """Test that IndexFile.commit() supports adding trailers.""" + rw_repo = Repo.init(osp.join(rw_dir, "test_index_trailers")) + path = osp.join(str(rw_repo.working_tree_dir), "hello.txt") + touch(path) + rw_repo.index.add([path]) + + trailers = {"Reviewed-by": "Reviewer "} + commit = rw_repo.index.commit( + "Test index commit with trailers", + trailers=trailers, + ) + + assert "Reviewed-by: Reviewer " in commit.message + assert commit.trailers_dict == { + "Reviewed-by": ["Reviewer "], + } From 9863f501ef5c6aef9b60acc0b490d5cc675aef4e Mon Sep 17 00:00:00 2001 From: Uwe Schwaeke Date: Wed, 25 Mar 2026 16:03:50 +0100 Subject: [PATCH 20/57] cmd: fix kwarg formatting in docstring example Update the example to accurately reflect the output of `transform_kwarg`. When a key is longer than one letter and its value is a non-empty, non-boolean type, it is transformed into the `--key=value` format, rather than missing the double dashes or using spaces. Signed-off-by: Uwe Schwaeke --- git/cmd.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git/cmd.py b/git/cmd.py index 78a9f4c78..b529bcc10 100644 --- a/git/cmd.py +++ b/git/cmd.py @@ -1572,7 +1572,7 @@ def _call_process( turns into:: - git rev-list max-count 10 --header master + git rev-list --max-count=10 --header=master :return: Same as :meth:`execute`. If no args are given, used :meth:`execute`'s From 0391926ac58b926ecdefc54a5e475555f494b8f9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Apr 2026 17:17:25 +0000 Subject: [PATCH 21/57] Bump https://github.com/astral-sh/ruff-pre-commit Bumps the pre-commit group with 1 update: [https://github.com/astral-sh/ruff-pre-commit](https://github.com/astral-sh/ruff-pre-commit). Updates `https://github.com/astral-sh/ruff-pre-commit` from v0.15.5 to 0.15.8 - [Release notes](https://github.com/astral-sh/ruff-pre-commit/releases) - [Commits](https://github.com/astral-sh/ruff-pre-commit/compare/v0.15.5...v0.15.8) --- updated-dependencies: - dependency-name: https://github.com/astral-sh/ruff-pre-commit dependency-version: 0.15.8 dependency-type: direct:production dependency-group: pre-commit ... Signed-off-by: dependabot[bot] --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3bd9cbce9..617111e1d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,7 +7,7 @@ repos: exclude: ^test/fixtures/ - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.5 + rev: v0.15.8 hooks: - id: ruff-check args: ["--fix"] From af0933cadc14f0df7a3463b655793d59cd586c46 Mon Sep 17 00:00:00 2001 From: Krishna Chaitanya Balusu Date: Mon, 6 Apr 2026 08:56:20 -0700 Subject: [PATCH 22/57] Use configured git executable and finalize process for trailer creation --- git/objects/commit.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/git/objects/commit.py b/git/objects/commit.py index 3438239b0..6ea252395 100644 --- a/git/objects/commit.py +++ b/git/objects/commit.py @@ -699,13 +699,15 @@ def create_from_tree( trailer_args.append("--trailer") trailer_args.append(f"{key}: {val}") - cmd = ["git", "interpret-trailers"] + trailer_args + cmd = [repo.git.GIT_PYTHON_GIT_EXECUTABLE, "interpret-trailers"] + trailer_args proc: Git.AutoInterrupt = repo.git.execute( # type: ignore[call-overload] cmd, as_process=True, istream=PIPE, ) - message = proc.communicate(str(message).encode())[0].decode("utf8") + stdout_bytes, _ = proc.communicate(str(message).encode()) + finalize_process(proc) + message = stdout_bytes.decode("utf8") # END apply trailers # CREATE NEW COMMIT From bd58716966ffb231f96aeada76c5159d5b4f9beb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Apr 2026 22:46:53 +0000 Subject: [PATCH 23/57] Use consistent interpret-trailers encoding Agent-Logs-Url: https://github.com/gitpython-developers/GitPython/sessions/1a855cb6-0111-4f52-b48d-46417aec5bde Co-authored-by: Byron <63622+Byron@users.noreply.github.com> --- git/objects/commit.py | 31 ++++++++++++++----------------- test/test_commit.py | 25 +++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/git/objects/commit.py b/git/objects/commit.py index 6ea252395..081ccf402 100644 --- a/git/objects/commit.py +++ b/git/objects/commit.py @@ -450,14 +450,7 @@ def trailers_list(self) -> List[Tuple[str, str]]: :return: List containing key-value tuples of whitespace stripped trailer information. """ - cmd = ["git", "interpret-trailers", "--parse"] - proc: Git.AutoInterrupt = self.repo.git.execute( # type: ignore[call-overload] - cmd, - as_process=True, - istream=PIPE, - ) - trailer: str = proc.communicate(str(self.message).encode())[0].decode("utf8") - trailer = trailer.strip() + trailer = self._interpret_trailers(self.repo, self.message, ["--parse"], self.encoding).strip() if not trailer: return [] @@ -469,6 +462,18 @@ def trailers_list(self) -> List[Tuple[str, str]]: return trailer_list + @staticmethod + def _interpret_trailers(repo: "Repo", message: str, trailer_args: Sequence[str], encoding: str) -> str: + cmd = [repo.git.GIT_PYTHON_GIT_EXECUTABLE, "interpret-trailers", *trailer_args] + proc: Git.AutoInterrupt = repo.git.execute( # type: ignore[call-overload] + cmd, + as_process=True, + istream=PIPE, + ) + stdout_bytes, _ = proc.communicate(message.encode(encoding, errors="strict")) + finalize_process(proc) + return stdout_bytes.decode(encoding, errors="strict") + @property def trailers_dict(self) -> Dict[str, List[str]]: """Get the trailers of the message as a dictionary. @@ -699,15 +704,7 @@ def create_from_tree( trailer_args.append("--trailer") trailer_args.append(f"{key}: {val}") - cmd = [repo.git.GIT_PYTHON_GIT_EXECUTABLE, "interpret-trailers"] + trailer_args - proc: Git.AutoInterrupt = repo.git.execute( # type: ignore[call-overload] - cmd, - as_process=True, - istream=PIPE, - ) - stdout_bytes, _ = proc.communicate(str(message).encode()) - finalize_process(proc) - message = stdout_bytes.decode("utf8") + message = cls._interpret_trailers(repo, str(message), trailer_args, conf_encoding) # END apply trailers # CREATE NEW COMMIT diff --git a/test/test_commit.py b/test/test_commit.py index 11308cbdb..5ea6642c0 100644 --- a/test/test_commit.py +++ b/test/test_commit.py @@ -622,6 +622,31 @@ def test_create_from_tree_with_trailers_list(self, rw_dir): "Issue": ["456"], } + @with_rw_directory + def test_create_from_tree_with_non_utf8_trailers(self, rw_dir): + """Test that trailer creation and parsing respect the configured commit encoding.""" + rw_repo = Repo.init(osp.join(rw_dir, "test_trailers_non_utf8")) + with rw_repo.config_writer() as writer: + writer.set_value("i18n", "commitencoding", "ISO-8859-1") + + path = osp.join(str(rw_repo.working_tree_dir), "hello.txt") + touch(path) + rw_repo.index.add([path]) + tree = rw_repo.index.write_tree() + + commit = Commit.create_from_tree( + rw_repo, + tree, + "Résumé", + head=True, + trailers={"Reviewed-by": "André "}, + ) + + assert commit.encoding == "ISO-8859-1" + assert "Résumé" in commit.message + assert "Reviewed-by: André " in commit.message + assert commit.trailers_list == [("Reviewed-by", "André ")] + @with_rw_directory def test_index_commit_with_trailers(self, rw_dir): """Test that IndexFile.commit() supports adding trailers.""" From 7cdf9c7fb5d27dfee1e22fa81fc28d9e538d58a7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Apr 2026 22:47:59 +0000 Subject: [PATCH 24/57] Normalize interpret-trailers subprocess IO Agent-Logs-Url: https://github.com/gitpython-developers/GitPython/sessions/1a855cb6-0111-4f52-b48d-46417aec5bde Co-authored-by: Byron <63622+Byron@users.noreply.github.com> --- git/objects/commit.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/git/objects/commit.py b/git/objects/commit.py index 081ccf402..a8bb5e852 100644 --- a/git/objects/commit.py +++ b/git/objects/commit.py @@ -450,7 +450,7 @@ def trailers_list(self) -> List[Tuple[str, str]]: :return: List containing key-value tuples of whitespace stripped trailer information. """ - trailer = self._interpret_trailers(self.repo, self.message, ["--parse"], self.encoding).strip() + trailer = self._interpret_trailers(self.repo, self.message, ["--parse"]).strip() if not trailer: return [] @@ -462,17 +462,17 @@ def trailers_list(self) -> List[Tuple[str, str]]: return trailer_list - @staticmethod - def _interpret_trailers(repo: "Repo", message: str, trailer_args: Sequence[str], encoding: str) -> str: + @classmethod + def _interpret_trailers(cls, repo: "Repo", message: str, trailer_args: Sequence[str]) -> str: cmd = [repo.git.GIT_PYTHON_GIT_EXECUTABLE, "interpret-trailers", *trailer_args] proc: Git.AutoInterrupt = repo.git.execute( # type: ignore[call-overload] cmd, as_process=True, istream=PIPE, ) - stdout_bytes, _ = proc.communicate(message.encode(encoding, errors="strict")) + stdout_bytes, _ = proc.communicate(message.encode(cls.default_encoding, errors="strict")) finalize_process(proc) - return stdout_bytes.decode(encoding, errors="strict") + return stdout_bytes.decode(cls.default_encoding, errors="strict") @property def trailers_dict(self) -> Dict[str, List[str]]: @@ -704,7 +704,7 @@ def create_from_tree( trailer_args.append("--trailer") trailer_args.append(f"{key}: {val}") - message = cls._interpret_trailers(repo, str(message), trailer_args, conf_encoding) + message = cls._interpret_trailers(repo, str(message), trailer_args) # END apply trailers # CREATE NEW COMMIT From 1e2a895ef55911b500b28360ee97c37e6678c014 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Apr 2026 23:43:59 +0000 Subject: [PATCH 25/57] Handle mypy CI typing failure Agent-Logs-Url: https://github.com/gitpython-developers/GitPython/sessions/3d3e7ffc-d3af-478e-9c6c-128731cdd102 Co-authored-by: Byron <63622+Byron@users.noreply.github.com> --- git/objects/commit.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/git/objects/commit.py b/git/objects/commit.py index a8bb5e852..6e5550e37 100644 --- a/git/objects/commit.py +++ b/git/objects/commit.py @@ -463,14 +463,15 @@ def trailers_list(self) -> List[Tuple[str, str]]: return trailer_list @classmethod - def _interpret_trailers(cls, repo: "Repo", message: str, trailer_args: Sequence[str]) -> str: + def _interpret_trailers(cls, repo: "Repo", message: Union[str, bytes], trailer_args: Sequence[str]) -> str: cmd = [repo.git.GIT_PYTHON_GIT_EXECUTABLE, "interpret-trailers", *trailer_args] proc: Git.AutoInterrupt = repo.git.execute( # type: ignore[call-overload] cmd, as_process=True, istream=PIPE, ) - stdout_bytes, _ = proc.communicate(message.encode(cls.default_encoding, errors="strict")) + message_bytes = message if isinstance(message, bytes) else message.encode(cls.default_encoding, errors="strict") + stdout_bytes, _ = proc.communicate(message_bytes) finalize_process(proc) return stdout_bytes.decode(cls.default_encoding, errors="strict") From 34ec40dc70ab897127184ba88792596fce78d44b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Apr 2026 03:37:51 +0000 Subject: [PATCH 26/57] Use commit encoding for trailer parsing Agent-Logs-Url: https://github.com/gitpython-developers/GitPython/sessions/519084d5-d5e2-4486-a9cc-5c258e596e13 Co-authored-by: Byron <63622+Byron@users.noreply.github.com> --- git/objects/commit.py | 14 ++++++++++---- test/test_commit.py | 24 ++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/git/objects/commit.py b/git/objects/commit.py index 6e5550e37..206b6fcc3 100644 --- a/git/objects/commit.py +++ b/git/objects/commit.py @@ -450,7 +450,7 @@ def trailers_list(self) -> List[Tuple[str, str]]: :return: List containing key-value tuples of whitespace stripped trailer information. """ - trailer = self._interpret_trailers(self.repo, self.message, ["--parse"]).strip() + trailer = self._interpret_trailers(self.repo, self.message, ["--parse"], encoding=self.encoding).strip() if not trailer: return [] @@ -463,17 +463,23 @@ def trailers_list(self) -> List[Tuple[str, str]]: return trailer_list @classmethod - def _interpret_trailers(cls, repo: "Repo", message: Union[str, bytes], trailer_args: Sequence[str]) -> str: + def _interpret_trailers( + cls, + repo: "Repo", + message: Union[str, bytes], + trailer_args: Sequence[str], + encoding: str = default_encoding, + ) -> str: cmd = [repo.git.GIT_PYTHON_GIT_EXECUTABLE, "interpret-trailers", *trailer_args] proc: Git.AutoInterrupt = repo.git.execute( # type: ignore[call-overload] cmd, as_process=True, istream=PIPE, ) - message_bytes = message if isinstance(message, bytes) else message.encode(cls.default_encoding, errors="strict") + message_bytes = message if isinstance(message, bytes) else message.encode(encoding, errors="strict") stdout_bytes, _ = proc.communicate(message_bytes) finalize_process(proc) - return stdout_bytes.decode(cls.default_encoding, errors="strict") + return stdout_bytes.decode(encoding, errors="strict") @property def trailers_dict(self) -> Dict[str, List[str]]: diff --git a/test/test_commit.py b/test/test_commit.py index 5ea6642c0..b3b5f03ec 100644 --- a/test/test_commit.py +++ b/test/test_commit.py @@ -647,6 +647,30 @@ def test_create_from_tree_with_non_utf8_trailers(self, rw_dir): assert "Reviewed-by: André " in commit.message assert commit.trailers_list == [("Reviewed-by", "André ")] + @with_rw_directory + def test_trailers_list_with_non_utf8_message_bytes(self, rw_dir): + """Test that trailer parsing handles non-UTF-8 commit message bytes.""" + rw_repo = Repo.init(osp.join(rw_dir, "test_trailers_non_utf8_bytes")) + with rw_repo.config_writer() as writer: + writer.set_value("i18n", "commitencoding", "ISO-8859-1") + + path = osp.join(str(rw_repo.working_tree_dir), "hello.txt") + touch(path) + rw_repo.index.add([path]) + tree = rw_repo.index.write_tree() + + commit = Commit.create_from_tree( + rw_repo, + tree, + "Résumé", + head=True, + trailers={"Reviewed-by": "André "}, + ) + + commit.message = commit.message.encode(commit.encoding) + + assert commit.trailers_list == [("Reviewed-by", "André ")] + @with_rw_directory def test_index_commit_with_trailers(self, rw_dir): """Test that IndexFile.commit() supports adding trailers.""" From 4aa8157cbb4d70f18f599c35cb358b500a364b59 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Apr 2026 03:39:22 +0000 Subject: [PATCH 27/57] Refine trailer encoding review follow-up Agent-Logs-Url: https://github.com/gitpython-developers/GitPython/sessions/519084d5-d5e2-4486-a9cc-5c258e596e13 Co-authored-by: Byron <63622+Byron@users.noreply.github.com> --- test/test_commit.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/test/test_commit.py b/test/test_commit.py index b3b5f03ec..8982db1e3 100644 --- a/test/test_commit.py +++ b/test/test_commit.py @@ -667,9 +667,14 @@ def test_trailers_list_with_non_utf8_message_bytes(self, rw_dir): trailers={"Reviewed-by": "André "}, ) - commit.message = commit.message.encode(commit.encoding) + bytes_commit = Commit( + rw_repo, + commit.binsha, + message=commit.message.encode(commit.encoding), + encoding=commit.encoding, + ) - assert commit.trailers_list == [("Reviewed-by", "André ")] + assert bytes_commit.trailers_list == [("Reviewed-by", "André ")] @with_rw_directory def test_index_commit_with_trailers(self, rw_dir): From 633abdbcd2e897656c998289e48369080a05f600 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Apr 2026 04:23:04 +0000 Subject: [PATCH 28/57] Fix trailer subprocess lifetime Agent-Logs-Url: https://github.com/gitpython-developers/GitPython/sessions/3cc0bd6d-d54d-4299-9a18-1576c2a91c12 Co-authored-by: Byron <63622+Byron@users.noreply.github.com> --- git/objects/commit.py | 10 ++++++---- test/test_commit.py | 11 +++++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/git/objects/commit.py b/git/objects/commit.py index 206b6fcc3..da7677ee0 100644 --- a/git/objects/commit.py +++ b/git/objects/commit.py @@ -470,16 +470,18 @@ def _interpret_trailers( trailer_args: Sequence[str], encoding: str = default_encoding, ) -> str: + message_bytes = message if isinstance(message, bytes) else message.encode(encoding, errors="strict") cmd = [repo.git.GIT_PYTHON_GIT_EXECUTABLE, "interpret-trailers", *trailer_args] proc: Git.AutoInterrupt = repo.git.execute( # type: ignore[call-overload] cmd, as_process=True, istream=PIPE, ) - message_bytes = message if isinstance(message, bytes) else message.encode(encoding, errors="strict") - stdout_bytes, _ = proc.communicate(message_bytes) - finalize_process(proc) - return stdout_bytes.decode(encoding, errors="strict") + try: + stdout_bytes, _ = proc.communicate(message_bytes) + return stdout_bytes.decode(encoding, errors="strict") + finally: + finalize_process(proc) @property def trailers_dict(self) -> Dict[str, List[str]]: diff --git a/test/test_commit.py b/test/test_commit.py index 8982db1e3..b56ad3a18 100644 --- a/test/test_commit.py +++ b/test/test_commit.py @@ -676,6 +676,17 @@ def test_trailers_list_with_non_utf8_message_bytes(self, rw_dir): assert bytes_commit.trailers_list == [("Reviewed-by", "André ")] + def test_interpret_trailers_encodes_before_launching_process(self): + """Test that encoding failures happen before spawning interpret-trailers.""" + repo = Mock() + repo.git = Mock() + repo.git.GIT_PYTHON_GIT_EXECUTABLE = "git" + + with self.assertRaises(UnicodeEncodeError): + Commit._interpret_trailers(repo, "Euro: €", ["--parse"], encoding="ISO-8859-1") + + repo.git.execute.assert_not_called() + @with_rw_directory def test_index_commit_with_trailers(self, rw_dir): """Test that IndexFile.commit() supports adding trailers.""" From d966a0deabe3c8cf09ba3d1b0f54a29bdbdb4f1d Mon Sep 17 00:00:00 2001 From: Enji Cooper Date: Fri, 17 Apr 2026 01:00:40 -0700 Subject: [PATCH 29/57] git.cmd.Git.execute(..): fix `with_stdout=False` In the event the end-user called one of the APIs with `with_stdout=False`, i.e., they didn't want to capture stdout, the code would crash with an AttributeError or ValueError when trying to dereference the stdout/stderr streams attached to `Popen(..)` objects. Be more defensive by checking the streams first to make sure they're not `None` before trying to access their corresponding attributes. Add myself to AUTHORS and add corresponding regression tests for the change. Signed-off-by: Enji Cooper --- AUTHORS | 1 + git/cmd.py | 20 ++++++++++++-------- test/test_git.py | 20 ++++++++++++++++++++ 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/AUTHORS b/AUTHORS index b57113edd..15333e1e5 100644 --- a/AUTHORS +++ b/AUTHORS @@ -56,5 +56,6 @@ Contributors are: -Ethan Lin -Jonas Scharpf -Gordon Marx +-Enji Cooper Portions derived from other open source works and are clearly marked. diff --git a/git/cmd.py b/git/cmd.py index b529bcc10..d5fbc7736 100644 --- a/git/cmd.py +++ b/git/cmd.py @@ -1364,25 +1364,29 @@ def communicate() -> Tuple[AnyStr, AnyStr]: if output_stream is None: stdout_value, stderr_value = communicate() # Strip trailing "\n". - if stdout_value.endswith(newline) and strip_newline_in_stdout: # type: ignore[arg-type] + if stdout_value is not None and stdout_value.endswith(newline) and strip_newline_in_stdout: # type: ignore[arg-type] stdout_value = stdout_value[:-1] - if stderr_value.endswith(newline): # type: ignore[arg-type] + if stderr_value is not None and stderr_value.endswith(newline): # type: ignore[arg-type] stderr_value = stderr_value[:-1] status = proc.returncode else: max_chunk_size = max_chunk_size if max_chunk_size and max_chunk_size > 0 else io.DEFAULT_BUFFER_SIZE - stream_copy(proc.stdout, output_stream, max_chunk_size) - stdout_value = proc.stdout.read() - stderr_value = proc.stderr.read() + 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() # Strip trailing "\n". - if stderr_value.endswith(newline): # type: ignore[arg-type] + if stderr_value is not None and stderr_value.endswith(newline): # type: ignore[arg-type] stderr_value = stderr_value[:-1] status = proc.wait() # END stdout handling finally: - proc.stdout.close() - proc.stderr.close() + if proc.stdout is not None: + proc.stdout.close() + if proc.stderr is not None: + proc.stderr.close() if self.GIT_PYTHON_TRACE == "full": cmdstr = " ".join(redacted_command) diff --git a/test/test_git.py b/test/test_git.py index 4a54d0d9b..da50fdfe8 100644 --- a/test/test_git.py +++ b/test/test_git.py @@ -6,6 +6,7 @@ import contextlib import gc import inspect +import io import logging import os import os.path as osp @@ -201,6 +202,25 @@ def test_it_logs_istream_summary_for_stdin(self, case): def test_it_executes_git_and_returns_result(self): self.assertRegex(self.git.execute(["git", "version"]), r"^git version [\d\.]{2}.*$") + def test_it_output_stream_with_stdout_is_false(self): + temp_stream = io.BytesIO() + self.git.execute( + ["git", "version"], + output_stream=temp_stream, + with_stdout=False, + ) + self.assertEqual(temp_stream.tell(), 0) + + def test_it_executes_git_without_stdout_redirect(self): + returncode, stdout, stderr = self.git.execute( + ["git", "version"], + with_extended_output=True, + with_stdout=False, + ) + self.assertEqual(returncode, 0) + self.assertIsNone(stdout) + self.assertIsNotNone(stderr) + @ddt.data( # chdir_to_repo, shell, command, use_shell_impostor (False, False, ["git", "version"], False), From 6fc474265d863cbb9fbabdbfcc957f27cea2b5c4 Mon Sep 17 00:00:00 2001 From: Enji Cooper Date: Fri, 17 Apr 2026 09:40:43 -0700 Subject: [PATCH 30/57] test_avoids_changing...: don't leave test artifacts behind Prior to this the test would fail [silently] on my macOS host during the test and then pytest would complain loudly about it being an issue post-session (regardless of whether or not the test was being run). Squash the unwritable directory to mute noise complaints from pytest. Signed-off-by: Enji Cooper --- test/test_util.py | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/test/test_util.py b/test/test_util.py index 000830f41..e7453769a 100644 --- a/test/test_util.py +++ b/test/test_util.py @@ -113,7 +113,7 @@ def test_deletes_dir_with_readonly_files(self, tmp_path): sys.platform == "cygwin", reason="Cygwin can't set the permissions that make the test meaningful.", ) - def test_avoids_changing_permissions_outside_tree(self, tmp_path): + def test_avoids_changing_permissions_outside_tree(self, tmp_path, request): # Automatically works on Windows, but on Unix requires either special handling # or refraining from attempting to fix PermissionError by making chmod calls. @@ -125,9 +125,32 @@ def test_avoids_changing_permissions_outside_tree(self, tmp_path): dir2 = tmp_path / "dir2" dir2.mkdir() - (dir2 / "symlink").symlink_to(dir1 / "file") + symlink = dir2 / "symlink" + symlink.symlink_to(dir1 / "file") dir2.chmod(stat.S_IRUSR | stat.S_IXUSR) + def preen_dir2(): + """Don't leave unwritable directories behind. + + pytest has difficulties cleaning up after the fact on some platforms, + e.g., macOS, and whines incessantly until the issue is resolved--regardless + of the pytest session. + """ + rwx = stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR + if not dir2.exists(): + return + if symlink.exists(): + try: + # Try lchmod first, if the platform supports it. + symlink.lchmod(rwx) + except NotImplementedError: + # The platform (probably win32) doesn't support lchmod; fall back to chmod. + symlink.chmod(rwx) + dir2.chmod(rwx) + rmtree(dir2) + + request.addfinalizer(preen_dir2) + try: rmtree(dir2) except PermissionError: From c9a26789d88b18f8b4620f37307df2976292d2a0 Mon Sep 17 00:00:00 2001 From: "GPT 5.4" Date: Tue, 21 Apr 2026 09:30:29 +0800 Subject: [PATCH 31/57] Make sure that multi-options are checked after splitting them with `shlex` Co-authored-by: Sebastian Thiel --- git/repo/base.py | 4 ++-- test/test_clone.py | 18 ++++++++++++++++++ test/test_submodule.py | 11 +++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/git/repo/base.py b/git/repo/base.py index 16807b9fa..96c78df56 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -1386,8 +1386,8 @@ def _clone( Git.check_unsafe_protocols(url) if not allow_unsafe_options: Git.check_unsafe_options(options=list(kwargs.keys()), unsafe_options=cls.unsafe_git_clone_options) - if not allow_unsafe_options and multi_options: - Git.check_unsafe_options(options=multi_options, unsafe_options=cls.unsafe_git_clone_options) + if not allow_unsafe_options and multi: + Git.check_unsafe_options(options=multi, unsafe_options=cls.unsafe_git_clone_options) proc = git.clone( multi, diff --git a/test/test_clone.py b/test/test_clone.py index 143a3b51f..768efbba6 100644 --- a/test/test_clone.py +++ b/test/test_clone.py @@ -137,6 +137,15 @@ def test_clone_unsafe_options(self, rw_repo): rw_repo.clone(tmp_dir, **unsafe_option) assert not tmp_file.exists() + @with_rw_repo("HEAD") + def test_clone_unsafe_options_are_checked_after_splitting_multi_options(self, rw_repo): + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = pathlib.Path(tdir) + payload = "--single-branch --config protocol.ext.allow=always" + + with self.assertRaises(UnsafeOptionError): + rw_repo.clone(tmp_dir, multi_options=[payload]) + @pytest.mark.xfail( sys.platform == "win32", reason=( @@ -216,6 +225,15 @@ def test_clone_from_unsafe_options(self, rw_repo): Repo.clone_from(rw_repo.working_dir, tmp_dir, **unsafe_option) assert not tmp_file.exists() + @with_rw_repo("HEAD") + def test_clone_from_unsafe_options_are_checked_after_splitting_multi_options(self, rw_repo): + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = pathlib.Path(tdir) + payload = "--single-branch --config protocol.ext.allow=always" + + with self.assertRaises(UnsafeOptionError): + Repo.clone_from(rw_repo.working_dir, tmp_dir, multi_options=[payload]) + @pytest.mark.xfail( sys.platform == "win32", reason=( diff --git a/test/test_submodule.py b/test/test_submodule.py index 47647f2a1..63bb007de 100644 --- a/test/test_submodule.py +++ b/test/test_submodule.py @@ -1332,6 +1332,17 @@ def test_submodule_update_unsafe_options(self, rw_repo): submodule.update(clone_multi_options=[unsafe_option]) assert not tmp_file.exists() + @with_rw_repo("HEAD") + def test_submodule_update_unsafe_options_are_checked_after_splitting_multi_options(self, rw_repo): + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = Path(tdir) + payload = "--single-branch --config protocol.ext.allow=always" + submodule = Submodule(rw_repo, b"\0" * 20, name="new", path="new", url=str(tmp_dir)) + + with self.assertRaises(UnsafeOptionError): + submodule.update(clone_multi_options=[payload]) + assert not submodule.module_exists() + @with_rw_repo("HEAD") def test_submodule_update_unsafe_options_allowed(self, rw_repo): with tempfile.TemporaryDirectory() as tdir: From 142195888e713542189533a52cdfc333f05c3af6 Mon Sep 17 00:00:00 2001 From: w Date: Mon, 20 Apr 2026 23:29:50 -0400 Subject: [PATCH 32/57] Block unsafe underscored git kwargs / Fix for GHSA-rpm5-65cw-6hj4 --- git/cmd.py | 21 +++++++++++++-------- test/test_clone.py | 2 ++ test/test_git.py | 16 ++++++++++++++++ test/test_remote.py | 5 +++-- 4 files changed, 34 insertions(+), 10 deletions(-) diff --git a/git/cmd.py b/git/cmd.py index d5fbc7736..3a4b69572 100644 --- a/git/cmd.py +++ b/git/cmd.py @@ -944,6 +944,12 @@ def check_unsafe_protocols(cls, url: str) -> None: f"The `{protocol}::` protocol looks suspicious, use `allow_unsafe_protocols=True` to allow it." ) + @classmethod + def _canonicalize_option_name(cls, option: str) -> str: + """Normalize an option or kwarg name for unsafe-option checks.""" + option_name = option.lstrip("-").split("=", 1)[0].split(None, 1)[0] + return dashify(option_name) + @classmethod def check_unsafe_options(cls, options: List[str], unsafe_options: List[str]) -> None: """Check for unsafe options. @@ -951,15 +957,14 @@ def check_unsafe_options(cls, options: List[str], unsafe_options: List[str]) -> Some options that are passed to ``git `` can be used to execute arbitrary commands. These are blocked by default. """ - # Options can be of the form `foo`, `--foo bar`, or `--foo=bar`, so we need to - # check if they start with "--foo" or if they are equal to "foo". - bare_unsafe_options = [option.lstrip("-") for option in unsafe_options] + # Options can be of the form `foo`, `--foo`, `--foo bar`, or `--foo=bar`. + canonical_unsafe_options = {cls._canonicalize_option_name(option): option for option in unsafe_options} for option in options: - for unsafe_option, bare_option in zip(unsafe_options, bare_unsafe_options): - if option.startswith(unsafe_option) or option == bare_option: - raise UnsafeOptionError( - f"{unsafe_option} is not allowed, use `allow_unsafe_options=True` to allow it." - ) + unsafe_option = canonical_unsafe_options.get(cls._canonicalize_option_name(option)) + if unsafe_option is not None: + raise UnsafeOptionError( + f"{unsafe_option} is not allowed, use `allow_unsafe_options=True` to allow it." + ) AutoInterrupt: TypeAlias = _AutoInterrupt diff --git a/test/test_clone.py b/test/test_clone.py index 768efbba6..653d50aa3 100644 --- a/test/test_clone.py +++ b/test/test_clone.py @@ -128,6 +128,7 @@ def test_clone_unsafe_options(self, rw_repo): unsafe_options = [ {"upload-pack": f"touch {tmp_file}"}, + {"upload_pack": f"touch {tmp_file}"}, {"u": f"touch {tmp_file}"}, {"config": "protocol.ext.allow=always"}, {"c": "protocol.ext.allow=always"}, @@ -216,6 +217,7 @@ def test_clone_from_unsafe_options(self, rw_repo): unsafe_options = [ {"upload-pack": f"touch {tmp_file}"}, + {"upload_pack": f"touch {tmp_file}"}, {"u": f"touch {tmp_file}"}, {"config": "protocol.ext.allow=always"}, {"c": "protocol.ext.allow=always"}, diff --git a/test/test_git.py b/test/test_git.py index da50fdfe8..24b60af9d 100644 --- a/test/test_git.py +++ b/test/test_git.py @@ -27,6 +27,7 @@ import ddt from git import Git, GitCommandError, GitCommandNotFound, Repo, cmd, refresh +from git.exc import UnsafeOptionError from git.util import cwd, finalize_process from test.lib import TestBase, fixture_path, with_rw_directory @@ -154,6 +155,21 @@ def test_it_transforms_kwargs_into_git_command_arguments(self): res = self.git.transform_kwargs(**{"s": True, "t": True}) self.assertEqual({"-s", "-t"}, set(res)) + def test_check_unsafe_options_normalizes_kwargs(self): + cases = [ + (["upload_pack"], ["--upload-pack"]), + (["receive_pack"], ["--receive-pack"]), + (["exec"], ["--exec"]), + (["u"], ["-u"]), + (["c"], ["-c"]), + (["--upload-pack=/tmp/helper"], ["--upload-pack"]), + (["--config core.filemode=false"], ["--config"]), + ] + + for options, unsafe_options in cases: + with self.assertRaises(UnsafeOptionError): + Git.check_unsafe_options(options=options, unsafe_options=unsafe_options) + _shell_cases = ( # value_in_call, value_from_class, expected_popen_arg (None, False, False), diff --git a/test/test_remote.py b/test/test_remote.py index b1d686f05..0551060cf 100644 --- a/test/test_remote.py +++ b/test/test_remote.py @@ -827,7 +827,7 @@ def test_fetch_unsafe_options(self, rw_repo): remote = rw_repo.remote("origin") tmp_dir = Path(tdir) tmp_file = tmp_dir / "pwn" - unsafe_options = [{"upload-pack": f"touch {tmp_file}"}] + unsafe_options = [{"upload-pack": f"touch {tmp_file}"}, {"upload_pack": f"touch {tmp_file}"}] for unsafe_option in unsafe_options: with self.assertRaises(UnsafeOptionError): remote.fetch(**unsafe_option) @@ -895,7 +895,7 @@ def test_pull_unsafe_options(self, rw_repo): remote = rw_repo.remote("origin") tmp_dir = Path(tdir) tmp_file = tmp_dir / "pwn" - unsafe_options = [{"upload-pack": f"touch {tmp_file}"}] + unsafe_options = [{"upload-pack": f"touch {tmp_file}"}, {"upload_pack": f"touch {tmp_file}"}] for unsafe_option in unsafe_options: with self.assertRaises(UnsafeOptionError): remote.pull(**unsafe_option) @@ -966,6 +966,7 @@ def test_push_unsafe_options(self, rw_repo): unsafe_options = [ { "receive-pack": f"touch {tmp_file}", + "receive_pack": f"touch {tmp_file}", "exec": f"touch {tmp_file}", } ] From 9aed7cf8c20f69effcfcf7ebef09f312f73ab826 Mon Sep 17 00:00:00 2001 From: w Date: Mon, 20 Apr 2026 23:43:59 -0400 Subject: [PATCH 33/57] linter fix --- git/cmd.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/git/cmd.py b/git/cmd.py index 3a4b69572..02d56616c 100644 --- a/git/cmd.py +++ b/git/cmd.py @@ -962,9 +962,7 @@ def check_unsafe_options(cls, options: List[str], unsafe_options: List[str]) -> for option in options: unsafe_option = canonical_unsafe_options.get(cls._canonicalize_option_name(option)) if unsafe_option is not None: - raise UnsafeOptionError( - f"{unsafe_option} is not allowed, use `allow_unsafe_options=True` to allow it." - ) + raise UnsafeOptionError(f"{unsafe_option} is not allowed, use `allow_unsafe_options=True` to allow it.") AutoInterrupt: TypeAlias = _AutoInterrupt From 43d92dec4683568d11495956dd556161f17c3ea8 Mon Sep 17 00:00:00 2001 From: w Date: Tue, 21 Apr 2026 12:03:20 -0400 Subject: [PATCH 34/57] git.cmd: harden unsafe option canonicalization and isolate push test cases --- git/cmd.py | 15 ++++++++++++--- test/test_remote.py | 15 ++++++--------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/git/cmd.py b/git/cmd.py index 02d56616c..096900819 100644 --- a/git/cmd.py +++ b/git/cmd.py @@ -946,9 +946,18 @@ def check_unsafe_protocols(cls, url: str) -> None: @classmethod def _canonicalize_option_name(cls, option: str) -> str: - """Normalize an option or kwarg name for unsafe-option checks.""" - option_name = option.lstrip("-").split("=", 1)[0].split(None, 1)[0] - return dashify(option_name) + """Return the option name used for unsafe-option checks. + + Examples: + ``"--upload-pack=/tmp/helper"`` -> ``"upload-pack"`` + ``"upload_pack"`` -> ``"upload-pack"`` + ``"--config core.filemode=false"`` -> ``"config"`` + """ + option_name = option.lstrip("-").split("=", 1)[0] + option_tokens = option_name.split(None, 1) + if not option_tokens: + return "" + return dashify(option_tokens[0]) @classmethod def check_unsafe_options(cls, options: List[str], unsafe_options: List[str]) -> None: diff --git a/test/test_remote.py b/test/test_remote.py index 0551060cf..1c627127a 100644 --- a/test/test_remote.py +++ b/test/test_remote.py @@ -964,11 +964,9 @@ def test_push_unsafe_options(self, rw_repo): tmp_dir = Path(tdir) tmp_file = tmp_dir / "pwn" unsafe_options = [ - { - "receive-pack": f"touch {tmp_file}", - "receive_pack": f"touch {tmp_file}", - "exec": f"touch {tmp_file}", - } + {"receive-pack": f"touch {tmp_file}"}, + {"receive_pack": f"touch {tmp_file}"}, + {"exec": f"touch {tmp_file}"}, ] for unsafe_option in unsafe_options: assert not tmp_file.exists() @@ -992,10 +990,9 @@ def test_push_unsafe_options_allowed(self, rw_repo): tmp_dir = Path(tdir) tmp_file = tmp_dir / "pwn" unsafe_options = [ - { - "receive-pack": f"touch {tmp_file}", - "exec": f"touch {tmp_file}", - } + {"receive-pack": f"touch {tmp_file}"}, + {"receive_pack": f"touch {tmp_file}"}, + {"exec": f"touch {tmp_file}"}, ] for unsafe_option in unsafe_options: # The options will be allowed, but the command will fail. From 4199cb89755f705801a4cb241723325b46201f51 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Wed, 22 Apr 2026 10:35:03 +0800 Subject: [PATCH 35/57] bump version to 3.1.47 --- VERSION | 2 +- doc/source/changes.rst | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/VERSION b/VERSION index fd84d1e83..e1ace7c6e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.1.46 +3.1.47 diff --git a/doc/source/changes.rst b/doc/source/changes.rst index 9b82e7513..90b2e0739 100644 --- a/doc/source/changes.rst +++ b/doc/source/changes.rst @@ -2,6 +2,15 @@ Changelog ========= +3.1.47 +====== + +Address various security issues related to bypassing injection-protection +of unsafe Git flags. + +See the following for all changes. +https://github.com/gitpython-developers/GitPython/releases/tag/3.1.47 + 3.1.46 ====== From 4c6ec603e21ce81ea37bb69a17a7aaf82f4c4eb2 Mon Sep 17 00:00:00 2001 From: Menashe Eliezer Date: Mon, 20 Apr 2026 11:59:39 +0200 Subject: [PATCH 36/57] fix: support Repo() autodiscovery from linked worktree GIT_DIR Handle linked worktree git directories when GIT_DIR points to .git/worktrees/. Previously Repo() could fail with InvalidGitRepositoryError in this scenario, while Repo(os.getcwd()) worked correctly. Add regression test to cover autodiscovery in linked worktrees. --- git/repo/base.py | 22 ++++++++++++++++++++++ test/test_repo.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/git/repo/base.py b/git/repo/base.py index 16807b9fa..7c3b5b2cc 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -242,6 +242,28 @@ def __init__( # It's important to normalize the paths, as submodules will otherwise # initialize their repo instances with paths that depend on path-portions # that will not exist after being removed. It's just cleaner. + if ( + osp.isfile(osp.join(curpath, "gitdir")) + and osp.isfile(osp.join(curpath, "commondir")) + and osp.isfile(osp.join(curpath, "HEAD")) + ): + git_dir = curpath + + if "GIT_WORK_TREE" in os.environ: + self._working_tree_dir = os.getenv("GIT_WORK_TREE") + else: + # Linked worktree administrative directories store the path to the + # worktree's .git file in their gitdir file (without "gitdir: " prefix). + with open(osp.join(git_dir, "gitdir")) as fp: + worktree_gitfile = fp.read().strip() + + if not osp.isabs(worktree_gitfile): + worktree_gitfile = osp.normpath(osp.join(git_dir, worktree_gitfile)) + + self._working_tree_dir = osp.dirname(worktree_gitfile) + + break + if is_git_dir(curpath): git_dir = curpath # from man git-config : core.worktree diff --git a/test/test_repo.py b/test/test_repo.py index 544b5c561..65d122fd7 100644 --- a/test/test_repo.py +++ b/test/test_repo.py @@ -1126,6 +1126,42 @@ def test_git_work_tree_env(self, rw_dir): self.assertEqual(r.working_tree_dir, repo_dir) self.assertEqual(r.working_dir, repo_dir) + @with_rw_directory + def test_git_work_tree_env_in_linked_worktree(self, rw_dir): + """Check that Repo() autodiscovers a linked worktree when GIT_DIR is set.""" + git = Git(rw_dir) + if git.version_info[:3] < (2, 5, 1): + raise RuntimeError("worktree feature unsupported (test needs git 2.5.1 or later)") + + rw_master = self.rorepo.clone(join_path_native(rw_dir, "master_repo")) + branch = rw_master.create_head("bbbbbbbb") + worktree_path = join_path_native(rw_dir, "worktree_repo") + if Git.is_cygwin(): + worktree_path = cygpath(worktree_path) + + rw_master.git.worktree("add", worktree_path, branch.name) + + git_dir = Git(worktree_path).rev_parse("--git-dir") + + patched_env = dict(os.environ) + patched_env["GIT_DIR"] = git_dir + patched_env.pop("GIT_WORK_TREE", None) + patched_env.pop("GIT_COMMON_DIR", None) + + with mock.patch.dict(os.environ, patched_env, clear=True): + old_cwd = os.getcwd() + try: + os.chdir(worktree_path) + + explicit = Repo(os.getcwd()) + autodiscovered = Repo() + + self.assertTrue(osp.samefile(explicit.working_tree_dir, worktree_path)) + self.assertTrue(osp.samefile(autodiscovered.working_tree_dir, worktree_path)) + self.assertTrue(osp.samefile(autodiscovered.working_tree_dir, explicit.working_tree_dir)) + finally: + os.chdir(old_cwd) + @with_rw_directory def test_rebasing(self, rw_dir): r = Repo.init(rw_dir) From 25ba54dd3fb374b8fade7de4be1ac2ac84722190 Mon Sep 17 00:00:00 2001 From: "GPT 5.5" Date: Tue, 28 Apr 2026 09:17:31 +0800 Subject: [PATCH 37/57] prevent out-of-repo access when manipulating references. This previously made it possible to create, modify and delete files outside outside of the repository, which is a problem if inputs aren't trusted. Co-authored-by: Sebastian Thiel --- git/refs/log.py | 2 +- git/refs/remote.py | 5 ++- git/refs/symbolic.py | 37 +++++++++++++++--- test/test_refs.py | 91 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 126 insertions(+), 9 deletions(-) diff --git a/git/refs/log.py b/git/refs/log.py index 4751cff99..037e143d5 100644 --- a/git/refs/log.py +++ b/git/refs/log.py @@ -213,7 +213,7 @@ def path(cls, ref: "SymbolicReference") -> str: :param ref: :class:`~git.refs.symbolic.SymbolicReference` instance """ - return osp.join(ref.repo.git_dir, "logs", to_native_path(ref.path)) + return to_native_path(ref._get_validated_reflog_path(ref.repo, ref.path)) @classmethod def iter_entries(cls, stream: Union[str, "BytesIO", mmap]) -> Iterator[RefLogEntry]: diff --git a/git/refs/remote.py b/git/refs/remote.py index b4f4f7b36..8244470b0 100644 --- a/git/refs/remote.py +++ b/git/refs/remote.py @@ -63,12 +63,13 @@ def delete(cls, repo: "Repo", *refs: "RemoteReference", **kwargs: Any) -> None: # generally ignored in the refs/ folder. We don't though and delete remainders # manually. for ref in refs: + cls._check_ref_name_valid(ref.path) try: - os.remove(os.path.join(repo.common_dir, ref.path)) + os.remove(cls._get_validated_path(repo.common_dir, ref.path)) except OSError: pass try: - os.remove(os.path.join(repo.git_dir, ref.path)) + os.remove(cls._get_validated_path(repo.git_dir, ref.path)) except OSError: pass # END for each ref diff --git a/git/refs/symbolic.py b/git/refs/symbolic.py index 99af4f57c..020de5e13 100644 --- a/git/refs/symbolic.py +++ b/git/refs/symbolic.py @@ -110,6 +110,32 @@ def name(self) -> str: def abspath(self) -> PathLike: return join_path_native(_git_dir(self.repo, self.path), self.path) + @staticmethod + def _get_validated_path(base: PathLike, path: PathLike) -> str: + path = os.fspath(path) + base_path = os.path.realpath(os.fspath(base)) + abs_path = os.path.realpath(os.path.join(base_path, path)) + try: + common_path = os.path.commonpath([base_path, abs_path]) + except ValueError as e: + raise ValueError("Reference path %r escapes the repository" % path) from e + if os.path.normcase(common_path) != os.path.normcase(base_path): + raise ValueError("Reference path %r escapes the repository" % path) + return abs_path + + @classmethod + def _get_validated_ref_path(cls, repo: "Repo", path: PathLike) -> str: + """Return the absolute filesystem path for a ref after validating it.""" + cls._check_ref_name_valid(path) + ref_path = os.fspath(path) + return cls._get_validated_path(_git_dir(repo, ref_path), ref_path) + + @classmethod + def _get_validated_reflog_path(cls, repo: "Repo", path: PathLike) -> str: + """Return the absolute filesystem path for a reflog after validating it.""" + cls._check_ref_name_valid(path) + return cls._get_validated_path(os.path.join(repo.git_dir, "logs"), path) + @classmethod def _get_packed_refs_path(cls, repo: "Repo") -> str: return os.path.join(repo.common_dir, "packed-refs") @@ -485,7 +511,7 @@ def set_reference( # END handle non-existing # END retrieve old hexsha - fpath = self.abspath + fpath = self._get_validated_ref_path(self.repo, self.path) assure_directory_exists(fpath, is_file=True) lfd = LockedFD(fpath) @@ -632,7 +658,7 @@ def delete(cls, repo: "Repo", path: PathLike) -> None: Alternatively the symbolic reference to be deleted. """ full_ref_path = cls.to_full_path(path) - abs_path = os.path.join(repo.common_dir, full_ref_path) + abs_path = cls._get_validated_ref_path(repo, full_ref_path) if os.path.exists(abs_path): os.remove(abs_path) else: @@ -695,9 +721,8 @@ def _create( symbolic reference. Otherwise it will be resolved to the corresponding object and a detached symbolic reference will be created instead. """ - git_dir = _git_dir(repo, path) full_ref_path = cls.to_full_path(path) - abs_ref_path = os.path.join(git_dir, full_ref_path) + abs_ref_path = cls._get_validated_ref_path(repo, full_ref_path) # Figure out target data. target = reference @@ -789,8 +814,8 @@ def rename(self, new_path: PathLike, force: bool = False) -> "SymbolicReference" if self.path == new_path: return self - new_abs_path = os.path.join(_git_dir(self.repo, new_path), new_path) - cur_abs_path = os.path.join(_git_dir(self.repo, self.path), self.path) + new_abs_path = self._get_validated_ref_path(self.repo, new_path) + cur_abs_path = self._get_validated_ref_path(self.repo, self.path) if os.path.isfile(new_abs_path): if not force: # If they point to the same file, it's not an error. diff --git a/test/test_refs.py b/test/test_refs.py index 329515807..4337f35e1 100644 --- a/test/test_refs.py +++ b/test/test_refs.py @@ -18,6 +18,7 @@ RefLog, Reference, RemoteReference, + Repo, SymbolicReference, TagReference, ) @@ -29,6 +30,14 @@ class TestRefs(TestBase): + def _repo_with_initial_commit(self, base_dir): + repo_dir = base_dir / "repo" + repo = Repo.init(repo_dir) + (repo_dir / "file.txt").write_text("initial\n", encoding="utf-8") + repo.index.add(["file.txt"]) + repo.index.commit("initial") + return repo + def test_from_path(self): # Should be able to create any reference directly. for ref_type in (Reference, Head, TagReference, RemoteReference): @@ -648,6 +657,88 @@ def test_refs_outside_repo(self): ref_file_name = Path(ref_file.name).name self.assertRaises(BadName, self.rorepo.commit, f"../../{ref_file_name}") + def test_reference_create_rejects_path_traversal(self): + with tempfile.TemporaryDirectory() as tmp_dir: + base_dir = Path(tmp_dir) + repo = self._repo_with_initial_commit(base_dir) + outside_path = base_dir / "outside_write.txt" + + self.assertRaises(ValueError, Reference.create, repo, "../../../outside_write.txt", "HEAD") + assert not outside_path.exists() + + def test_symbolic_reference_create_rejects_path_traversal(self): + with tempfile.TemporaryDirectory() as tmp_dir: + base_dir = Path(tmp_dir) + repo = self._repo_with_initial_commit(base_dir) + outside_path = base_dir / "outside_write.txt" + + self.assertRaises(ValueError, SymbolicReference.create, repo, "../../outside_write.txt", "HEAD") + assert not outside_path.exists() + + def test_symbolic_reference_set_reference_rejects_path_traversal(self): + with tempfile.TemporaryDirectory() as tmp_dir: + base_dir = Path(tmp_dir) + repo = self._repo_with_initial_commit(base_dir) + outside_path = base_dir / "outside_write.txt" + + self.assertRaises(ValueError, SymbolicReference(repo, "../../outside_write.txt").set_reference, "HEAD") + assert not outside_path.exists() + + def test_symbolic_reference_rename_rejects_path_traversal(self): + with tempfile.TemporaryDirectory() as tmp_dir: + base_dir = Path(tmp_dir) + repo = self._repo_with_initial_commit(base_dir) + outside_path = base_dir / "outside_move.txt" + ref = SymbolicReference.create(repo, "SAFE_RENAME_SOURCE", "HEAD") + + self.assertRaises(ValueError, ref.rename, "../../outside_move.txt") + assert not outside_path.exists() + assert Path(ref.abspath).is_file() + + def test_symbolic_reference_delete_rejects_path_traversal(self): + with tempfile.TemporaryDirectory() as tmp_dir: + base_dir = Path(tmp_dir) + repo = self._repo_with_initial_commit(base_dir) + outside_path = base_dir / "outside_delete.txt" + outside_path.write_text("do not delete\n", encoding="utf-8") + + self.assertRaises(ValueError, SymbolicReference.delete, repo, "../../outside_delete.txt") + assert outside_path.read_text(encoding="utf-8") == "do not delete\n" + + def test_symbolic_reference_log_append_rejects_path_traversal(self): + with tempfile.TemporaryDirectory() as tmp_dir: + base_dir = Path(tmp_dir) + repo = self._repo_with_initial_commit(base_dir) + outside_path = base_dir / "outside_reflog.txt" + + ref = SymbolicReference(repo, "../../../outside_reflog.txt") + self.assertRaises(ValueError, ref.log_append, Commit.NULL_BIN_SHA, "do not write", repo.head.commit.binsha) + assert not outside_path.exists() + + def test_remote_reference_delete_cleanup_rejects_path_traversal(self): + with tempfile.TemporaryDirectory() as tmp_dir: + base_dir = Path(tmp_dir) + git_dir = base_dir / "repo" / ".git" + git_dir.mkdir(parents=True) + outside_path = base_dir / "outside_remote_delete.txt" + outside_path.write_text("do not delete\n", encoding="utf-8") + + class GitStub: + def branch(self, *args): + pass + + class RepoStub: + pass + + repo = RepoStub() + repo.git = GitStub() + repo.common_dir = str(git_dir) + repo.git_dir = str(git_dir) + ref = RemoteReference(repo, "../../outside_remote_delete.txt", check_path=False) + + self.assertRaises(ValueError, RemoteReference.delete, repo, ref) + assert outside_path.read_text(encoding="utf-8") == "do not delete\n" + def test_validity_ref_names(self): """Ensure ref names are checked for validity. From 4af8463cca31c2369312fcaa5309dfc30756c7b6 Mon Sep 17 00:00:00 2001 From: "GPT 5.5" Date: Tue, 28 Apr 2026 09:30:41 +0800 Subject: [PATCH 38/57] address review feedback and CI failures Consolidate follow-up fixes from review and CI: - fix lint and mypy issues in reference log path handling - validate remote reference paths before invoking git branch deletion - add symlink escape coverage where realpath resolves symlinks - ensure temporary test repositories release git resources during cleanup Co-authored-by: Sebastian Thiel --- git/refs/log.py | 4 +- git/refs/remote.py | 4 +- git/util.py | 2 +- test/test_refs.py | 92 +++++++++++++++++++++++++++++++--------------- 4 files changed, 69 insertions(+), 33 deletions(-) diff --git a/git/refs/log.py b/git/refs/log.py index 037e143d5..fbbe66b22 100644 --- a/git/refs/log.py +++ b/git/refs/log.py @@ -4,7 +4,6 @@ __all__ = ["RefLog", "RefLogEntry"] from mmap import mmap -import os.path as osp import re import time as _time @@ -212,6 +211,9 @@ def path(cls, ref: "SymbolicReference") -> str: :param ref: :class:`~git.refs.symbolic.SymbolicReference` instance + + :raise ValueError: + If `ref.path` is invalid or escapes the repository's reflog directory. """ return to_native_path(ref._get_validated_reflog_path(ref.repo, ref.path)) diff --git a/git/refs/remote.py b/git/refs/remote.py index 8244470b0..e16ae70f8 100644 --- a/git/refs/remote.py +++ b/git/refs/remote.py @@ -58,12 +58,14 @@ def delete(cls, repo: "Repo", *refs: "RemoteReference", **kwargs: Any) -> None: `kwargs` are given for comparability with the base class method as we should not narrow the signature. """ + for ref in refs: + cls._check_ref_name_valid(ref.path) + repo.git.branch("-d", "-r", *refs) # The official deletion method will ignore remote symbolic refs - these are # generally ignored in the refs/ folder. We don't though and delete remainders # manually. for ref in refs: - cls._check_ref_name_valid(ref.path) try: os.remove(cls._get_validated_path(repo.common_dir, ref.path)) except OSError: diff --git a/git/util.py b/git/util.py index c3ffdd62b..712fabe85 100644 --- a/git/util.py +++ b/git/util.py @@ -289,7 +289,7 @@ def join_path(a: PathLike, *p: PathLike) -> PathLike: if sys.platform == "win32": - def to_native_path_windows(path: PathLike) -> PathLike: + def to_native_path_windows(path: PathLike) -> str: path = os.fspath(path) return path.replace("/", "\\") diff --git a/test/test_refs.py b/test/test_refs.py index 4337f35e1..d77b34eba 100644 --- a/test/test_refs.py +++ b/test/test_refs.py @@ -3,6 +3,7 @@ # This module is part of GitPython and is released under the # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/ +import contextlib from itertools import chain import os.path as osp from pathlib import Path @@ -30,13 +31,17 @@ class TestRefs(TestBase): + @contextlib.contextmanager def _repo_with_initial_commit(self, base_dir): repo_dir = base_dir / "repo" repo = Repo.init(repo_dir) (repo_dir / "file.txt").write_text("initial\n", encoding="utf-8") repo.index.add(["file.txt"]) repo.index.commit("initial") - return repo + try: + yield repo + finally: + repo.git.clear_cache() def test_from_path(self): # Should be able to create any reference directly. @@ -660,60 +665,84 @@ def test_refs_outside_repo(self): def test_reference_create_rejects_path_traversal(self): with tempfile.TemporaryDirectory() as tmp_dir: base_dir = Path(tmp_dir) - repo = self._repo_with_initial_commit(base_dir) - outside_path = base_dir / "outside_write.txt" + with self._repo_with_initial_commit(base_dir) as repo: + outside_path = base_dir / "outside_write.txt" - self.assertRaises(ValueError, Reference.create, repo, "../../../outside_write.txt", "HEAD") - assert not outside_path.exists() + self.assertRaises(ValueError, Reference.create, repo, "../../../outside_write.txt", "HEAD") + assert not outside_path.exists() def test_symbolic_reference_create_rejects_path_traversal(self): with tempfile.TemporaryDirectory() as tmp_dir: base_dir = Path(tmp_dir) - repo = self._repo_with_initial_commit(base_dir) - outside_path = base_dir / "outside_write.txt" + with self._repo_with_initial_commit(base_dir) as repo: + outside_path = base_dir / "outside_write.txt" - self.assertRaises(ValueError, SymbolicReference.create, repo, "../../outside_write.txt", "HEAD") - assert not outside_path.exists() + self.assertRaises(ValueError, SymbolicReference.create, repo, "../../outside_write.txt", "HEAD") + assert not outside_path.exists() def test_symbolic_reference_set_reference_rejects_path_traversal(self): with tempfile.TemporaryDirectory() as tmp_dir: base_dir = Path(tmp_dir) - repo = self._repo_with_initial_commit(base_dir) - outside_path = base_dir / "outside_write.txt" + with self._repo_with_initial_commit(base_dir) as repo: + outside_path = base_dir / "outside_write.txt" - self.assertRaises(ValueError, SymbolicReference(repo, "../../outside_write.txt").set_reference, "HEAD") - assert not outside_path.exists() + self.assertRaises(ValueError, SymbolicReference(repo, "../../outside_write.txt").set_reference, "HEAD") + assert not outside_path.exists() def test_symbolic_reference_rename_rejects_path_traversal(self): with tempfile.TemporaryDirectory() as tmp_dir: base_dir = Path(tmp_dir) - repo = self._repo_with_initial_commit(base_dir) - outside_path = base_dir / "outside_move.txt" - ref = SymbolicReference.create(repo, "SAFE_RENAME_SOURCE", "HEAD") + with self._repo_with_initial_commit(base_dir) as repo: + outside_path = base_dir / "outside_move.txt" + ref = SymbolicReference.create(repo, "SAFE_RENAME_SOURCE", "HEAD") - self.assertRaises(ValueError, ref.rename, "../../outside_move.txt") - assert not outside_path.exists() - assert Path(ref.abspath).is_file() + self.assertRaises(ValueError, ref.rename, "../../outside_move.txt") + assert not outside_path.exists() + assert Path(ref.abspath).is_file() def test_symbolic_reference_delete_rejects_path_traversal(self): with tempfile.TemporaryDirectory() as tmp_dir: base_dir = Path(tmp_dir) - repo = self._repo_with_initial_commit(base_dir) - outside_path = base_dir / "outside_delete.txt" - outside_path.write_text("do not delete\n", encoding="utf-8") + with self._repo_with_initial_commit(base_dir) as repo: + outside_path = base_dir / "outside_delete.txt" + outside_path.write_text("do not delete\n", encoding="utf-8") - self.assertRaises(ValueError, SymbolicReference.delete, repo, "../../outside_delete.txt") - assert outside_path.read_text(encoding="utf-8") == "do not delete\n" + self.assertRaises(ValueError, SymbolicReference.delete, repo, "../../outside_delete.txt") + assert outside_path.read_text(encoding="utf-8") == "do not delete\n" def test_symbolic_reference_log_append_rejects_path_traversal(self): with tempfile.TemporaryDirectory() as tmp_dir: base_dir = Path(tmp_dir) - repo = self._repo_with_initial_commit(base_dir) - outside_path = base_dir / "outside_reflog.txt" + with self._repo_with_initial_commit(base_dir) as repo: + outside_path = base_dir / "outside_reflog.txt" + + ref = SymbolicReference(repo, "../../../outside_reflog.txt") + self.assertRaises( + ValueError, ref.log_append, Commit.NULL_BIN_SHA, "do not write", repo.head.commit.binsha + ) + assert not outside_path.exists() - ref = SymbolicReference(repo, "../../../outside_reflog.txt") - self.assertRaises(ValueError, ref.log_append, Commit.NULL_BIN_SHA, "do not write", repo.head.commit.binsha) - assert not outside_path.exists() + def test_symbolic_reference_set_reference_rejects_symlink_escape(self): + with tempfile.TemporaryDirectory() as tmp_dir: + base_dir = Path(tmp_dir) + with self._repo_with_initial_commit(base_dir) as repo: + outside_dir = base_dir / "outside_refs" + outside_dir.mkdir() + outside_path = outside_dir / "escaped" + + refs_heads_dir = Path(repo.common_dir) / "refs" / "heads" + refs_heads_dir.mkdir(parents=True, exist_ok=True) + symlink_path = refs_heads_dir / "link_out" + try: + symlink_path.symlink_to(outside_dir, target_is_directory=True) + except (OSError, NotImplementedError) as ex: + self.skipTest("symlinks unavailable on this platform: %s" % ex) + if osp.realpath(symlink_path / "escaped") == osp.abspath(symlink_path / "escaped"): + self.skipTest("realpath does not resolve directory symlinks on this platform") + + ref = SymbolicReference(repo, "refs/heads/link_out/escaped") + self.assertRaises(ValueError, ref.set_reference, "HEAD") + assert not outside_path.exists() def test_remote_reference_delete_cleanup_rejects_path_traversal(self): with tempfile.TemporaryDirectory() as tmp_dir: @@ -724,8 +753,10 @@ def test_remote_reference_delete_cleanup_rejects_path_traversal(self): outside_path.write_text("do not delete\n", encoding="utf-8") class GitStub: + branch_called = False + def branch(self, *args): - pass + self.branch_called = True class RepoStub: pass @@ -737,6 +768,7 @@ class RepoStub: ref = RemoteReference(repo, "../../outside_remote_delete.txt", check_path=False) self.assertRaises(ValueError, RemoteReference.delete, repo, ref) + assert not repo.git.branch_called assert outside_path.read_text(encoding="utf-8") == "do not delete\n" def test_validity_ref_names(self): From 5a15361e0e1223f5c2e2c05688e6d094796b954d Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Tue, 28 Apr 2026 13:24:46 +0800 Subject: [PATCH 39/57] a new release with safer reference creation --- VERSION | 2 +- doc/source/changes.rst | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/VERSION b/VERSION index e1ace7c6e..94c78f538 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.1.47 +3.1.48 diff --git a/doc/source/changes.rst b/doc/source/changes.rst index 90b2e0739..4ac67d077 100644 --- a/doc/source/changes.rst +++ b/doc/source/changes.rst @@ -2,6 +2,15 @@ Changelog ========= +3.1.48 +====== + +Safe reference creation in the face of untrusted input. + +See the following for all changes. +https://github.com/gitpython-developers/GitPython/releases/tag/3.1.48 + + 3.1.47 ====== From c417af469f9aa3da8dfef78f996c0fb8c5d1f4c2 Mon Sep 17 00:00:00 2001 From: "GPT 5.5" Date: Wed, 29 Apr 2026 05:47:57 +0800 Subject: [PATCH 40/57] reject control chars in written values in configuration Reject CR, LF, and NUL in GitConfigParser values before writing them to git config files (which also is a deviation from Git which escapes them). GitConfigParser._write() serializes embedded newlines as indented continuation lines by replacing "\n" with "\n\t". Git itself skips leading whitespace before parsing config tokens, so an injected value such as: foo [core] hooksPath=/tmp/hooks is written in a form where the indented "[core]" line is still parsed by Git as a real section header. This lets attacker-controlled input passed to config_writer().set_value() poison repository config, including core.hooksPath, and redirect hook execution for later Git operations. Fail closed instead of stripping or normalizing these characters. Silent normalization can hide unsanitized caller input, and GitPython does not currently round-trip Git-style escaped values such as "\n" as embedded newlines. Apply the validation to set_value(), add_value(), and the public set() path so callers cannot bypass the safer helper API. Add regression tests for the advisory payload and for CR, LF, NUL, and bytes values. This preserves existing read behavior for config files that already contain multiline values while preventing GitPython from writing new unsafe values. Co-authored-by: Sebastian Thiel --- git/config.py | 24 ++++++++++++++++++++++-- test/test_config.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/git/config.py b/git/config.py index c6eaf8f7b..31d9e01cd 100644 --- a/git/config.py +++ b/git/config.py @@ -882,6 +882,24 @@ def _value_to_string(self, value: Union[str, bytes, int, float, bool]) -> str: return str(value) return force_text(value) + def _value_to_string_safe(self, value: Union[str, bytes, int, float, bool]) -> str: + value_str = self._value_to_string(value) + if re.search(r"[\r\n\x00]", value_str): + raise ValueError("Git config values must not contain CR, LF, or NUL") + return value_str + + @needs_values + @set_dirty_and_flush_changes + def set( + self, + section: str, + option: str, + value: Union[str, bytes, int, float, bool, None] = None, + ) -> None: + if value is not None: + value = self._value_to_string_safe(value) + return super().set(section, option, value) + @needs_values @set_dirty_and_flush_changes def set_value(self, section: str, option: str, value: Union[str, bytes, int, float, bool]) -> "GitConfigParser": @@ -902,9 +920,10 @@ def set_value(self, section: str, option: str, value: Union[str, bytes, int, flo :return: This instance """ + value_str = self._value_to_string_safe(value) if not self.has_section(section): self.add_section(section) - self.set(section, option, self._value_to_string(value)) + self.set(section, option, value_str) return self @needs_values @@ -929,9 +948,10 @@ def add_value(self, section: str, option: str, value: Union[str, bytes, int, flo :return: This instance """ + value_str = self._value_to_string_safe(value) if not self.has_section(section): self.add_section(section) - self._sections[section].add(option, self._value_to_string(value)) + self._sections[section].add(option, value_str) return self def rename_section(self, section: str, new_name: str) -> "GitConfigParser": diff --git a/test/test_config.py b/test/test_config.py index 11ea52d16..a9dcdb087 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -150,6 +150,39 @@ def test_config_value_with_trailing_new_line(self): git_config = GitConfigParser(config_file) git_config.read() # This should not throw an exception + @with_rw_directory + def test_set_value_rejects_config_injection(self, rw_dir): + config_path = osp.join(rw_dir, "config") + payload = "foo\n[core]\nhooksPath=/tmp/hooks" + + with GitConfigParser(config_path, read_only=False) as git_config: + with pytest.raises(ValueError, match="CR, LF, or NUL"): + git_config.set_value("user", "name", payload) + + with GitConfigParser(config_path, read_only=True) as git_config: + self.assertFalse(git_config.has_section("user")) + self.assertFalse(git_config.has_section("core")) + + @with_rw_directory + def test_set_and_add_value_reject_unsafe_value_characters(self, rw_dir): + config_path = osp.join(rw_dir, "config") + bad_values = ("foo\rbar", "foo\nbar", "foo\x00bar", b"foo\nbar") + + with GitConfigParser(config_path, read_only=False) as git_config: + git_config.add_section("user") + for bad_value in bad_values: + with pytest.raises(ValueError, match="CR, LF, or NUL"): + git_config.set("user", "name", bad_value) + with pytest.raises(ValueError, match="CR, LF, or NUL"): + git_config.set_value("user", "name", bad_value) + with pytest.raises(ValueError, match="CR, LF, or NUL"): + git_config.add_value("user", "name", bad_value) + + git_config.set_value("user", "name", "safe") + + with GitConfigParser(config_path, read_only=True) as git_config: + self.assertEqual(git_config.get_value("user", "name"), "safe") + def test_base(self): path_repo = fixture_path("git_config") path_global = fixture_path("git_config_global") From 8e24503b42c1d63dd98e8b2e6a2f655bdd0821e3 Mon Sep 17 00:00:00 2001 From: "GPT 5.5" Date: Wed, 29 Apr 2026 06:39:02 +0800 Subject: [PATCH 41/57] avoid duplicate validation in set_value Co-authored-by: Sebastian Thiel --- git/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git/config.py b/git/config.py index 31d9e01cd..97ae054e5 100644 --- a/git/config.py +++ b/git/config.py @@ -923,7 +923,7 @@ def set_value(self, section: str, option: str, value: Union[str, bytes, int, flo value_str = self._value_to_string_safe(value) if not self.has_section(section): self.add_section(section) - self.set(section, option, value_str) + super().set(section, option, value_str) return self @needs_values From d7ce6fc19199cf8698d722c7d8ae38ff81424fba Mon Sep 17 00:00:00 2001 From: "GPT 5.5" Date: Tue, 28 Apr 2026 21:47:27 +0000 Subject: [PATCH 42/57] Improve pure Python rev-parse coverage and behavior (#2135) Port object-resolving revspec cases inspired by gix-revision into deterministic GitPython tests, without shelling out to Git or Gix at runtime. Refactor rev_parse handling around anchors, navigation, peeling, reflog selectors, path/index lookups, describe-style names, and commit-message searches. Document observed Git/Gix behavior differences and the GitPython choices made for user-facing compatibility. Co-authored-by: Sebastian Thiel --- git/repo/fun.py | 513 ++++++++++++++++++++++++++++++----------- test/test_repo.py | 17 ++ test/test_rev_parse.py | 138 +++++++++++ 3 files changed, 536 insertions(+), 132 deletions(-) create mode 100644 test/test_rev_parse.py diff --git a/git/repo/fun.py b/git/repo/fun.py index 3f00e60ea..d91ce5c0b 100644 --- a/git/repo/fun.py +++ b/git/repo/fun.py @@ -20,6 +20,7 @@ import os import os.path as osp from pathlib import Path +import re import stat from string import digits @@ -28,12 +29,13 @@ from git.cmd import Git from git.exc import WorkTreeRepositoryUnsupported from git.objects import Object +from git.objects.util import parse_date from git.refs import SymbolicReference from git.util import cygpath, bin_to_hex, hex_to_bin # Typing ---------------------------------------------------------------------- -from typing import Optional, TYPE_CHECKING, Union, cast, overload +from typing import Optional, TYPE_CHECKING, Tuple, Union, cast, overload from git.types import AnyGitObject, Literal, PathLike @@ -41,6 +43,7 @@ from git.db import GitCmdObjectDB from git.objects import Commit, TagObject from git.refs.reference import Reference + from git.refs.log import RefLog, RefLogEntry from git.refs.tag import Tag from .base import Repo @@ -139,6 +142,23 @@ def short_to_long(odb: "GitCmdObjectDB", hexsha: str) -> Optional[bytes]: # END exception handling +def _describe_to_long(repo: "Repo", name: str) -> Optional[bytes]: + """Resolve git-describe style names to the abbreviated object they contain.""" + match = re.match(r"^.+-\d+-g([0-9A-Fa-f]{4,40})(?:-dirty)?$", name) + if match is None: + match = re.match(r"^.+-g([0-9A-Fa-f]{4,40})(?:-dirty)?$", name) + if match is None: + match = re.match(r"^([0-9A-Fa-f]{4,40})-dirty$", name) + if match is None: + return None + # END handle match + + hexsha = match.group(1) + if len(hexsha) == 40: + return hexsha.encode("ascii") + return short_to_long(repo.odb, hexsha) + + @overload def name_to_object(repo: "Repo", name: str, return_ref: Literal[False] = ...) -> AnyGitObject: ... @@ -170,6 +190,10 @@ def name_to_object(repo: "Repo", name: str, return_ref: bool = False) -> Union[A # END handle short shas # END find sha if it matches + if hexsha is None: + hexsha = _describe_to_long(repo, name) + # END handle describe output + # If we couldn't find an object for what seemed to be a short hexsha, try to find it # as reference anyway, it could be named 'aaa' for instance. if hexsha is None: @@ -227,6 +251,298 @@ def to_commit(obj: Object) -> "Commit": return obj +def _object_from_hexsha(repo: "Repo", hexsha: str) -> AnyGitObject: + return Object.new_from_sha(repo, hex_to_bin(hexsha)) + + +def _current_reflog_ref(repo: "Repo") -> SymbolicReference: + return repo.head + + +def _ref_log(repo: "Repo", ref: SymbolicReference) -> "RefLog": + try: + return ref.log() + except FileNotFoundError: + try: + if ref.path == repo.head.ref.path: + return repo.head.log() + # END handle linked-worktree current branch logs + except TypeError: + pass + # END handle detached head + raise + # END handle missing branch log + + +def _ref_log_entry(repo: "Repo", ref: SymbolicReference, index: int) -> "RefLogEntry": + try: + return ref.log_entry(index) + except FileNotFoundError: + try: + if ref.path == repo.head.ref.path: + return repo.head.log_entry(index) + # END handle linked-worktree current branch logs + except TypeError: + pass + # END handle detached head + raise + # END handle missing branch log + + +def _find_reflog_entry_by_date(repo: "Repo", ref: SymbolicReference, spec: str) -> str: + try: + timestamp, _offset = parse_date(spec) + except ValueError as e: + raise NotImplementedError("Support for additional @{...} modes not implemented") from e + # END handle unsupported dates + log = _ref_log(repo, ref) + if not log: + raise IndexError("Invalid revlog date: %s" % spec) + # END handle empty log + + for entry in reversed(log): + if entry.time[0] <= timestamp: + return entry.newhexsha + # END found candidate + # END for each entry + return log[0].newhexsha + + +def _previous_checked_out_branch(repo: "Repo", nth: int) -> AnyGitObject: + if nth <= 0: + raise ValueError("Invalid previous checkout selector: -%i" % nth) + # END handle invalid input + + seen = 0 + for entry in reversed(_ref_log(repo, repo.head)): + message = entry.message or "" + prefix = "checkout: moving from " + if not message.startswith(prefix): + continue + # END skip non-checkouts + + previous_branch = message[len(prefix) :].split(" to ", 1)[0] + seen += 1 + if seen == nth: + return name_to_object(repo, previous_branch) + # END found selector + # END for each entry + raise IndexError("Invalid previous checkout selector: -%i" % nth) + + +def _tracking_branch_object(repo: "Repo", ref: Optional[SymbolicReference]) -> AnyGitObject: + from git.refs.head import Head + + if ref is None: + try: + head = repo.active_branch + except TypeError as e: + raise BadName("@{upstream}") from e + elif isinstance(ref, Head): + head = ref + else: + raise BadName("%s@{upstream}" % ref.name) + # END handle head + + tracking_branch = head.tracking_branch() + if tracking_branch is None: + raise BadName("%s@{upstream}" % head.name) + # END handle missing upstream + return tracking_branch.commit + + +def _apply_reflog(repo: "Repo", ref: Optional[SymbolicReference], content: str) -> AnyGitObject: + if content.startswith("+"): + content = content[1:] + # END handle explicit positive sign + + if content.startswith("-"): + if ref is not None: + raise ValueError("Previous checkout selectors do not take an explicit ref") + if content == "-0": + raise ValueError("Negative zero is invalid in reflog selector") + # END handle invalid negative zero + try: + return _previous_checked_out_branch(repo, int(content[1:])) + except ValueError as e: + raise ValueError("Invalid previous checkout selector: %s" % content) from e + # END handle previous checkout branch + + content_lower = content.lower() + if content_lower in ("u", "upstream", "push"): + return _tracking_branch_object(repo, ref) + # END handle sibling branches + + ref = ref or _current_reflog_ref(repo) + try: + entry_no = int(content) + except ValueError: + hexsha = _find_reflog_entry_by_date(repo, ref, content) + else: + if entry_no >= 100000000: + hexsha = _find_reflog_entry_by_date(repo, ref, "%s +0000" % entry_no) + elif entry_no == 0: + return ref.commit + else: + try: + entry = _ref_log_entry(repo, ref, -(entry_no + 1)) + except IndexError as e: + raise IndexError("Invalid revlog index: %i" % entry_no) from e + # END handle index out of bound + hexsha = entry.newhexsha + # END handle offset or date-like timestamp + # END handle content + return _object_from_hexsha(repo, hexsha) + + +def _find_closing_brace(rev: str, start: int) -> int: + depth = 1 + escaped = False + for idx in range(start + 1, len(rev)): + char = rev[idx] + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return idx + # END found end + # END handle char + # END for each char + raise ValueError("Missing closing brace to define type in %s" % rev) + + +def _parse_search(pattern: str) -> Tuple[str, bool]: + if not pattern: + raise ValueError("Revision search requires a pattern") + # END handle empty pattern + + if pattern.startswith("!-"): + return pattern[2:], True + if pattern.startswith("!!"): + return pattern[1:], False + if pattern.startswith("!"): + raise ValueError("Need one character after /!, typically -") + return pattern, False + + +def _unescape_braced_regex(pattern: str) -> str: + out = [] + idx = 0 + while idx < len(pattern): + char = pattern[idx] + if char == "\\" and idx + 1 < len(pattern): + next_char = pattern[idx + 1] + if next_char in "{}\\": + out.append(next_char) + else: + out.append(char) + out.append(next_char) + # END handle escaped char + idx += 2 + continue + # END handle backslash + out.append(char) + idx += 1 + # END for each char + return "".join(out) + + +def _find_commit_by_message( + repo: "Repo", rev: Optional[AnyGitObject], pattern: str, braced: bool = False +) -> AnyGitObject: + pattern, negated = _parse_search(_unescape_braced_regex(pattern) if braced else pattern) + regex = re.compile(pattern) + if rev is None: + commits = repo.iter_commits("--all") + else: + commits = repo.iter_commits(to_commit(cast(Object, rev)).hexsha) + # END handle starting point + + for commit in commits: + matches = regex.search(commit.message or "") is not None + if matches != negated: + return commit + # END found commit + # END for each commit + raise BadName("No commit found matching message pattern %r" % pattern) + + +def _index_lookup(repo: "Repo", spec: str) -> AnyGitObject: + if not spec: + raise ValueError("':' must be followed by a path") + # END handle empty lookup + + stage = 0 + path = spec + if len(spec) >= 2 and spec[1] == ":" and spec[0] in "0123": + stage = int(spec[0]) + path = spec[2:] + # END handle stage + + try: + return repo.index.entries[(path, stage)].to_blob(repo) + except KeyError as e: + raise BadName("Path %r did not exist in the index at stage %i" % (path, stage)) from e + + +def _tree_lookup(obj: AnyGitObject, path: str) -> AnyGitObject: + if obj.type != "tree": + obj = to_commit(cast(Object, obj)).tree + # END get tree + if not path: + return obj + return obj[path] + + +def _peel(obj: AnyGitObject, output_type: str, repo: "Repo", rev: str) -> AnyGitObject: + if output_type == "/": + return obj + if output_type.startswith("/"): + return _find_commit_by_message(repo, obj, output_type[1:], braced=True) + if output_type == "": + return deref_tag(cast("TagObject", obj)) if obj.type == "tag" else obj + if output_type == "object": + return obj + if output_type == "commit": + return to_commit(cast(Object, obj)) + if output_type == "tree": + return to_commit(cast(Object, obj)).tree if obj.type != "tree" else obj + if output_type == "blob": + obj = deref_tag(cast("TagObject", obj)) if obj.type == "tag" else obj + if obj.type == output_type: + return obj + # END handle matching type + raise ValueError("Could not accommodate requested object type %r, got %s" % (output_type, obj.type)) + if output_type == "tag": + if obj.type == output_type: + return obj + # END handle matching type + raise ValueError("Could not accommodate requested object type %r, got %s" % (output_type, obj.type)) + # END handle known types + raise ValueError("Invalid output type: %s ( in %s )" % (output_type, rev)) + + +def _first_rev_token(rev: str) -> Optional[int]: + for idx, char in enumerate(rev): + if char in "^~:": + return idx + if char == "@": + next_char = rev[idx + 1] if idx + 1 < len(rev) else None + if idx == 0 and next_char in (None, "^", "~", ":", "{"): + return idx + if next_char == "{": + return idx + # END handle reflog selector + # END handle at symbol + # END for each char + return None + + def rev_parse(repo: "Repo", rev: str) -> AnyGitObject: """Parse a revision string. Like :manpage:`git-rev-parse(1)`. @@ -253,135 +569,81 @@ def rev_parse(repo: "Repo", rev: str) -> AnyGitObject: :raise IndexError: If an invalid reflog index is specified. """ - # Are we in colon search mode? if rev.startswith(":/"): - # Colon search mode - raise NotImplementedError("commit by message search (regex)") - # END handle search + return _find_commit_by_message(repo, None, rev[2:]) + if rev.startswith(":"): + return _index_lookup(repo, rev[1:]) + # END handle top-level colon modes obj: Optional[AnyGitObject] = None ref = None - output_type = "commit" - start = 0 - parsed_to = 0 lr = len(rev) - while start < lr: - if rev[start] not in "^~:@": - start += 1 - continue - # END handle start + first_token = _first_rev_token(rev) + if first_token is None: + return name_to_object(repo, rev) + # END handle plain name + + if first_token == 0: + if rev[0] != "@": + raise ValueError("Revision specifier must start with an object name: %s" % rev) + # END handle invalid leading token + ref = _current_reflog_ref(repo) + obj = ref.commit + start = 0 if rev.startswith("@{") else 1 + else: + if rev[first_token] == "@": + ref = cast("Reference", name_to_object(repo, rev[:first_token], return_ref=True)) + obj = ref.commit + else: + obj = name_to_object(repo, rev[:first_token]) + # END handle anchor + start = first_token + # END initialize anchor + while start < lr: token = rev[start] - if obj is None: - # token is a rev name. - if start == 0: - ref = repo.head.ref - else: - if token == "@": - ref = cast("Reference", name_to_object(repo, rev[:start], return_ref=True)) - else: - obj = name_to_object(repo, rev[:start]) - # END handle token - # END handle refname - else: - if ref is not None: - obj = ref.commit - # END handle ref - # END initialize obj on first token - - start += 1 + if token == "@": + if start + 1 >= lr or rev[start + 1] != "{": + raise ValueError("Invalid @ token in revision specifier: %s" % rev) + # END handle invalid @ + end = _find_closing_brace(rev, start + 1) + obj = _apply_reflog(repo, ref if first_token != 0 and start == first_token else None, rev[start + 2 : end]) + ref = None + start = end + 1 + continue + # END handle reflog - # Try to parse {type}. - if start < lr and rev[start] == "{": - end = rev.find("}", start) - if end == -1: - raise ValueError("Missing closing brace to define type in %s" % rev) - output_type = rev[start + 1 : end] # Exclude brace. - - # Handle type. - if output_type == "commit": - obj = cast("TagObject", obj) - if obj and obj.type == "tag": - obj = deref_tag(obj) - else: - # Cannot do anything for non-tags. - pass - # END handle tag - elif output_type == "tree": - try: - obj = cast(AnyGitObject, obj) - obj = to_commit(obj).tree - except (AttributeError, ValueError): - pass # Error raised later. - # END exception handling - elif output_type in ("", "blob"): - obj = cast("TagObject", obj) - if obj and obj.type == "tag": - obj = deref_tag(obj) - else: - # Cannot do anything for non-tags. - pass - # END handle tag - elif token == "@": - # try single int - assert ref is not None, "Require Reference to access reflog" - revlog_index = None - try: - # Transform reversed index into the format of our revlog. - revlog_index = -(int(output_type) + 1) - except ValueError as e: - # TODO: Try to parse the other date options, using parse_date maybe. - raise NotImplementedError("Support for additional @{...} modes not implemented") from e - # END handle revlog index - - try: - entry = ref.log_entry(revlog_index) - except IndexError as e: - raise IndexError("Invalid revlog index: %i" % revlog_index) from e - # END handle index out of bound - - obj = Object.new_from_sha(repo, hex_to_bin(entry.newhexsha)) - - # Make it pass the following checks. - output_type = "" - else: - raise ValueError("Invalid output type: %s ( in %s )" % (output_type, rev)) - # END handle output type + if token == ":": + return _tree_lookup(cast(AnyGitObject, obj), rev[start + 1 :]) + # END handle path - # Empty output types don't require any specific type, its just about - # dereferencing tags. - if output_type and obj and obj.type != output_type: - raise ValueError("Could not accommodate requested object type %r, got %s" % (output_type, obj.type)) - # END verify output type + start += 1 - start = end + 1 # Skip brace. - parsed_to = start + if token == "^" and start < lr and rev[start] == "{": + end = _find_closing_brace(rev, start) + obj = _peel(cast(AnyGitObject, obj), rev[start + 1 : end], repo, rev) + ref = None + start = end + 1 continue # END parse type - # Try to parse a number. num = 0 - if token != ":": - found_digit = False - while start < lr: - if rev[start] in digits: - num = num * 10 + int(rev[start]) - start += 1 - found_digit = True - else: - break - # END handle number - # END number parse loop - - # No explicit number given, 1 is the default. It could be 0 though. - if not found_digit: - num = 1 - # END set default num - # END number parsing only if non-blob mode - - parsed_to = start - # Handle hierarchy walk. + found_digit = False + while start < lr: + if rev[start] in digits: + num = num * 10 + int(rev[start]) + start += 1 + found_digit = True + else: + break + # END handle number + # END number parse loop + + if not found_digit: + num = 1 + # END set default num + try: obj = cast(AnyGitObject, obj) if token == "~": @@ -391,15 +653,11 @@ def rev_parse(repo: "Repo", rev: str) -> AnyGitObject: # END for each history item to walk elif token == "^": obj = to_commit(obj) - # Must be n'th parent. - if num: + if num == 0: + pass + else: obj = obj.parents[num - 1] - elif token == ":": - if obj.type != "tree": - obj = obj.tree - # END get tree type - obj = obj[rev[start:]] - parsed_to = lr + # END handle parent else: raise ValueError("Invalid token: %r" % token) # END end handle tag @@ -410,16 +668,7 @@ def rev_parse(repo: "Repo", rev: str) -> AnyGitObject: # END exception handling # END parse loop - # Still no obj? It's probably a simple name. - if obj is None: - obj = name_to_object(repo, rev) - parsed_to = lr - # END handle simple name - if obj is None: raise ValueError("Revision specifier could not be parsed: %s" % rev) - if parsed_to != lr: - raise ValueError("Didn't consume complete rev spec %s, consumed part: %s" % (rev, rev[:parsed_to])) - return obj diff --git a/test/test_repo.py b/test/test_repo.py index 544b5c561..0dd3d5945 100644 --- a/test/test_repo.py +++ b/test/test_repo.py @@ -146,6 +146,23 @@ def test_commit_from_revision(self): self.assertEqual(commit.type, "commit") self.assertEqual(self.rorepo.commit(commit), commit) + @with_rw_directory + def test_commit_from_tag_starting_with_at(self, rw_dir): + repo = Repo.init(rw_dir) + with repo.config_writer() as writer: + writer.set_value("user", "name", "GitPython Tests") + writer.set_value("user", "email", "gitpython@example.com") + + tracked_file = Path(rw_dir) / "hello.txt" + tracked_file.write_text("hello") + repo.index.add([str(tracked_file)]) + commit = repo.index.commit("init") + repo.create_tag("@foo") + + self.assertEqual(repo.tags["@foo"].commit, commit) + self.assertEqual(repo.commit("@"), commit) + self.assertEqual(repo.commit("@foo"), commit) + def test_commits(self): mc = 10 commits = list(self.rorepo.iter_commits("0.1.6", max_count=mc)) diff --git a/test/test_rev_parse.py b/test/test_rev_parse.py new file mode 100644 index 000000000..371210fa9 --- /dev/null +++ b/test/test_rev_parse.py @@ -0,0 +1,138 @@ +from pathlib import Path + +import pytest + +from git import Repo +from gitdb.exc import BadName + + +def _write(repo, path, content): + full_path = Path(repo.working_tree_dir) / path + full_path.parent.mkdir(parents=True, exist_ok=True) + full_path.write_text(content) + repo.index.add([str(full_path)]) + + +@pytest.fixture +def rev_parse_repo(tmp_path): + repo = Repo.init(tmp_path) + with repo.config_writer() as writer: + writer.set_value("user", "name", "GitPython Tests") + writer.set_value("user", "email", "gitpython@example.com") + + _write(repo, "README.md", "root\n") + _write(repo, "CHANGES", "root changes\n") + _write(repo, "dir/file.txt", "root file\n") + root = repo.index.commit("root commit") + repo.create_tag("ann", ref=root, message="annotated tag") + + _write(repo, "README.md", "release\n") + release = repo.index.commit("release candidate") + repo.create_tag("v1.0", ref=release) + main = repo.active_branch + + side = repo.create_head("side", root) + side.checkout() + _write(repo, "side.txt", "side\n") + side_commit = repo.index.commit("side branch") + + main.checkout() + repo.git.merge("--no-ff", "side", "-m", "merge side") + merge = repo.head.commit + + repo.create_head("aaaaaaaa", merge) + repo.create_tag("@foo", ref=merge) + + return { + "repo": repo, + "root": root, + "release": release, + "side": side_commit, + "merge": merge, + "main": main, + } + + +def test_rev_parse_names_hex_and_describe_forms(rev_parse_repo): + repo = rev_parse_repo["repo"] + merge = rev_parse_repo["merge"] + + assert repo.rev_parse("@") == merge + assert repo.rev_parse("@foo") == merge + assert repo.rev_parse("aaaaaaaa") == merge + assert repo.rev_parse(merge.hexsha[:7]) == merge + assert repo.rev_parse("v1.0-1-g%s" % merge.hexsha[:7]) == merge + assert repo.rev_parse("anything-9-g%s" % merge.hexsha[:7]) == merge + assert repo.rev_parse("%s-dirty" % merge.hexsha[:7]) == merge + + +def test_rev_parse_navigation_and_peeling(rev_parse_repo): + repo = rev_parse_repo["repo"] + root = rev_parse_repo["root"] + release = rev_parse_repo["release"] + side = rev_parse_repo["side"] + merge = rev_parse_repo["merge"] + tag = repo.rev_parse("ann") + + assert repo.rev_parse("HEAD^0") == merge + assert repo.rev_parse("HEAD~0") == merge + assert repo.rev_parse("HEAD^1") == release + assert repo.rev_parse("HEAD^2") == side + assert repo.rev_parse("HEAD~") == release + assert repo.rev_parse("HEAD^^") == root + + assert tag.type == "tag" + assert repo.rev_parse("ann^{object}") == tag + assert repo.rev_parse("ann^{tag}") == tag + assert repo.rev_parse("ann^{}") == root + assert repo.rev_parse("ann^{commit}") == root + assert repo.rev_parse("HEAD^{tree}") == merge.tree + assert repo.rev_parse("HEAD^{/}") == merge + + +def test_rev_parse_tree_and_index_paths(rev_parse_repo): + repo = rev_parse_repo["repo"] + merge = rev_parse_repo["merge"] + + assert repo.rev_parse("HEAD:") == merge.tree + assert repo.rev_parse("HEAD:README.md") == merge.tree["README.md"] + assert repo.rev_parse("HEAD^{tree}:README.md") == merge.tree["README.md"] + assert repo.rev_parse(":README.md").binsha == merge.tree["README.md"].binsha + assert repo.rev_parse(":0:README.md").binsha == merge.tree["README.md"].binsha + + +def test_rev_parse_reflog_selectors(rev_parse_repo): + repo = rev_parse_repo["repo"] + merge = rev_parse_repo["merge"] + side = rev_parse_repo["side"] + main = rev_parse_repo["main"] + + assert repo.rev_parse("@{0}") == merge + assert repo.rev_parse("@{+0}") == merge + assert repo.rev_parse("%s@{0}" % main.name) == merge + assert repo.rev_parse("@{-1}") == side + + +def test_rev_parse_commit_message_search(rev_parse_repo): + repo = rev_parse_repo["repo"] + release = rev_parse_repo["release"] + merge = rev_parse_repo["merge"] + + assert repo.rev_parse(":/release") == release + assert repo.rev_parse("HEAD^{/release}") == release + assert repo.rev_parse("HEAD^{/!-release}") == merge + + +def test_rev_parse_rejects_invalid_object_specs(rev_parse_repo): + repo = rev_parse_repo["repo"] + + with pytest.raises(ValueError): + repo.rev_parse(":") + with pytest.raises(ValueError): + repo.rev_parse(":/") + with pytest.raises(ValueError): + repo.rev_parse("@{-0}") + with pytest.raises(ValueError): + repo.rev_parse("HEAD^{invalid}") + with pytest.raises(BadName): + repo.rev_parse(":missing") From bdbdf4bba08f59042a2e1197313ca9a2060021d0 Mon Sep 17 00:00:00 2001 From: Codex GPT-5 Date: Wed, 29 Apr 2026 06:55:03 +0800 Subject: [PATCH 43/57] Fix rev-parse CI issues --- git/repo/fun.py | 42 ++++++++++++++++++++++++++++++++++-------- test/test_repo.py | 9 +++++++-- test/test_rev_parse.py | 2 ++ 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/git/repo/fun.py b/git/repo/fun.py index d91ce5c0b..ed00dd833 100644 --- a/git/repo/fun.py +++ b/git/repo/fun.py @@ -41,7 +41,7 @@ if TYPE_CHECKING: from git.db import GitCmdObjectDB - from git.objects import Commit, TagObject + from git.objects import Commit from git.refs.reference import Reference from git.refs.log import RefLog, RefLogEntry from git.refs.tag import Tag @@ -256,13 +256,30 @@ def _object_from_hexsha(repo: "Repo", hexsha: str) -> AnyGitObject: def _current_reflog_ref(repo: "Repo") -> SymbolicReference: - return repo.head + try: + return repo.head.ref + except TypeError: + return repo.head + # END handle detached head + + +def _common_reflog_path(repo: "Repo", ref: SymbolicReference) -> Optional[str]: + if repo.common_dir == repo.git_dir: + return None + # END handle normal repository + return SymbolicReference._get_validated_path(osp.join(repo.common_dir, "logs"), ref.path) def _ref_log(repo: "Repo", ref: SymbolicReference) -> "RefLog": try: return ref.log() except FileNotFoundError: + common_path = _common_reflog_path(repo, ref) + if common_path and osp.isfile(common_path): + from git.refs.log import RefLog + + return RefLog.from_file(common_path) + # END handle linked-worktree branch logs try: if ref.path == repo.head.ref.path: return repo.head.log() @@ -278,6 +295,12 @@ def _ref_log_entry(repo: "Repo", ref: SymbolicReference, index: int) -> "RefLogE try: return ref.log_entry(index) except FileNotFoundError: + common_path = _common_reflog_path(repo, ref) + if common_path and osp.isfile(common_path): + from git.refs.log import RefLog + + return RefLog.entry_at(common_path, index) + # END handle linked-worktree branch logs try: if ref.path == repo.head.ref.path: return repo.head.log_entry(index) @@ -464,7 +487,11 @@ def _find_commit_by_message( # END handle starting point for commit in commits: - matches = regex.search(commit.message or "") is not None + message = commit.message + if isinstance(message, bytes): + message = message.decode(commit.encoding, "replace") + # END handle bytes message + matches = regex.search(message or "") is not None if matches != negated: return commit # END found commit @@ -505,7 +532,7 @@ def _peel(obj: AnyGitObject, output_type: str, repo: "Repo", rev: str) -> AnyGit if output_type.startswith("/"): return _find_commit_by_message(repo, obj, output_type[1:], braced=True) if output_type == "": - return deref_tag(cast("TagObject", obj)) if obj.type == "tag" else obj + return deref_tag(obj) if obj.type == "tag" else obj if output_type == "object": return obj if output_type == "commit": @@ -513,7 +540,7 @@ def _peel(obj: AnyGitObject, output_type: str, repo: "Repo", rev: str) -> AnyGit if output_type == "tree": return to_commit(cast(Object, obj)).tree if obj.type != "tree" else obj if output_type == "blob": - obj = deref_tag(cast("TagObject", obj)) if obj.type == "tag" else obj + obj = deref_tag(obj) if obj.type == "tag" else obj if obj.type == output_type: return obj # END handle matching type @@ -615,14 +642,14 @@ def rev_parse(repo: "Repo", rev: str) -> AnyGitObject: # END handle reflog if token == ":": - return _tree_lookup(cast(AnyGitObject, obj), rev[start + 1 :]) + return _tree_lookup(obj, rev[start + 1 :]) # END handle path start += 1 if token == "^" and start < lr and rev[start] == "{": end = _find_closing_brace(rev, start) - obj = _peel(cast(AnyGitObject, obj), rev[start + 1 : end], repo, rev) + obj = _peel(obj, rev[start + 1 : end], repo, rev) ref = None start = end + 1 continue @@ -645,7 +672,6 @@ def rev_parse(repo: "Repo", rev: str) -> AnyGitObject: # END set default num try: - obj = cast(AnyGitObject, obj) if token == "~": obj = to_commit(obj) for _ in range(num): diff --git a/test/test_repo.py b/test/test_repo.py index 0dd3d5945..7262395bd 100644 --- a/test/test_repo.py +++ b/test/test_repo.py @@ -865,8 +865,13 @@ def test_rev_parse(self): # Currently, nothing more is supported. self.assertRaises(NotImplementedError, rev_parse, "@{1 week ago}") - # The last position. - assert rev_parse("@{1}") != head.commit + # The previous position, if this checkout has enough reflog history. + try: + previous = rev_parse("@{1}") + except IndexError: + pass + else: + self.assertNotEqual(previous, head.commit) def test_repo_odbtype(self): target_type = GitCmdObjectDB diff --git a/test/test_rev_parse.py b/test/test_rev_parse.py index 371210fa9..d96fdc1a2 100644 --- a/test/test_rev_parse.py +++ b/test/test_rev_parse.py @@ -106,9 +106,11 @@ def test_rev_parse_reflog_selectors(rev_parse_repo): merge = rev_parse_repo["merge"] side = rev_parse_repo["side"] main = rev_parse_repo["main"] + release = rev_parse_repo["release"] assert repo.rev_parse("@{0}") == merge assert repo.rev_parse("@{+0}") == merge + assert repo.rev_parse("@{1}") == release assert repo.rev_parse("%s@{0}" % main.name) == merge assert repo.rev_parse("@{-1}") == side From 6cf7ac33d449db095e8c301abba664836c16bfc8 Mon Sep 17 00:00:00 2001 From: Codex GPT-5 Date: Wed, 29 Apr 2026 07:11:05 +0800 Subject: [PATCH 44/57] Address rev-parse review feedback --- git/repo/fun.py | 56 ++++++++++++++++++++++++++++++++++-------- test/test_rev_parse.py | 35 ++++++++++++++++++++------ 2 files changed, 73 insertions(+), 18 deletions(-) diff --git a/git/repo/fun.py b/git/repo/fun.py index ed00dd833..66e7eba69 100644 --- a/git/repo/fun.py +++ b/git/repo/fun.py @@ -35,7 +35,7 @@ # Typing ---------------------------------------------------------------------- -from typing import Optional, TYPE_CHECKING, Tuple, Union, cast, overload +from typing import Iterator, Optional, TYPE_CHECKING, Tuple, Union, cast, overload from git.types import AnyGitObject, Literal, PathLike @@ -190,10 +190,6 @@ def name_to_object(repo: "Repo", name: str, return_ref: bool = False) -> Union[A # END handle short shas # END find sha if it matches - if hexsha is None: - hexsha = _describe_to_long(repo, name) - # END handle describe output - # If we couldn't find an object for what seemed to be a short hexsha, try to find it # as reference anyway, it could be named 'aaa' for instance. if hexsha is None: @@ -216,6 +212,10 @@ def name_to_object(repo: "Repo", name: str, return_ref: bool = False) -> Union[A # END for each base # END handle hexsha + if hexsha is None: + hexsha = _describe_to_long(repo, name) + # END handle describe output + # Didn't find any ref, this is an error. if return_ref: raise BadObject("Couldn't find reference named %r" % name) @@ -363,6 +363,8 @@ def _tracking_branch_object(repo: "Repo", ref: Optional[SymbolicReference]) -> A raise BadName("@{upstream}") from e elif isinstance(ref, Head): head = ref + elif os.fspath(ref.path).startswith("refs/heads/"): + head = Head(repo, ref.path) else: raise BadName("%s@{upstream}" % ref.name) # END handle head @@ -479,11 +481,15 @@ def _find_commit_by_message( repo: "Repo", rev: Optional[AnyGitObject], pattern: str, braced: bool = False ) -> AnyGitObject: pattern, negated = _parse_search(_unescape_braced_regex(pattern) if braced else pattern) - regex = re.compile(pattern) + try: + regex = re.compile(pattern) + except re.error as e: + raise ValueError("Invalid commit message regex %r" % pattern) from e + # END handle invalid regex if rev is None: - commits = repo.iter_commits("--all") + commits = _all_ref_commits(repo) else: - commits = repo.iter_commits(to_commit(cast(Object, rev)).hexsha) + commits = _reachable_commits([to_commit(cast(Object, rev))]) # END handle starting point for commit in commits: @@ -499,6 +505,38 @@ def _find_commit_by_message( raise BadName("No commit found matching message pattern %r" % pattern) +def _all_ref_commits(repo: "Repo") -> Iterator["Commit"]: + starts = [] + for ref in repo.references: + try: + starts.append(to_commit(cast(Object, ref.object))) + except (BadName, ValueError): + pass + # END skip refs that do not point to commits + # END for each ref + try: + starts.append(repo.head.commit) + except ValueError: + pass + # END handle unborn head + return _reachable_commits(starts) + + +def _reachable_commits(starts: list["Commit"]) -> Iterator["Commit"]: + seen = set() + pending = starts[:] + while pending: + pending.sort(key=lambda commit: commit.committed_date, reverse=True) + commit = pending.pop(0) + if commit.binsha in seen: + continue + # END skip seen commit + seen.add(commit.binsha) + yield commit + pending.extend(commit.parents) + # END while commits remain + + def _index_lookup(repo: "Repo", spec: str) -> AnyGitObject: if not spec: raise ValueError("':' must be followed by a path") @@ -527,8 +565,6 @@ def _tree_lookup(obj: AnyGitObject, path: str) -> AnyGitObject: def _peel(obj: AnyGitObject, output_type: str, repo: "Repo", rev: str) -> AnyGitObject: - if output_type == "/": - return obj if output_type.startswith("/"): return _find_commit_by_message(repo, obj, output_type[1:], braced=True) if output_type == "": diff --git a/test/test_rev_parse.py b/test/test_rev_parse.py index d96fdc1a2..b00347668 100644 --- a/test/test_rev_parse.py +++ b/test/test_rev_parse.py @@ -1,8 +1,15 @@ +# Copyright (C) 2026 Michael Trier (mtrier@gmail.com) and contributors +# +# This module is part of GitPython and is released under the +# 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/ + from pathlib import Path import pytest from git import Repo +from git.refs import RemoteReference +from git.refs import SymbolicReference from gitdb.exc import BadName @@ -31,14 +38,12 @@ def rev_parse_repo(tmp_path): repo.create_tag("v1.0", ref=release) main = repo.active_branch - side = repo.create_head("side", root) - side.checkout() _write(repo, "side.txt", "side\n") - side_commit = repo.index.commit("side branch") + side_commit = repo.index.commit("side branch", parent_commits=[root], head=False, skip_hooks=True) + repo.create_head("side", side_commit) - main.checkout() - repo.git.merge("--no-ff", "side", "-m", "merge side") - merge = repo.head.commit + merge = repo.index.commit("merge side", parent_commits=[release, side_commit], skip_hooks=True) + repo.head.log_append(side_commit.binsha, "checkout: moving from side to main", merge.binsha) repo.create_head("aaaaaaaa", merge) repo.create_tag("@foo", ref=merge) @@ -55,16 +60,21 @@ def rev_parse_repo(tmp_path): def test_rev_parse_names_hex_and_describe_forms(rev_parse_repo): repo = rev_parse_repo["repo"] + release = rev_parse_repo["release"] merge = rev_parse_repo["merge"] assert repo.rev_parse("@") == merge assert repo.rev_parse("@foo") == merge assert repo.rev_parse("aaaaaaaa") == merge assert repo.rev_parse(merge.hexsha[:7]) == merge + describe_name = "anything-9-g%s" % merge.hexsha[:7] assert repo.rev_parse("v1.0-1-g%s" % merge.hexsha[:7]) == merge - assert repo.rev_parse("anything-9-g%s" % merge.hexsha[:7]) == merge + assert repo.rev_parse(describe_name) == merge assert repo.rev_parse("%s-dirty" % merge.hexsha[:7]) == merge + repo.create_tag(describe_name, ref=release) + assert repo.rev_parse(describe_name) == release + def test_rev_parse_navigation_and_peeling(rev_parse_repo): repo = rev_parse_repo["repo"] @@ -87,7 +97,8 @@ def test_rev_parse_navigation_and_peeling(rev_parse_repo): assert repo.rev_parse("ann^{}") == root assert repo.rev_parse("ann^{commit}") == root assert repo.rev_parse("HEAD^{tree}") == merge.tree - assert repo.rev_parse("HEAD^{/}") == merge + with pytest.raises(ValueError): + repo.rev_parse("HEAD^{/}") def test_rev_parse_tree_and_index_paths(rev_parse_repo): @@ -114,6 +125,10 @@ def test_rev_parse_reflog_selectors(rev_parse_repo): assert repo.rev_parse("%s@{0}" % main.name) == merge assert repo.rev_parse("@{-1}") == side + SymbolicReference.create(repo, "refs/remotes/origin/%s" % main.name, merge) + main.set_tracking_branch(RemoteReference(repo, "refs/remotes/origin/%s" % main.name)) + assert repo.rev_parse("%s@{upstream}" % main.name) == merge + def test_rev_parse_commit_message_search(rev_parse_repo): repo = rev_parse_repo["repo"] @@ -132,6 +147,10 @@ def test_rev_parse_rejects_invalid_object_specs(rev_parse_repo): repo.rev_parse(":") with pytest.raises(ValueError): repo.rev_parse(":/") + with pytest.raises(ValueError): + repo.rev_parse(":/[") + with pytest.raises(ValueError): + repo.rev_parse("HEAD^{/[}") with pytest.raises(ValueError): repo.rev_parse("@{-0}") with pytest.raises(ValueError): From aee2fd5c13770954469e650f1df8f92f0183bc70 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Wed, 29 Apr 2026 08:30:21 +0800 Subject: [PATCH 45/57] bump version to 3.1.49 --- VERSION | 2 +- doc/source/changes.rst | 11 +++++++++++ git/ext/gitdb | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 94c78f538..8335f2d61 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.1.48 +3.1.49 diff --git a/doc/source/changes.rst b/doc/source/changes.rst index 4ac67d077..020673826 100644 --- a/doc/source/changes.rst +++ b/doc/source/changes.rst @@ -2,6 +2,17 @@ Changelog ========= +3.1.49 +====== + +Save setting of configuration values, +which cuold be used to inject other more configuration. + +Also more conforming `rev-parse` implementation. + +See the following for all changes. +https://github.com/gitpython-developers/GitPython/releases/tag/3.1.49 + 3.1.48 ====== diff --git a/git/ext/gitdb b/git/ext/gitdb index 5c1b3036a..335c0f661 160000 --- a/git/ext/gitdb +++ b/git/ext/gitdb @@ -1 +1 @@ -Subproject commit 5c1b3036a6e34782e0ab6ce85e5ae64fe777fdbe +Subproject commit 335c0f66173eecdc7b2597c2b6c3d1fde795df30 From b17f11315b3c3baf7c073234670ce58cc2bbf5ec Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 16:12:17 +0000 Subject: [PATCH 46/57] Bump https://github.com/astral-sh/ruff-pre-commit Bumps the pre-commit group with 1 update: [https://github.com/astral-sh/ruff-pre-commit](https://github.com/astral-sh/ruff-pre-commit). Updates `https://github.com/astral-sh/ruff-pre-commit` from v0.15.8 to 0.15.12 - [Release notes](https://github.com/astral-sh/ruff-pre-commit/releases) - [Commits](https://github.com/astral-sh/ruff-pre-commit/compare/v0.15.8...v0.15.12) --- updated-dependencies: - dependency-name: https://github.com/astral-sh/ruff-pre-commit dependency-version: 0.15.12 dependency-type: direct:production dependency-group: pre-commit ... Signed-off-by: dependabot[bot] --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 617111e1d..f3ab67035 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,7 +7,7 @@ repos: exclude: ^test/fixtures/ - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.8 + rev: v0.15.12 hooks: - id: ruff-check args: ["--fix"] From 714e2e16dc2a67567ee48f7bcffcb59b9ca12caa Mon Sep 17 00:00:00 2001 From: "GPT 5.5" Date: Sun, 3 May 2026 00:02:49 +0800 Subject: [PATCH 47/57] Xfail Windows symlink-capable index mutation test The Windows CI jobs for PR 2140 failed in test/test_index.py::TestIndex::test_index_mutation. The failing checkout path creates my_fake_symlink and Git for Windows 2.54 reports a symlink warning before GitPython raises GitCommandError. This is the same unsupported Windows symlink behavior that the test already marks as an expected failure when core.symlinks is true. Detect Windows hosts that can create symlinks directly and include GitCommandError in the expected failure types, so symlink-capable Windows runners do not fail this unrelated Dependabot PR. Co-authored-by: Sebastian Thiel --- test/test_index.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/test/test_index.py b/test/test_index.py index 33490f907..f8280450a 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -172,6 +172,19 @@ def _decode(stdout): _win_bash_status = WinBashStatus.check() +def _windows_supports_symlinks(): + if sys.platform != "win32": + return False + + with tempfile.TemporaryDirectory(prefix="gitpython-symlink-check-") as temp_dir: + link_path = osp.join(temp_dir, "link") + try: + os.symlink("missing-target", link_path) + except (NotImplementedError, OSError): + return False + return S_ISLNK(os.lstat(link_path)[ST_MODE]) + + def _make_hook(git_dir, name, content, make_exec=True): """A helper to create a hook""" hp = hook_path(name, git_dir) @@ -553,9 +566,9 @@ def _count_existing(self, repo, files): # END num existing helper @pytest.mark.xfail( - sys.platform == "win32" and Git().config("core.symlinks") == "true", + sys.platform == "win32" and (Git().config("core.symlinks") == "true" or _windows_supports_symlinks()), reason="Assumes symlinks are not created on Windows and opens a symlink to a nonexistent target.", - raises=FileNotFoundError, + raises=(FileNotFoundError, GitCommandError), ) @with_rw_repo("0.1.6") def test_index_mutation(self, rw_repo): From 4e8cd45685d33c8b6af2f70c77a341c4a15acf14 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 3 May 2026 13:32:18 +0000 Subject: [PATCH 48/57] Bump git/ext/gitdb from `335c0f6` to `53c94d6` Bumps [git/ext/gitdb](https://github.com/gitpython-developers/gitdb) from `335c0f6` to `53c94d6`. - [Release notes](https://github.com/gitpython-developers/gitdb/releases) - [Commits](https://github.com/gitpython-developers/gitdb/compare/335c0f66173eecdc7b2597c2b6c3d1fde795df30...53c94d682b541595918cea6fc2e96bb900eb0e8c) --- updated-dependencies: - dependency-name: git/ext/gitdb dependency-version: 53c94d682b541595918cea6fc2e96bb900eb0e8c dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- git/ext/gitdb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git/ext/gitdb b/git/ext/gitdb index 335c0f661..53c94d682 160000 --- a/git/ext/gitdb +++ b/git/ext/gitdb @@ -1 +1 @@ -Subproject commit 335c0f66173eecdc7b2597c2b6c3d1fde795df30 +Subproject commit 53c94d682b541595918cea6fc2e96bb900eb0e8c From 54538428f79b0c91ba52cda5229856a6edf7ac06 Mon Sep 17 00:00:00 2001 From: "GPT 5.5" Date: Tue, 5 May 2026 21:34:42 +0800 Subject: [PATCH 49/57] Validate config key section names before writing GitConfigParser already rejected CR, LF, and NUL in config values before writing, but section and option names could still reach configparser. Because GitPython writes section headers itself, a newline-bearing section name could split the output into additional headers. Reject CR, LF, and NUL in section and option names on write paths that create or set config keys: add_section(), set(), set_value(), add_value(), and rename_section() destinations. This matches Git config key validation behavior; Git source commit 94f057755b7941b321fd11fec1b2e3ca5313a4e0 reports invalid keys containing newlines from config.c, and local git 2.50.1 rejects newline-bearing config keys. Add a regression test covering unsafe section and option names while preserving safe writes. Co-authored-by: Sebastian Thiel --- git/config.py | 17 ++++++++++++++++- test/test_config.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/git/config.py b/git/config.py index 97ae054e5..82747eadd 100644 --- a/git/config.py +++ b/git/config.py @@ -72,6 +72,9 @@ 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.""" + class MetaParserBuilder(abc.ABCMeta): # noqa: B024 """Utility class wrapping base-class methods into decorators that assure read-only @@ -778,6 +781,7 @@ def _assure_writable(self, method_name: str) -> None: def add_section(self, section: "cp._SectionName") -> None: """Assures added options will stay in order.""" + self._assure_config_name_safe(section, "section") return super().add_section(section) @property @@ -884,10 +888,14 @@ def _value_to_string(self, value: Union[str, bytes, int, float, bool]) -> str: def _value_to_string_safe(self, value: Union[str, bytes, int, float, bool]) -> str: value_str = self._value_to_string(value) - if re.search(r"[\r\n\x00]", value_str): + if UNSAFE_CONFIG_CHARS_RE.search(value_str): raise ValueError("Git config values must not contain CR, LF, or NUL") return value_str + def _assure_config_name_safe(self, name: "cp._SectionName", label: str) -> None: + if isinstance(name, str) and UNSAFE_CONFIG_CHARS_RE.search(name): + raise ValueError("Git config %s names must not contain CR, LF, or NUL" % label) + @needs_values @set_dirty_and_flush_changes def set( @@ -896,6 +904,8 @@ def set( option: str, value: Union[str, bytes, int, float, bool, None] = None, ) -> None: + self._assure_config_name_safe(section, "section") + self._assure_config_name_safe(option, "option") if value is not None: value = self._value_to_string_safe(value) return super().set(section, option, value) @@ -920,6 +930,8 @@ def set_value(self, section: str, option: str, value: Union[str, bytes, int, flo :return: This instance """ + self._assure_config_name_safe(section, "section") + self._assure_config_name_safe(option, "option") value_str = self._value_to_string_safe(value) if not self.has_section(section): self.add_section(section) @@ -948,6 +960,8 @@ def add_value(self, section: str, option: str, value: Union[str, bytes, int, flo :return: This instance """ + self._assure_config_name_safe(section, "section") + self._assure_config_name_safe(option, "option") value_str = self._value_to_string_safe(value) if not self.has_section(section): self.add_section(section) @@ -968,6 +982,7 @@ def rename_section(self, section: str, new_name: str) -> "GitConfigParser": """ if not self.has_section(section): raise ValueError("Source section '%s' doesn't exist" % section) + self._assure_config_name_safe(new_name, "section") if self.has_section(new_name): raise ValueError("Destination section '%s' already exists" % new_name) diff --git a/test/test_config.py b/test/test_config.py index a9dcdb087..3ddaf0a4b 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -163,6 +163,37 @@ def test_set_value_rejects_config_injection(self, rw_dir): self.assertFalse(git_config.has_section("user")) self.assertFalse(git_config.has_section("core")) + @with_rw_directory + def test_set_value_rejects_unsafe_section_and_option_names(self, rw_dir): + config_path = osp.join(rw_dir, "config") + bad_keys = ("user]\n[core", "user]\r[core", "user]\x00[core") + + with GitConfigParser(config_path, read_only=False) as git_config: + git_config.add_section("user") + for bad_key in bad_keys: + with pytest.raises(ValueError, match="CR, LF, or NUL"): + git_config.add_section(bad_key) + with pytest.raises(ValueError, match="CR, LF, or NUL"): + git_config.set(bad_key, "hooksPath", "/tmp/hooks") + with pytest.raises(ValueError, match="CR, LF, or NUL"): + git_config.set("user", bad_key, "/tmp/hooks") + with pytest.raises(ValueError, match="CR, LF, or NUL"): + git_config.set_value(bad_key, "hooksPath", "/tmp/hooks") + with pytest.raises(ValueError, match="CR, LF, or NUL"): + git_config.set_value("user", bad_key, "/tmp/hooks") + with pytest.raises(ValueError, match="CR, LF, or NUL"): + git_config.add_value(bad_key, "hooksPath", "/tmp/hooks") + with pytest.raises(ValueError, match="CR, LF, or NUL"): + git_config.add_value("user", bad_key, "/tmp/hooks") + with pytest.raises(ValueError, match="CR, LF, or NUL"): + git_config.rename_section("user", bad_key) + + git_config.set_value("user", "name", "safe") + + with GitConfigParser(config_path, read_only=True) as git_config: + self.assertEqual(git_config.get_value("user", "name"), "safe") + self.assertFalse(git_config.has_section("core")) + @with_rw_directory def test_set_and_add_value_reject_unsafe_value_characters(self, rw_dir): config_path = osp.join(rw_dir, "config") From 5a294a6fc7ed5dc0946d4b576257bf926178f269 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Wed, 6 May 2026 12:00:26 +0800 Subject: [PATCH 50/57] bump version to 3.1.50 --- VERSION | 2 +- doc/source/changes.rst | 10 +++++++++- git/ext/gitdb | 2 +- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/VERSION b/VERSION index 8335f2d61..0bc461141 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.1.49 +3.1.50 diff --git a/doc/source/changes.rst b/doc/source/changes.rst index 020673826..b5152b3c5 100644 --- a/doc/source/changes.rst +++ b/doc/source/changes.rst @@ -2,11 +2,19 @@ Changelog ========= +3.1.50 +====== + +Save setting of configuration values, this time sections as well, as follow-up to 3.1.49. + +See the following for all changes. +https://github.com/gitpython-developers/GitPython/releases/tag/3.1.50 + 3.1.49 ====== Save setting of configuration values, -which cuold be used to inject other more configuration. +which could be used to inject other more configuration. Also more conforming `rev-parse` implementation. diff --git a/git/ext/gitdb b/git/ext/gitdb index 53c94d682..335c0f661 160000 --- a/git/ext/gitdb +++ b/git/ext/gitdb @@ -1 +1 @@ -Subproject commit 53c94d682b541595918cea6fc2e96bb900eb0e8c +Subproject commit 335c0f66173eecdc7b2597c2b6c3d1fde795df30 From 4941c314fc8c38ed6df037c29d4809f7a7fd9af9 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Wed, 6 May 2026 14:52:31 +0800 Subject: [PATCH 51/57] Add AI-disclusure and quality requirements to the contribution guidelines. Co-authored-by: GPT 5.5 --- CONTRIBUTING.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8536d7f73..d8a29f55d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,6 +9,35 @@ The following is a short step-by-step rundown of what one typically would do to - Feel free to add yourself to AUTHORS file. - Create a pull request. +## Quality expectations + +Contributions must be made with care and meet the quality bar of the surrounding code. +That means a change should not leave GitPython worse than it was before: it should be +readable, maintainable, tested where practical, documented and consistent with the +existing style and behavior. +A contribution that works only narrowly but lowers the quality of the +codebase may be declined, and the pull request closed without warning. + +## AI-assisted contributions + +If AI edits files for you, disclose it in the pull request description and commit +metadata. Prefer making the agent identity part of the commit, for example by using +an AI author such as `$agent $version ` or a co-author via +a `Co-authored-by: ` trailer. + +Agents operating through a person's GitHub account must identify themselves. For +example, comments posted by an agent should say so directly with phrases like +`AI agent on behalf of : ...`. + +Fully AI-generated comments on pull requests or issues must also be disclosed. +Undisclosed AI-generated comments may lead to the pull request or issue being closed. + +AI-assisted proofreading or wording polish does not need disclosure, but it is still +courteous to mention it when the AI materially influenced the final text. + +Automated or "full-auto" AI contributions without a human responsible for reviewing +and standing behind the work may be closed. + ## Fuzzing Test Specific Documentation For details related to contributing to the fuzzing test suite and OSS-Fuzz integration, please From 92ff6df86b82e94254874b52b05dee9ffe38716b Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Wed, 6 May 2026 09:58:33 -0400 Subject: [PATCH 52/57] Separate quality paragraphs and adjust decline wording Split the quality-expectations section into two paragraphs (the warning about low-quality contributions being declined was visually merged with the preceding paragraph). Replace "and the pull request closed without warning" with a note that maintainers may not always be able to provide detailed feedback, which conveys the same practical reality. Co-authored-by: Claude Opus 4.6 --- CONTRIBUTING.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d8a29f55d..60e34a651 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,8 +15,10 @@ Contributions must be made with care and meet the quality bar of the surrounding That means a change should not leave GitPython worse than it was before: it should be readable, maintainable, tested where practical, documented and consistent with the existing style and behavior. + A contribution that works only narrowly but lowers the quality of the -codebase may be declined, and the pull request closed without warning. +codebase may be declined. The maintainers may not always be able to provide +detailed feedback. ## AI-assisted contributions From d172e541455679ebdfb55df1411836298cb47b8d Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Thu, 7 May 2026 03:20:20 -0700 Subject: [PATCH 53/57] docs(cmd): clarify Git.execute() string vs list command argument Closes #2016 Users routinely hit GitCommandNotFound by passing a single string with spaces to repo.git.execute(...). With shell=False (default) subprocess treats the entire string as the executable name and fails. Document the recommended list form, the string-as-single-executable behavior, and the two ways to coerce a string into argv tokens (shlex.split or shell=True). --- git/cmd.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/git/cmd.py b/git/cmd.py index 78a9f4c78..1ecff9318 100644 --- a/git/cmd.py +++ b/git/cmd.py @@ -1119,9 +1119,16 @@ def execute( information (stdout). :param command: - The command argument list to execute. - It should be a sequence of program arguments, or a string. The - program to execute is the first item in the args sequence or string. + The command to execute. A sequence of program arguments is the + recommended form when `shell` is ``False`` (the default), e.g. + ``["git", "log", "-n", "1"]``. + + A string is accepted, but with `shell` set to ``False`` it is passed + as a single executable name to :class:`subprocess.Popen`. For example, + ``"git log -n 1"`` looks for an executable literally named + ``git log -n 1`` and will fail with :class:`GitCommandNotFound`. To + split a command string into argv tokens, pass ``shlex.split(...)`` as + a sequence or set `shell` to ``True`` (see the warning below). :param istream: Standard input filehandle passed to :class:`subprocess.Popen`. From 010a7bbfd237d2ab0627c20a542933abf978b690 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Fri, 8 May 2026 16:34:31 -0400 Subject: [PATCH 54/57] Rewrite Git.execute() command parameter docstring per #2146 #2146 identifies two factual problems in #2144's `:param command:` rewrite, both of which this fixes: 1. The claim that with `shell=False` a string "is passed as a single executable name to `subprocess.Popen`" is accurate on Unix-like systems, but on Windows `subprocess.Popen` forwards the string to `CreateProcessW` and Windows command-line parsing produces argv. So multi-word strings happen to work on Windows -- which makes the docstring misleading for Windows readers and unhelpful for anyone whose actual problem is portability. 2. The blanket recommendation to use `shlex.split` read as a general string-to-argv splitter. `shlex.split` parses POSIX shell syntax on all systems, and the resulting tokens are still unsafe for anything but fixed, fully trusted strings -- in particular, strings built by interpolating values can still inject extra arguments via embedded whitespace or quoting. Restructure `:param command:` into four paragraphs (description, platform-specific string handling, `shell=True` / `Git.USE_SHELL` warning, qualified `shlex.split` note) and cross-reference `USE_SHELL` for the long-form security discussion rather than reproducing it. Add a related paragraph to `:param shell:` noting `shlex.split`'s narrow value as a transitional tool when migrating existing string-command `shell=True` calls on Unix-like systems, with extreme care, and cross-referencing `:param command:` for the risks. This fixes #2146. Only documentation is changed. Co-authored-by: Claude Opus 4.7 (1M context) --- git/cmd.py | 37 +++++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/git/cmd.py b/git/cmd.py index 7f2564d45..861009099 100644 --- a/git/cmd.py +++ b/git/cmd.py @@ -1131,16 +1131,28 @@ def execute( information (stdout). :param command: - The command to execute. A sequence of program arguments is the - recommended form when `shell` is ``False`` (the default), e.g. - ``["git", "log", "-n", "1"]``. - - A string is accepted, but with `shell` set to ``False`` it is passed - as a single executable name to :class:`subprocess.Popen`. For example, - ``"git log -n 1"`` looks for an executable literally named - ``git log -n 1`` and will fail with :class:`GitCommandNotFound`. To - split a command string into argv tokens, pass ``shlex.split(...)`` as - a sequence or set `shell` to ``True`` (see the warning below). + The command to execute. A sequence of program arguments is recommended. + A string is also accepted, but its meaning is strongly platform-dependent. + + By default, a shell is not used. On Unix-like systems, a string is the whole + program name (so ``"git log -n 1"`` raises :class:`GitCommandNotFound`). On + Windows, the program parses the arguments itself, so multi-word strings can + work but are not portable. + + Avoid ``shell=True`` (and :attr:`Git.USE_SHELL`): this runs the command in + a shell, which is generally unsafe. The shell interprets metacharacters + such as ``;``, ``|``, ``&``, ``$(...)``, ``$VAR``, ``%VAR%``, and ``^`` + (depending on the platform) as syntax. Any untrusted text in the command + can then execute arbitrary OS commands. See :attr:`Git.USE_SHELL`. + + Producing a sequence automatically by :func:`shlex.split` and passing it + as the command is far safer than ``shell=True``. But :func:`shlex.split` + parses POSIX shell syntax on all systems, and the result is still unsafe + for anything but *fixed, fully trusted* strings. Do not use it on strings + built by interpolating values: whitespace or quoting in an untrusted value + can still inject arguments. For input derived in any way from untrusted + data, build the argument sequence yourself, while ensuring each argument + is fully sanitized. :param istream: Standard input filehandle passed to :class:`subprocess.Popen`. @@ -1208,6 +1220,11 @@ def execute( needed (nor useful) to work around any known operating system specific issues. + On Unix-like systems, when migrating away from passing string commands with + ``shell=True``, :func:`shlex.split` may serve as a transitional step in rare + cases, but this should be undertaken with extreme care. See the `command` + parameter above on the risks. + :param env: A dictionary of environment variables to be passed to :class:`subprocess.Popen`. From 38d62c4b60b5cd2ed743c00f2cac1875c76449e0 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 9 May 2026 21:19:10 -0400 Subject: [PATCH 55/57] Word `shell` mention of `shlex.split` more carefully To avert the mistake of not removing `shell=True` when using it. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) --- git/cmd.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/git/cmd.py b/git/cmd.py index 861009099..98bb62775 100644 --- a/git/cmd.py +++ b/git/cmd.py @@ -1222,8 +1222,8 @@ def execute( On Unix-like systems, when migrating away from passing string commands with ``shell=True``, :func:`shlex.split` may serve as a transitional step in rare - cases, but this should be undertaken with extreme care. See the `command` - parameter above on the risks. + cases, with extreme care. (Drop ``shell=True`` and pass the resulting + sequence as the command.) See the `command` parameter above on the risks. :param env: A dictionary of environment variables to be passed to From 0c1e40982438b46e2e47dc833bccdced779a47db Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 9 May 2026 21:49:47 -0400 Subject: [PATCH 56/57] Document init script behavior when multiple remotes have master When `master` is locally absent and more than one remote has it, `git checkout master --` fails by default even if all remotes agree, and the script falls back to creating `master` at `HEAD`. The reflog populated by the subsequent resets then traces `HEAD`'s history rather than a remote `master`'s. This is harmless, because `master` is reset to `__testing_point__` either way, but unintuitive. Add a comment so a reader of the script does not have to discover this from a confusing run. This fixes #2145. Co-Authored-By: Claude Opus 4.7 (1M context) --- init-tests-after-clone.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/init-tests-after-clone.sh b/init-tests-after-clone.sh index bfada01b0..a88f983fc 100755 --- a/init-tests-after-clone.sh +++ b/init-tests-after-clone.sh @@ -40,6 +40,11 @@ fi git tag __testing_point__ # The tests need a branch called master. +# +# If master is locally absent but more than one remote has it, checkout fails +# by default even if all remotes agree, and we fall back to creating it at +# HEAD. The reflog we populate below then traces HEAD's history rather than +# a remote master's, but master is reset to __testing_point__ either way. git checkout master -- || git checkout -b master # The tests need a reflog history on the master branch. From 465f1d587a13795b35a4b72ad7fafbbe9c7731ac Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 10 May 2026 13:32:12 +0000 Subject: [PATCH 57/57] Bump git/ext/gitdb from `335c0f6` to `0a019a2` Bumps [git/ext/gitdb](https://github.com/gitpython-developers/gitdb) from `335c0f6` to `0a019a2`. - [Release notes](https://github.com/gitpython-developers/gitdb/releases) - [Commits](https://github.com/gitpython-developers/gitdb/compare/335c0f66173eecdc7b2597c2b6c3d1fde795df30...0a019a2e2bd73158cf8b637ad78b5d4b8f15e42e) --- updated-dependencies: - dependency-name: git/ext/gitdb dependency-version: 0a019a2e2bd73158cf8b637ad78b5d4b8f15e42e dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- git/ext/gitdb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git/ext/gitdb b/git/ext/gitdb index 335c0f661..0a019a2e2 160000 --- a/git/ext/gitdb +++ b/git/ext/gitdb @@ -1 +1 @@ -Subproject commit 335c0f66173eecdc7b2597c2b6c3d1fde795df30 +Subproject commit 0a019a2e2bd73158cf8b637ad78b5d4b8f15e42e