diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 2fe73ca77..16d5f11bc 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,11 +1,20 @@ version: 2 +enable-beta-ecosystems: true updates: -- package-ecosystem: "github-actions" +- package-ecosystem: github-actions directory: "/" schedule: - interval: "weekly" + interval: weekly -- package-ecosystem: "gitsubmodule" +- package-ecosystem: gitsubmodule directory: "/" schedule: - interval: "weekly" + interval: weekly + +- package-ecosystem: pre-commit + directory: "/" + schedule: + interval: monthly + groups: + pre-commit: + patterns: ["*"] diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 956b38963..e32e946c8 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -7,7 +7,7 @@ permissions: jobs: lint: - runs-on: ubuntu-latest + runs-on: ubuntu-slim steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index ac764d9a7..874e18a8f 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -13,21 +13,32 @@ jobs: strategy: matrix: os-type: [ubuntu, macos, windows] - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.13t"] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.13t", "3.14", "3.14t"] exclude: - os-type: macos python-version: "3.7" # Not available for the ARM-based macOS runners. - os-type: macos python-version: "3.13t" + - os-type: macos + python-version: "3.14t" - os-type: windows - python-version: "3.13" # FIXME: Fix and enable Python 3.13 on Windows (#1955). + python-version: "3.13" # FIXME: Fix and enable Python 3.13 and 3.14 on Windows (#1955). - os-type: windows python-version: "3.13t" + - os-type: windows + python-version: "3.14" + - os-type: windows + python-version: "3.14t" include: - os-ver: latest - os-type: ubuntu python-version: "3.7" os-ver: "22.04" + - build-docs: true # We ensure documentation builds, except on very old interpreters. + - python-version: "3.7" + build-docs: false + - python-version: "3.8" + build-docs: false - experimental: false fail-fast: false @@ -54,8 +65,7 @@ jobs: uses: Vampire/setup-wsl@v6.0.0 with: wsl-version: 1 - distribution: Alpine - additional-packages: bash + distribution: Debian - name: Prepare this repo for tests run: | @@ -109,7 +119,7 @@ jobs: continue-on-error: false - name: Documentation - if: matrix.python-version != '3.7' + if: matrix.build-docs run: | pip install '.[doc]' make -C doc html diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 737b56d45..617111e1d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,13 +1,13 @@ repos: - repo: https://github.com/codespell-project/codespell - rev: v2.4.1 + rev: v2.4.2 hooks: - id: codespell additional_dependencies: [tomli] exclude: ^test/fixtures/ - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.12 + rev: v0.15.8 hooks: - id: ruff-check args: ["--fix"] @@ -16,14 +16,14 @@ repos: exclude: ^git/ext/ - repo: https://github.com/shellcheck-py/shellcheck-py - rev: v0.10.0.1 + rev: v0.11.0.1 hooks: - id: shellcheck args: [--color] exclude: ^test/fixtures/polyglot$|^git/ext/ - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: - id: end-of-file-fixer exclude: ^test/fixtures/|COPYING|LICENSE @@ -33,6 +33,6 @@ repos: - id: check-merge-conflict - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.24.1 + rev: v0.25 hooks: - id: validate-pyproject diff --git a/AUTHORS b/AUTHORS index b57113edd..15333e1e5 100644 --- a/AUTHORS +++ b/AUTHORS @@ -56,5 +56,6 @@ Contributors are: -Ethan Lin -Jonas Scharpf -Gordon Marx +-Enji Cooper Portions derived from other open source works and are clearly marked. diff --git a/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 diff --git a/doc/requirements.txt b/doc/requirements.txt index 81140d898..24472ba39 100644 --- a/doc/requirements.txt +++ b/doc/requirements.txt @@ -1,3 +1,3 @@ -sphinx >= 7.1.2, < 7.2 +sphinx >= 7.4.7, < 8 sphinx_rtd_theme sphinx-autodoc-typehints diff --git a/doc/source/tutorial.rst b/doc/source/tutorial.rst index fd3b14c57..d095d3be3 100644 --- a/doc/source/tutorial.rst +++ b/doc/source/tutorial.rst @@ -513,6 +513,12 @@ The GitDB is a pure-python implementation of the git object database. It is the repo = Repo("path/to/repo", odbt=GitDB) +.. warning:: + ``GitDB`` may fail or become extremely slow when traversing trees in + repositories with very large commits (thousands of changed files in a + single commit). If you encounter ``RecursionError`` or excessive + slowness during tree traversal, switch to ``GitCmdObjectDB`` instead. + GitCmdObjectDB ============== diff --git a/git/cmd.py b/git/cmd.py index 15d7820df..d5fbc7736 100644 --- a/git/cmd.py +++ b/git/cmd.py @@ -368,8 +368,12 @@ def _terminate(self) -> None: status = proc.wait() # Ensure the process goes away. self.status = self._status_code_if_terminate or status - except OSError as ex: - _logger.info("Ignored error after process had died: %r", ex) + except (OSError, AttributeError) as ex: + # On interpreter shutdown (notably on Windows), parts of the stdlib used by + # subprocess can already be torn down (e.g. `subprocess._winapi` becomes None), + # which can cause AttributeError during terminate(). In that case, we prefer + # to silently ignore to avoid noisy "Exception ignored in: __del__" messages. + _logger.info("Ignored error while terminating process: %r", ex) # END exception handling def __del__(self) -> None: @@ -1360,25 +1364,29 @@ def communicate() -> Tuple[AnyStr, AnyStr]: if output_stream is None: stdout_value, stderr_value = communicate() # Strip trailing "\n". - if stdout_value.endswith(newline) and strip_newline_in_stdout: # type: ignore[arg-type] + if stdout_value is not None and stdout_value.endswith(newline) and strip_newline_in_stdout: # type: ignore[arg-type] stdout_value = stdout_value[:-1] - if stderr_value.endswith(newline): # type: ignore[arg-type] + if stderr_value is not None and stderr_value.endswith(newline): # type: ignore[arg-type] stderr_value = stderr_value[:-1] status = proc.returncode else: max_chunk_size = max_chunk_size if max_chunk_size and max_chunk_size > 0 else io.DEFAULT_BUFFER_SIZE - stream_copy(proc.stdout, output_stream, max_chunk_size) - stdout_value = proc.stdout.read() - stderr_value = proc.stderr.read() + if proc.stdout is not None: + stream_copy(proc.stdout, output_stream, max_chunk_size) + stdout_value = proc.stdout.read() + if proc.stderr is not None: + stderr_value = proc.stderr.read() # Strip trailing "\n". - if stderr_value.endswith(newline): # type: ignore[arg-type] + if stderr_value is not None and stderr_value.endswith(newline): # type: ignore[arg-type] stderr_value = stderr_value[:-1] status = proc.wait() # END stdout handling finally: - proc.stdout.close() - proc.stderr.close() + if proc.stdout is not None: + proc.stdout.close() + if proc.stderr is not None: + proc.stderr.close() if self.GIT_PYTHON_TRACE == "full": cmdstr = " ".join(redacted_command) @@ -1568,7 +1576,7 @@ def _call_process( turns into:: - git rev-list max-count 10 --header master + git rev-list --max-count=10 --header=master :return: Same as :meth:`execute`. If no args are given, used :meth:`execute`'s 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/git/ext/gitdb b/git/ext/gitdb index 335c0f661..5c1b3036a 160000 --- a/git/ext/gitdb +++ b/git/ext/gitdb @@ -1 +1 @@ -Subproject commit 335c0f66173eecdc7b2597c2b6c3d1fde795df30 +Subproject commit 5c1b3036a6e34782e0ab6ce85e5ae64fe777fdbe diff --git a/git/index/base.py b/git/index/base.py index 93de7933c..2276343f2 100644 --- a/git/index/base.py +++ b/git/index/base.py @@ -1133,6 +1133,7 @@ def commit( author_date: Union[datetime.datetime, str, None] = None, commit_date: Union[datetime.datetime, str, None] = None, skip_hooks: bool = False, + trailers: Union[None, "Dict[str, str]", "List[Tuple[str, str]]"] = None, ) -> Commit: """Commit the current default index file, creating a :class:`~git.objects.commit.Commit` object. @@ -1169,6 +1170,7 @@ def commit( committer=committer, author_date=author_date, commit_date=commit_date, + trailers=trailers, ) if not skip_hooks: run_commit_hook("post-commit", self) diff --git a/git/objects/commit.py b/git/objects/commit.py index 8c51254a2..da7677ee0 100644 --- a/git/objects/commit.py +++ b/git/objects/commit.py @@ -450,14 +450,7 @@ def trailers_list(self) -> List[Tuple[str, str]]: :return: List containing key-value tuples of whitespace stripped trailer information. """ - cmd = ["git", "interpret-trailers", "--parse"] - proc: Git.AutoInterrupt = self.repo.git.execute( # type: ignore[call-overload] - cmd, - as_process=True, - istream=PIPE, - ) - trailer: str = proc.communicate(str(self.message).encode())[0].decode("utf8") - trailer = trailer.strip() + trailer = self._interpret_trailers(self.repo, self.message, ["--parse"], encoding=self.encoding).strip() if not trailer: return [] @@ -469,6 +462,27 @@ def trailers_list(self) -> List[Tuple[str, str]]: return trailer_list + @classmethod + def _interpret_trailers( + cls, + repo: "Repo", + message: Union[str, bytes], + trailer_args: Sequence[str], + encoding: str = default_encoding, + ) -> str: + message_bytes = message if isinstance(message, bytes) else message.encode(encoding, errors="strict") + cmd = [repo.git.GIT_PYTHON_GIT_EXECUTABLE, "interpret-trailers", *trailer_args] + proc: Git.AutoInterrupt = repo.git.execute( # type: ignore[call-overload] + cmd, + as_process=True, + istream=PIPE, + ) + try: + stdout_bytes, _ = proc.communicate(message_bytes) + return stdout_bytes.decode(encoding, errors="strict") + finally: + finalize_process(proc) + @property def trailers_dict(self) -> Dict[str, List[str]]: """Get the trailers of the message as a dictionary. @@ -570,6 +584,7 @@ def create_from_tree( committer: Union[None, Actor] = None, author_date: Union[None, str, datetime.datetime] = None, commit_date: Union[None, str, datetime.datetime] = None, + trailers: Union[None, Dict[str, str], List[Tuple[str, str]]] = None, ) -> "Commit": """Commit the given tree, creating a :class:`Commit` object. @@ -609,6 +624,14 @@ def create_from_tree( :param commit_date: The timestamp for the committer field. + :param trailers: + Optional trailer key-value pairs to append to the commit message. + Can be a dictionary mapping trailer keys to values, or a list of + ``(key, value)`` tuples (useful when the same key appears multiple + times, e.g. multiple ``Signed-off-by`` trailers). Trailers are + appended using ``git interpret-trailers``. + See :manpage:`git-interpret-trailers(1)`. + :return: :class:`Commit` object representing the new commit. @@ -678,6 +701,21 @@ def create_from_tree( tree = repo.tree(tree) # END tree conversion + # APPLY TRAILERS + if trailers: + trailer_args: List[str] = [] + if isinstance(trailers, dict): + for key, val in trailers.items(): + trailer_args.append("--trailer") + trailer_args.append(f"{key}: {val}") + else: + for key, val in trailers: + trailer_args.append("--trailer") + trailer_args.append(f"{key}: {val}") + + message = cls._interpret_trailers(repo, str(message), trailer_args) + # END apply trailers + # CREATE NEW COMMIT new_commit = cls( repo, diff --git a/git/repo/base.py b/git/repo/base.py index 1f543cc57..16807b9fa 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -1042,11 +1042,19 @@ def active_branch(self) -> Head: :raise TypeError: If HEAD is detached. + :raise ValueError: + If HEAD points to the ``.invalid`` ref Git uses to mark refs as + incompatible with older clients. + :return: :class:`~git.refs.head.Head` to the active branch """ - # reveal_type(self.head.reference) # => Reference - return self.head.reference + active_branch = self.head.reference + if active_branch.name == ".invalid": + raise ValueError( + "HEAD points to 'refs/heads/.invalid', which Git uses to mark refs as incompatible with older clients" + ) + return active_branch def blame_incremental(self, rev: str | HEAD | None, file: str, **kwargs: Any) -> Iterator["BlameEntry"]: """Iterator for blame information for the given file at the given revision. diff --git a/test/test_autointerrupt.py b/test/test_autointerrupt.py new file mode 100644 index 000000000..645ec402c --- /dev/null +++ b/test/test_autointerrupt.py @@ -0,0 +1,33 @@ +from git.cmd import Git + + +class _DummyProc: + """Minimal stand-in for subprocess.Popen used to exercise AutoInterrupt. + + We deliberately raise AttributeError from terminate() to simulate interpreter + shutdown on Windows where subprocess internals (e.g. subprocess._winapi) may + already be torn down. + """ + + stdin = None + stdout = None + stderr = None + + def poll(self): + return None + + def terminate(self): + raise AttributeError("TerminateProcess") + + def wait(self): # pragma: no cover - should not be reached in this test + raise AssertionError("wait() should not be called if terminate() fails") + + +def test_autointerrupt_terminate_ignores_attributeerror(): + ai = Git.AutoInterrupt(_DummyProc(), args=["git", "rev-list"]) + + # Should not raise, even if terminate() triggers AttributeError. + ai._terminate() + + # Ensure the reference is cleared to avoid repeated attempts. + assert ai.proc is None diff --git a/test/test_commit.py b/test/test_commit.py index 37c66e3e7..b56ad3a18 100644 --- a/test/test_commit.py +++ b/test/test_commit.py @@ -566,3 +566,142 @@ def test_commit_co_authors(self): Actor("test_user_2", "another_user-email@github.com"), Actor("test_user_3", "test_user_3@github.com"), ] + + @with_rw_directory + def test_create_from_tree_with_trailers_dict(self, rw_dir): + """Test that create_from_tree supports adding trailers via a dict.""" + rw_repo = Repo.init(osp.join(rw_dir, "test_trailers_dict")) + path = osp.join(str(rw_repo.working_tree_dir), "hello.txt") + touch(path) + rw_repo.index.add([path]) + tree = rw_repo.index.write_tree() + + trailers = {"Issue": "123", "Signed-off-by": "Test User "} + commit = Commit.create_from_tree( + rw_repo, + tree, + "Test commit with trailers", + head=True, + trailers=trailers, + ) + + assert "Issue: 123" in commit.message + assert "Signed-off-by: Test User " in commit.message + assert commit.trailers_dict == { + "Issue": ["123"], + "Signed-off-by": ["Test User "], + } + + @with_rw_directory + def test_create_from_tree_with_trailers_list(self, rw_dir): + """Test that create_from_tree supports adding trailers via a list of tuples.""" + rw_repo = Repo.init(osp.join(rw_dir, "test_trailers_list")) + path = osp.join(str(rw_repo.working_tree_dir), "hello.txt") + touch(path) + rw_repo.index.add([path]) + tree = rw_repo.index.write_tree() + + trailers = [ + ("Signed-off-by", "Alice "), + ("Signed-off-by", "Bob "), + ("Issue", "456"), + ] + commit = Commit.create_from_tree( + rw_repo, + tree, + "Test commit with multiple trailers", + head=True, + trailers=trailers, + ) + + assert "Signed-off-by: Alice " in commit.message + assert "Signed-off-by: Bob " in commit.message + assert "Issue: 456" in commit.message + assert commit.trailers_dict == { + "Signed-off-by": ["Alice ", "Bob "], + "Issue": ["456"], + } + + @with_rw_directory + def test_create_from_tree_with_non_utf8_trailers(self, rw_dir): + """Test that trailer creation and parsing respect the configured commit encoding.""" + rw_repo = Repo.init(osp.join(rw_dir, "test_trailers_non_utf8")) + with rw_repo.config_writer() as writer: + writer.set_value("i18n", "commitencoding", "ISO-8859-1") + + path = osp.join(str(rw_repo.working_tree_dir), "hello.txt") + touch(path) + rw_repo.index.add([path]) + tree = rw_repo.index.write_tree() + + commit = Commit.create_from_tree( + rw_repo, + tree, + "Résumé", + head=True, + trailers={"Reviewed-by": "André "}, + ) + + assert commit.encoding == "ISO-8859-1" + assert "Résumé" in commit.message + assert "Reviewed-by: André " in commit.message + assert commit.trailers_list == [("Reviewed-by", "André ")] + + @with_rw_directory + def test_trailers_list_with_non_utf8_message_bytes(self, rw_dir): + """Test that trailer parsing handles non-UTF-8 commit message bytes.""" + rw_repo = Repo.init(osp.join(rw_dir, "test_trailers_non_utf8_bytes")) + with rw_repo.config_writer() as writer: + writer.set_value("i18n", "commitencoding", "ISO-8859-1") + + path = osp.join(str(rw_repo.working_tree_dir), "hello.txt") + touch(path) + rw_repo.index.add([path]) + tree = rw_repo.index.write_tree() + + commit = Commit.create_from_tree( + rw_repo, + tree, + "Résumé", + head=True, + trailers={"Reviewed-by": "André "}, + ) + + bytes_commit = Commit( + rw_repo, + commit.binsha, + message=commit.message.encode(commit.encoding), + encoding=commit.encoding, + ) + + assert bytes_commit.trailers_list == [("Reviewed-by", "André ")] + + def test_interpret_trailers_encodes_before_launching_process(self): + """Test that encoding failures happen before spawning interpret-trailers.""" + repo = Mock() + repo.git = Mock() + repo.git.GIT_PYTHON_GIT_EXECUTABLE = "git" + + with self.assertRaises(UnicodeEncodeError): + Commit._interpret_trailers(repo, "Euro: €", ["--parse"], encoding="ISO-8859-1") + + repo.git.execute.assert_not_called() + + @with_rw_directory + def test_index_commit_with_trailers(self, rw_dir): + """Test that IndexFile.commit() supports adding trailers.""" + rw_repo = Repo.init(osp.join(rw_dir, "test_index_trailers")) + path = osp.join(str(rw_repo.working_tree_dir), "hello.txt") + touch(path) + rw_repo.index.add([path]) + + trailers = {"Reviewed-by": "Reviewer "} + commit = rw_repo.index.commit( + "Test index commit with trailers", + trailers=trailers, + ) + + assert "Reviewed-by: Reviewer " in commit.message + assert commit.trailers_dict == { + "Reviewed-by": ["Reviewer "], + } 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")', diff --git a/test/test_git.py b/test/test_git.py index 4a54d0d9b..da50fdfe8 100644 --- a/test/test_git.py +++ b/test/test_git.py @@ -6,6 +6,7 @@ import contextlib import gc import inspect +import io import logging import os import os.path as osp @@ -201,6 +202,25 @@ def test_it_logs_istream_summary_for_stdin(self, case): def test_it_executes_git_and_returns_result(self): self.assertRegex(self.git.execute(["git", "version"]), r"^git version [\d\.]{2}.*$") + def test_it_output_stream_with_stdout_is_false(self): + temp_stream = io.BytesIO() + self.git.execute( + ["git", "version"], + output_stream=temp_stream, + with_stdout=False, + ) + self.assertEqual(temp_stream.tell(), 0) + + def test_it_executes_git_without_stdout_redirect(self): + returncode, stdout, stderr = self.git.execute( + ["git", "version"], + with_extended_output=True, + with_stdout=False, + ) + self.assertEqual(returncode, 0) + self.assertIsNone(stdout) + self.assertIsNotNone(stderr) + @ddt.data( # chdir_to_repo, shell, command, use_shell_impostor (False, False, ["git", "version"], False), diff --git a/test/test_repo.py b/test/test_repo.py index 2a92c2523..544b5c561 100644 --- a/test/test_repo.py +++ b/test/test_repo.py @@ -962,6 +962,46 @@ def test_empty_repo(self, rw_dir): assert "BAD MESSAGE" not in contents, "log is corrupt" + @with_rw_directory + def test_active_branch_raises_value_error_when_head_ref_is_invalid(self, rw_dir): + repo = Repo.init(rw_dir) + with open(osp.join(rw_dir, ".git", "HEAD"), "w") as f: + f.write("ref: refs/heads/.invalid\n") + + self.assertRaisesRegex( + ValueError, + r"refs/heads/\.invalid.*older clients", + lambda: repo.active_branch, + ) + + @with_rw_directory + def test_empty_repo_reftable_active_branch(self, rw_dir): + git = Git(rw_dir) + try: + git.init(ref_format="reftable") + except GitCommandError as err: + if err.status == 129: + pytest.skip("git init --ref-format is not supported by this git version") + raise + + repo = Repo(rw_dir) + self.assertEqual(repo.head.reference.name, ".invalid") + self.assertRaisesRegex( + ValueError, + r"refs/heads/\.invalid.*older clients", + lambda: repo.active_branch, + ) + + @with_rw_directory + def test_active_branch_raises_type_error_when_head_is_detached(self, rw_dir): + repo = Repo.init(rw_dir) + with open(osp.join(rw_dir, "a.txt"), "w") as f: + f.write("a") + repo.index.add(["a.txt"]) + repo.index.commit("initial commit") + repo.git.checkout(repo.head.commit.hexsha) + self.assertRaisesRegex(TypeError, "detached symbolic reference", lambda: repo.active_branch) + def test_merge_base(self): repo = self.rorepo c1 = "f6aa8d1" diff --git a/test/test_submodule.py b/test/test_submodule.py index 2bf0940c9..47647f2a1 100644 --- a/test/test_submodule.py +++ b/test/test_submodule.py @@ -1011,6 +1011,7 @@ def test_rename(self, rwdir): # garbage collector detailed in https://github.com/python/cpython/issues/97922.) if sys.platform == "win32" and sys.version_info >= (3, 12): gc.collect() + gc.collect() # Some finalizer scenarios need two collections, at least in theory. new_path = "renamed/myname" assert sm.move(new_path).name == new_path diff --git a/test/test_util.py b/test/test_util.py index 000830f41..e7453769a 100644 --- a/test/test_util.py +++ b/test/test_util.py @@ -113,7 +113,7 @@ def test_deletes_dir_with_readonly_files(self, tmp_path): sys.platform == "cygwin", reason="Cygwin can't set the permissions that make the test meaningful.", ) - def test_avoids_changing_permissions_outside_tree(self, tmp_path): + def test_avoids_changing_permissions_outside_tree(self, tmp_path, request): # Automatically works on Windows, but on Unix requires either special handling # or refraining from attempting to fix PermissionError by making chmod calls. @@ -125,9 +125,32 @@ def test_avoids_changing_permissions_outside_tree(self, tmp_path): dir2 = tmp_path / "dir2" dir2.mkdir() - (dir2 / "symlink").symlink_to(dir1 / "file") + symlink = dir2 / "symlink" + symlink.symlink_to(dir1 / "file") dir2.chmod(stat.S_IRUSR | stat.S_IXUSR) + def preen_dir2(): + """Don't leave unwritable directories behind. + + pytest has difficulties cleaning up after the fact on some platforms, + e.g., macOS, and whines incessantly until the issue is resolved--regardless + of the pytest session. + """ + rwx = stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR + if not dir2.exists(): + return + if symlink.exists(): + try: + # Try lchmod first, if the platform supports it. + symlink.lchmod(rwx) + except NotImplementedError: + # The platform (probably win32) doesn't support lchmod; fall back to chmod. + symlink.chmod(rwx) + dir2.chmod(rwx) + rmtree(dir2) + + request.addfinalizer(preen_dir2) + try: rmtree(dir2) except PermissionError: