From 00b46127e54c104ac86333150708acdccce98cb7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 3 Jun 2025 03:22:44 +0000 Subject: [PATCH 01/27] Bump git/ext/gitdb from `f36c0cc` to `7e02fbd` Bumps [git/ext/gitdb](https://github.com/gitpython-developers/gitdb) from `f36c0cc` to `7e02fbd`. - [Release notes](https://github.com/gitpython-developers/gitdb/releases) - [Commits](https://github.com/gitpython-developers/gitdb/compare/f36c0cc42ea2f529291e441073f74e920988d4d2...7e02fbde5fcfcd52f541995fbcde22e85535adef) --- updated-dependencies: - dependency-name: git/ext/gitdb dependency-version: 7e02fbde5fcfcd52f541995fbcde22e85535adef 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 f36c0cc42..335c0f661 160000 --- a/git/ext/gitdb +++ b/git/ext/gitdb @@ -1 +1 @@ -Subproject commit f36c0cc42ea2f529291e441073f74e920988d4d2 +Subproject commit 335c0f66173eecdc7b2597c2b6c3d1fde795df30 From 67468a2396f56f792ac4c139f43c98745e93a2b5 Mon Sep 17 00:00:00 2001 From: betaboon Date: Fri, 6 Jun 2025 22:41:28 +0200 Subject: [PATCH 02/27] Fix GitConfigParser not removing quotes from values --- git/config.py | 2 ++ test/fixtures/git_config_with_quotes | 3 +++ test/test_config.py | 8 +++++++- 3 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/git_config_with_quotes diff --git a/git/config.py b/git/config.py index de3508360..2df99a753 100644 --- a/git/config.py +++ b/git/config.py @@ -508,6 +508,8 @@ def string_decode(v: str) -> str: if len(optval) > 1 and optval[0] == '"' and optval[-1] != '"': is_multi_line = True optval = string_decode(optval[1:]) + elif len(optval) > 1 and optval[0] == '"' and optval[-1] == '"': + optval = optval[1:-1].strip() # END handle multi-line # Preserves multiple values for duplicate optnames. cursect.add(optname, optval) diff --git a/test/fixtures/git_config_with_quotes b/test/fixtures/git_config_with_quotes new file mode 100644 index 000000000..40e6710d9 --- /dev/null +++ b/test/fixtures/git_config_with_quotes @@ -0,0 +1,3 @@ +[user] + name = "Cody Veal" + email = "cveal05@gmail.com" diff --git a/test/test_config.py b/test/test_config.py index 92997422d..886d5b136 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -391,7 +391,7 @@ def test_complex_aliases(self): with GitConfigParser(file_obj, read_only=False) as w_config: self.assertEqual( w_config.get("alias", "rbi"), - '"!g() { git rebase -i origin/${1:-master} ; } ; g"', + "!g() { git rebase -i origin/${1:-master} ; } ; g", ) self.assertEqual( file_obj.getvalue(), @@ -406,6 +406,12 @@ def test_empty_config_value(self): with self.assertRaises(cp.NoOptionError): cr.get_value("color", "ui") + def test_config_with_quotes(self): + cr = GitConfigParser(fixture_path("git_config_with_quotes"), read_only=True) + + self.assertEqual(cr.get("user", "name"), "Cody Veal") + self.assertEqual(cr.get("user", "email"), "cveal05@gmail.com") + def test_get_values_works_without_requiring_any_other_calls_first(self): file_obj = self._to_memcache(fixture_path("git_config_multiple")) cr = GitConfigParser(file_obj, read_only=True) From 5f303202ee0666f5298ff39a5a129162b69ff790 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 7 Jun 2025 01:08:07 -0400 Subject: [PATCH 03/27] Test a quoted config var with meaningful edge whitespace #2035 fixed issue #1923 by removing separate double quotation marks appearing on a single-line configuration variable when parsing a configuration file. However, it also stripped leading and trailing whitespace from the string obtained by removing the quotes. This adds a test case of a plausible scenario where such whitespace needs to be preserved and where a user would almost certainly expect it to preserve: setting a value like `# ` for `core.commentString`, in order to be able to easily create commit messages like this one, that contain a line that begins with a literal `#`, while still letting `#` in the more common case that it is followed by a space be interpreted as a comment. The effect of `git config --local core.commentString '# '` is to add a `commentString = "# "` line in the `[core]` section of `.git/config`. The changes in #2035 allow us to correctly parse more quoted strings than before, and almost allow us to parse this, but not quite, because of the `strip()` operation that turns `# ` into `#`. --- test/fixtures/git_config_with_quotes_whitespace | 2 ++ test/test_config.py | 4 ++++ 2 files changed, 6 insertions(+) create mode 100644 test/fixtures/git_config_with_quotes_whitespace diff --git a/test/fixtures/git_config_with_quotes_whitespace b/test/fixtures/git_config_with_quotes_whitespace new file mode 100644 index 000000000..c6014cc61 --- /dev/null +++ b/test/fixtures/git_config_with_quotes_whitespace @@ -0,0 +1,2 @@ +[core] + commentString = "# " diff --git a/test/test_config.py b/test/test_config.py index 886d5b136..671f34046 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -412,6 +412,10 @@ def test_config_with_quotes(self): self.assertEqual(cr.get("user", "name"), "Cody Veal") self.assertEqual(cr.get("user", "email"), "cveal05@gmail.com") + def test_config_with_quotes_with_literal_whitespace(self): + cr = GitConfigParser(fixture_path("git_config_with_quotes_whitespace"), read_only=True) + self.assertEqual(cr.get("core", "commentString"), "# ") + def test_get_values_works_without_requiring_any_other_calls_first(self): file_obj = self._to_memcache(fixture_path("git_config_multiple")) cr = GitConfigParser(file_obj, read_only=True) From bd2b930503ad5d97322aa81d7610ab743d915f84 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 7 Jun 2025 00:13:46 -0400 Subject: [PATCH 04/27] Preserve quoted leading and trailing single-line whitespace At least in a single line, whitespace in a double-quoted value in a configuration file, like `name = " abc def "`, would presumably be intended. This removes the `strip()` call that is applied to text `ConfigParser` obtained by removing the double quotes around it. This slightly refines the changes in #2035 by dropping the `strip()` call while continuing to remove opening and closing double quotes. --- git/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git/config.py b/git/config.py index 2df99a753..1d58db53a 100644 --- a/git/config.py +++ b/git/config.py @@ -509,7 +509,7 @@ def string_decode(v: str) -> str: is_multi_line = True optval = string_decode(optval[1:]) elif len(optval) > 1 and optval[0] == '"' and optval[-1] == '"': - optval = optval[1:-1].strip() + optval = optval[1:-1] # END handle multi-line # Preserves multiple values for duplicate optnames. cursect.add(optname, optval) From 5a8a4059d646fd313e81365ef240562556d8bd3f Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Fri, 6 Jun 2025 23:16:45 -0400 Subject: [PATCH 05/27] Refactor Git.{AutoInterrupt,CatFileContentStream} nesting This makes `Git.AutoInterrupt` and `Git.CatFileContentStream` transparent aliases to top-level nonpublic `_AutoInterrupt` and `_CatFileContentStream` classes in the `cmd` module. This does not change the "public" interface. It also does not change metadata relevant to documentation: the `__name__` and `__qualname__` attributes are set explicitly to the values they had before when these classes were defined nested, so that Sphinx continues to document them (and to do so in full) in `Git` and as `Git.AutoInterrupt` and `Git.CatFileContentStream`. The purpose of this is to increase readability. The `Git` class is big and complex, with a number of long members and various forms of nesting. Since these two classes can be understood even without reading the code of the `Git` class, moving the definitions out of the `Git` class into top-level nonpublic classes will hopefully increase readability and help with maintenance. --- git/cmd.py | 440 +++++++++++++++++++++++++++-------------------------- 1 file changed, 226 insertions(+), 214 deletions(-) diff --git a/git/cmd.py b/git/cmd.py index 7f46edc8f..a6195880d 100644 --- a/git/cmd.py +++ b/git/cmd.py @@ -308,6 +308,230 @@ def dict_to_slots_and__excluded_are_none(self: object, d: Mapping[str, Any], exc ## -- End Utilities -- @} + +class _AutoInterrupt: + """Process wrapper that terminates the wrapped process on finalization. + + This kills/interrupts the stored process instance once this instance goes out of + scope. It is used to prevent processes piling up in case iterators stop reading. + + All attributes are wired through to the contained process object. + + The wait method is overridden to perform automatic status code checking and possibly + raise. + """ + + __slots__ = ("proc", "args", "status") + + # If this is non-zero it will override any status code during _terminate, used + # to prevent race conditions in testing. + _status_code_if_terminate: int = 0 + + def __init__(self, proc: Union[None, subprocess.Popen], args: Any) -> None: + self.proc = proc + self.args = args + self.status: Union[int, None] = None + + def _terminate(self) -> None: + """Terminate the underlying process.""" + if self.proc is None: + return + + proc = self.proc + self.proc = None + if proc.stdin: + proc.stdin.close() + if proc.stdout: + proc.stdout.close() + if proc.stderr: + proc.stderr.close() + # Did the process finish already so we have a return code? + try: + if proc.poll() is not None: + self.status = self._status_code_if_terminate or proc.poll() + return + except OSError as ex: + _logger.info("Ignored error after process had died: %r", ex) + + # It can be that nothing really exists anymore... + if os is None or getattr(os, "kill", None) is None: + return + + # Try to kill it. + try: + proc.terminate() + status = proc.wait() # Ensure the process goes away. + + self.status = self._status_code_if_terminate or status + except OSError as ex: + _logger.info("Ignored error after process had died: %r", ex) + # END exception handling + + def __del__(self) -> None: + self._terminate() + + def __getattr__(self, attr: str) -> Any: + return getattr(self.proc, attr) + + # TODO: Bad choice to mimic `proc.wait()` but with different args. + def wait(self, stderr: Union[None, str, bytes] = b"") -> int: + """Wait for the process and return its status code. + + :param stderr: + Previously read value of stderr, in case stderr is already closed. + + :warn: + May deadlock if output or error pipes are used and not handled separately. + + :raise git.exc.GitCommandError: + If the return status is not 0. + """ + if stderr is None: + stderr_b = b"" + stderr_b = force_bytes(data=stderr, encoding="utf-8") + status: Union[int, None] + if self.proc is not None: + status = self.proc.wait() + p_stderr = self.proc.stderr + else: # Assume the underlying proc was killed earlier or never existed. + status = self.status + p_stderr = None + + def read_all_from_possibly_closed_stream(stream: Union[IO[bytes], None]) -> bytes: + if stream: + try: + return stderr_b + force_bytes(stream.read()) + except (OSError, ValueError): + return stderr_b or b"" + else: + return stderr_b or b"" + + # END status handling + + if status != 0: + errstr = read_all_from_possibly_closed_stream(p_stderr) + _logger.debug("AutoInterrupt wait stderr: %r" % (errstr,)) + raise GitCommandError(remove_password_if_present(self.args), status, errstr) + return status + + +_AutoInterrupt.__name__ = "AutoInterrupt" +_AutoInterrupt.__qualname__ = "Git.AutoInterrupt" + + +class _CatFileContentStream: + """Object representing a sized read-only stream returning the contents of + an object. + + This behaves like a stream, but counts the data read and simulates an empty stream + once our sized content region is empty. + + If not all data are read to the end of the object's lifetime, we read the rest to + ensure the underlying stream continues to work. + """ + + __slots__ = ("_stream", "_nbr", "_size") + + def __init__(self, size: int, stream: IO[bytes]) -> None: + self._stream = stream + self._size = size + self._nbr = 0 # Number of bytes read. + + # Special case: If the object is empty, has null bytes, get the final + # newline right away. + if size == 0: + stream.read(1) + # END handle empty streams + + def read(self, size: int = -1) -> bytes: + bytes_left = self._size - self._nbr + if bytes_left == 0: + return b"" + if size > -1: + # Ensure we don't try to read past our limit. + size = min(bytes_left, size) + else: + # They try to read all, make sure it's not more than what remains. + size = bytes_left + # END check early depletion + data = self._stream.read(size) + self._nbr += len(data) + + # Check for depletion, read our final byte to make the stream usable by + # others. + if self._size - self._nbr == 0: + self._stream.read(1) # final newline + # END finish reading + return data + + def readline(self, size: int = -1) -> bytes: + if self._nbr == self._size: + return b"" + + # Clamp size to lowest allowed value. + bytes_left = self._size - self._nbr + if size > -1: + size = min(bytes_left, size) + else: + size = bytes_left + # END handle size + + data = self._stream.readline(size) + self._nbr += len(data) + + # Handle final byte. + if self._size - self._nbr == 0: + self._stream.read(1) + # END finish reading + + return data + + def readlines(self, size: int = -1) -> List[bytes]: + if self._nbr == self._size: + return [] + + # Leave all additional logic to our readline method, we just check the size. + out = [] + nbr = 0 + while True: + line = self.readline() + if not line: + break + out.append(line) + if size > -1: + nbr += len(line) + if nbr > size: + break + # END handle size constraint + # END readline loop + return out + + # skipcq: PYL-E0301 + def __iter__(self) -> "Git.CatFileContentStream": + return self + + def __next__(self) -> bytes: + line = self.readline() + if not line: + raise StopIteration + + return line + + next = __next__ + + def __del__(self) -> None: + bytes_left = self._size - self._nbr + if bytes_left: + # Read and discard - seeking is impossible within a stream. + # This includes any terminating newline. + self._stream.read(bytes_left + 1) + # END handle incomplete read + + +_CatFileContentStream.__name__ = "CatFileContentStream" +_CatFileContentStream.__qualname__ = "Git.CatFileContentStream" + + _USE_SHELL_DEFAULT_MESSAGE = ( "Git.USE_SHELL is deprecated, because only its default value of False is safe. " "It will be removed in a future release." @@ -728,221 +952,9 @@ def check_unsafe_options(cls, options: List[str], unsafe_options: List[str]) -> f"{unsafe_option} is not allowed, use `allow_unsafe_options=True` to allow it." ) - class AutoInterrupt: - """Process wrapper that terminates the wrapped process on finalization. - - This kills/interrupts the stored process instance once this instance goes out of - scope. It is used to prevent processes piling up in case iterators stop reading. - - All attributes are wired through to the contained process object. - - The wait method is overridden to perform automatic status code checking and - possibly raise. - """ - - __slots__ = ("proc", "args", "status") - - # If this is non-zero it will override any status code during _terminate, used - # to prevent race conditions in testing. - _status_code_if_terminate: int = 0 - - def __init__(self, proc: Union[None, subprocess.Popen], args: Any) -> None: - self.proc = proc - self.args = args - self.status: Union[int, None] = None - - def _terminate(self) -> None: - """Terminate the underlying process.""" - if self.proc is None: - return - - proc = self.proc - self.proc = None - if proc.stdin: - proc.stdin.close() - if proc.stdout: - proc.stdout.close() - if proc.stderr: - proc.stderr.close() - # Did the process finish already so we have a return code? - try: - if proc.poll() is not None: - self.status = self._status_code_if_terminate or proc.poll() - return - except OSError as ex: - _logger.info("Ignored error after process had died: %r", ex) - - # It can be that nothing really exists anymore... - if os is None or getattr(os, "kill", None) is None: - return - - # Try to kill it. - try: - proc.terminate() - status = proc.wait() # Ensure the process goes away. - - self.status = self._status_code_if_terminate or status - except OSError as ex: - _logger.info("Ignored error after process had died: %r", ex) - # END exception handling - - def __del__(self) -> None: - self._terminate() - - def __getattr__(self, attr: str) -> Any: - return getattr(self.proc, attr) - - # TODO: Bad choice to mimic `proc.wait()` but with different args. - def wait(self, stderr: Union[None, str, bytes] = b"") -> int: - """Wait for the process and return its status code. - - :param stderr: - Previously read value of stderr, in case stderr is already closed. - - :warn: - May deadlock if output or error pipes are used and not handled - separately. - - :raise git.exc.GitCommandError: - If the return status is not 0. - """ - if stderr is None: - stderr_b = b"" - stderr_b = force_bytes(data=stderr, encoding="utf-8") - status: Union[int, None] - if self.proc is not None: - status = self.proc.wait() - p_stderr = self.proc.stderr - else: # Assume the underlying proc was killed earlier or never existed. - status = self.status - p_stderr = None - - def read_all_from_possibly_closed_stream(stream: Union[IO[bytes], None]) -> bytes: - if stream: - try: - return stderr_b + force_bytes(stream.read()) - except (OSError, ValueError): - return stderr_b or b"" - else: - return stderr_b or b"" - - # END status handling - - if status != 0: - errstr = read_all_from_possibly_closed_stream(p_stderr) - _logger.debug("AutoInterrupt wait stderr: %r" % (errstr,)) - raise GitCommandError(remove_password_if_present(self.args), status, errstr) - return status - - # END auto interrupt - - class CatFileContentStream: - """Object representing a sized read-only stream returning the contents of - an object. - - This behaves like a stream, but counts the data read and simulates an empty - stream once our sized content region is empty. - - If not all data are read to the end of the object's lifetime, we read the - rest to ensure the underlying stream continues to work. - """ - - __slots__ = ("_stream", "_nbr", "_size") - - def __init__(self, size: int, stream: IO[bytes]) -> None: - self._stream = stream - self._size = size - self._nbr = 0 # Number of bytes read. - - # Special case: If the object is empty, has null bytes, get the final - # newline right away. - if size == 0: - stream.read(1) - # END handle empty streams - - def read(self, size: int = -1) -> bytes: - bytes_left = self._size - self._nbr - if bytes_left == 0: - return b"" - if size > -1: - # Ensure we don't try to read past our limit. - size = min(bytes_left, size) - else: - # They try to read all, make sure it's not more than what remains. - size = bytes_left - # END check early depletion - data = self._stream.read(size) - self._nbr += len(data) - - # Check for depletion, read our final byte to make the stream usable by - # others. - if self._size - self._nbr == 0: - self._stream.read(1) # final newline - # END finish reading - return data - - def readline(self, size: int = -1) -> bytes: - if self._nbr == self._size: - return b"" - - # Clamp size to lowest allowed value. - bytes_left = self._size - self._nbr - if size > -1: - size = min(bytes_left, size) - else: - size = bytes_left - # END handle size - - data = self._stream.readline(size) - self._nbr += len(data) - - # Handle final byte. - if self._size - self._nbr == 0: - self._stream.read(1) - # END finish reading - - return data - - def readlines(self, size: int = -1) -> List[bytes]: - if self._nbr == self._size: - return [] - - # Leave all additional logic to our readline method, we just check the size. - out = [] - nbr = 0 - while True: - line = self.readline() - if not line: - break - out.append(line) - if size > -1: - nbr += len(line) - if nbr > size: - break - # END handle size constraint - # END readline loop - return out - - # skipcq: PYL-E0301 - def __iter__(self) -> "Git.CatFileContentStream": - return self - - def __next__(self) -> bytes: - line = self.readline() - if not line: - raise StopIteration - - return line - - next = __next__ + AutoInterrupt = _AutoInterrupt - def __del__(self) -> None: - bytes_left = self._size - self._nbr - if bytes_left: - # Read and discard - seeking is impossible within a stream. - # This includes any terminating newline. - self._stream.read(bytes_left + 1) - # END handle incomplete read + CatFileContentStream = _CatFileContentStream def __init__(self, working_dir: Union[None, PathLike] = None) -> None: """Initialize this instance with: From c6d16d01c54d45be20de40dedae4e9471b96c8ab Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 7 Jun 2025 11:35:08 -0400 Subject: [PATCH 06/27] Start on fixing AutoInterrupt/CatFileContentStream aliases This uses `TypeAlias` from the `typing` module, to make it so the assignment statments introduced in #2037 (to set `Git.AutoInterrupt` and `Git.CatFileContentStream` to nonpublic module-level implementations `_AutoInterrupt` and `_CatFileContentStream`) are treated by `mypy` as type aliases rather than as class variables. For details on the problem this partially fixes, see #2038 and: https://mypy.readthedocs.io/en/stable/common_issues.html#variables-vs-type-aliases The fix won't work in this form, however, because it attempts to import `TypeAlias` unconditionally from the standard-library `typing` module, which only gained it in Python 3.10. --- git/cmd.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/git/cmd.py b/git/cmd.py index a6195880d..7015d376f 100644 --- a/git/cmd.py +++ b/git/cmd.py @@ -55,6 +55,7 @@ TYPE_CHECKING, TextIO, Tuple, + TypeAlias, Union, cast, overload, @@ -952,9 +953,9 @@ def check_unsafe_options(cls, options: List[str], unsafe_options: List[str]) -> f"{unsafe_option} is not allowed, use `allow_unsafe_options=True` to allow it." ) - AutoInterrupt = _AutoInterrupt + AutoInterrupt: TypeAlias = _AutoInterrupt - CatFileContentStream = _CatFileContentStream + CatFileContentStream: TypeAlias = _CatFileContentStream def __init__(self, working_dir: Union[None, PathLike] = None) -> None: """Initialize this instance with: From c6c081230f4b96ca9efce4c9e2478396eaf348c9 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 7 Jun 2025 11:52:36 -0400 Subject: [PATCH 07/27] Import `TypeAlias` from `typing_extensions` where needed The standard library `typing` module introduced `TypeAlias` in Python 3.10. This uses it from `typing_extensions` where neederd, by making three changes: - Change the version lower bound for `typing-extensions` from 3.7.4.3 to 3.10.0.2, since 3.7.4.3 doesn't offer `TypeAlias`. (The reason not to go higher, to major version 4, is that it no longer supports versions of Python lower than 3.9, but we currently support Python 3.7 and Python 3.8.) - Require the `typing-extensions` dependency when using Python versions lower than 3.10, rather than only lower than 3.7 as before. - Conditionally import `TypeAlias` (in the `git.cmd` module) from either `typing` or `type_extensions` depending on the Python version, using a pattern that `mypy` and other type checkers recognize statically. Together with the preceding commit, this fixes #2038. (This is approach (2) described there.) --- git/cmd.py | 6 +++++- requirements.txt | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/git/cmd.py b/git/cmd.py index 7015d376f..6deded04b 100644 --- a/git/cmd.py +++ b/git/cmd.py @@ -55,12 +55,16 @@ TYPE_CHECKING, TextIO, Tuple, - TypeAlias, Union, cast, overload, ) +if sys.version_info >= (3, 10): + from typing import TypeAlias +else: + from typing_extensions import TypeAlias + from git.types import Literal, PathLike, TBD if TYPE_CHECKING: diff --git a/requirements.txt b/requirements.txt index 7159416a9..61d8403b0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ gitdb>=4.0.1,<5 -typing-extensions>=3.7.4.3;python_version<"3.8" +typing-extensions>=3.10.0.2;python_version<"3.10" From 8905e9e8f0244414fbff90c19b144d02b74af3b3 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 7 Jun 2025 12:33:56 -0400 Subject: [PATCH 08/27] Fix CI `mypy` command on free-threaded Python When the version is represented as `3.13t`, the `--python-version` option needs an operand of `3.13`, not `3.13t`. (This, and the fix here, will automatically apply to later threaded Pythons, such as 3.14t, too.) --- .github/workflows/pythonpackage.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 9fd660c6b..c4fbef48e 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -97,10 +97,11 @@ jobs: - name: Check types with mypy run: | - mypy --python-version=${{ matrix.python-version }} + mypy --python-version="${PYTHON_VERSION%t}" env: 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. continue-on-error: true From b45ae86aaaab25ccd07ee183382a0e78a92f65a2 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 7 Jun 2025 13:10:47 -0400 Subject: [PATCH 09/27] Add an explanatory comment for omitting "t" Since the `${var%pattern}` syntax may not be immediately obvious. --- .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 c4fbef48e..e7cb06cc0 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -97,7 +97,7 @@ jobs: - name: Check types with mypy run: | - mypy --python-version="${PYTHON_VERSION%t}" + mypy --python-version="${PYTHON_VERSION%t}" # Version only, with no "t" for free-threaded. env: MYPY_FORCE_COLOR: "1" TERM: "xterm-256color" # For color: https://github.com/python/mypy/issues/13817 From ba58f40d385e5f371c468470752a4d5aa0b16789 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 7 Jun 2025 13:37:22 -0400 Subject: [PATCH 10/27] Split Cygwin CI into two test jobs One job is for all tests except the `performance` tests, while the other job is for only the `performance` tests. The idea is to decrease the total time it takes for all CI jobs to complete in most cases, by splitting the long-running (currently usually about 13 minute) Cygwin job into two less-long jobs. --- .github/workflows/cygwin-test.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/cygwin-test.yml b/.github/workflows/cygwin-test.yml index 572a9197e..d73f0522e 100644 --- a/.github/workflows/cygwin-test.yml +++ b/.github/workflows/cygwin-test.yml @@ -10,6 +10,14 @@ jobs: runs-on: windows-latest strategy: + matrix: + selection: [fast, perf] + include: + - selection: fast + additional-pytest-args: --ignore=test/performance + - selection: perf + additional-pytest-args: test/performance/ + fail-fast: false env: @@ -87,4 +95,4 @@ jobs: - name: Test with pytest run: | - pytest --color=yes -p no:sugar --instafail -vv + pytest --color=yes -p no:sugar --instafail -vv ${{ matrix.additional-pytest-args }} From 414de64b095282ce39b9dd34876167e607b331cf Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 7 Jun 2025 14:13:25 -0400 Subject: [PATCH 11/27] Show differing `pytest` arguments in step name In the "Test with pytest" step of the Cygwin test jobs. This is to distinguish the newly split jobs from each other more clearly when glancing at their steps for a run. --- .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 d73f0522e..3c1eb3dc0 100644 --- a/.github/workflows/cygwin-test.yml +++ b/.github/workflows/cygwin-test.yml @@ -93,6 +93,6 @@ jobs: python --version python -c 'import os, sys; print(f"sys.platform={sys.platform!r}, os.name={os.name!r}")' - - name: Test with pytest + - name: Test with pytest (${{ matrix.additional-pytest-args }}) run: | pytest --color=yes -p no:sugar --instafail -vv ${{ matrix.additional-pytest-args }} From a6c623eecf12eb525ad115b0be75c7449c4f8efe Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 7 Jun 2025 14:18:14 -0400 Subject: [PATCH 12/27] Small stylistic consistency tweak This removes an unnecessary trailing slash that I had not used consistently anyway. --- .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 3c1eb3dc0..7c3eeedca 100644 --- a/.github/workflows/cygwin-test.yml +++ b/.github/workflows/cygwin-test.yml @@ -16,7 +16,7 @@ jobs: - selection: fast additional-pytest-args: --ignore=test/performance - selection: perf - additional-pytest-args: test/performance/ + additional-pytest-args: test/performance fail-fast: false From 31e1c035e1af514d5d2a9ed6630f71663df7b265 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 7 Jun 2025 15:01:21 -0400 Subject: [PATCH 13/27] Use static notation for `setuptools` in installation test The `VirtualEnvironment` test helper is used in multiple places, but it is only told to install/upgrade `pip` when used from `test_installation`. It implements the functionality it would ideally obtain by `upgrade_deps`, since the `upgrade_deps` parameter is only avaiilable in `venv` when using Python 3.9 and later. When `pip` is installed, `upgrade_deps` would install `setuptools` when using Python 3.11 or lower, but not when using Python 3.12 or higher. `VirtualEnvironment` does the same. (The reason for this is not just to avoid needlessly departing from what `upgrade_deps` would do. Rather, it should not generally be necessary to have `setuptools` installed for package management since Python 3.12, and if it were necessary then this would a bug we would want to detect while running tests.) Previously this conditional specification of `setuptools` was done by building different lists of package arguments to pass to `pip`, by checking `sys.version_info` to decide whether to append the string `setuptools`. This commit changes how it is done, to use a static list of package arguments instead. (The Python intepreter path continues to be obtained dynamically, but all its positional arguments, including those specifying packages, are now string literals.) The conditional `setuptools` requirement is now expressed statically using notation recognized by `pip`, as the string `setuptools; python_version<"3.12"`. --- test/lib/helper.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/test/lib/helper.py b/test/lib/helper.py index 5d91447ea..241d27341 100644 --- a/test/lib/helper.py +++ b/test/lib/helper.py @@ -415,9 +415,15 @@ def __init__(self, env_dir, *, with_pip): if with_pip: # The upgrade_deps parameter to venv.create is 3.9+ only, so do it this way. - command = [self.python, "-m", "pip", "install", "--upgrade", "pip"] - if sys.version_info < (3, 12): - command.append("setuptools") + command = [ + self.python, + "-m", + "pip", + "install", + "--upgrade", + "pip", + 'setuptools; python_version<"3.12"', + ] subprocess.check_output(command) @property From 727f4e9e54362d0356066a26d0c22028a465cccd Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 7 Jun 2025 15:21:00 -0400 Subject: [PATCH 14/27] Use static notation for `setuptools` in CI `pip` commands `setuptools` may potentially be needed for installation to work fully as desired prior to Python 3.12, so in those versions it is installed automatically in any virtual environment that is created with `pip` available. This is a behavior of the `venv` module that is not specific to CI. However, on CI we upgrade packages that are preinstalled in the virtual environment, or that we may otherwise wish to be present. This took the form of unconditionally installing/upgrading the `pip` and `wheel` packages, but conditionally upgrading the `setuptools` package only if we find that it is already installed. This commit changes the behavior to statically specify the same list of package specifications to `pip` in all environments and in all versions of Python, but to use the static notation recognized by `pip` to indicate that `setuptools` is to be instaled/upgraded only if the Python version is strictly less than Python 3.12. This seems to be more readable. It also avoids using unquoted shell parameter expansion in a way that is potentially confusing (for example, if we were running our CI script steps through ShellCheck, then it would automatically balk at that construction). It is also more consistent with how `test_installation` sets up its environment (especially since 31e1c03, but actually even before that, because it was still conditioning `setuptools` on the Python version rather than whether it was already installed). Finally, this behavior is what the preexisting comment already described. This also adjusts the shell quoting style slightly in other related commands (in the same workflows) that pass package specifications to `pip`, for consistency. (For now, `".[test]"` rather than `.[test]` remains written in the readme because it works in `cmd.exe` as well as other shells, but it may be changed there in the future too.) --- .github/workflows/alpine-test.yml | 4 ++-- .github/workflows/cygwin-test.yml | 4 ++-- .github/workflows/pythonpackage.yml | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/alpine-test.yml b/.github/workflows/alpine-test.yml index 513c65bb8..a3361798d 100644 --- a/.github/workflows/alpine-test.yml +++ b/.github/workflows/alpine-test.yml @@ -55,12 +55,12 @@ jobs: run: | # Get the latest pip, wheel, and prior to Python 3.12, setuptools. . .venv/bin/activate - python -m pip install -U pip $(pip freeze --all | grep -ow ^setuptools) wheel + python -m pip install -U pip 'setuptools; python_version<"3.12"' wheel - name: Install project and test dependencies run: | . .venv/bin/activate - pip install ".[test]" + pip install '.[test]' - name: Show version and platform information run: | diff --git a/.github/workflows/cygwin-test.yml b/.github/workflows/cygwin-test.yml index 7c3eeedca..6943db09c 100644 --- a/.github/workflows/cygwin-test.yml +++ b/.github/workflows/cygwin-test.yml @@ -79,11 +79,11 @@ jobs: - name: Update PyPA packages run: | # Get the latest pip, wheel, and prior to Python 3.12, setuptools. - python -m pip install -U pip $(pip freeze --all | grep -ow ^setuptools) wheel + python -m pip install -U pip 'setuptools; python_version<"3.12"' wheel - name: Install project and test dependencies run: | - pip install ".[test]" + pip install '.[test]' - name: Show version and platform information run: | diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index e7cb06cc0..c56d45df7 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -72,11 +72,11 @@ jobs: - name: Update PyPA packages run: | # Get the latest pip, wheel, and prior to Python 3.12, setuptools. - python -m pip install -U pip $(pip freeze --all | grep -ow ^setuptools) wheel + python -m pip install -U pip 'setuptools; python_version<"3.12"' wheel - name: Install project and test dependencies run: | - pip install ".[test]" + pip install '.[test]' - name: Show version and platform information run: | @@ -114,5 +114,5 @@ jobs: - name: Documentation if: matrix.python-version != '3.7' run: | - pip install ".[doc]" + pip install '.[doc]' make -C doc html From 2b85cd1d7ccbbc596cf0d3149bdc854498ebe35e Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 7 Jun 2025 16:35:08 -0400 Subject: [PATCH 15/27] Fix ambiguous `_safer_popen_windows` comment This fixes some ambiguous wording in a comment in `_safer_popen_wording`, where it was unclear if the secondary problem -- where it would be possible to run a wrong `cmd.exe`-type shell -- would happen under two separate circumstances, or only when both circumstances occurred together. This adjusts its wording to make clear that it is the latter. This also fixes a minor typo in another `_safer_popen_windows` comment. This might be viewed as building on the improvements in b9d9e56 (#1859), but the changes here are to comments only. --- git/cmd.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/git/cmd.py b/git/cmd.py index 6deded04b..71096197c 100644 --- a/git/cmd.py +++ b/git/cmd.py @@ -273,12 +273,12 @@ def _safer_popen_windows( if shell: # The original may be immutable, or the caller may reuse it. Mutate a copy. env = {} if env is None else dict(env) - env["NoDefaultCurrentDirectoryInExePath"] = "1" # The "1" can be an value. + env["NoDefaultCurrentDirectoryInExePath"] = "1" # The "1" can be any value. # When not using a shell, the current process does the search in a # CreateProcessW API call, so the variable must be set in our environment. With # a shell, that's unnecessary if https://github.com/python/cpython/issues/101283 - # is patched. In Python versions where it is unpatched, and in the rare case the + # is patched. In Python versions where it is unpatched, in the rare case the # ComSpec environment variable is unset, the search for the shell itself is # unsafe. Setting NoDefaultCurrentDirectoryInExePath in all cases, as done here, # is simpler and protects against that. (As above, the "1" can be any value.) From 253099fe91744801c39b41527e126c85c17ec003 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 7 Jun 2025 18:28:17 -0400 Subject: [PATCH 16/27] Clarify `USE_SHELL` warning helper signature This is a minor refactor of how `_warn_use_shell` can be, and is, invoked. The `_warn_use_shell` helper function in `git.cmd` takes a single `bool`-valued argument `extra_danger`, which is conceptually associated with having a `True` value of `USE_SHELL`, but the association is not necessarily obvious. Specifically: - For the warning given when reading `USE_SHELL` on the `Git` class or through an instance, `extra_danger` is always `False`. This is so even if the `USE_SHELL` value is currently `True`, because the danger that arises from `True` occurs internally. - For the warning given when writing `USE_SHELL`, which can only be done on the `Git` class and not on or through an instance, `extra_danger` is the value set for the attribute. This is because setting `USE_SHELL` to `True` incurs the danger described in #1896. When reading the code, which passed `extra_danger` positionally, the meaning of the parameter may not always have been obvious. This makes the `extra_danger` parameter keyword-only, and passes it by keyword in all invocations, so that its meaning is clearer. --- git/cmd.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/git/cmd.py b/git/cmd.py index 71096197c..15d7820df 100644 --- a/git/cmd.py +++ b/git/cmd.py @@ -550,7 +550,7 @@ def __del__(self) -> None: ) -def _warn_use_shell(extra_danger: bool) -> None: +def _warn_use_shell(*, extra_danger: bool) -> None: warnings.warn( _USE_SHELL_DANGER_MESSAGE if extra_danger else _USE_SHELL_DEFAULT_MESSAGE, DeprecationWarning, @@ -566,12 +566,12 @@ class _GitMeta(type): def __getattribute(cls, name: str) -> Any: if name == "USE_SHELL": - _warn_use_shell(False) + _warn_use_shell(extra_danger=False) return super().__getattribute__(name) def __setattr(cls, name: str, value: Any) -> Any: if name == "USE_SHELL": - _warn_use_shell(value) + _warn_use_shell(extra_danger=value) super().__setattr__(name, value) if not TYPE_CHECKING: @@ -988,7 +988,7 @@ def __init__(self, working_dir: Union[None, PathLike] = None) -> None: def __getattribute__(self, name: str) -> Any: if name == "USE_SHELL": - _warn_use_shell(False) + _warn_use_shell(extra_danger=False) return super().__getattribute__(name) def __getattr__(self, name: str) -> Any: From e5a2db58d4d7fee765bd1146647bf2c09b026ce8 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 7 Jun 2025 19:24:31 -0400 Subject: [PATCH 17/27] Test `ConfigParser` with whitespace outside the value In both the: - Unquoted case, where extra whitespace is at the edges of what can be parsed as the value. - Quoted case, where extra whitespace is next to but outside of the quotes. The case where the whitespace is at the edges of the quoted value and *inside* the quotes, and thus part of the value, is already covered in #2036. (That is merely renamed here, to distinguish it.) --- test/fixtures/git_config_with_extra_whitespace | 2 ++ ...espace => git_config_with_quotes_whitespace_inside} | 0 .../fixtures/git_config_with_quotes_whitespace_outside | 2 ++ test/test_config.py | 10 +++++++++- 4 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/git_config_with_extra_whitespace rename test/fixtures/{git_config_with_quotes_whitespace => git_config_with_quotes_whitespace_inside} (100%) create mode 100644 test/fixtures/git_config_with_quotes_whitespace_outside diff --git a/test/fixtures/git_config_with_extra_whitespace b/test/fixtures/git_config_with_extra_whitespace new file mode 100644 index 000000000..0f727cb5d --- /dev/null +++ b/test/fixtures/git_config_with_extra_whitespace @@ -0,0 +1,2 @@ +[init] + defaultBranch = trunk diff --git a/test/fixtures/git_config_with_quotes_whitespace b/test/fixtures/git_config_with_quotes_whitespace_inside similarity index 100% rename from test/fixtures/git_config_with_quotes_whitespace rename to test/fixtures/git_config_with_quotes_whitespace_inside diff --git a/test/fixtures/git_config_with_quotes_whitespace_outside b/test/fixtures/git_config_with_quotes_whitespace_outside new file mode 100644 index 000000000..4b1615a51 --- /dev/null +++ b/test/fixtures/git_config_with_quotes_whitespace_outside @@ -0,0 +1,2 @@ +[init] + defaultBranch = "trunk" diff --git a/test/test_config.py b/test/test_config.py index 671f34046..879b98365 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -398,6 +398,10 @@ def test_complex_aliases(self): self._to_memcache(fixture_path(".gitconfig")).getvalue(), ) + def test_config_with_extra_whitespace(self): + cr = GitConfigParser(fixture_path("git_config_with_extra_whitespace"), read_only=True) + self.assertEqual(cr.get("init", "defaultBranch"), "trunk") + def test_empty_config_value(self): cr = GitConfigParser(fixture_path("git_config_with_empty_value"), read_only=True) @@ -413,9 +417,13 @@ def test_config_with_quotes(self): self.assertEqual(cr.get("user", "email"), "cveal05@gmail.com") def test_config_with_quotes_with_literal_whitespace(self): - cr = GitConfigParser(fixture_path("git_config_with_quotes_whitespace"), read_only=True) + cr = GitConfigParser(fixture_path("git_config_with_quotes_whitespace_inside"), read_only=True) self.assertEqual(cr.get("core", "commentString"), "# ") + def test_config_with_quotes_with_whitespace_outside_value(self): + cr = GitConfigParser(fixture_path("git_config_with_quotes_whitespace_outside"), read_only=True) + self.assertEqual(cr.get("init", "defaultBranch"), "trunk") + def test_get_values_works_without_requiring_any_other_calls_first(self): file_obj = self._to_memcache(fixture_path("git_config_multiple")) cr = GitConfigParser(file_obj, read_only=True) From 4ebe407493363a814f590a97f3734364e6c5c1e1 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 7 Jun 2025 22:33:35 -0400 Subject: [PATCH 18/27] Test that ConfigParser treats empty quotes as an empty value The ConfigParser has supported this for a long time, but it is now done redundantly since #2035. This adds a test for it, both to make clearer that it is intended to work and to allow verifying that it continues to hold once the legacy special-casing for it is removed. --- test/fixtures/git_config_with_empty_quotes | 2 ++ test/test_config.py | 4 ++++ 2 files changed, 6 insertions(+) create mode 100644 test/fixtures/git_config_with_empty_quotes diff --git a/test/fixtures/git_config_with_empty_quotes b/test/fixtures/git_config_with_empty_quotes new file mode 100644 index 000000000..f11fe4248 --- /dev/null +++ b/test/fixtures/git_config_with_empty_quotes @@ -0,0 +1,2 @@ +[core] + filemode = "" diff --git a/test/test_config.py b/test/test_config.py index 879b98365..76b918a54 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -416,6 +416,10 @@ def test_config_with_quotes(self): self.assertEqual(cr.get("user", "name"), "Cody Veal") self.assertEqual(cr.get("user", "email"), "cveal05@gmail.com") + def test_config_with_empty_quotes(self): + cr = GitConfigParser(fixture_path("git_config_with_empty_quotes"), read_only=True) + self.assertEqual(cr.get("core", "filemode"), "", "quotes can form a literal empty string as value") + def test_config_with_quotes_with_literal_whitespace(self): cr = GitConfigParser(fixture_path("git_config_with_quotes_whitespace_inside"), read_only=True) self.assertEqual(cr.get("core", "commentString"), "# ") From 2f225244286567b72c865fc7c0219c7dc043575f Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 7 Jun 2025 22:59:24 -0400 Subject: [PATCH 19/27] Remove explicit empty `""` handling in ConfigParser Because literal `""` is a special case of `"..."` as parsed since #2035. --- git/config.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/git/config.py b/git/config.py index 1d58db53a..cdcc32e7b 100644 --- a/git/config.py +++ b/git/config.py @@ -501,9 +501,6 @@ def string_decode(v: str) -> str: if pos != -1 and optval[pos - 1].isspace(): optval = optval[:pos] optval = optval.strip() - if optval == '""': - optval = "" - # END handle empty string optname = self.optionxform(optname.rstrip()) if len(optval) > 1 and optval[0] == '"' and optval[-1] != '"': is_multi_line = True From c8e4aa0f30d06ea2437539a5d56eede1ffa11432 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 7 Jun 2025 23:15:05 -0400 Subject: [PATCH 20/27] Refactor quote parsing, to prepare for more checks This refactors ConfigParser double-quote parsing near the single line double-quoted value parsing code, so that: - Code that parses the name is less intermixed with code that parses the value. - Conditional logic is less duplicated. - The `END` comment notation appears next to the code it describes. - The final `else` can be turned into one or more `elif` followed by `else` to cover different cases of `"..."` differently. (But those are not added here. This commit is purely a refactoring.) (The `pass` suite when `len(optval) < 2 or optval[0] != '"'` is awkward and not really justified right now, but it looks like it may be able to help with readabilty and help keep nesting down when new `elif` cases are added.) --- git/config.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/git/config.py b/git/config.py index cdcc32e7b..afec498f2 100644 --- a/git/config.py +++ b/git/config.py @@ -496,18 +496,23 @@ def string_decode(v: str) -> str: if mo: # We might just have handled the last line, which could contain a quotation we want to remove. optname, vi, optval = mo.group("option", "vi", "value") + optname = self.optionxform(optname.rstrip()) + if vi in ("=", ":") and ";" in optval and not optval.strip().startswith('"'): pos = optval.find(";") if pos != -1 and optval[pos - 1].isspace(): optval = optval[:pos] optval = optval.strip() - optname = self.optionxform(optname.rstrip()) - if len(optval) > 1 and optval[0] == '"' and optval[-1] != '"': + + if len(optval) < 2 or optval[0] != '"': + pass # Nothing to treat as opening quotation. + elif optval[-1] != '"': is_multi_line = True optval = string_decode(optval[1:]) - elif len(optval) > 1 and optval[0] == '"' and optval[-1] == '"': - optval = optval[1:-1] # END handle multi-line + else: + optval = optval[1:-1] + # Preserves multiple values for duplicate optnames. cursect.add(optname, optval) else: From 7bcea08873ca4052ab743f9e2f7cff42e7fe62d8 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 7 Jun 2025 23:59:46 -0400 Subject: [PATCH 21/27] Add tests of ConfigParser with `"..."` with `"` or `\` inside These are cases where just removing the outer quotes without doing anything to the text inside does not give the correct result, and where keeping the quotes may be preferable, in that it was the long-standing behavior of `GitConfigParser`. That this was the long-standing behavior may justify bringing it back when the `"`-`"`-enclosed text contains such characters, but it does not justify preserving it indefinitely: it will still be better to parse the escape sequences, at least in the type case that all of them in a value's representation are well-formed. --- test/fixtures/git_config_with_quotes_escapes | 9 +++++++++ test/test_config.py | 20 ++++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 test/fixtures/git_config_with_quotes_escapes diff --git a/test/fixtures/git_config_with_quotes_escapes b/test/fixtures/git_config_with_quotes_escapes new file mode 100644 index 000000000..33332c221 --- /dev/null +++ b/test/fixtures/git_config_with_quotes_escapes @@ -0,0 +1,9 @@ +[custom] + hasnewline = "first\nsecond" + hasbackslash = "foo\\bar" + hasquote = "ab\"cd" + hastrailingbackslash = "word\\" + hasunrecognized = "p\qrs" + hasunescapedquotes = "ab"cd"e" + ordinary = "hello world" + unquoted = good evening diff --git a/test/test_config.py b/test/test_config.py index 76b918a54..8e1007d9e 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -428,6 +428,26 @@ def test_config_with_quotes_with_whitespace_outside_value(self): cr = GitConfigParser(fixture_path("git_config_with_quotes_whitespace_outside"), read_only=True) self.assertEqual(cr.get("init", "defaultBranch"), "trunk") + def test_config_with_quotes_containing_escapes(self): + """For now just suppress quote removal. But it would be good to interpret most of these.""" + cr = GitConfigParser(fixture_path("git_config_with_quotes_escapes"), read_only=True) + + # These can eventually be supported by substituting the represented character. + self.assertEqual(cr.get("custom", "hasnewline"), R'"first\nsecond"') + self.assertEqual(cr.get("custom", "hasbackslash"), R'"foo\\bar"') + self.assertEqual(cr.get("custom", "hasquote"), R'"ab\"cd"') + self.assertEqual(cr.get("custom", "hastrailingbackslash"), R'"word\\"') + self.assertEqual(cr.get("custom", "hasunrecognized"), R'"p\qrs"') + + # It is less obvious whether and what to eventually do with this. + self.assertEqual(cr.get("custom", "hasunescapedquotes"), '"ab"cd"e"') + + # Cases where quote removal is clearly safe should happen even after those. + self.assertEqual(cr.get("custom", "ordinary"), "hello world") + + # Cases without quotes should still parse correctly even after those, too. + self.assertEqual(cr.get("custom", "unquoted"), "good evening") + def test_get_values_works_without_requiring_any_other_calls_first(self): file_obj = self._to_memcache(fixture_path("git_config_multiple")) cr = GitConfigParser(file_obj, read_only=True) From f2b80410e96a256aed044fba0387eab0440a1525 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sun, 8 Jun 2025 00:09:49 -0400 Subject: [PATCH 22/27] Don't remove quotes if `\` or `"` are present inside This is for single line quoting in the ConfigParser. This leaves the changes in #2035 (as adjusted in #2036) intact for the cases where it addressed #1923: when the `...` in `"..."` (appearing in the value position on a single `{name} = {value}"` line) has no occurrences of `\` or `"`, quote removal is enough. But when `\` or `"` does appear, this suppresses quote removal. This is with the idea that, while it would be better to interpret such lines as Git does, we do not yet do that, so it is preferable to return the same results we have in the past (which some programs may already be handling themselves). This should make the test introduced in the preceding commit pass. But it will be even better to support more syntax, at least well-formed escapes. As noted in the test, both the test and the code under test can be adjusted for that. (See comments in #2035 for context.) --- git/config.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/git/config.py b/git/config.py index afec498f2..5fc099a27 100644 --- a/git/config.py +++ b/git/config.py @@ -505,13 +505,16 @@ def string_decode(v: str) -> str: optval = optval.strip() if len(optval) < 2 or optval[0] != '"': - pass # Nothing to treat as opening quotation. + # Does not open quoting. + pass elif optval[-1] != '"': + # Opens quoting and does not close: appears to start multi-line quoting. is_multi_line = True optval = string_decode(optval[1:]) - # END handle multi-line - else: + elif optval.find("\\", 1, -1) == -1 and optval.find('"', 1, -1) == -1: + # Opens and closes quoting. Single line, and all we need is quote removal. optval = optval[1:-1] + # TODO: Handle other quoted content, especially well-formed backslash escapes. # Preserves multiple values for duplicate optnames. cursect.add(optname, optval) From 1b79d449c90cc6fca8220246e2de159d24666837 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sun, 8 Jun 2025 14:33:58 -0400 Subject: [PATCH 23/27] Improve line-ending consistency in requirements files `requirements-dev.txt`, but none of the others, was tracked with Windows-style (CRLF) line endings. This appears to have been the case since it was introduced in a1b7634 (as `dev-requirements.txt`) and not to be intentional. This only changes how it is stored in the repository. This does not change `.gitattributes` (it is not forced to have LF line endings if automatic line-ending conversions are configured in Git). --- requirements-dev.txt | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index f626644af..01cb2d040 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,8 +1,8 @@ --r requirements.txt --r test-requirements.txt - -# For additional local testing/linting - to be added elsewhere eventually. -ruff -shellcheck -pytest-icdiff -# pytest-profiling +-r requirements.txt +-r test-requirements.txt + +# For additional local testing/linting - to be added elsewhere eventually. +ruff +shellcheck +pytest-icdiff +# pytest-profiling From 6f4f7f5137d63facb61eae2955e6c0801c71b7b5 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sun, 8 Jun 2025 15:05:03 -0400 Subject: [PATCH 24/27] Update Ruff configuration This resolves two warnings about Ruff configuration, by: - No longer setting `ignore-init-module-imports = true` explicitly, which was deprecated since `ruff` 0.4.4. We primarily use `ruff` via `pre-commit`, for which this deprecation has applied since we upgraded the version in `.pre-commit-config.yaml` from 0.4.3 to 0.6.0 in d1582d1 (#1953). We continue to list `F401` ("Module imported but unused") as not automatically fixable, to avoid inadvertently removing imports that may be needed. See also: https://docs.astral.sh/ruff/settings/#lint_ignore-init-module-imports - Rename the rule `TCH004` to `TC004`, since `TCH004` is the old name that may eventually be removed and that is deprecated since 0.8.0. We upgraded `ruff` in `.pre-commit-config.yml` again in b7ce712 (#2031), from 0.6.0 to 0.11.12, at which point this deprecation applied. See also https://astral.sh/blog/ruff-v0.8.0. These changes make those configuration-related warnings go away, and no new diagnostics (errors/warnings) are produced when running `ruff check` or `pre-commit run --all-files`. No F401-related diagnostics are triggered when testing with explicit `ignore-init-module-imports = false`, in preview mode or otherwise. In addition, this commit makes two changes that are not needed to resolve warnings: - Stop excluding `E203` ("Whitespace before ':'"). That diagnostic is no longer failing with the current code here in the current version of `ruff`, and code changes that would cause it to fail would likely be accidentally mis-st - Add the version lower bound `>=0.8` for `ruff` in `requirements-dev.txt`. That file is rarely used, as noted in a8a73ff7 (#1871), but as long as we have it, there may be a benefit to excluding dependency versions for which our configuration is no longer compatible. This is the only change in this commit outside of `pyproject.toml`. --- pyproject.toml | 10 ++++------ requirements-dev.txt | 2 +- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 090972eed..0097e9951 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,16 +60,14 @@ lint.select = [ # "UP", # See: https://docs.astral.sh/ruff/rules/#pyupgrade-up ] lint.extend-select = [ - # "A", # See: https://pypi.org/project/flake8-builtins - "B", # See: https://pypi.org/project/flake8-bugbear - "C4", # See: https://pypi.org/project/flake8-comprehensions - "TCH004", # See: https://docs.astral.sh/ruff/rules/runtime-import-in-type-checking-block/ + # "A", # See: https://pypi.org/project/flake8-builtins + "B", # See: https://pypi.org/project/flake8-bugbear + "C4", # See: https://pypi.org/project/flake8-comprehensions + "TC004", # See: https://docs.astral.sh/ruff/rules/runtime-import-in-type-checking-block/ ] lint.ignore = [ - "E203", # Whitespace before ':' "E731", # Do not assign a `lambda` expression, use a `def` ] -lint.ignore-init-module-imports = true lint.unfixable = [ "F401", # Module imported but unused ] diff --git a/requirements-dev.txt b/requirements-dev.txt index 01cb2d040..066b192b8 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,7 +2,7 @@ -r test-requirements.txt # For additional local testing/linting - to be added elsewhere eventually. -ruff +ruff >=0.8 shellcheck pytest-icdiff # pytest-profiling From a36b8a5a726ee5a17cfd492e49c0b0b2a05b2136 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sun, 8 Jun 2025 16:25:17 -0400 Subject: [PATCH 25/27] Always use a `def` instead of assigning a `lambda` This stops listing Ruff rule `E731` ("Do not assign a `lambda` expression, use a `def`") as ignored, and fixes all occurrences of it: - Spacing is manually adjusted so that readability is not harmed, while still satisfying the current formatting conventions. - Although the affected test modules do not currently use type annotations, the non-test modules do. Some of the lambdas already had type annotations, by annotating the variable itself with an expression formed by subscripting `Callable`. This change preserves them, converting them to paramter and return type annotations in the resulting `def`. Where such type annotations were absent (in lambdas in non-test modules), or partly absent, all missing annotations are added to the `def`. - Unused paramters are prefixed with a `_`. - `IndexFile.checkout` assigned a lambda to `make_exc`, whose body was somewhat difficult to read. Separately from converting it to a `def`, this refactors the expression in the `return` statement to use code like `(x, *ys)` in place of `(x,) + tuple(ys)`. This change does not appear to have introduced (nor fixed) any `mypy` errors. This only affects lambdas that were assigned directly to variables. Other lambda expressions remain unchanged. --- git/index/base.py | 19 +++++++++++++++---- git/objects/tree.py | 4 +++- pyproject.toml | 2 +- test/test_fun.py | 6 +++++- test/test_index.py | 5 ++++- test/test_tree.py | 10 ++++++++-- 6 files changed, 36 insertions(+), 10 deletions(-) diff --git a/git/index/base.py b/git/index/base.py index a95762dca..7cc9d3ade 100644 --- a/git/index/base.py +++ b/git/index/base.py @@ -530,7 +530,10 @@ def unmerged_blobs(self) -> Dict[PathLike, List[Tuple[StageType, Blob]]]: stage. That is, a file removed on the 'other' branch whose entries are at stage 3 will not have a stage 3 entry. """ - is_unmerged_blob = lambda t: t[0] != 0 + + def is_unmerged_blob(t: Tuple[StageType, Blob]) -> bool: + return t[0] != 0 + path_map: Dict[PathLike, List[Tuple[StageType, Blob]]] = {} for stage, blob in self.iter_blobs(is_unmerged_blob): path_map.setdefault(blob.path, []).append((stage, blob)) @@ -690,12 +693,17 @@ def _store_path(self, filepath: PathLike, fprogress: Callable) -> BaseIndexEntry This must be ensured in the calling code. """ st = os.lstat(filepath) # Handles non-symlinks as well. + if S_ISLNK(st.st_mode): # In PY3, readlink is a string, but we need bytes. # In PY2, it was just OS encoded bytes, we assumed UTF-8. - open_stream: Callable[[], BinaryIO] = lambda: BytesIO(force_bytes(os.readlink(filepath), encoding=defenc)) + def open_stream() -> BinaryIO: + return BytesIO(force_bytes(os.readlink(filepath), encoding=defenc)) else: - open_stream = lambda: open(filepath, "rb") + + def open_stream() -> BinaryIO: + return open(filepath, "rb") + with open_stream() as stream: fprogress(filepath, False, filepath) istream = self.repo.odb.store(IStream(Blob.type, st.st_size, stream)) @@ -1336,8 +1344,11 @@ def handle_stderr(proc: "Popen[bytes]", iter_checked_out_files: Iterable[PathLik kwargs["as_process"] = True kwargs["istream"] = subprocess.PIPE proc = self.repo.git.checkout_index(args, **kwargs) + # FIXME: Reading from GIL! - make_exc = lambda: GitCommandError(("git-checkout-index",) + tuple(args), 128, proc.stderr.read()) + def make_exc() -> GitCommandError: + return GitCommandError(("git-checkout-index", *args), 128, proc.stderr.read()) + checked_out_files: List[PathLike] = [] for path in paths: diff --git a/git/objects/tree.py b/git/objects/tree.py index 09184a781..1845d0d0d 100644 --- a/git/objects/tree.py +++ b/git/objects/tree.py @@ -50,7 +50,9 @@ # -------------------------------------------------------- -cmp: Callable[[str, str], int] = lambda a, b: (a > b) - (a < b) + +def cmp(a: str, b: str) -> int: + return (a > b) - (a < b) class TreeModifier: diff --git a/pyproject.toml b/pyproject.toml index 0097e9951..58ed81f17 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,7 +66,7 @@ lint.extend-select = [ "TC004", # See: https://docs.astral.sh/ruff/rules/runtime-import-in-type-checking-block/ ] lint.ignore = [ - "E731", # Do not assign a `lambda` expression, use a `def` + # If it becomes necessary to ignore any rules, list them here. ] lint.unfixable = [ "F401", # Module imported but unused diff --git a/test/test_fun.py b/test/test_fun.py index b8593b400..a456b8aab 100644 --- a/test/test_fun.py +++ b/test/test_fun.py @@ -243,6 +243,7 @@ def test_tree_traversal(self): B_old = self.rorepo.tree("1f66cfbbce58b4b552b041707a12d437cc5f400a") # old base tree # Two very different trees. + entries = traverse_trees_recursive(odb, [B_old.binsha, H.binsha], "") self._assert_tree_entries(entries, 2) @@ -251,7 +252,10 @@ def test_tree_traversal(self): self._assert_tree_entries(oentries, 2) # Single tree. - is_no_tree = lambda i, d: i.type != "tree" + + def is_no_tree(i, _d): + return i.type != "tree" + entries = traverse_trees_recursive(odb, [B.binsha], "") assert len(entries) == len(list(B.traverse(predicate=is_no_tree))) self._assert_tree_entries(entries, 1) diff --git a/test/test_index.py b/test/test_index.py index c42032e70..cf3b90fa6 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -330,7 +330,10 @@ def test_index_file_from_tree(self, rw_repo): assert len([e for e in three_way_index.entries.values() if e.stage != 0]) # ITERATE BLOBS - merge_required = lambda t: t[0] != 0 + + def merge_required(t): + return t[0] != 0 + merge_blobs = list(three_way_index.iter_blobs(merge_required)) assert merge_blobs assert merge_blobs[0][0] in (1, 2, 3) diff --git a/test/test_tree.py b/test/test_tree.py index 73158113d..7ba93bd36 100644 --- a/test/test_tree.py +++ b/test/test_tree.py @@ -126,12 +126,18 @@ def test_traverse(self): assert len(list(root)) == len(list(root.traverse(depth=1))) # Only choose trees. - trees_only = lambda i, d: i.type == "tree" + + def trees_only(i, _d): + return i.type == "tree" + trees = list(root.traverse(predicate=trees_only)) assert len(trees) == len([i for i in root.traverse() if trees_only(i, 0)]) # Test prune. - lib_folder = lambda t, d: t.path == "lib" + + def lib_folder(t, _d): + return t.path == "lib" + pruned_trees = list(root.traverse(predicate=trees_only, prune=lib_folder)) assert len(pruned_trees) < len(trees) From 1d808917168ee292a9e4c5fb575a79133d253bb3 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 11 Jun 2025 14:05:31 +0200 Subject: [PATCH 26/27] 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 27/27] 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")), ]