From 1d808917168ee292a9e4c5fb575a79133d253bb3 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 11 Jun 2025 14:05:31 +0200 Subject: [PATCH 01/72] fix updating submodules with relative urls This fixes running repo.update_submodules(init=True) on repositories that are using relative for the modules. Fixes #730 --- git/objects/submodule/base.py | 5 +++++ test/test_submodule.py | 16 ++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/git/objects/submodule/base.py b/git/objects/submodule/base.py index fa60bcdaf..0e55b8fa9 100644 --- a/git/objects/submodule/base.py +++ b/git/objects/submodule/base.py @@ -353,6 +353,11 @@ def _clone_repo( os.makedirs(module_abspath_dir) module_checkout_path = osp.join(str(repo.working_tree_dir), path) + if url.startswith("../"): + remote_name = repo.active_branch.tracking_branch().remote_name + repo_remote_url = repo.remote(remote_name).url + url = os.path.join(repo_remote_url, url) + clone = git.Repo.clone_from( url, module_checkout_path, diff --git a/test/test_submodule.py b/test/test_submodule.py index d88f9dab0..f44f086c2 100644 --- a/test/test_submodule.py +++ b/test/test_submodule.py @@ -753,6 +753,22 @@ def test_add_empty_repo(self, rwdir): ) # END for each checkout mode + @with_rw_directory + @_patch_git_config("protocol.file.allow", "always") + def test_update_submodule_with_relative_path(self, rwdir): + repo_path = osp.join(rwdir, "parent") + repo = git.Repo.init(repo_path) + module_repo_path = osp.join(rwdir, "module") + module_repo = git.Repo.init(module_repo_path) + module_repo.git.commit(m="test", allow_empty=True) + repo.git.submodule("add", "../module", "module") + repo.index.commit("add submodule") + + cloned_repo_path = osp.join(rwdir, "cloned_repo") + cloned_repo = git.Repo.clone_from(repo_path, cloned_repo_path) + + cloned_repo.submodule_update(init=True, recursive=True) + @with_rw_directory @_patch_git_config("protocol.file.allow", "always") def test_list_only_valid_submodules(self, rwdir): From 088d090ed3016aca68f3376736bf9f2b18d0fe7e Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Thu, 12 Jun 2025 02:39:11 -0400 Subject: [PATCH 02/72] Run `cat_file.py` fixture without site customizations This fixes a new `TestGit::test_handle_process_output` test failure on Cygwin where a `CoverageWarning` was printed to stderr in the Python interpreter subprocess running the `cat_file.py` fixture. We usually run the test suite with `pytest-cov` enabled. This is configured in `pyproject.toml` to happen by default. `pytest-cov` uses the `coverage` module, but it adds some more functionality. This includes instrumenting subprocesses, which is achieved by installing its `pytest-cov.pth` file into `site-packages` to be run by all Python interpreter instances. This causes interpeters to check for environment variables such as `COV_CORE_SOURCE` and to conditionally initialize `pytest_cov`. For details, see: https://pytest-cov.readthedocs.io/en/latest/subprocess-support.html `coverage` 7.9.0 was recently released. One of the changes is to start issuing a warning if it can't import the C tracer core. See: https://github.com/nedbat/coveragepy/releases/tag/7.9.0 If this warning is issued in the `cat_file.py` subprocess used in `test_handle_process_output`, it causes the test to fail, because the subprocess writes two more lines to its standard error stream, which cause the line count to come out as two more than expected: /cygdrive/d/a/GitPython/GitPython/.venv/lib/python3.9/site-packages/coverage/core.py:96: CoverageWarning: Couldn't import C tracer: No module named 'coverage.tracer' (no-ctracer) warn(f"Couldn't import C tracer: {IMPORT_ERROR}", slug="no-ctracer", once=True) On most platforms, there is no failure, because the condition the warnings describe does not occur, so there are no warnings. But on Cygwin it does occur, resulting in a new test failure, showing > self.assertEqual(len(actual_lines[2]), expected_line_count, repr(actual_lines[2])) E AssertionError: 5004 != 5002 : ["/cygdrive/d/a/GitPython/GitPython/.venv/lib/python3.9/site-packages/coverage/core.py:96: CoverageWarning: Couldn't import C tracer: No module named 'coverage.tracer' (no-ctracer)\n", ' warn(f"Couldn\'t import C tracer: {IMPORT_ERROR}", slug="no-ctracer", once=True)\n', 'From github.com:jantman/gitpython_issue_301\n', ' = [up to date] master -> origin/master\n', ' = [up to date] testcommit1 -> origin/testcommit1\n', ' = [up to date] testcommit10 -> origin/testcommit10\n', ... where the first two elements of the list are from the lines of the warning message, and the others are as expected. (The above is a highly abridged extract, with the `...` at the end standing for many more list items obtained through the `cat_file.py` fixture.) This new failure is triggered specifically by the new `coverage` package version. It is not due to any recent changes in GitPython. It can be observed by rerunning CI checks that have previously passed, or in: https://github.com/EliahKagan/GitPython/actions/runs/15598239952/job/43940156308#step:14:355 There is more than one possible way to fix this, including fixing the underlying condition being warned about on Cygwin, or sanitizing environment variables for the subprocess. The approach taken here instead is based on the idea that the `cat_file.py` fixture is very simple, and that it is conceptually just a standalone Python script that doesn't do anything meant to depend on the current Python environment. Accordingly, this passes the `-S` option to the interpreter for the `cat_file.py` subprocess, so that interpreter refrains from loading the `site` module. This includes, among other simplifying effects, that the subprocess performs no `.pth` customizations. --- test/test_git.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test/test_git.py b/test/test_git.py index 5bcf89bdd..4a54d0d9b 100644 --- a/test/test_git.py +++ b/test/test_git.py @@ -773,6 +773,7 @@ def stderr_handler(line): cmdline = [ sys.executable, + "-S", # Keep any `CoverageWarning` messages out of the subprocess stderr. fixture_path("cat_file.py"), str(fixture_path("issue-301_stderr")), ] From 66955cc76a9b33716baa6bdcc575d0446cf9175e Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 14 Jun 2025 14:42:31 -0400 Subject: [PATCH 03/72] Minor CI clarifications - In the Cygwin CI workflow, move `runs-on` below `strategy`, for greater consistency with other workflows. - In the Cygwin CI jobs, use `pwsh` rather than `bash` for the `git config` command run outside of Cygwin, since `pwsh` is the default shell for such commands, it's the shell the Cygwin setup action uses, and it avoids creating the wrong impression that `bash` is needed. - Use "virtual environment" instead of "virtualenv" in some step names to avoid possible confusion with the `virtualenv` pacakge. - Remove comments in the PyPA package upgrade steps, which are more self-documenting since 727f4e9 (#2043). --- .github/workflows/alpine-test.yml | 3 +-- .github/workflows/cygwin-test.yml | 9 ++++----- .github/workflows/pythonpackage.yml | 1 - 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/.github/workflows/alpine-test.yml b/.github/workflows/alpine-test.yml index a3361798d..ceba11fb8 100644 --- a/.github/workflows/alpine-test.yml +++ b/.github/workflows/alpine-test.yml @@ -47,13 +47,12 @@ jobs: # and cause subsequent tests to fail cat test/fixtures/.gitconfig >> ~/.gitconfig - - name: Set up virtualenv + - name: Set up virtual environment run: | python -m venv .venv - name: Update PyPA packages run: | - # Get the latest pip, wheel, and prior to Python 3.12, setuptools. . .venv/bin/activate python -m pip install -U pip 'setuptools; python_version<"3.12"' wheel diff --git a/.github/workflows/cygwin-test.yml b/.github/workflows/cygwin-test.yml index 6943db09c..28a41a362 100644 --- a/.github/workflows/cygwin-test.yml +++ b/.github/workflows/cygwin-test.yml @@ -7,8 +7,6 @@ permissions: jobs: test: - runs-on: windows-latest - strategy: matrix: selection: [fast, perf] @@ -20,6 +18,8 @@ jobs: fail-fast: false + runs-on: windows-latest + env: CHERE_INVOKING: "1" CYGWIN_NOWINPATH: "1" @@ -32,7 +32,7 @@ jobs: - name: Force LF line endings run: | git config --global core.autocrlf false # Affects the non-Cygwin git. - shell: bash # Use Git Bash instead of Cygwin Bash for this step. + shell: pwsh # Do this outside Cygwin, to affect actions/checkout. - uses: actions/checkout@v4 with: @@ -67,7 +67,7 @@ jobs: # and cause subsequent tests to fail cat test/fixtures/.gitconfig >> ~/.gitconfig - - name: Set up virtualenv + - name: Set up virtual environment run: | python3.9 -m venv --without-pip .venv echo 'BASH_ENV=.venv/bin/activate' >>"$GITHUB_ENV" @@ -78,7 +78,6 @@ jobs: - name: Update PyPA packages run: | - # Get the latest pip, wheel, and prior to Python 3.12, setuptools. python -m pip install -U pip 'setuptools; python_version<"3.12"' wheel - name: Install project and test dependencies diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index c56d45df7..f3d977760 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -71,7 +71,6 @@ jobs: - name: Update PyPA packages run: | - # Get the latest pip, wheel, and prior to Python 3.12, setuptools. python -m pip install -U pip 'setuptools; python_version<"3.12"' wheel - name: Install project and test dependencies From 8e24edfb4688180287c970b023516c2b71946778 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 14 Jun 2025 14:57:40 -0400 Subject: [PATCH 04/72] Use *-wheel packages as a better fix for #2004 This installs the `python-pip-wheel`, `python-setuptools-wheel`, and `python-wheel-wheel` packages on Cygwini CI, which provide `.whl` files for `pip`, `setuptools`, and `wheel`. By making those wheels available, this fixes #2004 better than the previous workaround, allowing `ensurepip` to run without the error: Traceback (most recent call last): File "/usr/lib/python3.9/runpy.py", line 188, in _run_module_as_main mod_name, mod_spec, code = _get_module_details(mod_name, _Error) File "/usr/lib/python3.9/runpy.py", line 147, in _get_module_details return _get_module_details(pkg_main_name, error) File "/usr/lib/python3.9/runpy.py", line 111, in _get_module_details __import__(pkg_name) File "/usr/lib/python3.9/ensurepip/__init__.py", line [30](https://github.com/EliahKagan/GitPython/actions/runs/13454947366/job/37596811693#step:10:31), in _SETUPTOOLS_VERSION = _get_most_recent_wheel_version("setuptools") File "/usr/lib/python3.9/ensurepip/__init__.py", line 27, in _get_most_recent_wheel_version return str(max(_wheels[pkg], key=distutils.version.LooseVersion)) ValueError: max() arg is an empty sequence This change takes the place of the main changes in #2007 and #2009. In particular, it should allow `test_installation` to pass again. This also delists non-wheel Cygwin packages such as `python39-pip`, which are not needed (or at least no longer needed). (The python-{pip,setuptools,wheel}-wheel packages are, as their names suggest, intentionally not specific to Python 3.9. However, this technique will not necessarily carry over to Python 3.12, depending on what versions are supplied and other factors. This may be relevant when another attempt like #1988 is made to test/support Python 3.12 on Cygwin. At least for now, though, this seems worthwhile for fixing the Cygwin 3.9 environment, making it more similar to working local Cygwin environments and letting the workflow be more usable as guidance to how to set up a local Cygwin environment for GitPython development, and letting the installation test pass automatically.) --- .github/workflows/cygwin-test.yml | 8 ++------ test/test_installation.py | 8 -------- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/.github/workflows/cygwin-test.yml b/.github/workflows/cygwin-test.yml index 28a41a362..2d0378490 100644 --- a/.github/workflows/cygwin-test.yml +++ b/.github/workflows/cygwin-test.yml @@ -41,7 +41,7 @@ jobs: - name: Install Cygwin uses: cygwin/cygwin-install-action@v5 with: - packages: python39 python39-pip python39-virtualenv git wget + packages: git python39 python-pip-wheel python-setuptools-wheel python-wheel-wheel add-to-path: false # No need to change $PATH outside the Cygwin environment. - name: Arrange for verbose output @@ -69,13 +69,9 @@ jobs: - name: Set up virtual environment run: | - python3.9 -m venv --without-pip .venv + python3.9 -m venv .venv echo 'BASH_ENV=.venv/bin/activate' >>"$GITHUB_ENV" - - name: Bootstrap pip in virtualenv - run: | - wget -qO- https://bootstrap.pypa.io/get-pip.py | python - - name: Update PyPA packages run: | python -m pip install -U pip 'setuptools; python_version<"3.12"' wheel diff --git a/test/test_installation.py b/test/test_installation.py index a35826bd0..ae6472e98 100644 --- a/test/test_installation.py +++ b/test/test_installation.py @@ -4,19 +4,11 @@ import ast import os import subprocess -import sys - -import pytest from test.lib import TestBase, VirtualEnvironment, with_rw_directory class TestInstallation(TestBase): - @pytest.mark.xfail( - sys.platform == "cygwin" and "CI" in os.environ, - reason="Trouble with pip on Cygwin CI, see issue #2004", - raises=subprocess.CalledProcessError, - ) @with_rw_directory def test_installation(self, rw_dir): venv = self._set_up_venv(rw_dir) From 953d1616e7385ec6aac1e7a6212c689874c9409c Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sun, 15 Jun 2025 19:04:15 -0400 Subject: [PATCH 05/72] Comment what `TestInstallation._set_up_venv` does So that the meaning of the `venv.sources` accesses in `test_installation` is more readily clear. --- test/test_installation.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/test_installation.py b/test/test_installation.py index ae6472e98..8231b3512 100644 --- a/test/test_installation.py +++ b/test/test_installation.py @@ -64,10 +64,14 @@ def test_installation(self, rw_dir): @staticmethod def _set_up_venv(rw_dir): + # Initialize the virtual environment. venv = VirtualEnvironment(rw_dir, with_pip=True) + + # Make its src directory a symlink to our own top-level source tree. os.symlink( os.path.dirname(os.path.dirname(__file__)), venv.sources, target_is_directory=True, ) + return venv From 84632c78512e070a7aaa9ff1dd046c77293a58a4 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sun, 15 Jun 2025 19:06:58 -0400 Subject: [PATCH 06/72] Extract `subprocess.run` logic repeated in `test_installation` This creates a function (technically, a callable `partial` object) for `test_installation` to use instead of repeating `subproces.run` keyword arguments all the time. This relates directly to steps in `_set_up_venv`, and it's makes about as much sense to do it there as in `test_installation`, so it is placed (and described) in `_set_up_venv`. --- test/test_installation.py | 36 ++++++++++++++---------------------- 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/test/test_installation.py b/test/test_installation.py index 8231b3512..b428f413a 100644 --- a/test/test_installation.py +++ b/test/test_installation.py @@ -2,6 +2,7 @@ # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/ import ast +import functools import os import subprocess @@ -11,35 +12,23 @@ class TestInstallation(TestBase): @with_rw_directory def test_installation(self, rw_dir): - venv = self._set_up_venv(rw_dir) + venv, run = self._set_up_venv(rw_dir) - result = subprocess.run( - [venv.pip, "install", "."], - stdout=subprocess.PIPE, - cwd=venv.sources, - ) + result = run([venv.pip, "install", "."]) self.assertEqual( 0, result.returncode, msg=result.stderr or result.stdout or "Can't install project", ) - result = subprocess.run( - [venv.python, "-c", "import git"], - stdout=subprocess.PIPE, - cwd=venv.sources, - ) + result = run([venv.python, "-c", "import git"]) self.assertEqual( 0, result.returncode, msg=result.stderr or result.stdout or "Self-test failed", ) - result = subprocess.run( - [venv.python, "-c", "import gitdb; import smmap"], - stdout=subprocess.PIPE, - cwd=venv.sources, - ) + result = run([venv.python, "-c", "import gitdb; import smmap"]) self.assertEqual( 0, result.returncode, @@ -49,11 +38,7 @@ def test_installation(self, rw_dir): # Even IF gitdb or any other dependency is supplied during development by # inserting its location into PYTHONPATH or otherwise patched into sys.path, # make sure it is not wrongly inserted as the *first* entry. - result = subprocess.run( - [venv.python, "-c", "import sys; import git; print(sys.path)"], - stdout=subprocess.PIPE, - cwd=venv.sources, - ) + result = run([venv.python, "-c", "import sys; import git; print(sys.path)"]) syspath = result.stdout.decode("utf-8").splitlines()[0] syspath = ast.literal_eval(syspath) self.assertEqual( @@ -74,4 +59,11 @@ def _set_up_venv(rw_dir): target_is_directory=True, ) - return venv + # Create a convenience function to run commands in it. + run = functools.partial( + subprocess.run, + stdout=subprocess.PIPE, + cwd=venv.sources, + ) + + return venv, run From a2ba4804a6544642b400dced6eac0f4f1ad28750 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sun, 15 Jun 2025 19:11:16 -0400 Subject: [PATCH 07/72] Have `test_installation` test that operations produce no warnings By setting the `PYTHONWARNINGS` environment variable to `error` in each of the subprocess invocations. This is strictly stronger than passing `-Werror` for the `python` commands, because it automatically applies to subprocesses (unless they are created with a sanitized environment or otherwise with one in which `PYTHONWARNINGS` has been customized), and because it works for `pip` automatically. Importantly, this will cause warnings internal to Python subprocesses created by `pip` to be treated as errors. It should thus surface any warnings coming from the `setuptools` backend. --- test/test_installation.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test/test_installation.py b/test/test_installation.py index b428f413a..5ba8a82b0 100644 --- a/test/test_installation.py +++ b/test/test_installation.py @@ -64,6 +64,7 @@ def _set_up_venv(rw_dir): subprocess.run, stdout=subprocess.PIPE, cwd=venv.sources, + env={**os.environ, "PYTHONWARNINGS": "error"}, ) return venv, run From a0e08fe90135149de203a3adfb21a5a254cb1bdb Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sun, 15 Jun 2025 19:48:39 -0400 Subject: [PATCH 08/72] Show more information when `test_installation` fails Previously, it attempted to show stderr unless empty, first falling back to stdout unless empty, then falling back to the prewritten summary identifying the specific assertion. This now has the `test_installation` assertions capture stderr as well as stdout, handle standard streams as text rather than binary, and show more information when failing, always distinguishing where the information came from: the summary, then labeled captured stdout (empty or not), then labeled captured stderr (empty or not). That applies to all but the last assertion, which does not try to show information differently when it fails, but is simplified to do the right thing now that `subprocess.run` is using text streams. (This subtly changes its semantics, but overall it should be as effective as before at finding the `sys.path` woe it anticipates.) --- test/test_installation.py | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/test/test_installation.py b/test/test_installation.py index 5ba8a82b0..39797c134 100644 --- a/test/test_installation.py +++ b/test/test_installation.py @@ -15,31 +15,19 @@ def test_installation(self, rw_dir): venv, run = self._set_up_venv(rw_dir) result = run([venv.pip, "install", "."]) - self.assertEqual( - 0, - result.returncode, - msg=result.stderr or result.stdout or "Can't install project", - ) + self._check_result(result, "Can't install project") result = run([venv.python, "-c", "import git"]) - self.assertEqual( - 0, - result.returncode, - msg=result.stderr or result.stdout or "Self-test failed", - ) + self._check_result(result, "Self-test failed") result = run([venv.python, "-c", "import gitdb; import smmap"]) - self.assertEqual( - 0, - result.returncode, - msg=result.stderr or result.stdout or "Dependencies not installed", - ) + self._check_result(result, "Dependencies not installed") # Even IF gitdb or any other dependency is supplied during development by # inserting its location into PYTHONPATH or otherwise patched into sys.path, # make sure it is not wrongly inserted as the *first* entry. result = run([venv.python, "-c", "import sys; import git; print(sys.path)"]) - syspath = result.stdout.decode("utf-8").splitlines()[0] + syspath = result.stdout.splitlines()[0] syspath = ast.literal_eval(syspath) self.assertEqual( "", @@ -63,8 +51,17 @@ def _set_up_venv(rw_dir): run = functools.partial( subprocess.run, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True, cwd=venv.sources, env={**os.environ, "PYTHONWARNINGS": "error"}, ) return venv, run + + def _check_result(self, result, failure_summary): + self.assertEqual( + 0, + result.returncode, + f"{failure_summary}\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}", + ) From 6826b594e2ef43b0972f29f335e7be51c9574303 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sun, 15 Jun 2025 20:05:56 -0400 Subject: [PATCH 09/72] Improve failure message whitespace clarity --- test/test_installation.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/test_installation.py b/test/test_installation.py index 39797c134..da0b86ed2 100644 --- a/test/test_installation.py +++ b/test/test_installation.py @@ -63,5 +63,11 @@ def _check_result(self, result, failure_summary): self.assertEqual( 0, result.returncode, - f"{failure_summary}\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}", + self._prepare_failure_message(result, failure_summary), ) + + @staticmethod + def _prepare_failure_message(result, failure_summary): + stdout = result.stdout.rstrip() + stderr = result.stderr.rstrip() + return f"{failure_summary}\n\nstdout:\n{stdout}\n\nstderr:\n{stderr}" From d0868bd5d6a43c3d95a3eaddab1389a0ef76d8f0 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sun, 15 Jun 2025 20:25:55 -0400 Subject: [PATCH 10/72] Remove deprecated license classifier GitPython project metadata specify the project's license primarily through the value of `license`, currently passed as an argument to `setuptools.setup`, which holds the string `"BSD-3-Clause"`. This is an SPDX license identifier readily understood by both humans and machines. The PyPI trove classifier "License :: OSI Approved :: BSD License" has also been specified in `setup.py`. However, this is not ideal, because: 1. It does not identify a specific license. There are multiple "BSD" licenses in use, with BSD-2-Clause and BSD-3-Clause both in very wide use. 2. It is no longer recommended to use a trove classifier to indicate a license. The use of license classifiers (even unambiguous ones) has been deprecated. See: - https://packaging.python.org/en/latest/specifications/core-metadata/#classifier-multiple-use - https://peps.python.org/pep-0639/#deprecate-license-classifiers This commit removes the classifier. The license itself is of course unchanged, as is the `license` value of `"BSD-3-Clause"`. (An expected effect of this change is that, starting in the next release of GitPython, PyPI may show "License: BSD-3-Clause" instead of the current text "License: BSD License (BSD-3-Clause)".) This change fixes a warning issued by a subprocess of `pip` when installing the package. The warning, until this change, could be observed by running `pip install . -v` or `pip install -e . -v` and examining the verbose output, or by running `pip install .` or `pip install -e .` with the `PYTHONWARNINGS` environment variable set to `error`: SetuptoolsDeprecationWarning: License classifiers are deprecated. !! ******************************************************************************** Please consider removing the following classifiers in favor of a SPDX license expression: License :: OSI Approved :: BSD License See https://packaging.python.org/en/latest/guides/writing-pyproject-toml/#license for details. ******************************************************************************** !! (In preceding commits, `test_installation` has been augmented to set that environment variable, surfacing the error. This change should allow that test to pass, unless it finds other problems.) --- setup.py | 1 - 1 file changed, 1 deletion(-) diff --git a/setup.py b/setup.py index f28fedb85..a7b1eab00 100755 --- a/setup.py +++ b/setup.py @@ -95,7 +95,6 @@ def _stamp_version(filename: str) -> None: # "Development Status :: 7 - Inactive", "Environment :: Console", "Intended Audience :: Developers", - "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Operating System :: POSIX", "Operating System :: Microsoft :: Windows", From b21c32a302dcd91d52636e5f91e89ae129654bd2 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sun, 15 Jun 2025 23:37:27 -0400 Subject: [PATCH 11/72] Pass assertion `msg` as a keyword argument in `test_installation` It was originally made explicit like this, but it ended up becoming position in my last few commits. This restores that clearer aspect of how it was written before, while keeping all the other changes. --- test/test_installation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_installation.py b/test/test_installation.py index da0b86ed2..7c82bd403 100644 --- a/test/test_installation.py +++ b/test/test_installation.py @@ -63,7 +63,7 @@ def _check_result(self, result, failure_summary): self.assertEqual( 0, result.returncode, - self._prepare_failure_message(result, failure_summary), + msg=self._prepare_failure_message(result, failure_summary), ) @staticmethod From ac3437df0c5b5ad597915db15276dcb326d3d970 Mon Sep 17 00:00:00 2001 From: Tom Bedor Date: Tue, 17 Jun 2025 09:03:52 -0700 Subject: [PATCH 12/72] Add clearer error version for unsupported index error --- git/index/fun.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git/index/fun.py b/git/index/fun.py index 59cce6ae6..d03ec6759 100644 --- a/git/index/fun.py +++ b/git/index/fun.py @@ -207,7 +207,7 @@ def read_header(stream: IO[bytes]) -> Tuple[int, int]: version, num_entries = unpacked # TODO: Handle version 3: extended data, see read-cache.c. - assert version in (1, 2) + assert version in (1, 2), "Unsupported git index version %i, only 1 and 2 are supported" % version return version, num_entries From bb5c22629aba914a00b8f33a10522324ca7bb049 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Jul 2025 15:20:34 +0000 Subject: [PATCH 13/72] Bump Vampire/setup-wsl from 5.0.1 to 6.0.0 Bumps [Vampire/setup-wsl](https://github.com/vampire/setup-wsl) from 5.0.1 to 6.0.0. - [Release notes](https://github.com/vampire/setup-wsl/releases) - [Commits](https://github.com/vampire/setup-wsl/compare/v5.0.1...v6.0.0) --- updated-dependencies: - dependency-name: Vampire/setup-wsl dependency-version: 6.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/pythonpackage.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index f3d977760..4457a341f 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -51,7 +51,7 @@ jobs: - name: Set up WSL (Windows) if: matrix.os-type == 'windows' - uses: Vampire/setup-wsl@v5.0.1 + uses: Vampire/setup-wsl@v6.0.0 with: wsl-version: 1 distribution: Alpine From 496392b9bf781904421cbd171c0c5395a6fe330c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Jul 2025 15:21:27 +0000 Subject: [PATCH 14/72] Bump cygwin/cygwin-install-action from 5 to 6 Bumps [cygwin/cygwin-install-action](https://github.com/cygwin/cygwin-install-action) from 5 to 6. - [Release notes](https://github.com/cygwin/cygwin-install-action/releases) - [Commits](https://github.com/cygwin/cygwin-install-action/compare/v5...v6) --- updated-dependencies: - dependency-name: cygwin/cygwin-install-action dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/cygwin-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cygwin-test.yml b/.github/workflows/cygwin-test.yml index 2d0378490..cc9e1edf0 100644 --- a/.github/workflows/cygwin-test.yml +++ b/.github/workflows/cygwin-test.yml @@ -39,7 +39,7 @@ jobs: fetch-depth: 0 - name: Install Cygwin - uses: cygwin/cygwin-install-action@v5 + uses: cygwin/cygwin-install-action@v6 with: packages: git python39 python-pip-wheel python-setuptools-wheel python-wheel-wheel add-to-path: false # No need to change $PATH outside the Cygwin environment. From a4aadb0c04bd13af824c14dcc39f88345aa5c440 Mon Sep 17 00:00:00 2001 From: Niklas Mertsch Date: Sun, 20 Jul 2025 14:17:49 +0200 Subject: [PATCH 15/72] Fix name collision --- git/config.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/git/config.py b/git/config.py index 5fc099a27..345290a39 100644 --- a/git/config.py +++ b/git/config.py @@ -87,15 +87,15 @@ def __new__(cls, name: str, bases: Tuple, clsdict: Dict[str, Any]) -> "MetaParse mutating_methods = clsdict[kmm] for base in bases: methods = (t for t in inspect.getmembers(base, inspect.isroutine) if not t[0].startswith("_")) - for name, method in methods: - if name in clsdict: + for method_name, method in methods: + if method_name in clsdict: continue method_with_values = needs_values(method) - if name in mutating_methods: + if method_name in mutating_methods: method_with_values = set_dirty_and_flush_changes(method_with_values) # END mutating methods handling - clsdict[name] = method_with_values + clsdict[method_name] = method_with_values # END for each name/method pair # END for each base # END if mutating methods configuration is set From 80fd2c16211738156e65258381a17cdc429ddd08 Mon Sep 17 00:00:00 2001 From: Niklas Mertsch Date: Sun, 20 Jul 2025 14:17:58 +0200 Subject: [PATCH 16/72] Don't treat sphinx warnings as errors Workaround for python/cpython#100520 (rst syntax error in configparser docstrings), which was fixed in CPython 3.10+. Docutils raises warnings about the invalid docstrings, and `-W` instructs sphinx to treat this as errors. We can't control or silence these warnings, so we accept them and don't treat them as errors. See the discussion in gitpython-developers/GitPython#2060 for details. --- doc/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/Makefile b/doc/Makefile index ddeadbd7e..7e0d325fe 100644 --- a/doc/Makefile +++ b/doc/Makefile @@ -3,7 +3,7 @@ # You can set these variables from the command line. BUILDDIR = build -SPHINXOPTS = -W +SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = From ec2e2c8b894512e7a2364774d77cdd9db73f0566 Mon Sep 17 00:00:00 2001 From: Tom Webber Date: Tue, 22 Jul 2025 12:23:18 +0200 Subject: [PATCH 17/72] Allow relative path url in submodules for submodule_update --- git/objects/submodule/base.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/git/objects/submodule/base.py b/git/objects/submodule/base.py index 0e55b8fa9..5031a2e71 100644 --- a/git/objects/submodule/base.py +++ b/git/objects/submodule/base.py @@ -11,6 +11,7 @@ import stat import sys import uuid +import urllib import git from git.cmd import Git @@ -799,9 +800,13 @@ def update( + "Cloning url '%s' to '%s' in submodule %r" % (self.url, checkout_module_abspath, self.name), ) if not dry_run: + if self.url.startswith("."): + url = urllib.parse.urljoin(self.repo.remotes.origin.url + "/", self.url) + else: + url = self.url mrepo = self._clone_repo( self.repo, - self.url, + url, self.path, self.name, n=True, From 1ee1e781929074afd66bff1eae007bbee41d117e Mon Sep 17 00:00:00 2001 From: Tom Webber Date: Wed, 23 Jul 2025 07:12:27 +0200 Subject: [PATCH 18/72] Add test case for cloning submodules with relative path --- test/test_submodule.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/test/test_submodule.py b/test/test_submodule.py index f44f086c2..4a248eb60 100644 --- a/test/test_submodule.py +++ b/test/test_submodule.py @@ -1350,3 +1350,23 @@ def test_submodule_update_unsafe_options_allowed(self, rw_repo): for unsafe_option in unsafe_options: with self.assertRaises(GitCommandError): submodule.update(clone_multi_options=[unsafe_option], allow_unsafe_options=True) + + @with_rw_directory + @_patch_git_config("protocol.file.allow", "always") + def test_submodule_update_relative_url(self, rwdir): + parent_path = osp.join(rwdir, "parent") + parent_repo = git.Repo.init(parent_path) + submodule_path = osp.join(rwdir, "module") + submodule_repo = git.Repo.init(submodule_path) + submodule_repo.git.commit(m="initial commit", allow_empty=True) + + parent_repo.git.submodule("add", "../module", "module") + parent_repo.index.commit("add submodule with relative URL") + + cloned_path = osp.join(rwdir, "cloned_repo") + cloned_repo = git.Repo.clone_from(parent_path, cloned_path) + + cloned_repo.submodule_update(init=True, recursive=True) + + has_module = any(sm.name == "module" for sm in cloned_repo.submodules) + assert has_module, "Relative submodule was not updated properly" From 6ba2c0a2f9ee7feffd7e079621c4845820180c9a Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Thu, 24 Jul 2025 05:37:18 +0200 Subject: [PATCH 19/72] Prepare a new release --- VERSION | 2 +- doc/source/changes.rst | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/VERSION b/VERSION index e6af1c454..3c91929a4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.1.44 +3.1.45 diff --git a/doc/source/changes.rst b/doc/source/changes.rst index 00a3c660e..151059ed2 100644 --- a/doc/source/changes.rst +++ b/doc/source/changes.rst @@ -2,6 +2,12 @@ Changelog ========= +3.1.45 +====== + +See the following for all changes. +https://github.com/gitpython-developers/GitPython/releases/tag/3.1.45 + 3.1.44 ====== From de6d177aaa8ccd6d82576ab0b3de5074cf7647fa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Aug 2025 18:05:41 +0000 Subject: [PATCH 20/72] Bump actions/checkout from 4 to 5 Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/alpine-test.yml | 2 +- .github/workflows/codeql.yml | 2 +- .github/workflows/cygwin-test.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/pythonpackage.yml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/alpine-test.yml b/.github/workflows/alpine-test.yml index ceba11fb8..a9c29117e 100644 --- a/.github/workflows/alpine-test.yml +++ b/.github/workflows/alpine-test.yml @@ -26,7 +26,7 @@ jobs: adduser runner docker shell: sh -exo pipefail {0} # Run this as root, not the "runner" user. - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 2bee952af..9191471c3 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -47,7 +47,7 @@ jobs: # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 # Add any setup steps before running the `github/codeql-action/init` action. # This includes steps like installing compilers or runtimes (`actions/setup-node` diff --git a/.github/workflows/cygwin-test.yml b/.github/workflows/cygwin-test.yml index cc9e1edf0..5c42c8583 100644 --- a/.github/workflows/cygwin-test.yml +++ b/.github/workflows/cygwin-test.yml @@ -34,7 +34,7 @@ jobs: git config --global core.autocrlf false # Affects the non-Cygwin git. shell: pwsh # Do this outside Cygwin, to affect actions/checkout. - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index ceba0dd85..16978f9a8 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: actions/setup-python@v5 with: diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 4457a341f..4e5d82a55 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -39,7 +39,7 @@ jobs: shell: bash --noprofile --norc -exo pipefail {0} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 From fe81519436a8ab8b735a40a3973c8c5bd9cfec47 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Aug 2025 20:42:38 +0000 Subject: [PATCH 21/72] Bump git/ext/gitdb from `335c0f6` to `39d7dbf` Bumps [git/ext/gitdb](https://github.com/gitpython-developers/gitdb) from `335c0f6` to `39d7dbf`. - [Release notes](https://github.com/gitpython-developers/gitdb/releases) - [Commits](https://github.com/gitpython-developers/gitdb/compare/335c0f66173eecdc7b2597c2b6c3d1fde795df30...39d7dbf285df058e44ea501c23ea8d31ae8bce0e) --- updated-dependencies: - dependency-name: git/ext/gitdb dependency-version: 39d7dbf285df058e44ea501c23ea8d31ae8bce0e 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..39d7dbf28 160000 --- a/git/ext/gitdb +++ b/git/ext/gitdb @@ -1 +1 @@ -Subproject commit 335c0f66173eecdc7b2597c2b6c3d1fde795df30 +Subproject commit 39d7dbf285df058e44ea501c23ea8d31ae8bce0e From ca51dad69071898af377c8e62210c69e8d211c69 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 14:41:33 +0000 Subject: [PATCH 22/72] Bump actions/setup-python from 5 to 6 Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 6. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/setup-python dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/lint.yml | 2 +- .github/workflows/pythonpackage.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 16978f9a8..ed535a914 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,7 +12,7 @@ jobs: steps: - uses: actions/checkout@v5 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: "3.x" diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 4e5d82a55..7088310e5 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -44,7 +44,7 @@ jobs: fetch-depth: 0 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} allow-prereleases: ${{ matrix.experimental }} From 9f913ec0cb0c6f7ab3eea7245657d01048fd7065 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 15:00:47 +0000 Subject: [PATCH 23/72] Bump git/ext/gitdb from `39d7dbf` to `f8fdfec` Bumps [git/ext/gitdb](https://github.com/gitpython-developers/gitdb) from `39d7dbf` to `f8fdfec`. - [Release notes](https://github.com/gitpython-developers/gitdb/releases) - [Commits](https://github.com/gitpython-developers/gitdb/compare/39d7dbf285df058e44ea501c23ea8d31ae8bce0e...f8fdfec0fd0a0aed9171c6cf2c5cb8d73e2bb305) --- updated-dependencies: - dependency-name: git/ext/gitdb dependency-version: f8fdfec0fd0a0aed9171c6cf2c5cb8d73e2bb305 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 39d7dbf28..f8fdfec0f 160000 --- a/git/ext/gitdb +++ b/git/ext/gitdb @@ -1 +1 @@ -Subproject commit 39d7dbf285df058e44ea501c23ea8d31ae8bce0e +Subproject commit f8fdfec0fd0a0aed9171c6cf2c5cb8d73e2bb305 From 7c55a2b839e05f10a9dc3cf2bc53785350372c88 Mon Sep 17 00:00:00 2001 From: Emmanuel Ferdman Date: Tue, 30 Sep 2025 17:47:14 +0300 Subject: [PATCH 24/72] Fix type hint for `SymbolicReference.reference` property Signed-off-by: Emmanuel Ferdman --- git/refs/symbolic.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/git/refs/symbolic.py b/git/refs/symbolic.py index 1b90a3115..74bb1fe0a 100644 --- a/git/refs/symbolic.py +++ b/git/refs/symbolic.py @@ -40,6 +40,7 @@ from git.config import GitConfigParser from git.objects.commit import Actor from git.refs.log import RefLogEntry + from git.refs.reference import Reference from git.repo import Repo @@ -404,7 +405,7 @@ def object(self) -> AnyGitObject: def object(self, object: Union[AnyGitObject, "SymbolicReference", str]) -> "SymbolicReference": return self.set_object(object) - def _get_reference(self) -> "SymbolicReference": + def _get_reference(self) -> "Reference": """ :return: :class:`~git.refs.reference.Reference` object we point to @@ -416,7 +417,7 @@ def _get_reference(self) -> "SymbolicReference": sha, target_ref_path = self._get_ref_info(self.repo, self.path) if target_ref_path is None: raise TypeError("%s is a detached symbolic reference as it points to %r" % (self, sha)) - return self.from_path(self.repo, target_ref_path) + return cast("Reference", self.from_path(self.repo, target_ref_path)) def set_reference( self, @@ -502,7 +503,7 @@ def set_reference( # Aliased reference @property - def reference(self) -> "SymbolicReference": + def reference(self) -> "Reference": return self._get_reference() @reference.setter From bcdcccdc7ea7d50ec5831aad961ba80df0f1379b Mon Sep 17 00:00:00 2001 From: Brunno Vanelli Date: Tue, 7 Oct 2025 20:49:46 +0200 Subject: [PATCH 25/72] feat: Add support for hasconfig git rule. --- git/config.py | 8 ++++++-- test/test_config.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/git/config.py b/git/config.py index 345290a39..ffe1c8ccd 100644 --- a/git/config.py +++ b/git/config.py @@ -66,7 +66,7 @@ CONFIG_LEVELS: ConfigLevels_Tup = ("system", "user", "global", "repository") """The configuration level of a configuration file.""" -CONDITIONAL_INCLUDE_REGEXP = re.compile(r"(?<=includeIf )\"(gitdir|gitdir/i|onbranch):(.+)\"") +CONDITIONAL_INCLUDE_REGEXP = re.compile(r"(?<=includeIf )\"(gitdir|gitdir/i|onbranch|hasconfig:remote\.\*\.url):(.+)\"") """Section pattern to detect conditional includes. See: https://git-scm.com/docs/git-config#_conditional_includes @@ -590,7 +590,11 @@ def _included_paths(self) -> List[Tuple[str, str]]: if fnmatch.fnmatchcase(branch_name, value): paths += self.items(section) - + elif keyword == "hasconfig:remote.*.url": + for remote in self._repo.remotes: + if fnmatch.fnmatch(remote.url, value): + paths += self.items(section) + break return paths def read(self) -> None: # type: ignore[override] diff --git a/test/test_config.py b/test/test_config.py index 8e1007d9e..56ac0f304 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -373,6 +373,41 @@ def test_conditional_includes_from_branch_name_error(self, rw_dir): assert not config._has_includes() assert config._included_paths() == [] + @with_rw_directory + def test_conditional_includes_remote_url(self, rw_dir): + # Initiate mocked repository. + repo = mock.Mock() + repo.remotes = [mock.Mock(url="https://github.com/foo/repo")] + + # Initiate config files. + path1 = osp.join(rw_dir, "config1") + path2 = osp.join(rw_dir, "config2") + template = '[includeIf "hasconfig:remote.*.url:{}"]\n path={}\n' + + # Ensure that config with hasconfig and full url is correct. + with open(path1, "w") as stream: + stream.write(template.format("https://github.com/foo/repo", path2)) + + with GitConfigParser(path1, repo=repo) as config: + assert config._has_includes() + assert config._included_paths() == [("path", path2)] + + # Ensure that config with hasconfig and incorrect url is incorrect. + with open(path1, "w") as stream: + stream.write(template.format("incorrect", path2)) + + with GitConfigParser(path1, repo=repo) as config: + assert not config._has_includes() + assert config._included_paths() == [] + + # Ensure that config with hasconfig and url using glob pattern is correct. + with open(path1, "w") as stream: + stream.write(template.format("**/**github.com*/**", path2)) + + with GitConfigParser(path1, repo=repo) as config: + assert config._has_includes() + assert config._included_paths() == [("path", path2)] + def test_rename(self): file_obj = self._to_memcache(fixture_path("git_config")) with GitConfigParser(file_obj, read_only=False, merge_includes=False) as cw: From 6cf863374820a1bcf1fa14b3c2ea87214752bf74 Mon Sep 17 00:00:00 2001 From: Brunno Vanelli Date: Tue, 7 Oct 2025 21:19:31 +0200 Subject: [PATCH 26/72] fix: Use fnmatch instead. --- git/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git/config.py b/git/config.py index ffe1c8ccd..200c81bb7 100644 --- a/git/config.py +++ b/git/config.py @@ -592,7 +592,7 @@ def _included_paths(self) -> List[Tuple[str, str]]: paths += self.items(section) elif keyword == "hasconfig:remote.*.url": for remote in self._repo.remotes: - if fnmatch.fnmatch(remote.url, value): + if fnmatch.fnmatchcase(remote.url, value): paths += self.items(section) break return paths From a6247a585600c09894a9fae85e11f7581bfccbe0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Oct 2025 13:32:58 +0000 Subject: [PATCH 27/72] Bump github/codeql-action from 3 to 4 Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3 to 4. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v3...v4) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: '4' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 9191471c3..32d5e84e4 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -57,7 +57,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -85,6 +85,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@v4 with: category: "/language:${{matrix.language}}" From 9dd0081213d57f41919ed37e93656410277bfb0b Mon Sep 17 00:00:00 2001 From: Andreas Oberritter Date: Tue, 21 Oct 2025 11:11:16 +0200 Subject: [PATCH 28/72] Use actual return type in annotation for method submodule_update Fixes #2077 --- git/repo/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git/repo/base.py b/git/repo/base.py index 7e918df8c..6ea96aad2 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -520,7 +520,7 @@ def iter_submodules(self, *args: Any, **kwargs: Any) -> Iterator[Submodule]: """ return RootModule(self).traverse(*args, **kwargs) - def submodule_update(self, *args: Any, **kwargs: Any) -> Iterator[Submodule]: + def submodule_update(self, *args: Any, **kwargs: Any) -> RootModule: """Update the submodules, keeping the repository consistent as it will take the previous state into consideration. From 74ff8e5e1cb814fbf3b916111d7181bd6e3f3906 Mon Sep 17 00:00:00 2001 From: Yikai Zhao Date: Sun, 2 Nov 2025 10:25:33 +0800 Subject: [PATCH 29/72] Support index format v3 --- git/index/fun.py | 16 +++++++++++----- git/index/typ.py | 15 ++++++++++++++- test/test_index.py | 25 +++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/git/index/fun.py b/git/index/fun.py index d03ec6759..0b3d79cf1 100644 --- a/git/index/fun.py +++ b/git/index/fun.py @@ -36,7 +36,7 @@ ) from git.util import IndexFileSHA1Writer, finalize_process -from .typ import BaseIndexEntry, IndexEntry, CE_NAMEMASK, CE_STAGESHIFT +from .typ import CE_EXTENDED, BaseIndexEntry, IndexEntry, CE_NAMEMASK, CE_STAGESHIFT from .util import pack, unpack # typing ----------------------------------------------------------------------------- @@ -158,7 +158,7 @@ def write_cache( write = stream_sha.write # Header - version = 2 + version = 3 if any(entry.extended_flags for entry in entries) else 2 write(b"DIRC") write(pack(">LL", version, len(entries))) @@ -172,6 +172,8 @@ def write_cache( plen = len(path) & CE_NAMEMASK # Path length assert plen == len(path), "Path %s too long to fit into index" % entry.path flags = plen | (entry.flags & CE_NAMEMASK_INV) # Clear possible previous values. + if entry.extended_flags: + flags |= CE_EXTENDED write( pack( ">LLLLLL20sH", @@ -185,6 +187,8 @@ def write_cache( flags, ) ) + if entry.extended_flags: + write(pack(">H", entry.extended_flags)) write(path) real_size = (tell() - beginoffset + 8) & ~7 write(b"\0" * ((beginoffset + real_size) - tell())) @@ -206,8 +210,7 @@ def read_header(stream: IO[bytes]) -> Tuple[int, int]: unpacked = cast(Tuple[int, int], unpack(">LL", stream.read(4 * 2))) version, num_entries = unpacked - # TODO: Handle version 3: extended data, see read-cache.c. - assert version in (1, 2), "Unsupported git index version %i, only 1 and 2 are supported" % version + assert version in (1, 2, 3), "Unsupported git index version %i, only 1, 2, and 3 are supported" % version return version, num_entries @@ -260,12 +263,15 @@ def read_cache( ctime = unpack(">8s", read(8))[0] mtime = unpack(">8s", read(8))[0] (dev, ino, mode, uid, gid, size, sha, flags) = unpack(">LLLLLL20sH", read(20 + 4 * 6 + 2)) + extended_flags = 0 + if flags & CE_EXTENDED: + extended_flags = unpack(">H", read(2))[0] path_size = flags & CE_NAMEMASK path = read(path_size).decode(defenc) real_size = (tell() - beginoffset + 8) & ~7 read((beginoffset + real_size) - tell()) - entry = IndexEntry((mode, sha, flags, path, ctime, mtime, dev, ino, uid, gid, size)) + entry = IndexEntry((mode, sha, flags, path, ctime, mtime, dev, ino, uid, gid, size, extended_flags)) # entry_key would be the method to use, but we save the effort. entries[(path, entry.stage)] = entry count += 1 diff --git a/git/index/typ.py b/git/index/typ.py index 974252528..4bcb604ab 100644 --- a/git/index/typ.py +++ b/git/index/typ.py @@ -32,6 +32,9 @@ CE_VALID = 0x8000 CE_STAGESHIFT = 12 +CE_EXT_SKIP_WORKTREE = 0x4000 +CE_EXT_INTENT_TO_ADD = 0x2000 + # } END invariants @@ -87,6 +90,8 @@ class BaseIndexEntryHelper(NamedTuple): uid: int = 0 gid: int = 0 size: int = 0 + # version 3 extended flags, only when (flags & CE_EXTENDED) is set + extended_flags: int = 0 class BaseIndexEntry(BaseIndexEntryHelper): @@ -102,7 +107,7 @@ def __new__( cls, inp_tuple: Union[ Tuple[int, bytes, int, PathLike], - Tuple[int, bytes, int, PathLike, bytes, bytes, int, int, int, int, int], + Tuple[int, bytes, int, PathLike, bytes, bytes, int, int, int, int, int, int], ], ) -> "BaseIndexEntry": """Override ``__new__`` to allow construction from a tuple for backwards @@ -134,6 +139,14 @@ def stage(self) -> int: """ return (self.flags & CE_STAGEMASK) >> CE_STAGESHIFT + @property + def skip_worktree(self) -> bool: + return (self.extended_flags & CE_EXT_SKIP_WORKTREE) > 0 + + @property + def intent_to_add(self) -> bool: + return (self.extended_flags & CE_EXT_INTENT_TO_ADD) > 0 + @classmethod def from_blob(cls, blob: Blob, stage: int = 0) -> "BaseIndexEntry": """:return: Fully equipped BaseIndexEntry at the given stage""" diff --git a/test/test_index.py b/test/test_index.py index cf3b90fa6..6d90d7965 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -1218,6 +1218,31 @@ def test_index_add_non_normalized_path(self, rw_repo): rw_repo.index.add(non_normalized_path) + @with_rw_directory + def test_index_version_v3(self, tmp_dir): + tmp_dir = Path(tmp_dir) + with cwd(tmp_dir): + subprocess.run(["git", "init", "-q"], check=True) + file = tmp_dir / "file.txt" + file.write_text("hello") + subprocess.run(["git", "add", "-N", "file.txt"], check=True) + + repo = Repo(tmp_dir) + + assert len(repo.index.entries) == 1 + entry = list(repo.index.entries.values())[0] + assert entry.path == "file.txt" + assert entry.intent_to_add + + file2 = tmp_dir / "file2.txt" + file2.write_text("world") + repo.index.add(["file2.txt"]) + repo.index.write() + + status_str = subprocess.check_output(["git", "status", "--porcelain"], text=True) + assert " A file.txt\n" in status_str + assert "A file2.txt\n" in status_str + class TestIndexUtils: @pytest.mark.parametrize("file_path_type", [str, Path]) From 3150ebdaa43df5be2c27e717807381724131b128 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Nov 2025 13:08:06 +0000 Subject: [PATCH 30/72] Bump git/ext/gitdb from `f8fdfec` to `65321a2` Bumps [git/ext/gitdb](https://github.com/gitpython-developers/gitdb) from `f8fdfec` to `65321a2`. - [Release notes](https://github.com/gitpython-developers/gitdb/releases) - [Commits](https://github.com/gitpython-developers/gitdb/compare/f8fdfec0fd0a0aed9171c6cf2c5cb8d73e2bb305...65321a28b586df60b9d1508228e2f53a35f938eb) --- updated-dependencies: - dependency-name: git/ext/gitdb dependency-version: 65321a28b586df60b9d1508228e2f53a35f938eb 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 f8fdfec0f..65321a28b 160000 --- a/git/ext/gitdb +++ b/git/ext/gitdb @@ -1 +1 @@ -Subproject commit f8fdfec0fd0a0aed9171c6cf2c5cb8d73e2bb305 +Subproject commit 65321a28b586df60b9d1508228e2f53a35f938eb From 8a884fea3ff91f1444a36785cc22c8d7fc6bf329 Mon Sep 17 00:00:00 2001 From: Yikai Zhao Date: Sat, 8 Nov 2025 14:01:41 +0800 Subject: [PATCH 31/72] improve unit test --- test/fixtures/index_extended_flags | Bin 0 -> 436 bytes test/test_index.py | 37 +++++++++++++++++++++-------- 2 files changed, 27 insertions(+), 10 deletions(-) create mode 100644 test/fixtures/index_extended_flags diff --git a/test/fixtures/index_extended_flags b/test/fixtures/index_extended_flags new file mode 100644 index 0000000000000000000000000000000000000000..f03713b684711d4a5aedab0bafd6b254137c9e5d GIT binary patch literal 436 zcmZ?q402{*U|l!>^Ob+tC2sPz z@}frL4{5V^!oJTI8&x~7IWT1AWtQlbFff4htEWE|gV7LkPHLi=!|-hGquHA-UUT;D z)?8N}b>q;Jp5TcNoDK}drAhjUDJiKbK+8Y?WR7Zr=1~|8b=Nnd%;P~auOvSoVcry8 zhad+>tlD0`4JmGI|5U?^ V?tRm?Z1&1ue!#pbGJMwrUjTmQph*A# literal 0 HcmV?d00001 diff --git a/test/test_index.py b/test/test_index.py index 6d90d7965..bb05d3108 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -1218,30 +1218,47 @@ def test_index_add_non_normalized_path(self, rw_repo): rw_repo.index.add(non_normalized_path) + def test_index_file_v3(self): + index = IndexFile(self.rorepo, fixture_path("index_extended_flags")) + assert index.entries + assert index.version == 3 + assert len(index.entries) == 4 + assert index.entries[('init.t', 0)].skip_worktree + + # Write the data - it must match the original. + with tempfile.NamedTemporaryFile() as tmpfile: + index.write(tmpfile.name) + assert Path(tmpfile.name).read_bytes() == Path(fixture_path("index_extended_flags")).read_bytes() + @with_rw_directory - def test_index_version_v3(self, tmp_dir): + def test_index_file_v3_with_git_command(self, tmp_dir): tmp_dir = Path(tmp_dir) with cwd(tmp_dir): - subprocess.run(["git", "init", "-q"], check=True) + git = Git(tmp_dir) + git.init() + file = tmp_dir / "file.txt" file.write_text("hello") - subprocess.run(["git", "add", "-N", "file.txt"], check=True) + git.add("--intent-to-add", "file.txt") # intent-to-add sets extended flag repo = Repo(tmp_dir) + index = repo.index - assert len(repo.index.entries) == 1 - entry = list(repo.index.entries.values())[0] + assert len(index.entries) == 1 + assert index.version == 3 + entry = list(index.entries.values())[0] assert entry.path == "file.txt" assert entry.intent_to_add file2 = tmp_dir / "file2.txt" file2.write_text("world") - repo.index.add(["file2.txt"]) - repo.index.write() + index.add(["file2.txt"]) + index.write() - status_str = subprocess.check_output(["git", "status", "--porcelain"], text=True) - assert " A file.txt\n" in status_str - assert "A file2.txt\n" in status_str + status_str = git.status(porcelain=True) + status_lines = status_str.splitlines() + assert " A file.txt" in status_lines + assert "A file2.txt" in status_lines class TestIndexUtils: From 107b1b44e91a19ebbe5e41cd7312ce7838534732 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Sun, 9 Nov 2025 08:41:10 +0100 Subject: [PATCH 32/72] make linter happy --- test/test_index.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_index.py b/test/test_index.py index bb05d3108..711b43a0b 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -1223,7 +1223,7 @@ def test_index_file_v3(self): assert index.entries assert index.version == 3 assert len(index.entries) == 4 - assert index.entries[('init.t', 0)].skip_worktree + assert index.entries[("init.t", 0)].skip_worktree # Write the data - it must match the original. with tempfile.NamedTemporaryFile() as tmpfile: From 98e860d1c3a0855e2ff29bf24c5adaca7a57366f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Nov 2025 13:52:55 +0000 Subject: [PATCH 33/72] Bump actions/checkout from 5 to 6 Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/alpine-test.yml | 2 +- .github/workflows/codeql.yml | 2 +- .github/workflows/cygwin-test.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/pythonpackage.yml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/alpine-test.yml b/.github/workflows/alpine-test.yml index a9c29117e..b7de7482e 100644 --- a/.github/workflows/alpine-test.yml +++ b/.github/workflows/alpine-test.yml @@ -26,7 +26,7 @@ jobs: adduser runner docker shell: sh -exo pipefail {0} # Run this as root, not the "runner" user. - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: fetch-depth: 0 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 32d5e84e4..e243416a8 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -47,7 +47,7 @@ jobs: # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 # Add any setup steps before running the `github/codeql-action/init` action. # This includes steps like installing compilers or runtimes (`actions/setup-node` diff --git a/.github/workflows/cygwin-test.yml b/.github/workflows/cygwin-test.yml index 5c42c8583..327e1f10c 100644 --- a/.github/workflows/cygwin-test.yml +++ b/.github/workflows/cygwin-test.yml @@ -34,7 +34,7 @@ jobs: git config --global core.autocrlf false # Affects the non-Cygwin git. shell: pwsh # Do this outside Cygwin, to affect actions/checkout. - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: fetch-depth: 0 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index ed535a914..956b38963 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: actions/setup-python@v6 with: diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 7088310e5..975c2e29d 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -39,7 +39,7 @@ jobs: shell: bash --noprofile --norc -exo pipefail {0} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: fetch-depth: 0 From e3f38ffe1c3e37ec5a8f18d60961ddd0cd0f62f4 Mon Sep 17 00:00:00 2001 From: George Ogden Date: Thu, 27 Nov 2025 17:32:48 +0000 Subject: [PATCH 34/72] Move clone tests into dedicated file --- test/test_clone.py | 294 ++++++++++++++++++++++++++++++++++++++++++++- test/test_repo.py | 283 +------------------------------------------ 2 files changed, 294 insertions(+), 283 deletions(-) diff --git a/test/test_clone.py b/test/test_clone.py index 126ef0063..91b7d7621 100644 --- a/test/test_clone.py +++ b/test/test_clone.py @@ -1,12 +1,23 @@ # This module is part of GitPython and is released under the # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/ +import os +import os.path as osp +import pathlib +import sys +import tempfile +from unittest import skip + +from git import GitCommandError, Repo +from git.exc import UnsafeOptionError, UnsafeProtocolError + +from test.lib import TestBase, with_rw_directory, with_rw_repo + from pathlib import Path import re import git - -from test.lib import TestBase, with_rw_directory +import pytest class TestClone(TestBase): @@ -29,3 +40,282 @@ def test_checkout_in_non_empty_dir(self, rw_dir): ) else: self.fail("GitCommandError not raised") + + @with_rw_directory + def test_clone_from_pathlib(self, rw_dir): + original_repo = Repo.init(osp.join(rw_dir, "repo")) + + Repo.clone_from(original_repo.git_dir, pathlib.Path(rw_dir) / "clone_pathlib") + + @with_rw_directory + def test_clone_from_pathlib_withConfig(self, rw_dir): + original_repo = Repo.init(osp.join(rw_dir, "repo")) + + cloned = Repo.clone_from( + original_repo.git_dir, + pathlib.Path(rw_dir) / "clone_pathlib_withConfig", + multi_options=[ + "--recurse-submodules=repo", + "--config core.filemode=false", + "--config submodule.repo.update=checkout", + "--config filter.lfs.clean='git-lfs clean -- %f'", + ], + allow_unsafe_options=True, + ) + + self.assertEqual(cloned.config_reader().get_value("submodule", "active"), "repo") + self.assertEqual(cloned.config_reader().get_value("core", "filemode"), False) + self.assertEqual(cloned.config_reader().get_value('submodule "repo"', "update"), "checkout") + self.assertEqual( + cloned.config_reader().get_value('filter "lfs"', "clean"), + "git-lfs clean -- %f", + ) + + def test_clone_from_with_path_contains_unicode(self): + with tempfile.TemporaryDirectory() as tmpdir: + unicode_dir_name = "\u0394" + path_with_unicode = os.path.join(tmpdir, unicode_dir_name) + os.makedirs(path_with_unicode) + + try: + Repo.clone_from( + url=self._small_repo_url(), + to_path=path_with_unicode, + ) + except UnicodeEncodeError: + self.fail("Raised UnicodeEncodeError") + + @with_rw_directory + @skip( + """The referenced repository was removed, and one needs to set up a new + password controlled repo under the org's control.""" + ) + def test_leaking_password_in_clone_logs(self, rw_dir): + password = "fakepassword1234" + try: + Repo.clone_from( + url="https://fakeuser:{}@fakerepo.example.com/testrepo".format(password), + to_path=rw_dir, + ) + except GitCommandError as err: + assert password not in str(err), "The error message '%s' should not contain the password" % err + # Working example from a blank private project. + Repo.clone_from( + url="https://gitlab+deploy-token-392045:mLWhVus7bjLsy8xj8q2V@gitlab.com/mercierm/test_git_python", + to_path=rw_dir, + ) + + @with_rw_repo("HEAD") + def test_clone_unsafe_options(self, rw_repo): + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = pathlib.Path(tdir) + tmp_file = tmp_dir / "pwn" + unsafe_options = [ + f"--upload-pack='touch {tmp_file}'", + f"-u 'touch {tmp_file}'", + "--config=protocol.ext.allow=always", + "-c protocol.ext.allow=always", + ] + for unsafe_option in unsafe_options: + with self.assertRaises(UnsafeOptionError): + rw_repo.clone(tmp_dir, multi_options=[unsafe_option]) + assert not tmp_file.exists() + + unsafe_options = [ + {"upload-pack": f"touch {tmp_file}"}, + {"u": f"touch {tmp_file}"}, + {"config": "protocol.ext.allow=always"}, + {"c": "protocol.ext.allow=always"}, + ] + for unsafe_option in unsafe_options: + with self.assertRaises(UnsafeOptionError): + rw_repo.clone(tmp_dir, **unsafe_option) + assert not tmp_file.exists() + + @pytest.mark.xfail( + sys.platform == "win32", + reason=( + "File not created. A separate Windows command may be needed. This and the " + "currently passing test test_clone_unsafe_options must be adjusted in the " + "same way. Until then, test_clone_unsafe_options is unreliable on Windows." + ), + raises=AssertionError, + ) + @with_rw_repo("HEAD") + def test_clone_unsafe_options_allowed(self, rw_repo): + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = pathlib.Path(tdir) + tmp_file = tmp_dir / "pwn" + unsafe_options = [ + f"--upload-pack='touch {tmp_file}'", + f"-u 'touch {tmp_file}'", + ] + for i, unsafe_option in enumerate(unsafe_options): + destination = tmp_dir / str(i) + assert not tmp_file.exists() + # The options will be allowed, but the command will fail. + with self.assertRaises(GitCommandError): + rw_repo.clone(destination, multi_options=[unsafe_option], allow_unsafe_options=True) + assert tmp_file.exists() + tmp_file.unlink() + + unsafe_options = [ + "--config=protocol.ext.allow=always", + "-c protocol.ext.allow=always", + ] + for i, unsafe_option in enumerate(unsafe_options): + destination = tmp_dir / str(i) + assert not destination.exists() + rw_repo.clone(destination, multi_options=[unsafe_option], allow_unsafe_options=True) + assert destination.exists() + + @with_rw_repo("HEAD") + def test_clone_safe_options(self, rw_repo): + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = pathlib.Path(tdir) + options = [ + "--depth=1", + "--single-branch", + "-q", + ] + for option in options: + destination = tmp_dir / option + assert not destination.exists() + rw_repo.clone(destination, multi_options=[option]) + assert destination.exists() + + @with_rw_repo("HEAD") + def test_clone_from_unsafe_options(self, rw_repo): + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = pathlib.Path(tdir) + tmp_file = tmp_dir / "pwn" + unsafe_options = [ + f"--upload-pack='touch {tmp_file}'", + f"-u 'touch {tmp_file}'", + "--config=protocol.ext.allow=always", + "-c protocol.ext.allow=always", + ] + for unsafe_option in unsafe_options: + with self.assertRaises(UnsafeOptionError): + Repo.clone_from(rw_repo.working_dir, tmp_dir, multi_options=[unsafe_option]) + assert not tmp_file.exists() + + unsafe_options = [ + {"upload-pack": f"touch {tmp_file}"}, + {"u": f"touch {tmp_file}"}, + {"config": "protocol.ext.allow=always"}, + {"c": "protocol.ext.allow=always"}, + ] + for unsafe_option in unsafe_options: + with self.assertRaises(UnsafeOptionError): + Repo.clone_from(rw_repo.working_dir, tmp_dir, **unsafe_option) + assert not tmp_file.exists() + + @pytest.mark.xfail( + sys.platform == "win32", + reason=( + "File not created. A separate Windows command may be needed. This and the " + "currently passing test test_clone_from_unsafe_options must be adjusted in the " + "same way. Until then, test_clone_from_unsafe_options is unreliable on Windows." + ), + raises=AssertionError, + ) + @with_rw_repo("HEAD") + def test_clone_from_unsafe_options_allowed(self, rw_repo): + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = pathlib.Path(tdir) + tmp_file = tmp_dir / "pwn" + unsafe_options = [ + f"--upload-pack='touch {tmp_file}'", + f"-u 'touch {tmp_file}'", + ] + for i, unsafe_option in enumerate(unsafe_options): + destination = tmp_dir / str(i) + assert not tmp_file.exists() + # The options will be allowed, but the command will fail. + with self.assertRaises(GitCommandError): + Repo.clone_from( + rw_repo.working_dir, destination, multi_options=[unsafe_option], allow_unsafe_options=True + ) + assert tmp_file.exists() + tmp_file.unlink() + + unsafe_options = [ + "--config=protocol.ext.allow=always", + "-c protocol.ext.allow=always", + ] + for i, unsafe_option in enumerate(unsafe_options): + destination = tmp_dir / str(i) + assert not destination.exists() + Repo.clone_from( + rw_repo.working_dir, destination, multi_options=[unsafe_option], allow_unsafe_options=True + ) + assert destination.exists() + + @with_rw_repo("HEAD") + def test_clone_from_safe_options(self, rw_repo): + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = pathlib.Path(tdir) + options = [ + "--depth=1", + "--single-branch", + "-q", + ] + for option in options: + destination = tmp_dir / option + assert not destination.exists() + Repo.clone_from(rw_repo.common_dir, destination, multi_options=[option]) + assert destination.exists() + + def test_clone_from_unsafe_protocol(self): + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = pathlib.Path(tdir) + tmp_file = tmp_dir / "pwn" + urls = [ + f"ext::sh -c touch% {tmp_file}", + "fd::17/foo", + ] + for url in urls: + with self.assertRaises(UnsafeProtocolError): + Repo.clone_from(url, tmp_dir / "repo") + assert not tmp_file.exists() + + def test_clone_from_unsafe_protocol_allowed(self): + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = pathlib.Path(tdir) + tmp_file = tmp_dir / "pwn" + urls = [ + f"ext::sh -c touch% {tmp_file}", + "fd::/foo", + ] + for url in urls: + # The URL will be allowed into the command, but the command will + # fail since we don't have that protocol enabled in the Git config file. + with self.assertRaises(GitCommandError): + Repo.clone_from(url, tmp_dir / "repo", allow_unsafe_protocols=True) + assert not tmp_file.exists() + + def test_clone_from_unsafe_protocol_allowed_and_enabled(self): + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = pathlib.Path(tdir) + tmp_file = tmp_dir / "pwn" + urls = [ + f"ext::sh -c touch% {tmp_file}", + ] + allow_ext = [ + "--config=protocol.ext.allow=always", + ] + for url in urls: + # The URL will be allowed into the command, and the protocol is enabled, + # but the command will fail since it can't read from the remote repo. + assert not tmp_file.exists() + with self.assertRaises(GitCommandError): + Repo.clone_from( + url, + tmp_dir / "repo", + multi_options=allow_ext, + allow_unsafe_protocols=True, + allow_unsafe_options=True, + ) + assert tmp_file.exists() + tmp_file.unlink() diff --git a/test/test_repo.py b/test/test_repo.py index bfa1bbb78..dc2cfe7b1 100644 --- a/test/test_repo.py +++ b/test/test_repo.py @@ -14,7 +14,7 @@ import pickle import sys import tempfile -from unittest import mock, skip +from unittest import mock import pytest @@ -36,7 +36,7 @@ Submodule, Tree, ) -from git.exc import BadObject, UnsafeOptionError, UnsafeProtocolError +from git.exc import BadObject from git.repo.fun import touch from git.util import bin_to_hex, cwd, cygpath, join_path_native, rmfile, rmtree @@ -214,285 +214,6 @@ def test_date_format(self, rw_dir): # @-timestamp is the format used by git commit hooks. repo.index.commit("Commit messages", commit_date="@1400000000 +0000") - @with_rw_directory - def test_clone_from_pathlib(self, rw_dir): - original_repo = Repo.init(osp.join(rw_dir, "repo")) - - Repo.clone_from(original_repo.git_dir, pathlib.Path(rw_dir) / "clone_pathlib") - - @with_rw_directory - def test_clone_from_pathlib_withConfig(self, rw_dir): - original_repo = Repo.init(osp.join(rw_dir, "repo")) - - cloned = Repo.clone_from( - original_repo.git_dir, - pathlib.Path(rw_dir) / "clone_pathlib_withConfig", - multi_options=[ - "--recurse-submodules=repo", - "--config core.filemode=false", - "--config submodule.repo.update=checkout", - "--config filter.lfs.clean='git-lfs clean -- %f'", - ], - allow_unsafe_options=True, - ) - - self.assertEqual(cloned.config_reader().get_value("submodule", "active"), "repo") - self.assertEqual(cloned.config_reader().get_value("core", "filemode"), False) - self.assertEqual(cloned.config_reader().get_value('submodule "repo"', "update"), "checkout") - self.assertEqual( - cloned.config_reader().get_value('filter "lfs"', "clean"), - "git-lfs clean -- %f", - ) - - def test_clone_from_with_path_contains_unicode(self): - with tempfile.TemporaryDirectory() as tmpdir: - unicode_dir_name = "\u0394" - path_with_unicode = os.path.join(tmpdir, unicode_dir_name) - os.makedirs(path_with_unicode) - - try: - Repo.clone_from( - url=self._small_repo_url(), - to_path=path_with_unicode, - ) - except UnicodeEncodeError: - self.fail("Raised UnicodeEncodeError") - - @with_rw_directory - @skip( - """The referenced repository was removed, and one needs to set up a new - password controlled repo under the org's control.""" - ) - def test_leaking_password_in_clone_logs(self, rw_dir): - password = "fakepassword1234" - try: - Repo.clone_from( - url="https://fakeuser:{}@fakerepo.example.com/testrepo".format(password), - to_path=rw_dir, - ) - except GitCommandError as err: - assert password not in str(err), "The error message '%s' should not contain the password" % err - # Working example from a blank private project. - Repo.clone_from( - url="https://gitlab+deploy-token-392045:mLWhVus7bjLsy8xj8q2V@gitlab.com/mercierm/test_git_python", - to_path=rw_dir, - ) - - @with_rw_repo("HEAD") - def test_clone_unsafe_options(self, rw_repo): - with tempfile.TemporaryDirectory() as tdir: - tmp_dir = pathlib.Path(tdir) - tmp_file = tmp_dir / "pwn" - unsafe_options = [ - f"--upload-pack='touch {tmp_file}'", - f"-u 'touch {tmp_file}'", - "--config=protocol.ext.allow=always", - "-c protocol.ext.allow=always", - ] - for unsafe_option in unsafe_options: - with self.assertRaises(UnsafeOptionError): - rw_repo.clone(tmp_dir, multi_options=[unsafe_option]) - assert not tmp_file.exists() - - unsafe_options = [ - {"upload-pack": f"touch {tmp_file}"}, - {"u": f"touch {tmp_file}"}, - {"config": "protocol.ext.allow=always"}, - {"c": "protocol.ext.allow=always"}, - ] - for unsafe_option in unsafe_options: - with self.assertRaises(UnsafeOptionError): - rw_repo.clone(tmp_dir, **unsafe_option) - assert not tmp_file.exists() - - @pytest.mark.xfail( - sys.platform == "win32", - reason=( - "File not created. A separate Windows command may be needed. This and the " - "currently passing test test_clone_unsafe_options must be adjusted in the " - "same way. Until then, test_clone_unsafe_options is unreliable on Windows." - ), - raises=AssertionError, - ) - @with_rw_repo("HEAD") - def test_clone_unsafe_options_allowed(self, rw_repo): - with tempfile.TemporaryDirectory() as tdir: - tmp_dir = pathlib.Path(tdir) - tmp_file = tmp_dir / "pwn" - unsafe_options = [ - f"--upload-pack='touch {tmp_file}'", - f"-u 'touch {tmp_file}'", - ] - for i, unsafe_option in enumerate(unsafe_options): - destination = tmp_dir / str(i) - assert not tmp_file.exists() - # The options will be allowed, but the command will fail. - with self.assertRaises(GitCommandError): - rw_repo.clone(destination, multi_options=[unsafe_option], allow_unsafe_options=True) - assert tmp_file.exists() - tmp_file.unlink() - - unsafe_options = [ - "--config=protocol.ext.allow=always", - "-c protocol.ext.allow=always", - ] - for i, unsafe_option in enumerate(unsafe_options): - destination = tmp_dir / str(i) - assert not destination.exists() - rw_repo.clone(destination, multi_options=[unsafe_option], allow_unsafe_options=True) - assert destination.exists() - - @with_rw_repo("HEAD") - def test_clone_safe_options(self, rw_repo): - with tempfile.TemporaryDirectory() as tdir: - tmp_dir = pathlib.Path(tdir) - options = [ - "--depth=1", - "--single-branch", - "-q", - ] - for option in options: - destination = tmp_dir / option - assert not destination.exists() - rw_repo.clone(destination, multi_options=[option]) - assert destination.exists() - - @with_rw_repo("HEAD") - def test_clone_from_unsafe_options(self, rw_repo): - with tempfile.TemporaryDirectory() as tdir: - tmp_dir = pathlib.Path(tdir) - tmp_file = tmp_dir / "pwn" - unsafe_options = [ - f"--upload-pack='touch {tmp_file}'", - f"-u 'touch {tmp_file}'", - "--config=protocol.ext.allow=always", - "-c protocol.ext.allow=always", - ] - for unsafe_option in unsafe_options: - with self.assertRaises(UnsafeOptionError): - Repo.clone_from(rw_repo.working_dir, tmp_dir, multi_options=[unsafe_option]) - assert not tmp_file.exists() - - unsafe_options = [ - {"upload-pack": f"touch {tmp_file}"}, - {"u": f"touch {tmp_file}"}, - {"config": "protocol.ext.allow=always"}, - {"c": "protocol.ext.allow=always"}, - ] - for unsafe_option in unsafe_options: - with self.assertRaises(UnsafeOptionError): - Repo.clone_from(rw_repo.working_dir, tmp_dir, **unsafe_option) - assert not tmp_file.exists() - - @pytest.mark.xfail( - sys.platform == "win32", - reason=( - "File not created. A separate Windows command may be needed. This and the " - "currently passing test test_clone_from_unsafe_options must be adjusted in the " - "same way. Until then, test_clone_from_unsafe_options is unreliable on Windows." - ), - raises=AssertionError, - ) - @with_rw_repo("HEAD") - def test_clone_from_unsafe_options_allowed(self, rw_repo): - with tempfile.TemporaryDirectory() as tdir: - tmp_dir = pathlib.Path(tdir) - tmp_file = tmp_dir / "pwn" - unsafe_options = [ - f"--upload-pack='touch {tmp_file}'", - f"-u 'touch {tmp_file}'", - ] - for i, unsafe_option in enumerate(unsafe_options): - destination = tmp_dir / str(i) - assert not tmp_file.exists() - # The options will be allowed, but the command will fail. - with self.assertRaises(GitCommandError): - Repo.clone_from( - rw_repo.working_dir, destination, multi_options=[unsafe_option], allow_unsafe_options=True - ) - assert tmp_file.exists() - tmp_file.unlink() - - unsafe_options = [ - "--config=protocol.ext.allow=always", - "-c protocol.ext.allow=always", - ] - for i, unsafe_option in enumerate(unsafe_options): - destination = tmp_dir / str(i) - assert not destination.exists() - Repo.clone_from( - rw_repo.working_dir, destination, multi_options=[unsafe_option], allow_unsafe_options=True - ) - assert destination.exists() - - @with_rw_repo("HEAD") - def test_clone_from_safe_options(self, rw_repo): - with tempfile.TemporaryDirectory() as tdir: - tmp_dir = pathlib.Path(tdir) - options = [ - "--depth=1", - "--single-branch", - "-q", - ] - for option in options: - destination = tmp_dir / option - assert not destination.exists() - Repo.clone_from(rw_repo.common_dir, destination, multi_options=[option]) - assert destination.exists() - - def test_clone_from_unsafe_protocol(self): - with tempfile.TemporaryDirectory() as tdir: - tmp_dir = pathlib.Path(tdir) - tmp_file = tmp_dir / "pwn" - urls = [ - f"ext::sh -c touch% {tmp_file}", - "fd::17/foo", - ] - for url in urls: - with self.assertRaises(UnsafeProtocolError): - Repo.clone_from(url, tmp_dir / "repo") - assert not tmp_file.exists() - - def test_clone_from_unsafe_protocol_allowed(self): - with tempfile.TemporaryDirectory() as tdir: - tmp_dir = pathlib.Path(tdir) - tmp_file = tmp_dir / "pwn" - urls = [ - f"ext::sh -c touch% {tmp_file}", - "fd::/foo", - ] - for url in urls: - # The URL will be allowed into the command, but the command will - # fail since we don't have that protocol enabled in the Git config file. - with self.assertRaises(GitCommandError): - Repo.clone_from(url, tmp_dir / "repo", allow_unsafe_protocols=True) - assert not tmp_file.exists() - - def test_clone_from_unsafe_protocol_allowed_and_enabled(self): - with tempfile.TemporaryDirectory() as tdir: - tmp_dir = pathlib.Path(tdir) - tmp_file = tmp_dir / "pwn" - urls = [ - f"ext::sh -c touch% {tmp_file}", - ] - allow_ext = [ - "--config=protocol.ext.allow=always", - ] - for url in urls: - # The URL will be allowed into the command, and the protocol is enabled, - # but the command will fail since it can't read from the remote repo. - assert not tmp_file.exists() - with self.assertRaises(GitCommandError): - Repo.clone_from( - url, - tmp_dir / "repo", - multi_options=allow_ext, - allow_unsafe_protocols=True, - allow_unsafe_options=True, - ) - assert tmp_file.exists() - tmp_file.unlink() - @with_rw_repo("HEAD") def test_max_chunk_size(self, repo): class TestOutputStream(TestBase): From 24abf10dc2913f9c1674c6d60dd70c0ec775a6d4 Mon Sep 17 00:00:00 2001 From: George Ogden Date: Thu, 27 Nov 2025 17:39:15 +0000 Subject: [PATCH 35/72] Allow Pathlike urls and destinations when cloning --- git/repo/base.py | 6 ++++-- test/test_clone.py | 16 +++++++++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/git/repo/base.py b/git/repo/base.py index 6ea96aad2..fbed6e471 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -1362,8 +1362,10 @@ def _clone( odbt = kwargs.pop("odbt", odb_default_type) # When pathlib.Path or other class-based path is passed + if not isinstance(url, str): + url = url.__fspath__() if not isinstance(path, str): - path = str(path) + path = path.__fspath__() ## A bug win cygwin's Git, when `--bare` or `--separate-git-dir` # it prepends the cwd or(?) the `url` into the `path, so:: @@ -1380,7 +1382,7 @@ def _clone( multi = shlex.split(" ".join(multi_options)) if not allow_unsafe_protocols: - Git.check_unsafe_protocols(str(url)) + 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: diff --git a/test/test_clone.py b/test/test_clone.py index 91b7d7621..489931458 100644 --- a/test/test_clone.py +++ b/test/test_clone.py @@ -1,6 +1,7 @@ # This module is part of GitPython and is released under the # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/ +from dataclasses import dataclass import os import os.path as osp import pathlib @@ -45,7 +46,20 @@ def test_checkout_in_non_empty_dir(self, rw_dir): def test_clone_from_pathlib(self, rw_dir): original_repo = Repo.init(osp.join(rw_dir, "repo")) - Repo.clone_from(original_repo.git_dir, pathlib.Path(rw_dir) / "clone_pathlib") + Repo.clone_from(pathlib.Path(original_repo.git_dir), pathlib.Path(rw_dir) / "clone_pathlib") + + @with_rw_directory + def test_clone_from_pathlike(self, rw_dir): + original_repo = Repo.init(osp.join(rw_dir, "repo")) + + @dataclass + class PathLikeMock: + path: str + + def __fspath__(self) -> str: + return self.path + + Repo.clone_from(PathLikeMock(original_repo.git_dir), PathLikeMock(os.path.join(rw_dir, "clone_pathlike"))) @with_rw_directory def test_clone_from_pathlib_withConfig(self, rw_dir): From ad1ae5fea338d2a716506f46532932dc458f791a Mon Sep 17 00:00:00 2001 From: George Ogden Date: Thu, 27 Nov 2025 17:41:07 +0000 Subject: [PATCH 36/72] Simplify logic with direct path conversion --- git/index/typ.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/git/index/typ.py b/git/index/typ.py index 4bcb604ab..5fb1b9abc 100644 --- a/git/index/typ.py +++ b/git/index/typ.py @@ -58,9 +58,9 @@ def __init__(self, paths: Sequence[PathLike]) -> None: def __call__(self, stage_blob: Tuple[StageType, Blob]) -> bool: blob_pathlike: PathLike = stage_blob[1].path - blob_path: Path = blob_pathlike if isinstance(blob_pathlike, Path) else Path(blob_pathlike) + blob_path = Path(blob_pathlike) for pathlike in self.paths: - path: Path = pathlike if isinstance(pathlike, Path) else Path(pathlike) + path = Path(pathlike) # TODO: Change to use `PosixPath.is_relative_to` once Python 3.8 is no # longer supported. filter_parts = path.parts From 5d26325f59880864863b5e56a08aa0f83b623f2d Mon Sep 17 00:00:00 2001 From: George Ogden Date: Thu, 27 Nov 2025 17:46:13 +0000 Subject: [PATCH 37/72] Allow Pathlike paths when creating a git repo --- git/repo/base.py | 2 +- test/test_repo.py | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/git/repo/base.py b/git/repo/base.py index fbed6e471..b1b95ce42 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -223,7 +223,7 @@ def __init__( epath = epath or path or os.getcwd() if not isinstance(epath, str): - epath = str(epath) + epath = epath.__fspath__() if expand_vars and re.search(self.re_envvars, epath): warnings.warn( "The use of environment variables in paths is deprecated" diff --git a/test/test_repo.py b/test/test_repo.py index dc2cfe7b1..cd22430a7 100644 --- a/test/test_repo.py +++ b/test/test_repo.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/ +from dataclasses import dataclass import gc import glob import io @@ -105,6 +106,18 @@ def test_repo_creation_pathlib(self, rw_repo): r_from_gitdir = Repo(pathlib.Path(rw_repo.git_dir)) self.assertEqual(r_from_gitdir.git_dir, rw_repo.git_dir) + @with_rw_repo("0.3.2.1") + def test_repo_creation_pathlike(self, rw_repo): + @dataclass + class PathLikeMock: + path: str + + def __fspath__(self) -> str: + return self.path + + r_from_gitdir = Repo(PathLikeMock(rw_repo.git_dir)) + self.assertEqual(r_from_gitdir.git_dir, rw_repo.git_dir) + def test_description(self): txt = "Test repository" self.rorepo.description = txt From 59c3c8065a402c4cd8a71625f51b6792fdc04863 Mon Sep 17 00:00:00 2001 From: George Ogden Date: Thu, 27 Nov 2025 19:20:07 +0000 Subject: [PATCH 38/72] Fix missing path conversion --- git/repo/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git/repo/base.py b/git/repo/base.py index b1b95ce42..be50300b5 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -219,7 +219,7 @@ def __init__( # Given how the tests are written, this seems more likely to catch Cygwin # git used from Windows than Windows git used from Cygwin. Therefore # changing to Cygwin-style paths is the relevant operation. - epath = cygpath(str(epath)) + epath = cygpath(epath if isinstance(epath, str) else epath.__fspath__()) epath = epath or path or os.getcwd() if not isinstance(epath, str): From 91d4cc5ea05df04c82fcfd3e35a6af2e903cc554 Mon Sep 17 00:00:00 2001 From: George Ogden Date: Fri, 28 Nov 2025 08:57:54 +0000 Subject: [PATCH 39/72] Use os.fspath instead of __fspath__ for reading paths --- git/config.py | 2 +- git/repo/base.py | 13 ++++--------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/git/config.py b/git/config.py index 200c81bb7..e3081401d 100644 --- a/git/config.py +++ b/git/config.py @@ -634,7 +634,7 @@ def read(self) -> None: # type: ignore[override] self._read(file_path, file_path.name) else: # Assume a path if it is not a file-object. - file_path = cast(PathLike, file_path) + file_path = os.fspath(file_path) try: with open(file_path, "rb") as fp: file_ok = True diff --git a/git/repo/base.py b/git/repo/base.py index be50300b5..2d87a06b7 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -219,11 +219,9 @@ def __init__( # Given how the tests are written, this seems more likely to catch Cygwin # git used from Windows than Windows git used from Cygwin. Therefore # changing to Cygwin-style paths is the relevant operation. - epath = cygpath(epath if isinstance(epath, str) else epath.__fspath__()) + epath = cygpath(os.fspath(epath)) - epath = epath or path or os.getcwd() - if not isinstance(epath, str): - epath = epath.__fspath__() + epath = os.fspath(epath) if expand_vars and re.search(self.re_envvars, epath): warnings.warn( "The use of environment variables in paths is deprecated" @@ -1361,11 +1359,8 @@ def _clone( ) -> "Repo": odbt = kwargs.pop("odbt", odb_default_type) - # When pathlib.Path or other class-based path is passed - if not isinstance(url, str): - url = url.__fspath__() - if not isinstance(path, str): - path = path.__fspath__() + url = os.fspath(url) + path = os.fspath(path) ## A bug win cygwin's Git, when `--bare` or `--separate-git-dir` # it prepends the cwd or(?) the `url` into the `path, so:: From 497ca401fe094fcae11410a46518e8f56d7bd665 Mon Sep 17 00:00:00 2001 From: George Ogden Date: Fri, 28 Nov 2025 09:04:19 +0000 Subject: [PATCH 40/72] Pin mypy==1.18.2 --- test-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-requirements.txt b/test-requirements.txt index 75e9e81fa..460597539 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,7 +1,7 @@ coverage[toml] ddt >= 1.1.1, != 1.4.3 mock ; python_version < "3.8" -mypy +mypy==1.18.2 # pin mypy to avoid new errors pre-commit pytest >= 7.3.1 pytest-cov From 50762f112fef28230deea55c2d0ca344c6c6cb2c Mon Sep 17 00:00:00 2001 From: George Ogden Date: Fri, 28 Nov 2025 09:08:42 +0000 Subject: [PATCH 41/72] Fail remote pipeline when mypy fails --- .github/workflows/pythonpackage.yml | 1 - pyproject.toml | 1 - 2 files changed, 2 deletions(-) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 975c2e29d..4666f3480 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -103,7 +103,6 @@ jobs: PYTHON_VERSION: ${{ matrix.python-version }} # With new versions of mypy new issues might arise. This is a problem if there is # nobody able to fix them, so we have to ignore errors until that changes. - continue-on-error: true - name: Test with pytest run: | diff --git a/pyproject.toml b/pyproject.toml index 58ed81f17..149f2dc92 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,6 @@ testpaths = "test" # Space separated list of paths from root e.g test tests doc # filterwarnings ignore::WarningType # ignores those warnings [tool.mypy] -python_version = "3.8" files = ["git/", "test/deprecation/"] disallow_untyped_defs = true no_implicit_optional = true From 8469a1292f51d5e211e69849844f418d773268e1 Mon Sep 17 00:00:00 2001 From: George Ogden Date: Fri, 28 Nov 2025 09:37:54 +0000 Subject: [PATCH 42/72] Fix or ignore all mypy errors --- git/config.py | 6 ++---- git/diff.py | 6 ++++-- git/index/typ.py | 4 ++-- git/objects/commit.py | 2 +- git/objects/submodule/base.py | 3 ++- git/refs/head.py | 6 +----- git/refs/log.py | 2 +- git/refs/symbolic.py | 3 +-- git/refs/tag.py | 8 ++++---- git/repo/base.py | 10 +++------- git/repo/fun.py | 2 +- git/types.py | 6 +++--- git/util.py | 8 +++++--- test/deprecation/test_types.py | 2 +- test/lib/helper.py | 4 ++-- test/test_submodule.py | 2 +- 16 files changed, 34 insertions(+), 40 deletions(-) diff --git a/git/config.py b/git/config.py index 200c81bb7..458151d05 100644 --- a/git/config.py +++ b/git/config.py @@ -574,7 +574,7 @@ def _included_paths(self) -> List[Tuple[str, str]]: if keyword.endswith("/i"): value = re.sub( r"[a-zA-Z]", - lambda m: "[{}{}]".format(m.group().lower(), m.group().upper()), + lambda m: f"[{m.group().lower()!r}{m.group().upper()!r}]", value, ) if self._repo.git_dir: @@ -633,8 +633,6 @@ def read(self) -> None: # type: ignore[override] file_path = cast(IO[bytes], file_path) self._read(file_path, file_path.name) else: - # Assume a path if it is not a file-object. - file_path = cast(PathLike, file_path) try: with open(file_path, "rb") as fp: file_ok = True @@ -768,7 +766,7 @@ def _assure_writable(self, method_name: str) -> None: if self.read_only: raise IOError("Cannot execute non-constant method %s.%s" % (self, method_name)) - def add_section(self, section: str) -> None: + def add_section(self, section: str | cp._UNNAMED_SECTION) -> None: """Assures added options will stay in order.""" return super().add_section(section) diff --git a/git/diff.py b/git/diff.py index 9c6ae59e0..2b1fd928c 100644 --- a/git/diff.py +++ b/git/diff.py @@ -21,15 +21,17 @@ Any, Iterator, List, + Literal, Match, Optional, + Sequence, Tuple, TYPE_CHECKING, TypeVar, Union, cast, ) -from git.types import Literal, PathLike +from git.types import PathLike if TYPE_CHECKING: from subprocess import Popen @@ -289,7 +291,7 @@ class DiffIndex(List[T_Diff]): The class improves the diff handling convenience. """ - change_type = ("A", "C", "D", "R", "M", "T") + change_type: Sequence[Literal["A", "C", "D", "R", "M", "T"]] = ("A", "C", "D", "R", "M", "T") """Change type invariant identifying possible ways a blob can have changed: * ``A`` = Added diff --git a/git/index/typ.py b/git/index/typ.py index 4bcb604ab..927633a9f 100644 --- a/git/index/typ.py +++ b/git/index/typ.py @@ -192,7 +192,7 @@ def from_base(cls, base: "BaseIndexEntry") -> "IndexEntry": Instance of type :class:`BaseIndexEntry`. """ time = pack(">LL", 0, 0) - return IndexEntry((base.mode, base.binsha, base.flags, base.path, time, time, 0, 0, 0, 0, 0)) + return IndexEntry((base.mode, base.binsha, base.flags, base.path, time, time, 0, 0, 0, 0, 0)) # type: ignore[arg-type] @classmethod def from_blob(cls, blob: Blob, stage: int = 0) -> "IndexEntry": @@ -211,5 +211,5 @@ def from_blob(cls, blob: Blob, stage: int = 0) -> "IndexEntry": 0, 0, blob.size, - ) + ) # type: ignore[arg-type] ) diff --git a/git/objects/commit.py b/git/objects/commit.py index fbe0ee9c0..8c51254a2 100644 --- a/git/objects/commit.py +++ b/git/objects/commit.py @@ -900,7 +900,7 @@ def co_authors(self) -> List[Actor]: if self.message: results = re.findall( r"^Co-authored-by: (.*) <(.*?)>$", - self.message, + str(self.message), re.MULTILINE, ) for author in results: diff --git a/git/objects/submodule/base.py b/git/objects/submodule/base.py index 5031a2e71..b4a4ca467 100644 --- a/git/objects/submodule/base.py +++ b/git/objects/submodule/base.py @@ -25,6 +25,7 @@ ) from git.objects.base import IndexObject, Object from git.objects.util import TraversableIterableObj +from ...refs.remote import RemoteReference from git.util import ( IterableList, RemoteProgress, @@ -355,7 +356,7 @@ def _clone_repo( module_checkout_path = osp.join(str(repo.working_tree_dir), path) if url.startswith("../"): - remote_name = repo.active_branch.tracking_branch().remote_name + remote_name = cast(RemoteReference, repo.active_branch.tracking_branch()).remote_name repo_remote_url = repo.remote(remote_name).url url = os.path.join(repo_remote_url, url) diff --git a/git/refs/head.py b/git/refs/head.py index 683634451..3c43993e7 100644 --- a/git/refs/head.py +++ b/git/refs/head.py @@ -22,7 +22,6 @@ from git.types import Commit_ish, PathLike if TYPE_CHECKING: - from git.objects import Commit from git.refs import RemoteReference from git.repo import Repo @@ -44,9 +43,6 @@ class HEAD(SymbolicReference): __slots__ = () - # TODO: This can be removed once SymbolicReference.commit has static type hints. - commit: "Commit" - def __init__(self, repo: "Repo", path: PathLike = _HEAD_NAME) -> None: if path != self._HEAD_NAME: raise ValueError("HEAD instance must point to %r, got %r" % (self._HEAD_NAME, path)) @@ -149,7 +145,7 @@ class Head(Reference): k_config_remote_ref = "merge" # Branch to merge from remote. @classmethod - def delete(cls, repo: "Repo", *heads: "Union[Head, str]", force: bool = False, **kwargs: Any) -> None: + def delete(cls, repo: "Repo", *heads: "Union[Head, str]", force: bool = False, **kwargs: Any) -> None: # type: ignore[override] """Delete the given heads. :param force: diff --git a/git/refs/log.py b/git/refs/log.py index 8f2f2cd38..4e3666993 100644 --- a/git/refs/log.py +++ b/git/refs/log.py @@ -145,7 +145,7 @@ def from_line(cls, line: bytes) -> "RefLogEntry": actor = Actor._from_string(info[82 : email_end + 1]) time, tz_offset = parse_date(info[email_end + 2 :]) # skipcq: PYL-W0621 - return RefLogEntry((oldhexsha, newhexsha, actor, (time, tz_offset), msg)) + return RefLogEntry(oldhexsha, newhexsha, actor, (time, tz_offset), msg) # type: ignore[call-arg] class RefLog(List[RefLogEntry], Serializable): diff --git a/git/refs/symbolic.py b/git/refs/symbolic.py index 74bb1fe0a..f0d2abcf4 100644 --- a/git/refs/symbolic.py +++ b/git/refs/symbolic.py @@ -916,8 +916,7 @@ def from_path(cls: Type[T_References], repo: "Repo", path: PathLike) -> T_Refere SymbolicReference, ): try: - instance: T_References - instance = ref_type(repo, path) + instance = cast(T_References, ref_type(repo, path)) if instance.__class__ is SymbolicReference and instance.is_detached: raise ValueError("SymbolicRef was detached, we drop it") else: diff --git a/git/refs/tag.py b/git/refs/tag.py index 1e38663ae..4525b09cb 100644 --- a/git/refs/tag.py +++ b/git/refs/tag.py @@ -45,8 +45,8 @@ class TagReference(Reference): _common_default = "tags" _common_path_default = Reference._common_path_default + "/" + _common_default - @property - def commit(self) -> "Commit": # type: ignore[override] # LazyMixin has unrelated commit method + @property # type: ignore[misc] + def commit(self) -> "Commit": # LazyMixin has unrelated commit method """:return: Commit object the tag ref points to :raise ValueError: @@ -80,8 +80,8 @@ def tag(self) -> Union["TagObject", None]: return None # Make object read-only. It should be reasonably hard to adjust an existing tag. - @property - def object(self) -> AnyGitObject: # type: ignore[override] + @property # type: ignore[misc] + def object(self) -> AnyGitObject: return Reference._get_object(self) @classmethod diff --git a/git/repo/base.py b/git/repo/base.py index 6ea96aad2..1ef7114af 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -684,11 +684,7 @@ def _config_reader( git_dir: Optional[PathLike] = None, ) -> GitConfigParser: if config_level is None: - files = [ - self._get_config_path(cast(Lit_config_levels, f), git_dir) - for f in self.config_level - if cast(Lit_config_levels, f) - ] + files = [self._get_config_path(f, git_dir) for f in self.config_level if f] else: files = [self._get_config_path(config_level, git_dir)] return GitConfigParser(files, read_only=True, repo=self) @@ -1484,7 +1480,7 @@ def clone( self.common_dir, path, type(self.odb), - progress, + progress, # type: ignore[arg-type] multi_options, allow_unsafe_protocols=allow_unsafe_protocols, allow_unsafe_options=allow_unsafe_options, @@ -1545,7 +1541,7 @@ def clone_from( url, to_path, GitCmdObjectDB, - progress, + progress, # type: ignore[arg-type] multi_options, allow_unsafe_protocols=allow_unsafe_protocols, allow_unsafe_options=allow_unsafe_options, diff --git a/git/repo/fun.py b/git/repo/fun.py index 1c995c6c6..3f00e60ea 100644 --- a/git/repo/fun.py +++ b/git/repo/fun.py @@ -286,7 +286,7 @@ def rev_parse(repo: "Repo", rev: str) -> AnyGitObject: # END handle refname else: if ref is not None: - obj = cast("Commit", ref.commit) + obj = ref.commit # END handle ref # END initialize obj on first token diff --git a/git/types.py b/git/types.py index cce184530..c6dbb717b 100644 --- a/git/types.py +++ b/git/types.py @@ -13,7 +13,7 @@ Sequence as Sequence, Tuple, TYPE_CHECKING, - Type, + TypeAlias, TypeVar, Union, ) @@ -117,7 +117,7 @@ object types. """ -GitObjectTypeString = Literal["commit", "tag", "blob", "tree"] +GitObjectTypeString: TypeAlias = Literal["commit", "tag", "blob", "tree"] """Literal strings identifying git object types and the :class:`~git.objects.base.Object`-based types that represent them. @@ -130,7 +130,7 @@ https://git-scm.com/docs/gitglossary#def_object_type """ -Lit_commit_ish: Type[Literal["commit", "tag"]] +Lit_commit_ish: TypeAlias = Literal["commit", "tag"] """Deprecated. Type of literal strings identifying typically-commitish git object types. Prior to a bugfix, this type had been defined more broadly. Any usage is in practice diff --git a/git/util.py b/git/util.py index 0aff5eb64..54a5b7bd1 100644 --- a/git/util.py +++ b/git/util.py @@ -1143,7 +1143,7 @@ def _obtain_lock(self) -> None: # END endless loop -class IterableList(List[T_IterableObj]): +class IterableList(List[T_IterableObj]): # type: ignore[type-var] """List of iterable objects allowing to query an object by id or by named index:: heads = repo.heads @@ -1214,14 +1214,16 @@ def __getitem__(self, index: Union[SupportsIndex, int, slice, str]) -> T_Iterabl raise ValueError("Index should be an int or str") else: try: + if not isinstance(index, str): + raise AttributeError(f"{index} is not a valid attribute") return getattr(self, index) except AttributeError as e: - raise IndexError("No item found with id %r" % (self._prefix + index)) from e + raise IndexError(f"No item found with id {self._prefix}{index}") from e # END handle getattr def __delitem__(self, index: Union[SupportsIndex, int, slice, str]) -> None: delindex = cast(int, index) - if not isinstance(index, int): + if isinstance(index, str): delindex = -1 name = self._prefix + index for i, item in enumerate(self): diff --git a/test/deprecation/test_types.py b/test/deprecation/test_types.py index f97375a85..d3c6af645 100644 --- a/test/deprecation/test_types.py +++ b/test/deprecation/test_types.py @@ -36,7 +36,7 @@ def test_can_access_lit_commit_ish_but_it_is_not_usable() -> None: assert 'Literal["commit", "tag"]' in message, "Has new definition." assert "GitObjectTypeString" in message, "Has new type name for old definition." - _: Lit_commit_ish = "commit" # type: ignore[valid-type] + _: Lit_commit_ish = "commit" # It should be as documented (even though deliberately unusable in static checks). assert Lit_commit_ish == Literal["commit", "tag"] diff --git a/test/lib/helper.py b/test/lib/helper.py index 241d27341..b4615f400 100644 --- a/test/lib/helper.py +++ b/test/lib/helper.py @@ -149,7 +149,7 @@ def repo_creator(self): os.chdir(rw_repo.working_dir) try: return func(self, rw_repo) - except: # noqa: E722 B001 + except: # noqa: E722 _logger.info("Keeping repo after failure: %s", repo_dir) repo_dir = None raise @@ -309,7 +309,7 @@ def remote_repo_creator(self): with cwd(rw_repo.working_dir): try: return func(self, rw_repo, rw_daemon_repo) - except: # noqa: E722 B001 + except: # noqa: E722 _logger.info( "Keeping repos after failure: \n rw_repo_dir: %s \n rw_daemon_repo_dir: %s", rw_repo_dir, diff --git a/test/test_submodule.py b/test/test_submodule.py index 4a248eb60..a92dd8fd4 100644 --- a/test/test_submodule.py +++ b/test/test_submodule.py @@ -932,7 +932,7 @@ def assert_exists(sm, value=True): csm.repo.index.commit("Have to commit submodule change for algorithm to pick it up") assert csm.url == "bar" - self.assertRaises( + self.assertRaises( # noqa: B017 Exception, rsm.update, recursive=True, From a9756bc0c8997482a7f69cc8e46a9f461afea8f6 Mon Sep 17 00:00:00 2001 From: George Ogden Date: Fri, 28 Nov 2025 09:46:47 +0000 Subject: [PATCH 43/72] Fix typing so that code can run --- git/config.py | 2 +- git/objects/submodule/base.py | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/git/config.py b/git/config.py index 458151d05..bccf61258 100644 --- a/git/config.py +++ b/git/config.py @@ -766,7 +766,7 @@ def _assure_writable(self, method_name: str) -> None: if self.read_only: raise IOError("Cannot execute non-constant method %s.%s" % (self, method_name)) - def add_section(self, section: str | cp._UNNAMED_SECTION) -> None: + def add_section(self, section: "cp._SectionName") -> None: """Assures added options will stay in order.""" return super().add_section(section) diff --git a/git/objects/submodule/base.py b/git/objects/submodule/base.py index b4a4ca467..20f3e9ccf 100644 --- a/git/objects/submodule/base.py +++ b/git/objects/submodule/base.py @@ -25,7 +25,6 @@ ) from git.objects.base import IndexObject, Object from git.objects.util import TraversableIterableObj -from ...refs.remote import RemoteReference from git.util import ( IterableList, RemoteProgress, @@ -67,7 +66,7 @@ if TYPE_CHECKING: from git.index import IndexFile from git.objects.commit import Commit - from git.refs import Head + from git.refs import Head, RemoteReference from git.repo import Repo # ----------------------------------------------------------------------------- @@ -356,7 +355,7 @@ def _clone_repo( module_checkout_path = osp.join(str(repo.working_tree_dir), path) if url.startswith("../"): - remote_name = cast(RemoteReference, repo.active_branch.tracking_branch()).remote_name + remote_name = cast("RemoteReference", repo.active_branch.tracking_branch()).remote_name repo_remote_url = repo.remote(remote_name).url url = os.path.join(repo_remote_url, url) From 0aba3e7bdec7544a86b6e6ba4b0ad8e2ac5cd2c7 Mon Sep 17 00:00:00 2001 From: George Ogden Date: Fri, 28 Nov 2025 10:02:07 +0000 Subject: [PATCH 44/72] Stop Lit_commit_ish being imported --- git/types.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/git/types.py b/git/types.py index c6dbb717b..100fff43f 100644 --- a/git/types.py +++ b/git/types.py @@ -13,7 +13,6 @@ Sequence as Sequence, Tuple, TYPE_CHECKING, - TypeAlias, TypeVar, Union, ) @@ -117,7 +116,7 @@ object types. """ -GitObjectTypeString: TypeAlias = Literal["commit", "tag", "blob", "tree"] +GitObjectTypeString = Literal["commit", "tag", "blob", "tree"] """Literal strings identifying git object types and the :class:`~git.objects.base.Object`-based types that represent them. @@ -130,7 +129,8 @@ https://git-scm.com/docs/gitglossary#def_object_type """ -Lit_commit_ish: TypeAlias = Literal["commit", "tag"] +if TYPE_CHECKING: + Lit_commit_ish = Literal["commit", "tag"] """Deprecated. Type of literal strings identifying typically-commitish git object types. Prior to a bugfix, this type had been defined more broadly. Any usage is in practice From 019f270785fd01558b48d21fe1469b9a2132d04b Mon Sep 17 00:00:00 2001 From: George Ogden Date: Fri, 28 Nov 2025 10:02:45 +0000 Subject: [PATCH 45/72] Set __test__ = False in not tested classes --- test/test_remote.py | 5 ++++- test/test_submodule.py | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/test/test_remote.py b/test/test_remote.py index 5ddb41bc0..b1d686f05 100644 --- a/test/test_remote.py +++ b/test/test_remote.py @@ -44,7 +44,7 @@ class TestRemoteProgress(RemoteProgress): __slots__ = ("_seen_lines", "_stages_per_op", "_num_progress_messages") - def __init__(self): + def __init__(self) -> None: super().__init__() self._seen_lines = [] self._stages_per_op = {} @@ -103,6 +103,9 @@ def assert_received_message(self): assert self._num_progress_messages +TestRemoteProgress.__test__ = False # type: ignore + + class TestRemote(TestBase): def tearDown(self): gc.collect() diff --git a/test/test_submodule.py b/test/test_submodule.py index a92dd8fd4..edff064c4 100644 --- a/test/test_submodule.py +++ b/test/test_submodule.py @@ -58,6 +58,7 @@ def update(self, op, cur_count, max_count, message=""): print(op, cur_count, max_count, message) +TestRootProgress.__test__ = False prog = TestRootProgress() From ca5a2e817829861c5a0830806c0a40a33a5ab83f Mon Sep 17 00:00:00 2001 From: George Ogden Date: Fri, 28 Nov 2025 11:40:23 +0000 Subject: [PATCH 46/72] Add missing parentheses around tuple constructor --- git/refs/log.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git/refs/log.py b/git/refs/log.py index 4e3666993..4751cff99 100644 --- a/git/refs/log.py +++ b/git/refs/log.py @@ -145,7 +145,7 @@ def from_line(cls, line: bytes) -> "RefLogEntry": actor = Actor._from_string(info[82 : email_end + 1]) time, tz_offset = parse_date(info[email_end + 2 :]) # skipcq: PYL-W0621 - return RefLogEntry(oldhexsha, newhexsha, actor, (time, tz_offset), msg) # type: ignore[call-arg] + return RefLogEntry((oldhexsha, newhexsha, actor, (time, tz_offset), msg)) # type: ignore [arg-type] class RefLog(List[RefLogEntry], Serializable): From c75790837d0fd5bb9a7ba26a48b957b5d70987fb Mon Sep 17 00:00:00 2001 From: George Ogden Date: Fri, 28 Nov 2025 11:43:21 +0000 Subject: [PATCH 47/72] Install mypy for Python >= 3.9 --- test-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-requirements.txt b/test-requirements.txt index 460597539..e6e01c683 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,7 +1,7 @@ coverage[toml] ddt >= 1.1.1, != 1.4.3 mock ; python_version < "3.8" -mypy==1.18.2 # pin mypy to avoid new errors +mypy==1.18.2 ; python_version >= "3.9" # pin mypy version to avoid new errors pre-commit pytest >= 7.3.1 pytest-cov From 9decf740ad2f1d89b55bda3a42880fa4f7b652ea Mon Sep 17 00:00:00 2001 From: George Ogden Date: Fri, 28 Nov 2025 11:49:10 +0000 Subject: [PATCH 48/72] Skip mypy when Python < 3.9 --- .github/workflows/pythonpackage.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 4666f3480..9e05b3fe6 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -95,6 +95,7 @@ jobs: continue-on-error: true - name: Check types with mypy + if: matrix.python-version != '3.7' && matrix.python-version != '3.8' run: | mypy --python-version="${PYTHON_VERSION%t}" # Version only, with no "t" for free-threaded. env: From a1f094c81fcf4a6b559c2a26fc622c89e4f19735 Mon Sep 17 00:00:00 2001 From: George Ogden Date: Fri, 28 Nov 2025 12:10:04 +0000 Subject: [PATCH 49/72] Use git.types.Literal instead of typing.Literal --- git/diff.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/git/diff.py b/git/diff.py index 2b1fd928c..23cb5675e 100644 --- a/git/diff.py +++ b/git/diff.py @@ -21,7 +21,6 @@ Any, Iterator, List, - Literal, Match, Optional, Sequence, @@ -31,7 +30,7 @@ Union, cast, ) -from git.types import PathLike +from git.types import PathLike, Literal if TYPE_CHECKING: from subprocess import Popen @@ -291,7 +290,7 @@ class DiffIndex(List[T_Diff]): The class improves the diff handling convenience. """ - change_type: Sequence[Literal["A", "C", "D", "R", "M", "T"]] = ("A", "C", "D", "R", "M", "T") + change_type: Sequence[Literal["A", "C", "D", "R", "M", "T"]] = ("A", "C", "D", "R", "M", "T") # noqa: F821 """Change type invariant identifying possible ways a blob can have changed: * ``A`` = Added From 0414bf78cfc7d1889121c414efa7841f57343984 Mon Sep 17 00:00:00 2001 From: George Ogden Date: Fri, 28 Nov 2025 21:28:17 +0000 Subject: [PATCH 50/72] Replace extra occurrences of str with fspath --- git/config.py | 2 +- git/index/base.py | 18 +++++++++--------- git/index/fun.py | 4 ++-- git/index/util.py | 2 +- git/objects/blob.py | 3 ++- git/objects/submodule/base.py | 10 +++++----- git/refs/reference.py | 3 ++- git/refs/symbolic.py | 18 +++++++++--------- git/repo/base.py | 4 ++-- git/util.py | 20 ++++++++++---------- test/lib/helper.py | 11 +++++++++++ test/test_clone.py | 11 +---------- test/test_index.py | 22 ++++++++++++---------- test/test_repo.py | 10 +--------- 14 files changed, 68 insertions(+), 70 deletions(-) diff --git a/git/config.py b/git/config.py index e3081401d..732f347f0 100644 --- a/git/config.py +++ b/git/config.py @@ -578,7 +578,7 @@ def _included_paths(self) -> List[Tuple[str, str]]: value, ) if self._repo.git_dir: - if fnmatch.fnmatchcase(str(self._repo.git_dir), value): + if fnmatch.fnmatchcase(os.fspath(self._repo.git_dir), value): paths += self.items(section) elif keyword == "onbranch": diff --git a/git/index/base.py b/git/index/base.py index 7cc9d3ade..905feb076 100644 --- a/git/index/base.py +++ b/git/index/base.py @@ -404,10 +404,10 @@ def _iter_expand_paths(self: "IndexFile", paths: Sequence[PathLike]) -> Iterator def raise_exc(e: Exception) -> NoReturn: raise e - r = str(self.repo.working_tree_dir) + r = os.fspath(self.repo.working_tree_dir) rs = r + os.sep for path in paths: - abs_path = str(path) + abs_path = os.fspath(path) if not osp.isabs(abs_path): abs_path = osp.join(r, path) # END make absolute path @@ -434,7 +434,7 @@ def raise_exc(e: Exception) -> NoReturn: # characters. if abs_path not in resolved_paths: for f in self._iter_expand_paths(glob.glob(abs_path)): - yield str(f).replace(rs, "") + yield os.fspath(f).replace(rs, "") continue # END glob handling try: @@ -569,7 +569,7 @@ def resolve_blobs(self, iter_blobs: Iterator[Blob]) -> "IndexFile": for blob in iter_blobs: stage_null_key = (blob.path, 0) if stage_null_key in self.entries: - raise ValueError("Path %r already exists at stage 0" % str(blob.path)) + raise ValueError("Path %r already exists at stage 0" % os.fspath(blob.path)) # END assert blob is not stage 0 already # Delete all possible stages. @@ -656,10 +656,10 @@ def _to_relative_path(self, path: PathLike) -> PathLike: return path if self.repo.bare: raise InvalidGitRepositoryError("require non-bare repository") - if not osp.normpath(str(path)).startswith(str(self.repo.working_tree_dir)): + if not osp.normpath(os.fspath(path)).startswith(os.fspath(self.repo.working_tree_dir)): raise ValueError("Absolute path %r is not in git repository at %r" % (path, self.repo.working_tree_dir)) result = os.path.relpath(path, self.repo.working_tree_dir) - if str(path).endswith(os.sep) and not result.endswith(os.sep): + if os.fspath(path).endswith(os.sep) and not result.endswith(os.sep): result += os.sep return result @@ -731,7 +731,7 @@ def _entries_for_paths( for path in paths: if osp.isabs(path): abspath = path - gitrelative_path = path[len(str(self.repo.working_tree_dir)) + 1 :] + gitrelative_path = path[len(os.fspath(self.repo.working_tree_dir)) + 1 :] else: gitrelative_path = path if self.repo.working_tree_dir: @@ -1359,11 +1359,11 @@ def make_exc() -> GitCommandError: try: self.entries[(co_path, 0)] except KeyError: - folder = str(co_path) + folder = os.fspath(co_path) if not folder.endswith("/"): folder += "/" for entry in self.entries.values(): - if str(entry.path).startswith(folder): + if os.fspath(entry.path).startswith(folder): p = entry.path self._write_path_to_stdin(proc, p, p, make_exc, fprogress, read_from_stdout=False) checked_out_files.append(p) diff --git a/git/index/fun.py b/git/index/fun.py index 0b3d79cf1..845221c61 100644 --- a/git/index/fun.py +++ b/git/index/fun.py @@ -87,7 +87,7 @@ def run_commit_hook(name: str, index: "IndexFile", *args: str) -> None: return env = os.environ.copy() - env["GIT_INDEX_FILE"] = safe_decode(str(index.path)) + env["GIT_INDEX_FILE"] = safe_decode(os.fspath(index.path)) env["GIT_EDITOR"] = ":" cmd = [hp] try: @@ -167,7 +167,7 @@ def write_cache( beginoffset = tell() write(entry.ctime_bytes) # ctime write(entry.mtime_bytes) # mtime - path_str = str(entry.path) + path_str = os.fspath(entry.path) path: bytes = force_bytes(path_str, encoding=defenc) plen = len(path) & CE_NAMEMASK # Path length assert plen == len(path), "Path %s too long to fit into index" % entry.path diff --git a/git/index/util.py b/git/index/util.py index e59cb609f..2d2422ab4 100644 --- a/git/index/util.py +++ b/git/index/util.py @@ -106,7 +106,7 @@ def git_working_dir(func: Callable[..., _T]) -> Callable[..., _T]: @wraps(func) def set_git_working_dir(self: "IndexFile", *args: Any, **kwargs: Any) -> _T: cur_wd = os.getcwd() - os.chdir(str(self.repo.working_tree_dir)) + os.chdir(os.fspath(self.repo.working_tree_dir)) try: return func(self, *args, **kwargs) finally: diff --git a/git/objects/blob.py b/git/objects/blob.py index 58de59642..f7d49c9cc 100644 --- a/git/objects/blob.py +++ b/git/objects/blob.py @@ -6,6 +6,7 @@ __all__ = ["Blob"] from mimetypes import guess_type +import os import sys if sys.version_info >= (3, 8): @@ -44,5 +45,5 @@ def mime_type(self) -> str: """ guesses = None if self.path: - guesses = guess_type(str(self.path)) + guesses = guess_type(os.fspath(self.path)) return guesses and guesses[0] or self.DEFAULT_MIME_TYPE diff --git a/git/objects/submodule/base.py b/git/objects/submodule/base.py index 5031a2e71..cc1f94c6c 100644 --- a/git/objects/submodule/base.py +++ b/git/objects/submodule/base.py @@ -352,7 +352,7 @@ def _clone_repo( module_abspath_dir = osp.dirname(module_abspath) if not osp.isdir(module_abspath_dir): os.makedirs(module_abspath_dir) - module_checkout_path = osp.join(str(repo.working_tree_dir), path) + module_checkout_path = osp.join(os.fspath(repo.working_tree_dir), path) if url.startswith("../"): remote_name = repo.active_branch.tracking_branch().remote_name @@ -541,7 +541,7 @@ def add( if sm.exists(): # Reretrieve submodule from tree. try: - sm = repo.head.commit.tree[str(path)] + sm = repo.head.commit.tree[os.fspath(path)] sm._name = name return sm except KeyError: @@ -1016,7 +1016,7 @@ def move(self, module_path: PathLike, configuration: bool = True, module: bool = return self # END handle no change - module_checkout_abspath = join_path_native(str(self.repo.working_tree_dir), module_checkout_path) + module_checkout_abspath = join_path_native(os.fspath(self.repo.working_tree_dir), module_checkout_path) if osp.isfile(module_checkout_abspath): raise ValueError("Cannot move repository onto a file: %s" % module_checkout_abspath) # END handle target files @@ -1313,7 +1313,7 @@ def set_parent_commit(self, commit: Union[Commit_ish, str, None], check: bool = # If check is False, we might see a parent-commit that doesn't even contain the # submodule anymore. in that case, mark our sha as being NULL. try: - self.binsha = pctree[str(self.path)].binsha + self.binsha = pctree[os.fspath(self.path)].binsha except KeyError: self.binsha = self.NULL_BIN_SHA @@ -1395,7 +1395,7 @@ def rename(self, new_name: str) -> "Submodule": destination_module_abspath = self._module_abspath(self.repo, self.path, new_name) source_dir = mod.git_dir # Let's be sure the submodule name is not so obviously tied to a directory. - if str(destination_module_abspath).startswith(str(mod.git_dir)): + if os.fspath(destination_module_abspath).startswith(os.fspath(mod.git_dir)): tmp_dir = self._module_abspath(self.repo, self.path, str(uuid.uuid4())) os.renames(source_dir, tmp_dir) source_dir = tmp_dir diff --git a/git/refs/reference.py b/git/refs/reference.py index e5d473779..0c4327225 100644 --- a/git/refs/reference.py +++ b/git/refs/reference.py @@ -3,6 +3,7 @@ __all__ = ["Reference"] +import os from git.util import IterableObj, LazyMixin from .symbolic import SymbolicReference, T_References @@ -65,7 +66,7 @@ def __init__(self, repo: "Repo", path: PathLike, check_path: bool = True) -> Non If ``False``, you can provide any path. Otherwise the path must start with the default path prefix of this type. """ - if check_path and not str(path).startswith(self._common_path_default + "/"): + if check_path and not os.fspath(path).startswith(self._common_path_default + "/"): raise ValueError(f"Cannot instantiate {self.__class__.__name__!r} from path {path}") self.path: str # SymbolicReference converts to string at the moment. super().__init__(repo, path) diff --git a/git/refs/symbolic.py b/git/refs/symbolic.py index 74bb1fe0a..c7db129d9 100644 --- a/git/refs/symbolic.py +++ b/git/refs/symbolic.py @@ -79,7 +79,7 @@ def __init__(self, repo: "Repo", path: PathLike, check_path: bool = False) -> No self.path = path def __str__(self) -> str: - return str(self.path) + return os.fspath(self.path) def __repr__(self) -> str: return '' % (self.__class__.__name__, self.path) @@ -103,7 +103,7 @@ def name(self) -> str: In case of symbolic references, the shortest assumable name is the path itself. """ - return str(self.path) + return os.fspath(self.path) @property def abspath(self) -> PathLike: @@ -178,7 +178,7 @@ def _check_ref_name_valid(ref_path: PathLike) -> None: """ previous: Union[str, None] = None one_before_previous: Union[str, None] = None - for c in str(ref_path): + for c in os.fspath(ref_path): if c in " ~^:?*[\\": raise ValueError( f"Invalid reference '{ref_path}': references cannot contain spaces, tildes (~), carets (^)," @@ -212,7 +212,7 @@ def _check_ref_name_valid(ref_path: PathLike) -> None: raise ValueError(f"Invalid reference '{ref_path}': references cannot end with a forward slash (/)") elif previous == "@" and one_before_previous is None: raise ValueError(f"Invalid reference '{ref_path}': references cannot be '@'") - elif any(component.endswith(".lock") for component in str(ref_path).split("/")): + elif any(component.endswith(".lock") for component in os.fspath(ref_path).split("/")): raise ValueError( f"Invalid reference '{ref_path}': references cannot have slash-separated components that end with" " '.lock'" @@ -235,7 +235,7 @@ def _get_ref_info_helper( tokens: Union[None, List[str], Tuple[str, str]] = None repodir = _git_dir(repo, ref_path) try: - with open(os.path.join(repodir, str(ref_path)), "rt", encoding="UTF-8") as fp: + with open(os.path.join(repodir, os.fspath(ref_path)), "rt", encoding="UTF-8") as fp: value = fp.read().rstrip() # Don't only split on spaces, but on whitespace, which allows to parse lines like: # 60b64ef992065e2600bfef6187a97f92398a9144 branch 'master' of git-server:/path/to/repo @@ -614,7 +614,7 @@ def to_full_path(cls, path: Union[PathLike, "SymbolicReference"]) -> PathLike: full_ref_path = path if not cls._common_path_default: return full_ref_path - if not str(path).startswith(cls._common_path_default + "/"): + if not os.fspath(path).startswith(cls._common_path_default + "/"): full_ref_path = "%s/%s" % (cls._common_path_default, path) return full_ref_path @@ -706,7 +706,7 @@ def _create( if not force and os.path.isfile(abs_ref_path): target_data = str(target) if isinstance(target, SymbolicReference): - target_data = str(target.path) + target_data = os.fspath(target.path) if not resolve: target_data = "ref: " + target_data with open(abs_ref_path, "rb") as fd: @@ -842,7 +842,7 @@ def _iter_items( # Read packed refs. for _sha, rela_path in cls._iter_packed_refs(repo): - if rela_path.startswith(str(common_path)): + if rela_path.startswith(os.fspath(common_path)): rela_paths.add(rela_path) # END relative path matches common path # END packed refs reading @@ -931,4 +931,4 @@ def from_path(cls: Type[T_References], repo: "Repo", path: PathLike) -> T_Refere def is_remote(self) -> bool: """:return: True if this symbolic reference points to a remote branch""" - return str(self.path).startswith(self._remote_common_path_default + "/") + return os.fspath(self.path).startswith(self._remote_common_path_default + "/") diff --git a/git/repo/base.py b/git/repo/base.py index 2d87a06b7..2fe18f48c 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -554,7 +554,7 @@ def tag(self, path: PathLike) -> TagReference: @staticmethod def _to_full_tag_path(path: PathLike) -> str: - path_str = str(path) + path_str = os.fspath(path) if path_str.startswith(TagReference._common_path_default + "/"): return path_str if path_str.startswith(TagReference._common_default + "/"): @@ -959,7 +959,7 @@ def is_dirty( if not submodules: default_args.append("--ignore-submodules") if path: - default_args.extend(["--", str(path)]) + default_args.extend(["--", os.fspath(path)]) if index: # diff index against HEAD. if osp.isfile(self.index.path) and len(self.git.diff("--cached", *default_args)): diff --git a/git/util.py b/git/util.py index 0aff5eb64..f18eb6e52 100644 --- a/git/util.py +++ b/git/util.py @@ -272,9 +272,9 @@ def stream_copy(source: BinaryIO, destination: BinaryIO, chunk_size: int = 512 * def join_path(a: PathLike, *p: PathLike) -> PathLike: R"""Join path tokens together similar to osp.join, but always use ``/`` instead of possibly ``\`` on Windows.""" - path = str(a) + path = os.fspath(a) for b in p: - b = str(b) + b = os.fspath(b) if not b: continue if b.startswith("/"): @@ -290,18 +290,18 @@ def join_path(a: PathLike, *p: PathLike) -> PathLike: if sys.platform == "win32": def to_native_path_windows(path: PathLike) -> PathLike: - path = str(path) + path = os.fspath(path) return path.replace("/", "\\") def to_native_path_linux(path: PathLike) -> str: - path = str(path) + path = os.fspath(path) return path.replace("\\", "/") to_native_path = to_native_path_windows else: # No need for any work on Linux. def to_native_path_linux(path: PathLike) -> str: - return str(path) + return os.fspath(path) to_native_path = to_native_path_linux @@ -372,7 +372,7 @@ def is_exec(fpath: str) -> bool: progs = [] if not path: path = os.environ["PATH"] - for folder in str(path).split(os.pathsep): + for folder in os.fspath(path).split(os.pathsep): folder = folder.strip('"') if folder: exe_path = osp.join(folder, program) @@ -397,7 +397,7 @@ def _cygexpath(drive: Optional[str], path: str) -> str: p = cygpath(p) elif drive: p = "/proc/cygdrive/%s/%s" % (drive.lower(), p) - p_str = str(p) # ensure it is a str and not AnyPath + p_str = os.fspath(p) # ensure it is a str and not AnyPath return p_str.replace("\\", "/") @@ -418,7 +418,7 @@ def _cygexpath(drive: Optional[str], path: str) -> str: def cygpath(path: str) -> str: """Use :meth:`git.cmd.Git.polish_url` instead, that works on any environment.""" - path = str(path) # Ensure is str and not AnyPath. + path = os.fspath(path) # Ensure is str and not AnyPath. # Fix to use Paths when 3.5 dropped. Or to be just str if only for URLs? if not path.startswith(("/cygdrive", "//", "/proc/cygdrive")): for regex, parser, recurse in _cygpath_parsers: @@ -438,7 +438,7 @@ def cygpath(path: str) -> str: def decygpath(path: PathLike) -> str: - path = str(path) + path = os.fspath(path) m = _decygpath_regex.match(path) if m: drive, rest_path = m.groups() @@ -497,7 +497,7 @@ def is_cygwin_git(git_executable: Union[None, PathLike]) -> bool: elif git_executable is None: return False else: - return _is_cygwin_git(str(git_executable)) + return _is_cygwin_git(os.fspath(git_executable)) def get_user_id() -> str: diff --git a/test/lib/helper.py b/test/lib/helper.py index 241d27341..e51f428e3 100644 --- a/test/lib/helper.py +++ b/test/lib/helper.py @@ -10,6 +10,7 @@ "with_rw_directory", "with_rw_repo", "with_rw_and_rw_remote_repo", + "PathLikeMock", "TestBase", "VirtualEnvironment", "TestCase", @@ -20,6 +21,7 @@ ] import contextlib +from dataclasses import dataclass from functools import wraps import gc import io @@ -49,6 +51,15 @@ _logger = logging.getLogger(__name__) + +@dataclass +class PathLikeMock: + path: str + + def __fspath__(self) -> str: + return self.path + + # { Routines diff --git a/test/test_clone.py b/test/test_clone.py index 489931458..143a3b51f 100644 --- a/test/test_clone.py +++ b/test/test_clone.py @@ -1,7 +1,6 @@ # This module is part of GitPython and is released under the # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/ -from dataclasses import dataclass import os import os.path as osp import pathlib @@ -12,7 +11,7 @@ from git import GitCommandError, Repo from git.exc import UnsafeOptionError, UnsafeProtocolError -from test.lib import TestBase, with_rw_directory, with_rw_repo +from test.lib import TestBase, with_rw_directory, with_rw_repo, PathLikeMock from pathlib import Path import re @@ -51,14 +50,6 @@ def test_clone_from_pathlib(self, rw_dir): @with_rw_directory def test_clone_from_pathlike(self, rw_dir): original_repo = Repo.init(osp.join(rw_dir, "repo")) - - @dataclass - class PathLikeMock: - path: str - - def __fspath__(self) -> str: - return self.path - Repo.clone_from(PathLikeMock(original_repo.git_dir), PathLikeMock(os.path.join(rw_dir, "clone_pathlike"))) @with_rw_directory diff --git a/test/test_index.py b/test/test_index.py index 711b43a0b..c1db4166b 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -37,14 +37,7 @@ from git.objects import Blob from git.util import Actor, cwd, hex_to_bin, rmtree -from test.lib import ( - TestBase, - VirtualEnvironment, - fixture, - fixture_path, - with_rw_directory, - with_rw_repo, -) +from test.lib import TestBase, VirtualEnvironment, fixture, fixture_path, with_rw_directory, with_rw_repo, PathLikeMock HOOKS_SHEBANG = "#!/usr/bin/env sh\n" @@ -586,7 +579,7 @@ def mixed_iterator(): if type_id == 0: # path (str) yield entry.path elif type_id == 1: # path (PathLike) - yield Path(entry.path) + yield PathLikeMock(entry.path) elif type_id == 2: # blob yield Blob(rw_repo, entry.binsha, entry.mode, entry.path) elif type_id == 3: # BaseIndexEntry @@ -1198,7 +1191,7 @@ def test_commit_msg_hook_fail(self, rw_repo): raise AssertionError("Should have caught a HookExecutionError") @with_rw_repo("HEAD") - def test_index_add_pathlike(self, rw_repo): + def test_index_add_pathlib(self, rw_repo): git_dir = Path(rw_repo.git_dir) file = git_dir / "file.txt" @@ -1206,6 +1199,15 @@ def test_index_add_pathlike(self, rw_repo): rw_repo.index.add(file) + @with_rw_repo("HEAD") + def test_index_add_pathlike(self, rw_repo): + git_dir = Path(rw_repo.git_dir) + + file = git_dir / "file.txt" + file.touch() + + rw_repo.index.add(PathLikeMock(str(file))) + @with_rw_repo("HEAD") def test_index_add_non_normalized_path(self, rw_repo): git_dir = Path(rw_repo.git_dir) diff --git a/test/test_repo.py b/test/test_repo.py index cd22430a7..f089bf6b8 100644 --- a/test/test_repo.py +++ b/test/test_repo.py @@ -3,7 +3,6 @@ # This module is part of GitPython and is released under the # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/ -from dataclasses import dataclass import gc import glob import io @@ -41,7 +40,7 @@ from git.repo.fun import touch from git.util import bin_to_hex, cwd, cygpath, join_path_native, rmfile, rmtree -from test.lib import TestBase, fixture, with_rw_directory, with_rw_repo +from test.lib import TestBase, fixture, with_rw_directory, with_rw_repo, PathLikeMock def iter_flatten(lol): @@ -108,13 +107,6 @@ def test_repo_creation_pathlib(self, rw_repo): @with_rw_repo("0.3.2.1") def test_repo_creation_pathlike(self, rw_repo): - @dataclass - class PathLikeMock: - path: str - - def __fspath__(self) -> str: - return self.path - r_from_gitdir = Repo(PathLikeMock(rw_repo.git_dir)) self.assertEqual(r_from_gitdir.git_dir, rw_repo.git_dir) From 3801505d1218242e853dda17e981e2a2fa795b0e Mon Sep 17 00:00:00 2001 From: George Ogden Date: Fri, 28 Nov 2025 21:43:09 +0000 Subject: [PATCH 51/72] Convert paths in constructors and large function calls --- git/index/base.py | 5 +++-- git/index/util.py | 2 +- git/refs/head.py | 2 ++ git/refs/log.py | 3 ++- git/refs/symbolic.py | 2 +- git/repo/fun.py | 1 + git/util.py | 2 +- 7 files changed, 11 insertions(+), 6 deletions(-) diff --git a/git/index/base.py b/git/index/base.py index 905feb076..cf7220ef8 100644 --- a/git/index/base.py +++ b/git/index/base.py @@ -652,14 +652,15 @@ def _to_relative_path(self, path: PathLike) -> PathLike: :raise ValueError: """ + path = os.fspath(path) if not osp.isabs(path): return path if self.repo.bare: raise InvalidGitRepositoryError("require non-bare repository") - if not osp.normpath(os.fspath(path)).startswith(os.fspath(self.repo.working_tree_dir)): + if not osp.normpath(path).startswith(os.fspath(self.repo.working_tree_dir)): raise ValueError("Absolute path %r is not in git repository at %r" % (path, self.repo.working_tree_dir)) result = os.path.relpath(path, self.repo.working_tree_dir) - if os.fspath(path).endswith(os.sep) and not result.endswith(os.sep): + if path.endswith(os.sep) and not result.endswith(os.sep): result += os.sep return result diff --git a/git/index/util.py b/git/index/util.py index 2d2422ab4..231634cd6 100644 --- a/git/index/util.py +++ b/git/index/util.py @@ -37,7 +37,7 @@ class TemporaryFileSwap: __slots__ = ("file_path", "tmp_file_path") def __init__(self, file_path: PathLike) -> None: - self.file_path = file_path + self.file_path = os.fspath(file_path) dirname, basename = osp.split(file_path) fd, self.tmp_file_path = tempfile.mkstemp(prefix=basename, dir=dirname) os.close(fd) diff --git a/git/refs/head.py b/git/refs/head.py index 683634451..03daa3973 100644 --- a/git/refs/head.py +++ b/git/refs/head.py @@ -8,6 +8,7 @@ __all__ = ["HEAD", "Head"] +import os from git.config import GitConfigParser, SectionConstraint from git.exc import GitCommandError from git.util import join_path @@ -48,6 +49,7 @@ class HEAD(SymbolicReference): commit: "Commit" def __init__(self, repo: "Repo", path: PathLike = _HEAD_NAME) -> None: + path = os.fspath(path) if path != self._HEAD_NAME: raise ValueError("HEAD instance must point to %r, got %r" % (self._HEAD_NAME, path)) super().__init__(repo, path) diff --git a/git/refs/log.py b/git/refs/log.py index 8f2f2cd38..48bb02c60 100644 --- a/git/refs/log.py +++ b/git/refs/log.py @@ -4,6 +4,7 @@ __all__ = ["RefLog", "RefLogEntry"] from mmap import mmap +import os import os.path as osp import re import time as _time @@ -167,7 +168,7 @@ def __init__(self, filepath: Union[PathLike, None] = None) -> None: """Initialize this instance with an optional filepath, from which we will initialize our data. The path is also used to write changes back using the :meth:`write` method.""" - self._path = filepath + self._path = os.fspath(filepath) if filepath is not None: self._read_from_file() # END handle filepath diff --git a/git/refs/symbolic.py b/git/refs/symbolic.py index c7db129d9..24a72257d 100644 --- a/git/refs/symbolic.py +++ b/git/refs/symbolic.py @@ -76,7 +76,7 @@ class SymbolicReference: def __init__(self, repo: "Repo", path: PathLike, check_path: bool = False) -> None: self.repo = repo - self.path = path + self.path = os.fspath(path) def __str__(self) -> str: return os.fspath(self.path) diff --git a/git/repo/fun.py b/git/repo/fun.py index 1c995c6c6..1c7cfcb04 100644 --- a/git/repo/fun.py +++ b/git/repo/fun.py @@ -62,6 +62,7 @@ def is_git_dir(d: PathLike) -> bool: clearly indicates that we don't support it. There is the unlikely danger to throw if we see directories which just look like a worktree dir, but are none. """ + d = os.fspath(d) if osp.isdir(d): if (osp.isdir(osp.join(d, "objects")) or "GIT_OBJECT_DIRECTORY" in os.environ) and osp.isdir( osp.join(d, "refs") diff --git a/git/util.py b/git/util.py index f18eb6e52..5326af9d1 100644 --- a/git/util.py +++ b/git/util.py @@ -1011,7 +1011,7 @@ class LockFile: __slots__ = ("_file_path", "_owns_lock") def __init__(self, file_path: PathLike) -> None: - self._file_path = file_path + self._file_path = os.fspath(file_path) self._owns_lock = False def __del__(self) -> None: From 086e83239388988772e21ee820c23e59533382f7 Mon Sep 17 00:00:00 2001 From: George Ogden Date: Fri, 28 Nov 2025 21:48:38 +0000 Subject: [PATCH 52/72] Fix union type conversion to path --- git/refs/log.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git/refs/log.py b/git/refs/log.py index 48bb02c60..d1cc393f4 100644 --- a/git/refs/log.py +++ b/git/refs/log.py @@ -168,7 +168,7 @@ def __init__(self, filepath: Union[PathLike, None] = None) -> None: """Initialize this instance with an optional filepath, from which we will initialize our data. The path is also used to write changes back using the :meth:`write` method.""" - self._path = os.fspath(filepath) + self._path = None if filepath is None else os.fspath(filepath) if filepath is not None: self._read_from_file() # END handle filepath From b5c834af59531456551d406eb857934e7e87f1ce Mon Sep 17 00:00:00 2001 From: George Ogden Date: Sat, 29 Nov 2025 11:49:23 +0000 Subject: [PATCH 53/72] Remve comment about skipping mypy --- .github/workflows/pythonpackage.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 9e05b3fe6..ac764d9a7 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -102,8 +102,6 @@ jobs: MYPY_FORCE_COLOR: "1" TERM: "xterm-256color" # For color: https://github.com/python/mypy/issues/13817 PYTHON_VERSION: ${{ matrix.python-version }} - # With new versions of mypy new issues might arise. This is a problem if there is - # nobody able to fix them, so we have to ignore errors until that changes. - name: Test with pytest run: | From eb15123b82dbd13f9cc88606b8a580c424335fe7 Mon Sep 17 00:00:00 2001 From: George Ogden Date: Sat, 29 Nov 2025 11:51:47 +0000 Subject: [PATCH 54/72] Use cast to allow silent getattrs --- git/util.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/git/util.py b/git/util.py index 54a5b7bd1..1f1595f1c 100644 --- a/git/util.py +++ b/git/util.py @@ -1214,9 +1214,7 @@ def __getitem__(self, index: Union[SupportsIndex, int, slice, str]) -> T_Iterabl raise ValueError("Index should be an int or str") else: try: - if not isinstance(index, str): - raise AttributeError(f"{index} is not a valid attribute") - return getattr(self, index) + return getattr(self, cast(str, index)) except AttributeError as e: raise IndexError(f"No item found with id {self._prefix}{index}") from e # END handle getattr From b3908ed04815b1d89a000fc7824a804c37906365 Mon Sep 17 00:00:00 2001 From: George Ogden <38294960+George-Ogden@users.noreply.github.com> Date: Sat, 29 Nov 2025 11:56:57 +0000 Subject: [PATCH 55/72] Use converted file path Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- git/index/util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git/index/util.py b/git/index/util.py index 231634cd6..80f0b014c 100644 --- a/git/index/util.py +++ b/git/index/util.py @@ -38,7 +38,7 @@ class TemporaryFileSwap: def __init__(self, file_path: PathLike) -> None: self.file_path = os.fspath(file_path) - dirname, basename = osp.split(file_path) + dirname, basename = osp.split(self.file_path) fd, self.tmp_file_path = tempfile.mkstemp(prefix=basename, dir=dirname) os.close(fd) with contextlib.suppress(OSError): # It may be that the source does not exist. From 50aea998641248f501735421ddc6165cbdb5d08c Mon Sep 17 00:00:00 2001 From: George Ogden <38294960+George-Ogden@users.noreply.github.com> Date: Sat, 29 Nov 2025 11:57:25 +0000 Subject: [PATCH 56/72] Remove redundant `fspath` Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- git/refs/symbolic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git/refs/symbolic.py b/git/refs/symbolic.py index 24a72257d..7cf812416 100644 --- a/git/refs/symbolic.py +++ b/git/refs/symbolic.py @@ -79,7 +79,7 @@ def __init__(self, repo: "Repo", path: PathLike, check_path: bool = False) -> No self.path = os.fspath(path) def __str__(self) -> str: - return os.fspath(self.path) + return self.path def __repr__(self) -> str: return '' % (self.__class__.__name__, self.path) From 57a3af1ddc9b03f59a3e6d3f012c6043905763c0 Mon Sep 17 00:00:00 2001 From: George Ogden <38294960+George-Ogden@users.noreply.github.com> Date: Sat, 29 Nov 2025 11:57:55 +0000 Subject: [PATCH 57/72] Remove redundant `fspath` Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- git/refs/symbolic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git/refs/symbolic.py b/git/refs/symbolic.py index 7cf812416..3cadb5061 100644 --- a/git/refs/symbolic.py +++ b/git/refs/symbolic.py @@ -103,7 +103,7 @@ def name(self) -> str: In case of symbolic references, the shortest assumable name is the path itself. """ - return os.fspath(self.path) + return self.path @property def abspath(self) -> PathLike: From df8087a2c90e25692eb8d4f09c8726fc78e21d05 Mon Sep 17 00:00:00 2001 From: George Ogden Date: Sat, 29 Nov 2025 12:24:01 +0000 Subject: [PATCH 58/72] Remove a large number of redundant fspaths --- git/index/base.py | 2 +- git/index/fun.py | 2 +- git/objects/submodule/base.py | 2 +- git/refs/symbolic.py | 4 ++-- git/repo/base.py | 6 +++--- git/util.py | 3 +-- test/test_index.py | 8 +++++--- 7 files changed, 14 insertions(+), 13 deletions(-) diff --git a/git/index/base.py b/git/index/base.py index cf7220ef8..2489949c1 100644 --- a/git/index/base.py +++ b/git/index/base.py @@ -1360,7 +1360,7 @@ def make_exc() -> GitCommandError: try: self.entries[(co_path, 0)] except KeyError: - folder = os.fspath(co_path) + folder = co_path if not folder.endswith("/"): folder += "/" for entry in self.entries.values(): diff --git a/git/index/fun.py b/git/index/fun.py index 845221c61..9832aea6b 100644 --- a/git/index/fun.py +++ b/git/index/fun.py @@ -87,7 +87,7 @@ def run_commit_hook(name: str, index: "IndexFile", *args: str) -> None: return env = os.environ.copy() - env["GIT_INDEX_FILE"] = safe_decode(os.fspath(index.path)) + env["GIT_INDEX_FILE"] = safe_decode(index.path) env["GIT_EDITOR"] = ":" cmd = [hp] try: diff --git a/git/objects/submodule/base.py b/git/objects/submodule/base.py index cc1f94c6c..ffc1d3595 100644 --- a/git/objects/submodule/base.py +++ b/git/objects/submodule/base.py @@ -1229,7 +1229,7 @@ def remove( wtd = mod.working_tree_dir del mod # Release file-handles (Windows). gc.collect() - rmtree(str(wtd)) + rmtree(wtd) # END delete tree if possible # END handle force diff --git a/git/refs/symbolic.py b/git/refs/symbolic.py index 3cadb5061..557e8f5b4 100644 --- a/git/refs/symbolic.py +++ b/git/refs/symbolic.py @@ -706,7 +706,7 @@ def _create( if not force and os.path.isfile(abs_ref_path): target_data = str(target) if isinstance(target, SymbolicReference): - target_data = os.fspath(target.path) + target_data = target.path if not resolve: target_data = "ref: " + target_data with open(abs_ref_path, "rb") as fd: @@ -931,4 +931,4 @@ def from_path(cls: Type[T_References], repo: "Repo", path: PathLike) -> T_Refere def is_remote(self) -> bool: """:return: True if this symbolic reference points to a remote branch""" - return os.fspath(self.path).startswith(self._remote_common_path_default + "/") + return self.path.startswith(self._remote_common_path_default + "/") diff --git a/git/repo/base.py b/git/repo/base.py index 2fe18f48c..e721aea40 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -126,7 +126,7 @@ class Repo: working_dir: PathLike """The working directory of the git command.""" - _working_tree_dir: Optional[PathLike] = None + _working_tree_dir: Optional[str] = None git_dir: PathLike """The ``.git`` repository directory.""" @@ -306,7 +306,7 @@ def __init__( self._working_tree_dir = None # END working dir handling - self.working_dir: PathLike = self._working_tree_dir or self.common_dir + self.working_dir: str = self._working_tree_dir or self.common_dir self.git = self.GitCommandWrapperType(self.working_dir) # Special handling, in special times. @@ -366,7 +366,7 @@ def description(self, descr: str) -> None: fp.write((descr + "\n").encode(defenc)) @property - def working_tree_dir(self) -> Optional[PathLike]: + def working_tree_dir(self) -> Optional[str]: """ :return: The working tree directory of our git repository. diff --git a/git/util.py b/git/util.py index 5326af9d1..94452ab17 100644 --- a/git/util.py +++ b/git/util.py @@ -397,8 +397,7 @@ def _cygexpath(drive: Optional[str], path: str) -> str: p = cygpath(p) elif drive: p = "/proc/cygdrive/%s/%s" % (drive.lower(), p) - p_str = os.fspath(p) # ensure it is a str and not AnyPath - return p_str.replace("\\", "/") + return p.replace("\\", "/") _cygpath_parsers: Tuple[Tuple[Pattern[str], Callable, bool], ...] = ( diff --git a/test/test_index.py b/test/test_index.py index c1db4166b..bca353748 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -579,12 +579,14 @@ def mixed_iterator(): if type_id == 0: # path (str) yield entry.path elif type_id == 1: # path (PathLike) + yield Path(entry.path) + elif type_id == 2: # path mock (PathLike) yield PathLikeMock(entry.path) - elif type_id == 2: # blob + elif type_id == 3: # blob yield Blob(rw_repo, entry.binsha, entry.mode, entry.path) - elif type_id == 3: # BaseIndexEntry + elif type_id == 4: # BaseIndexEntry yield BaseIndexEntry(entry[:4]) - elif type_id == 4: # IndexEntry + elif type_id == 5: # IndexEntry yield entry else: raise AssertionError("Invalid Type") From 17225612969dd68cdfa6dc6b7d5ea8d1956da533 Mon Sep 17 00:00:00 2001 From: George Ogden Date: Sat, 29 Nov 2025 13:31:22 +0000 Subject: [PATCH 59/72] Remove redundant str call --- git/repo/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git/repo/base.py b/git/repo/base.py index e721aea40..72869c562 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -1386,7 +1386,7 @@ def _clone( proc = git.clone( multi, "--", - Git.polish_url(str(url)), + Git.polish_url(url), clone_path, with_extended_output=True, as_process=True, From 921ca8a1ddcfe84fce3d6f7f9b50a421cbee9012 Mon Sep 17 00:00:00 2001 From: George Ogden Date: Sat, 29 Nov 2025 13:37:12 +0000 Subject: [PATCH 60/72] Limit mypy version due to Cygwin errors --- test-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-requirements.txt b/test-requirements.txt index 75e9e81fa..552496ae4 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,7 +1,7 @@ coverage[toml] ddt >= 1.1.1, != 1.4.3 mock ; python_version < "3.8" -mypy +mypy<1.19.0 pre-commit pytest >= 7.3.1 pytest-cov From 12e15ba71a49652af33a8bb1556a24a19dd15c91 Mon Sep 17 00:00:00 2001 From: George Ogden Date: Sat, 29 Nov 2025 21:27:12 +0000 Subject: [PATCH 61/72] Validate every fspath with tests --- git/index/base.py | 9 ++++----- git/index/fun.py | 4 ++-- git/index/typ.py | 4 ++-- git/index/util.py | 6 +++--- git/objects/submodule/base.py | 10 +++++----- git/refs/head.py | 2 -- git/refs/log.py | 3 +-- git/refs/symbolic.py | 15 ++++++++------- git/repo/base.py | 14 ++++++++------ git/repo/fun.py | 1 - git/util.py | 13 +++++++------ test/test_index.py | 8 +++++--- test/test_refs.py | 21 ++++++++++++++++++++- test/test_repo.py | 10 ++++++++++ test/test_submodule.py | 6 +++++- 15 files changed, 80 insertions(+), 46 deletions(-) diff --git a/git/index/base.py b/git/index/base.py index 2489949c1..43def2f06 100644 --- a/git/index/base.py +++ b/git/index/base.py @@ -434,7 +434,7 @@ def raise_exc(e: Exception) -> NoReturn: # characters. if abs_path not in resolved_paths: for f in self._iter_expand_paths(glob.glob(abs_path)): - yield os.fspath(f).replace(rs, "") + yield str(f).replace(rs, "") continue # END glob handling try: @@ -569,7 +569,7 @@ def resolve_blobs(self, iter_blobs: Iterator[Blob]) -> "IndexFile": for blob in iter_blobs: stage_null_key = (blob.path, 0) if stage_null_key in self.entries: - raise ValueError("Path %r already exists at stage 0" % os.fspath(blob.path)) + raise ValueError("Path %r already exists at stage 0" % str(blob.path)) # END assert blob is not stage 0 already # Delete all possible stages. @@ -652,7 +652,6 @@ def _to_relative_path(self, path: PathLike) -> PathLike: :raise ValueError: """ - path = os.fspath(path) if not osp.isabs(path): return path if self.repo.bare: @@ -660,7 +659,7 @@ def _to_relative_path(self, path: PathLike) -> PathLike: if not osp.normpath(path).startswith(os.fspath(self.repo.working_tree_dir)): raise ValueError("Absolute path %r is not in git repository at %r" % (path, self.repo.working_tree_dir)) result = os.path.relpath(path, self.repo.working_tree_dir) - if path.endswith(os.sep) and not result.endswith(os.sep): + if os.fspath(path).endswith(os.sep) and not result.endswith(os.sep): result += os.sep return result @@ -1364,7 +1363,7 @@ def make_exc() -> GitCommandError: if not folder.endswith("/"): folder += "/" for entry in self.entries.values(): - if os.fspath(entry.path).startswith(folder): + if entry.path.startswith(folder): p = entry.path self._write_path_to_stdin(proc, p, p, make_exc, fprogress, read_from_stdout=False) checked_out_files.append(p) diff --git a/git/index/fun.py b/git/index/fun.py index 9832aea6b..629c19b1e 100644 --- a/git/index/fun.py +++ b/git/index/fun.py @@ -87,7 +87,7 @@ def run_commit_hook(name: str, index: "IndexFile", *args: str) -> None: return env = os.environ.copy() - env["GIT_INDEX_FILE"] = safe_decode(index.path) + env["GIT_INDEX_FILE"] = safe_decode(os.fspath(index.path)) env["GIT_EDITOR"] = ":" cmd = [hp] try: @@ -167,7 +167,7 @@ def write_cache( beginoffset = tell() write(entry.ctime_bytes) # ctime write(entry.mtime_bytes) # mtime - path_str = os.fspath(entry.path) + path_str = str(entry.path) path: bytes = force_bytes(path_str, encoding=defenc) plen = len(path) & CE_NAMEMASK # Path length assert plen == len(path), "Path %s too long to fit into index" % entry.path diff --git a/git/index/typ.py b/git/index/typ.py index 8273e5895..927633a9f 100644 --- a/git/index/typ.py +++ b/git/index/typ.py @@ -58,9 +58,9 @@ def __init__(self, paths: Sequence[PathLike]) -> None: def __call__(self, stage_blob: Tuple[StageType, Blob]) -> bool: blob_pathlike: PathLike = stage_blob[1].path - blob_path = Path(blob_pathlike) + blob_path: Path = blob_pathlike if isinstance(blob_pathlike, Path) else Path(blob_pathlike) for pathlike in self.paths: - path = Path(pathlike) + path: Path = pathlike if isinstance(pathlike, Path) else Path(pathlike) # TODO: Change to use `PosixPath.is_relative_to` once Python 3.8 is no # longer supported. filter_parts = path.parts diff --git a/git/index/util.py b/git/index/util.py index 80f0b014c..15eba0052 100644 --- a/git/index/util.py +++ b/git/index/util.py @@ -37,8 +37,8 @@ class TemporaryFileSwap: __slots__ = ("file_path", "tmp_file_path") def __init__(self, file_path: PathLike) -> None: - self.file_path = os.fspath(file_path) - dirname, basename = osp.split(self.file_path) + self.file_path = file_path + dirname, basename = osp.split(file_path) fd, self.tmp_file_path = tempfile.mkstemp(prefix=basename, dir=dirname) os.close(fd) with contextlib.suppress(OSError): # It may be that the source does not exist. @@ -106,7 +106,7 @@ def git_working_dir(func: Callable[..., _T]) -> Callable[..., _T]: @wraps(func) def set_git_working_dir(self: "IndexFile", *args: Any, **kwargs: Any) -> _T: cur_wd = os.getcwd() - os.chdir(os.fspath(self.repo.working_tree_dir)) + os.chdir(self.repo.working_tree_dir) try: return func(self, *args, **kwargs) finally: diff --git a/git/objects/submodule/base.py b/git/objects/submodule/base.py index ca6253883..36ec7c538 100644 --- a/git/objects/submodule/base.py +++ b/git/objects/submodule/base.py @@ -352,7 +352,7 @@ def _clone_repo( module_abspath_dir = osp.dirname(module_abspath) if not osp.isdir(module_abspath_dir): os.makedirs(module_abspath_dir) - module_checkout_path = osp.join(os.fspath(repo.working_tree_dir), path) + module_checkout_path = osp.join(repo.working_tree_dir, path) if url.startswith("../"): remote_name = cast("RemoteReference", repo.active_branch.tracking_branch()).remote_name @@ -1016,7 +1016,7 @@ def move(self, module_path: PathLike, configuration: bool = True, module: bool = return self # END handle no change - module_checkout_abspath = join_path_native(os.fspath(self.repo.working_tree_dir), module_checkout_path) + module_checkout_abspath = join_path_native(str(self.repo.working_tree_dir), module_checkout_path) if osp.isfile(module_checkout_abspath): raise ValueError("Cannot move repository onto a file: %s" % module_checkout_abspath) # END handle target files @@ -1229,7 +1229,7 @@ def remove( wtd = mod.working_tree_dir del mod # Release file-handles (Windows). gc.collect() - rmtree(wtd) + rmtree(str(wtd)) # END delete tree if possible # END handle force @@ -1313,7 +1313,7 @@ def set_parent_commit(self, commit: Union[Commit_ish, str, None], check: bool = # If check is False, we might see a parent-commit that doesn't even contain the # submodule anymore. in that case, mark our sha as being NULL. try: - self.binsha = pctree[os.fspath(self.path)].binsha + self.binsha = pctree[str(self.path)].binsha except KeyError: self.binsha = self.NULL_BIN_SHA @@ -1395,7 +1395,7 @@ def rename(self, new_name: str) -> "Submodule": destination_module_abspath = self._module_abspath(self.repo, self.path, new_name) source_dir = mod.git_dir # Let's be sure the submodule name is not so obviously tied to a directory. - if os.fspath(destination_module_abspath).startswith(os.fspath(mod.git_dir)): + if str(destination_module_abspath).startswith(str(mod.git_dir)): tmp_dir = self._module_abspath(self.repo, self.path, str(uuid.uuid4())) os.renames(source_dir, tmp_dir) source_dir = tmp_dir diff --git a/git/refs/head.py b/git/refs/head.py index 9959b889b..3c43993e7 100644 --- a/git/refs/head.py +++ b/git/refs/head.py @@ -8,7 +8,6 @@ __all__ = ["HEAD", "Head"] -import os from git.config import GitConfigParser, SectionConstraint from git.exc import GitCommandError from git.util import join_path @@ -45,7 +44,6 @@ class HEAD(SymbolicReference): __slots__ = () def __init__(self, repo: "Repo", path: PathLike = _HEAD_NAME) -> None: - path = os.fspath(path) if path != self._HEAD_NAME: raise ValueError("HEAD instance must point to %r, got %r" % (self._HEAD_NAME, path)) super().__init__(repo, path) diff --git a/git/refs/log.py b/git/refs/log.py index a5dfa6d20..4751cff99 100644 --- a/git/refs/log.py +++ b/git/refs/log.py @@ -4,7 +4,6 @@ __all__ = ["RefLog", "RefLogEntry"] from mmap import mmap -import os import os.path as osp import re import time as _time @@ -168,7 +167,7 @@ def __init__(self, filepath: Union[PathLike, None] = None) -> None: """Initialize this instance with an optional filepath, from which we will initialize our data. The path is also used to write changes back using the :meth:`write` method.""" - self._path = None if filepath is None else os.fspath(filepath) + self._path = filepath if filepath is not None: self._read_from_file() # END handle filepath diff --git a/git/refs/symbolic.py b/git/refs/symbolic.py index 77e4b98f2..a422fb78c 100644 --- a/git/refs/symbolic.py +++ b/git/refs/symbolic.py @@ -4,6 +4,7 @@ __all__ = ["SymbolicReference"] import os +from pathlib import Path from gitdb.exc import BadName, BadObject @@ -76,10 +77,10 @@ class SymbolicReference: def __init__(self, repo: "Repo", path: PathLike, check_path: bool = False) -> None: self.repo = repo - self.path = os.fspath(path) + self.path: PathLike = path def __str__(self) -> str: - return self.path + return os.fspath(self.path) def __repr__(self) -> str: return '' % (self.__class__.__name__, self.path) @@ -103,7 +104,7 @@ def name(self) -> str: In case of symbolic references, the shortest assumable name is the path itself. """ - return self.path + return os.fspath(self.path) @property def abspath(self) -> PathLike: @@ -212,7 +213,7 @@ def _check_ref_name_valid(ref_path: PathLike) -> None: raise ValueError(f"Invalid reference '{ref_path}': references cannot end with a forward slash (/)") elif previous == "@" and one_before_previous is None: raise ValueError(f"Invalid reference '{ref_path}': references cannot be '@'") - elif any(component.endswith(".lock") for component in os.fspath(ref_path).split("/")): + elif any(component.endswith(".lock") for component in Path(ref_path).parts): raise ValueError( f"Invalid reference '{ref_path}': references cannot have slash-separated components that end with" " '.lock'" @@ -235,7 +236,7 @@ def _get_ref_info_helper( tokens: Union[None, List[str], Tuple[str, str]] = None repodir = _git_dir(repo, ref_path) try: - with open(os.path.join(repodir, os.fspath(ref_path)), "rt", encoding="UTF-8") as fp: + with open(os.path.join(repodir, ref_path), "rt", encoding="UTF-8") as fp: value = fp.read().rstrip() # Don't only split on spaces, but on whitespace, which allows to parse lines like: # 60b64ef992065e2600bfef6187a97f92398a9144 branch 'master' of git-server:/path/to/repo @@ -706,7 +707,7 @@ def _create( if not force and os.path.isfile(abs_ref_path): target_data = str(target) if isinstance(target, SymbolicReference): - target_data = target.path + target_data = os.fspath(target.path) if not resolve: target_data = "ref: " + target_data with open(abs_ref_path, "rb") as fd: @@ -930,4 +931,4 @@ def from_path(cls: Type[T_References], repo: "Repo", path: PathLike) -> T_Refere def is_remote(self) -> bool: """:return: True if this symbolic reference points to a remote branch""" - return self.path.startswith(self._remote_common_path_default + "/") + return os.fspath(self.path).startswith(self._remote_common_path_default + "/") diff --git a/git/repo/base.py b/git/repo/base.py index d1af79620..1f543cc57 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -126,7 +126,8 @@ class Repo: working_dir: PathLike """The working directory of the git command.""" - _working_tree_dir: Optional[str] = None + # stored as string for easier processing, but annotated as path for clearer intention + _working_tree_dir: Optional[PathLike] = None git_dir: PathLike """The ``.git`` repository directory.""" @@ -215,13 +216,13 @@ def __init__( epath = path or os.getenv("GIT_DIR") if not epath: epath = os.getcwd() + epath = os.fspath(epath) if Git.is_cygwin(): # Given how the tests are written, this seems more likely to catch Cygwin # git used from Windows than Windows git used from Cygwin. Therefore # changing to Cygwin-style paths is the relevant operation. - epath = cygpath(os.fspath(epath)) + epath = cygpath(epath) - epath = os.fspath(epath) if expand_vars and re.search(self.re_envvars, epath): warnings.warn( "The use of environment variables in paths is deprecated" @@ -306,7 +307,7 @@ def __init__( self._working_tree_dir = None # END working dir handling - self.working_dir: str = self._working_tree_dir or self.common_dir + self.working_dir: PathLike = self._working_tree_dir or self.common_dir self.git = self.GitCommandWrapperType(self.working_dir) # Special handling, in special times. @@ -366,7 +367,7 @@ def description(self, descr: str) -> None: fp.write((descr + "\n").encode(defenc)) @property - def working_tree_dir(self) -> Optional[str]: + def working_tree_dir(self) -> Optional[PathLike]: """ :return: The working tree directory of our git repository. @@ -554,7 +555,7 @@ def tag(self, path: PathLike) -> TagReference: @staticmethod def _to_full_tag_path(path: PathLike) -> str: - path_str = os.fspath(path) + path_str = str(path) if path_str.startswith(TagReference._common_path_default + "/"): return path_str if path_str.startswith(TagReference._common_default + "/"): @@ -1355,6 +1356,7 @@ def _clone( ) -> "Repo": odbt = kwargs.pop("odbt", odb_default_type) + # url may be a path and this has no effect if it is a string url = os.fspath(url) path = os.fspath(path) diff --git a/git/repo/fun.py b/git/repo/fun.py index 49097c373..3f00e60ea 100644 --- a/git/repo/fun.py +++ b/git/repo/fun.py @@ -62,7 +62,6 @@ def is_git_dir(d: PathLike) -> bool: clearly indicates that we don't support it. There is the unlikely danger to throw if we see directories which just look like a worktree dir, but are none. """ - d = os.fspath(d) if osp.isdir(d): if (osp.isdir(osp.join(d, "objects")) or "GIT_OBJECT_DIRECTORY" in os.environ) and osp.isdir( osp.join(d, "refs") diff --git a/git/util.py b/git/util.py index e82ccdcfa..c3ffdd62b 100644 --- a/git/util.py +++ b/git/util.py @@ -36,7 +36,7 @@ import logging import os import os.path as osp -import pathlib +from pathlib import Path import platform import re import shutil @@ -397,7 +397,8 @@ def _cygexpath(drive: Optional[str], path: str) -> str: p = cygpath(p) elif drive: p = "/proc/cygdrive/%s/%s" % (drive.lower(), p) - return p.replace("\\", "/") + p_str = os.fspath(p) # ensure it is a str and not AnyPath + return p_str.replace("\\", "/") _cygpath_parsers: Tuple[Tuple[Pattern[str], Callable, bool], ...] = ( @@ -464,7 +465,7 @@ def _is_cygwin_git(git_executable: str) -> bool: # Just a name given, not a real path. uname_cmd = osp.join(git_dir, "uname") - if not (pathlib.Path(uname_cmd).is_file() and os.access(uname_cmd, os.X_OK)): + if not (Path(uname_cmd).is_file() and os.access(uname_cmd, os.X_OK)): _logger.debug(f"Failed checking if running in CYGWIN: {uname_cmd} is not an executable") _is_cygwin_cache[git_executable] = is_cygwin return is_cygwin @@ -496,7 +497,7 @@ def is_cygwin_git(git_executable: Union[None, PathLike]) -> bool: elif git_executable is None: return False else: - return _is_cygwin_git(os.fspath(git_executable)) + return _is_cygwin_git(str(git_executable)) def get_user_id() -> str: @@ -522,7 +523,7 @@ def expand_path(p: PathLike, expand_vars: bool = ...) -> str: def expand_path(p: Union[None, PathLike], expand_vars: bool = True) -> Optional[PathLike]: - if isinstance(p, pathlib.Path): + if isinstance(p, Path): return p.resolve() try: p = osp.expanduser(p) # type: ignore[arg-type] @@ -1010,7 +1011,7 @@ class LockFile: __slots__ = ("_file_path", "_owns_lock") def __init__(self, file_path: PathLike) -> None: - self._file_path = os.fspath(file_path) + self._file_path = file_path self._owns_lock = False def __del__(self) -> None: diff --git a/test/test_index.py b/test/test_index.py index bca353748..33490f907 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -582,11 +582,13 @@ def mixed_iterator(): yield Path(entry.path) elif type_id == 2: # path mock (PathLike) yield PathLikeMock(entry.path) - elif type_id == 3: # blob + elif type_id == 3: # path mock in a blob yield Blob(rw_repo, entry.binsha, entry.mode, entry.path) - elif type_id == 4: # BaseIndexEntry + elif type_id == 4: # blob + yield Blob(rw_repo, entry.binsha, entry.mode, entry.path) + elif type_id == 5: # BaseIndexEntry yield BaseIndexEntry(entry[:4]) - elif type_id == 5: # IndexEntry + elif type_id == 6: # IndexEntry yield entry else: raise AssertionError("Invalid Type") diff --git a/test/test_refs.py b/test/test_refs.py index 08096e69e..329515807 100644 --- a/test/test_refs.py +++ b/test/test_refs.py @@ -25,7 +25,7 @@ import git.refs as refs from git.util import Actor -from test.lib import TestBase, with_rw_repo +from test.lib import TestBase, with_rw_repo, PathLikeMock class TestRefs(TestBase): @@ -43,6 +43,25 @@ def test_from_path(self): self.assertRaises(ValueError, TagReference, self.rorepo, "refs/invalid/tag") # Works without path check. TagReference(self.rorepo, "refs/invalid/tag", check_path=False) + # Check remoteness + assert Reference(self.rorepo, "refs/remotes/origin").is_remote() + + def test_from_pathlike(self): + # Should be able to create any reference directly. + for ref_type in (Reference, Head, TagReference, RemoteReference): + for name in ("rela_name", "path/rela_name"): + full_path = ref_type.to_full_path(PathLikeMock(name)) + instance = ref_type.from_path(self.rorepo, PathLikeMock(full_path)) + assert isinstance(instance, ref_type) + # END for each name + # END for each type + + # Invalid path. + self.assertRaises(ValueError, TagReference, self.rorepo, "refs/invalid/tag") + # Works without path check. + TagReference(self.rorepo, PathLikeMock("refs/invalid/tag"), check_path=False) + # Check remoteness + assert Reference(self.rorepo, PathLikeMock("refs/remotes/origin")).is_remote() def test_tag_base(self): tag_object_refs = [] diff --git a/test/test_repo.py b/test/test_repo.py index f089bf6b8..2a92c2523 100644 --- a/test/test_repo.py +++ b/test/test_repo.py @@ -15,6 +15,7 @@ import sys import tempfile from unittest import mock +from pathlib import Path import pytest @@ -369,6 +370,15 @@ def test_is_dirty_with_path(self, rwrepo): assert rwrepo.is_dirty(path="doc") is False assert rwrepo.is_dirty(untracked_files=True, path="doc") is True + @with_rw_repo("HEAD") + def test_is_dirty_with_pathlib_and_pathlike(self, rwrepo): + with open(osp.join(rwrepo.working_dir, "git", "util.py"), "at") as f: + f.write("junk") + assert rwrepo.is_dirty(path=Path("git")) is True + assert rwrepo.is_dirty(path=PathLikeMock("git")) is True + assert rwrepo.is_dirty(path=Path("doc")) is False + assert rwrepo.is_dirty(path=PathLikeMock("doc")) is False + def test_head(self): self.assertEqual(self.rorepo.head.reference.object, self.rorepo.active_branch.object) diff --git a/test/test_submodule.py b/test/test_submodule.py index edff064c4..2bf0940c9 100644 --- a/test/test_submodule.py +++ b/test/test_submodule.py @@ -28,7 +28,7 @@ from git.repo.fun import find_submodule_git_dir, touch from git.util import HIDE_WINDOWS_KNOWN_ERRORS, join_path_native, to_native_path_linux -from test.lib import TestBase, with_rw_directory, with_rw_repo +from test.lib import TestBase, with_rw_directory, with_rw_repo, PathLikeMock @contextlib.contextmanager @@ -175,6 +175,10 @@ def _do_base_tests(self, rwrepo): sma = Submodule.add(rwrepo, sm.name, sm.path) assert sma.path == sm.path + # Adding existing as pathlike + sma = Submodule.add(rwrepo, sm.name, PathLikeMock(sm.path)) + assert sma.path == sm.path + # No url and no module at path fails. self.assertRaises(ValueError, Submodule.add, rwrepo, "newsubm", "pathtorepo", url=None) From 8434967c010cc108d3a8a01d1db6d0c3971b0048 Mon Sep 17 00:00:00 2001 From: George Ogden Date: Sat, 29 Nov 2025 21:46:54 +0000 Subject: [PATCH 62/72] Fix type hints --- git/index/base.py | 10 +++++----- git/index/util.py | 4 ++-- git/objects/submodule/base.py | 2 +- git/refs/symbolic.py | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/git/index/base.py b/git/index/base.py index 43def2f06..93de7933c 100644 --- a/git/index/base.py +++ b/git/index/base.py @@ -404,7 +404,7 @@ def _iter_expand_paths(self: "IndexFile", paths: Sequence[PathLike]) -> Iterator def raise_exc(e: Exception) -> NoReturn: raise e - r = os.fspath(self.repo.working_tree_dir) + r = str(self.repo.working_tree_dir) rs = r + os.sep for path in paths: abs_path = os.fspath(path) @@ -656,7 +656,7 @@ def _to_relative_path(self, path: PathLike) -> PathLike: return path if self.repo.bare: raise InvalidGitRepositoryError("require non-bare repository") - if not osp.normpath(path).startswith(os.fspath(self.repo.working_tree_dir)): + if not osp.normpath(path).startswith(str(self.repo.working_tree_dir)): raise ValueError("Absolute path %r is not in git repository at %r" % (path, self.repo.working_tree_dir)) result = os.path.relpath(path, self.repo.working_tree_dir) if os.fspath(path).endswith(os.sep) and not result.endswith(os.sep): @@ -731,7 +731,7 @@ def _entries_for_paths( for path in paths: if osp.isabs(path): abspath = path - gitrelative_path = path[len(os.fspath(self.repo.working_tree_dir)) + 1 :] + gitrelative_path = path[len(str(self.repo.working_tree_dir)) + 1 :] else: gitrelative_path = path if self.repo.working_tree_dir: @@ -1036,7 +1036,7 @@ def remove( args.append("--") # Preprocess paths. - paths = self._items_to_rela_paths(items) + paths = list(map(os.fspath, self._items_to_rela_paths(items))) # type: ignore[arg-type] removed_paths = self.repo.git.rm(args, paths, **kwargs).splitlines() # Process output to gain proper paths. @@ -1363,7 +1363,7 @@ def make_exc() -> GitCommandError: if not folder.endswith("/"): folder += "/" for entry in self.entries.values(): - if entry.path.startswith(folder): + if os.fspath(entry.path).startswith(folder): p = entry.path self._write_path_to_stdin(proc, p, p, make_exc, fprogress, read_from_stdout=False) checked_out_files.append(p) diff --git a/git/index/util.py b/git/index/util.py index 15eba0052..982a5afb7 100644 --- a/git/index/util.py +++ b/git/index/util.py @@ -15,7 +15,7 @@ # typing ---------------------------------------------------------------------- -from typing import Any, Callable, TYPE_CHECKING, Optional, Type +from typing import Any, Callable, TYPE_CHECKING, Optional, Type, cast from git.types import Literal, PathLike, _T @@ -106,7 +106,7 @@ def git_working_dir(func: Callable[..., _T]) -> Callable[..., _T]: @wraps(func) def set_git_working_dir(self: "IndexFile", *args: Any, **kwargs: Any) -> _T: cur_wd = os.getcwd() - os.chdir(self.repo.working_tree_dir) + os.chdir(cast(PathLike, self.repo.working_tree_dir)) try: return func(self, *args, **kwargs) finally: diff --git a/git/objects/submodule/base.py b/git/objects/submodule/base.py index 36ec7c538..d183672db 100644 --- a/git/objects/submodule/base.py +++ b/git/objects/submodule/base.py @@ -352,7 +352,7 @@ def _clone_repo( module_abspath_dir = osp.dirname(module_abspath) if not osp.isdir(module_abspath_dir): os.makedirs(module_abspath_dir) - module_checkout_path = osp.join(repo.working_tree_dir, path) + module_checkout_path = osp.join(repo.working_tree_dir, path) # type: ignore[arg-type] if url.startswith("../"): remote_name = cast("RemoteReference", repo.active_branch.tracking_branch()).remote_name diff --git a/git/refs/symbolic.py b/git/refs/symbolic.py index a422fb78c..99af4f57c 100644 --- a/git/refs/symbolic.py +++ b/git/refs/symbolic.py @@ -236,7 +236,7 @@ def _get_ref_info_helper( tokens: Union[None, List[str], Tuple[str, str]] = None repodir = _git_dir(repo, ref_path) try: - with open(os.path.join(repodir, ref_path), "rt", encoding="UTF-8") as fp: + with open(os.path.join(repodir, ref_path), "rt", encoding="UTF-8") as fp: # type: ignore[arg-type] value = fp.read().rstrip() # Don't only split on spaces, but on whitespace, which allows to parse lines like: # 60b64ef992065e2600bfef6187a97f92398a9144 branch 'master' of git-server:/path/to/repo From 171062655e24b6a6ca1a3beab3c7679278350ab5 Mon Sep 17 00:00:00 2001 From: George Ogden Date: Mon, 1 Dec 2025 16:44:37 +0000 Subject: [PATCH 63/72] Add tests with non-ascii characters --- test/test_clone.py | 7 +++++++ test/test_index.py | 9 +++++++++ 2 files changed, 16 insertions(+) diff --git a/test/test_clone.py b/test/test_clone.py index 143a3b51f..2d00a9e79 100644 --- a/test/test_clone.py +++ b/test/test_clone.py @@ -52,6 +52,13 @@ def test_clone_from_pathlike(self, rw_dir): original_repo = Repo.init(osp.join(rw_dir, "repo")) Repo.clone_from(PathLikeMock(original_repo.git_dir), PathLikeMock(os.path.join(rw_dir, "clone_pathlike"))) + @with_rw_directory + def test_clone_from_pathlike_unicode_repr(self, rw_dir): + original_repo = Repo.init(osp.join(rw_dir, "repo-áēñöưḩ̣")) + Repo.clone_from( + PathLikeMock(original_repo.git_dir), PathLikeMock(os.path.join(rw_dir, "clone_pathlike-áēñöưḩ̣")) + ) + @with_rw_directory def test_clone_from_pathlib_withConfig(self, rw_dir): original_repo = Repo.init(osp.join(rw_dir, "repo")) diff --git a/test/test_index.py b/test/test_index.py index 33490f907..dcdc3b56d 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -1212,6 +1212,15 @@ def test_index_add_pathlike(self, rw_repo): rw_repo.index.add(PathLikeMock(str(file))) + @with_rw_repo("HEAD") + def test_index_add_pathlike_unicode(self, rw_repo): + git_dir = Path(rw_repo.git_dir) + + file = git_dir / "file-áēñöưḩ̣.txt" + file.touch() + + rw_repo.index.add(PathLikeMock(str(file))) + @with_rw_repo("HEAD") def test_index_add_non_normalized_path(self, rw_repo): git_dir = Path(rw_repo.git_dir) From 0cb55fb4adca4f2b26767e85ef8652ef13b834a1 Mon Sep 17 00:00:00 2001 From: George Ogden Date: Fri, 5 Dec 2025 18:12:07 +0000 Subject: [PATCH 64/72] Revert "Add tests with non-ascii characters" This reverts commit 171062655e24b6a6ca1a3beab3c7679278350ab5. --- test/test_clone.py | 7 ------- test/test_index.py | 9 --------- 2 files changed, 16 deletions(-) diff --git a/test/test_clone.py b/test/test_clone.py index 2d00a9e79..143a3b51f 100644 --- a/test/test_clone.py +++ b/test/test_clone.py @@ -52,13 +52,6 @@ def test_clone_from_pathlike(self, rw_dir): original_repo = Repo.init(osp.join(rw_dir, "repo")) Repo.clone_from(PathLikeMock(original_repo.git_dir), PathLikeMock(os.path.join(rw_dir, "clone_pathlike"))) - @with_rw_directory - def test_clone_from_pathlike_unicode_repr(self, rw_dir): - original_repo = Repo.init(osp.join(rw_dir, "repo-áēñöưḩ̣")) - Repo.clone_from( - PathLikeMock(original_repo.git_dir), PathLikeMock(os.path.join(rw_dir, "clone_pathlike-áēñöưḩ̣")) - ) - @with_rw_directory def test_clone_from_pathlib_withConfig(self, rw_dir): original_repo = Repo.init(osp.join(rw_dir, "repo")) diff --git a/test/test_index.py b/test/test_index.py index dcdc3b56d..33490f907 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -1212,15 +1212,6 @@ def test_index_add_pathlike(self, rw_repo): rw_repo.index.add(PathLikeMock(str(file))) - @with_rw_repo("HEAD") - def test_index_add_pathlike_unicode(self, rw_repo): - git_dir = Path(rw_repo.git_dir) - - file = git_dir / "file-áēñöưḩ̣.txt" - file.touch() - - rw_repo.index.add(PathLikeMock(str(file))) - @with_rw_repo("HEAD") def test_index_add_non_normalized_path(self, rw_repo): git_dir = Path(rw_repo.git_dir) From f738029ab05fe8356022248e68f9119c46b2f1e5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Dec 2025 13:06:22 +0000 Subject: [PATCH 65/72] Bump git/ext/gitdb from `65321a2` to `4c63ee6` Bumps [git/ext/gitdb](https://github.com/gitpython-developers/gitdb) from `65321a2` to `4c63ee6`. - [Release notes](https://github.com/gitpython-developers/gitdb/releases) - [Commits](https://github.com/gitpython-developers/gitdb/compare/65321a28b586df60b9d1508228e2f53a35f938eb...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 65321a28b..4c63ee663 160000 --- a/git/ext/gitdb +++ b/git/ext/gitdb @@ -1 +1 @@ -Subproject commit 65321a28b586df60b9d1508228e2f53a35f938eb +Subproject commit 4c63ee6636a6a3370f58b05d0bd19fec2f16dd5a From 9fa28ae108dc39cfb13282cd18d4251d0118dd52 Mon Sep 17 00:00:00 2001 From: George Ogden Date: Fri, 12 Dec 2025 21:41:25 +0000 Subject: [PATCH 66/72] Add failing tests for joining paths --- test/test_tree.py | 58 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/test/test_tree.py b/test/test_tree.py index 7ba93bd36..dafd32847 100644 --- a/test/test_tree.py +++ b/test/test_tree.py @@ -8,10 +8,14 @@ from pathlib import Path import subprocess +import pytest + from git.objects import Blob, Tree +from git.repo import Repo from git.util import cwd from test.lib import TestBase, with_rw_directory +from .lib.helper import PathLikeMock, with_rw_repo class TestTree(TestBase): @@ -161,3 +165,57 @@ def lib_folder(t, _d): assert root[item.path] == item == root / item.path # END for each item assert found_slash + + @with_rw_repo("0.3.2.1") + def test_repo_lookup_string_path(self, rw_repo): + repo = Repo(rw_repo.git_dir) + blob = repo.tree() / ".gitignore" + assert isinstance(blob, Blob) + assert blob.hexsha == "787b3d442a113b78e343deb585ab5531eb7187fa" + + @with_rw_repo("0.3.2.1") + def test_repo_lookup_pathlike_path(self, rw_repo): + repo = Repo(rw_repo.git_dir) + blob = repo.tree() / PathLikeMock(".gitignore") + assert isinstance(blob, Blob) + assert blob.hexsha == "787b3d442a113b78e343deb585ab5531eb7187fa" + + @with_rw_repo("0.3.2.1") + def test_repo_lookup_invalid_string_path(self, rw_repo): + repo = Repo(rw_repo.git_dir) + with pytest.raises(KeyError): + repo.tree() / "doesnotexist" + + @with_rw_repo("0.3.2.1") + def test_repo_lookup_invalid_pathlike_path(self, rw_repo): + repo = Repo(rw_repo.git_dir) + with pytest.raises(KeyError): + repo.tree() / PathLikeMock("doesnotexist") + + @with_rw_repo("0.3.2.1") + def test_repo_lookup_nested_string_path(self, rw_repo): + repo = Repo(rw_repo.git_dir) + blob = repo.tree() / "git/__init__.py" + assert isinstance(blob, Blob) + assert blob.hexsha == "d87dcbdbb65d2782e14eea27e7f833a209c052f3" + + @with_rw_repo("0.3.2.1") + def test_repo_lookup_nested_pathlike_path(self, rw_repo): + repo = Repo(rw_repo.git_dir) + blob = repo.tree() / PathLikeMock("git/__init__.py") + assert isinstance(blob, Blob) + assert blob.hexsha == "d87dcbdbb65d2782e14eea27e7f833a209c052f3" + + @with_rw_repo("0.3.2.1") + def test_repo_lookup_folder_string_path(self, rw_repo): + repo = Repo(rw_repo.git_dir) + blob = repo.tree() / "git" + assert isinstance(blob, Tree) + assert blob.hexsha == "ec8ae429156d65afde4bbb3455570193b56f0977" + + @with_rw_repo("0.3.2.1") + def test_repo_lookup_folder_pathlike_path(self, rw_repo): + repo = Repo(rw_repo.git_dir) + blob = repo.tree() / PathLikeMock("git") + assert isinstance(blob, Tree) + assert blob.hexsha == "ec8ae429156d65afde4bbb3455570193b56f0977" From 88e26141c738f6ac3beb1a433039611f88c2c30d Mon Sep 17 00:00:00 2001 From: George Ogden Date: Fri, 12 Dec 2025 21:41:50 +0000 Subject: [PATCH 67/72] Allow joining path to tree --- git/objects/tree.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/git/objects/tree.py b/git/objects/tree.py index 1845d0d0d..a3d611c80 100644 --- a/git/objects/tree.py +++ b/git/objects/tree.py @@ -5,6 +5,7 @@ __all__ = ["TreeModifier", "Tree"] +import os import sys import git.diff as git_diff @@ -230,7 +231,7 @@ def _iter_convert_to_object(self, iterable: Iterable[TreeCacheTup]) -> Iterator[ raise TypeError("Unknown mode %o found in tree data for path '%s'" % (mode, path)) from e # END for each item - def join(self, file: str) -> IndexObjUnion: + def join(self, file: PathLike) -> IndexObjUnion: """Find the named object in this tree's contents. :return: @@ -241,6 +242,7 @@ def join(self, file: str) -> IndexObjUnion: If the given file or tree does not exist in this tree. """ msg = "Blob or Tree named %r not found" + file = os.fspath(file) if "/" in file: tree = self item = self @@ -269,7 +271,7 @@ def join(self, file: str) -> IndexObjUnion: raise KeyError(msg % file) # END handle long paths - def __truediv__(self, file: str) -> IndexObjUnion: + def __truediv__(self, file: PathLike) -> IndexObjUnion: """The ``/`` operator is another syntax for joining. See :meth:`join` for details. From c8b58c09904dabe67222165e4d3eecf4c8f07490 Mon Sep 17 00:00:00 2001 From: George Ogden <38294960+George-Ogden@users.noreply.github.com> Date: Sun, 14 Dec 2025 17:42:21 +0000 Subject: [PATCH 68/72] Update test/test_tree.py Rename blob to tree Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- test/test_tree.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/test_tree.py b/test/test_tree.py index dafd32847..629fd4d32 100644 --- a/test/test_tree.py +++ b/test/test_tree.py @@ -209,13 +209,13 @@ def test_repo_lookup_nested_pathlike_path(self, rw_repo): @with_rw_repo("0.3.2.1") def test_repo_lookup_folder_string_path(self, rw_repo): repo = Repo(rw_repo.git_dir) - blob = repo.tree() / "git" - assert isinstance(blob, Tree) - assert blob.hexsha == "ec8ae429156d65afde4bbb3455570193b56f0977" + tree = repo.tree() / "git" + assert isinstance(tree, Tree) + assert tree.hexsha == "ec8ae429156d65afde4bbb3455570193b56f0977" @with_rw_repo("0.3.2.1") def test_repo_lookup_folder_pathlike_path(self, rw_repo): repo = Repo(rw_repo.git_dir) - blob = repo.tree() / PathLikeMock("git") - assert isinstance(blob, Tree) - assert blob.hexsha == "ec8ae429156d65afde4bbb3455570193b56f0977" + tree = repo.tree() / PathLikeMock("git") + assert isinstance(tree, Tree) + assert tree.hexsha == "ec8ae429156d65afde4bbb3455570193b56f0977" From 9e24eb6b72c1851e46e09133b83b48f2059037d7 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Thu, 1 Jan 2026 16:32:19 +0100 Subject: [PATCH 69/72] Prepare next release --- VERSION | 2 +- doc/source/changes.rst | 34 ++++++++++++++++++++-------------- git/ext/gitdb | 2 +- 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/VERSION b/VERSION index 3c91929a4..fd84d1e83 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.1.45 +3.1.46 diff --git a/doc/source/changes.rst b/doc/source/changes.rst index 151059ed2..9b82e7513 100644 --- a/doc/source/changes.rst +++ b/doc/source/changes.rst @@ -2,6 +2,12 @@ Changelog ========= +3.1.46 +====== + +See the following for all changes. +https://github.com/gitpython-developers/GitPython/releases/tag/3.1.46 + 3.1.45 ====== @@ -111,7 +117,7 @@ https://github.com/gitpython-developers/gitpython/milestone/61?closed=1 but a necessary fix for https://github.com/gitpython-developers/GitPython/issues/1515. Please take a look at the PR for more information and how to bypass these protections in case they cause breakage: https://github.com/gitpython-developers/GitPython/pull/1521. - + See the following for all changes. https://github.com/gitpython-developers/gitpython/milestone/60?closed=1 @@ -176,38 +182,38 @@ https://github.com/gitpython-developers/gitpython/milestone/53?closed=1 * General: - Remove python 3.6 support - + - Remove distutils ahead of deprecation in standard library. - + - Update sphinx to 4.1.12 and use autodoc-typehints. - + - Include README as long_description on PyPI - + - Test against earliest and latest minor version available on Github Actions (e.g. 3.9.0 and 3.9.7) - + * Typing: - Add types to ALL functions. - + - Ensure py.typed is collected. - + - Increase mypy strictness with disallow_untyped_defs, warn_redundant_casts, warn_unreachable. - + - Use typing.NamedTuple and typing.OrderedDict now 3.6 dropped. - + - Make Protocol classes ABCs at runtime due to new behaviour/bug in 3.9.7 & 3.10.0-rc1 - + - Remove use of typing.TypeGuard until later release, to allow dependent libs time to update. - + - Tracking issue: https://github.com/gitpython-developers/GitPython/issues/1095 * Runtime improvements: - Add clone_multi_options support to submodule.add() - + - Delay calling get_user_id() unless essential, to support sand-boxed environments. - + - Add timeout to handle_process_output(), in case thread.join() hangs. See the following for details: diff --git a/git/ext/gitdb b/git/ext/gitdb index 4c63ee663..335c0f661 160000 --- a/git/ext/gitdb +++ b/git/ext/gitdb @@ -1 +1 @@ -Subproject commit 4c63ee6636a6a3370f58b05d0bd19fec2f16dd5a +Subproject commit 335c0f66173eecdc7b2597c2b6c3d1fde795df30 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 70/72] 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 71/72] 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 72/72] 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")',