From 8f51abd30647c9dfe857621ab97f52d3e5f2fbd9 Mon Sep 17 00:00:00 2001 From: SergeantMenacingGarlic <87030047+SergeantMenacingGarlic@users.noreply.github.com> Date: Mon, 10 Oct 2022 16:13:36 -0400 Subject: [PATCH 001/114] Add datetime.datetime type to commit_date and author_date --- git/index/base.py | 5 +++-- git/objects/commit.py | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/git/index/base.py b/git/index/base.py index 10f8b8b25..17d18db58 100644 --- a/git/index/base.py +++ b/git/index/base.py @@ -4,6 +4,7 @@ # This module is part of GitPython and is released under # the BSD License: http://www.opensource.org/licenses/bsd-license.php +import datetime import glob from io import BytesIO import os @@ -1032,8 +1033,8 @@ def commit( head: bool = True, author: Union[None, "Actor"] = None, committer: Union[None, "Actor"] = None, - author_date: Union[str, None] = None, - commit_date: Union[str, None] = None, + author_date: Union[datetime.datetime, str, None] = None, + commit_date: Union[datetime.datetime, str, None] = None, skip_hooks: bool = False, ) -> Commit: """Commit the current default index file, creating a commit object. diff --git a/git/objects/commit.py b/git/objects/commit.py index cf7d9aaa2..82d2387b3 100644 --- a/git/objects/commit.py +++ b/git/objects/commit.py @@ -435,8 +435,8 @@ def create_from_tree( head: bool = False, author: Union[None, Actor] = None, committer: Union[None, Actor] = None, - author_date: Union[None, str] = None, - commit_date: Union[None, str] = None, + author_date: Union[None, str, datetime.datetime] = None, + commit_date: Union[None, str, datetime.datetime] = None, ) -> "Commit": """Commit the given tree, creating a commit object. From 4171333c24bcbe978328c1be50911d633e57ca47 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Mon, 28 Nov 2022 15:11:31 +0100 Subject: [PATCH 002/114] fix CI by allowing the file protocol as well. --- .github/workflows/cygwin-test.yml | 4 +++- .github/workflows/pythonpackage.yml | 5 +++++ git/ext/gitdb | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cygwin-test.yml b/.github/workflows/cygwin-test.yml index 6fe501249..c80070ef0 100644 --- a/.github/workflows/cygwin-test.yml +++ b/.github/workflows/cygwin-test.yml @@ -28,7 +28,9 @@ jobs: packages: python39 python39-pip python39-virtualenv git - name: Tell git to trust this repo shell: bash.exe -eo pipefail -o igncr "{0}" - run: /usr/bin/git config --global --add safe.directory $(pwd) + run: | + /usr/bin/git config --global --add safe.directory $(pwd) + /usr/bin/git config --global protocol.file.allow always - name: Install dependencies and prepare tests shell: bash.exe -eo pipefail -o igncr "{0}" run: | diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index adc51d29f..5c698bae1 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -60,6 +60,11 @@ jobs: set -x mypy -p git + - name: Tell git to trust this repo + run: | + /usr/bin/git config --global --add safe.directory $(pwd) + /usr/bin/git config --global protocol.file.allow always + - name: Test with pytest run: | set -x diff --git a/git/ext/gitdb b/git/ext/gitdb index 4762d99d9..49c317871 160000 --- a/git/ext/gitdb +++ b/git/ext/gitdb @@ -1 +1 @@ -Subproject commit 4762d99d978586fcdf08ade552f4712bfde6ef22 +Subproject commit 49c3178711ddb3510f0e96297187f823cc019871 From 17ff2630af26b37f82ac1158ee3495c4390da699 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 28 Nov 2022 13:07:35 +0000 Subject: [PATCH 003/114] Bump cygwin/cygwin-install-action from 2 to 3 Bumps [cygwin/cygwin-install-action](https://github.com/cygwin/cygwin-install-action) from 2 to 3. - [Release notes](https://github.com/cygwin/cygwin-install-action/releases) - [Commits](https://github.com/cygwin/cygwin-install-action/compare/v2...v3) --- updated-dependencies: - dependency-name: cygwin/cygwin-install-action dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/cygwin-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cygwin-test.yml b/.github/workflows/cygwin-test.yml index c80070ef0..16b42f89c 100644 --- a/.github/workflows/cygwin-test.yml +++ b/.github/workflows/cygwin-test.yml @@ -23,7 +23,7 @@ jobs: - uses: actions/checkout@v3 with: fetch-depth: 9999 - - uses: cygwin/cygwin-install-action@v2 + - uses: cygwin/cygwin-install-action@v3 with: packages: python39 python39-pip python39-virtualenv git - name: Tell git to trust this repo From fbf9c7e72218e44bc29eb4907d5c00118370376b Mon Sep 17 00:00:00 2001 From: Santos Gallegos Date: Tue, 20 Dec 2022 20:26:37 -0500 Subject: [PATCH 004/114] Fix command injection Add `--` in some commands that receive user input and if interpreted as options could lead to remote code execution (RCE). There may be more commands that could benefit from `--` so the input is never interpreted as an option, but most of those aren't dangerous. Fixed commands: - push - pull - fetch - clone/clone_from and friends - archive (not sure if this one can be exploited, but it doesn't hurt adding `--` :)) For anyone using GitPython and exposing any of the GitPython methods to users, make sure to always validate the input (like if starts with `--`). And for anyone allowing users to pass arbitrary options, be aware that some options may lead fo RCE, like `--exc`, `--upload-pack`, `--receive-pack`, `--config` (https://github.com/gitpython-developers/GitPython/pull/1516). Ref https://github.com/gitpython-developers/GitPython/issues/1517 --- git/remote.py | 5 +++-- git/repo/base.py | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/git/remote.py b/git/remote.py index 7b44020c5..483d536ae 100644 --- a/git/remote.py +++ b/git/remote.py @@ -964,7 +964,7 @@ def fetch( args = [refspec] proc = self.repo.git.fetch( - self, *args, as_process=True, with_stdout=False, universal_newlines=True, v=verbose, **kwargs + "--", self, *args, as_process=True, with_stdout=False, universal_newlines=True, v=verbose, **kwargs ) res = self._get_fetch_info_from_stderr(proc, progress, kill_after_timeout=kill_after_timeout) if hasattr(self.repo.odb, "update_cache"): @@ -991,7 +991,7 @@ def pull( self._assert_refspec() kwargs = add_progress(kwargs, self.repo.git, progress) proc = self.repo.git.pull( - self, refspec, with_stdout=False, as_process=True, universal_newlines=True, v=True, **kwargs + "--", self, refspec, with_stdout=False, as_process=True, universal_newlines=True, v=True, **kwargs ) res = self._get_fetch_info_from_stderr(proc, progress, kill_after_timeout=kill_after_timeout) if hasattr(self.repo.odb, "update_cache"): @@ -1034,6 +1034,7 @@ def push( be 0.""" kwargs = add_progress(kwargs, self.repo.git, progress) proc = self.repo.git.push( + "--", self, refspec, porcelain=True, diff --git a/git/repo/base.py b/git/repo/base.py index c49c61184..49a3d5a16 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -1169,6 +1169,7 @@ def _clone( multi = shlex.split(" ".join(multi_options)) proc = git.clone( multi, + "--", Git.polish_url(str(url)), clone_path, with_extended_output=True, @@ -1305,7 +1306,7 @@ def archive( if not isinstance(path, (tuple, list)): path = [path] # end assure paths is list - self.git.archive(treeish, *path, **kwargs) + self.git.archive("--", treeish, *path, **kwargs) return self def has_separate_working_tree(self) -> bool: From 3c51865399ab7e4454d6d2568d30f9a10ed36f8d Mon Sep 17 00:00:00 2001 From: Santos Gallegos Date: Tue, 20 Dec 2022 22:11:12 -0500 Subject: [PATCH 005/114] Fix CI Taken from https://github.com/gitpython-developers/GitPython/pull/1516/ --- .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 5c698bae1..5373dace6 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.7, 3.7.5, 3.7.12, 3.8, 3.8.0, 3.8.11, 3.8, 3.9, 3.9.0, 3.9.7, "3.10"] + python-version: [3.7, 3.8, 3.9, "3.10", "3.11"] steps: - uses: actions/checkout@v3 From 7918fccff8ba341a8747381162f587749f08d23a Mon Sep 17 00:00:00 2001 From: Santos Gallegos Date: Wed, 21 Dec 2022 22:15:55 -0500 Subject: [PATCH 006/114] Add test --- test/test_repo.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/test_repo.py b/test/test_repo.py index 703dbb438..6382db7e4 100644 --- a/test/test_repo.py +++ b/test/test_repo.py @@ -1180,3 +1180,29 @@ def test_do_not_strip_newline_in_stdout(self, rw_dir): r.git.add(Git.polish_url(fp)) r.git.commit(message="init") self.assertEqual(r.git.show("HEAD:hello.txt", strip_newline_in_stdout=False), "hello\n") + + @with_rw_repo("HEAD") + def test_clone_command_injection(self, rw_repo): + tmp_dir = pathlib.Path(tempfile.mkdtemp()) + unexpected_file = tmp_dir / "pwn" + assert not unexpected_file.exists() + + payload = f"--upload-pack=touch {unexpected_file}" + rw_repo.clone(payload) + + assert not unexpected_file.exists() + # A repo was cloned with the payload as name + assert pathlib.Path(payload).exists() + + @with_rw_repo("HEAD") + def test_clone_from_command_injection(self, rw_repo): + tmp_dir = pathlib.Path(tempfile.mkdtemp()) + temp_repo = Repo.init(tmp_dir / "repo") + unexpected_file = tmp_dir / "pwn" + + assert not unexpected_file.exists() + payload = f"--upload-pack=touch {unexpected_file}" + with self.assertRaises(GitCommandError): + rw_repo.clone_from(payload, temp_repo.common_dir) + + assert not unexpected_file.exists() From 2aae532a3993a100d5074cde70abe548cfc45861 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Thu, 22 Dec 2022 17:08:27 +0100 Subject: [PATCH 007/114] update changelog --- doc/source/changes.rst | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/doc/source/changes.rst b/doc/source/changes.rst index d36194c86..a784a096a 100644 --- a/doc/source/changes.rst +++ b/doc/source/changes.rst @@ -2,6 +2,18 @@ Changelog ========= +3.1.30 +====== + +- Make injections of command-invocations harder or impossible for clone and others. + See https://github.com/gitpython-developers/GitPython/pull/1518 for details. + Note that this might constitute a breaking change for some users, and if so please + let us know and we add an opt-out to this. + +See the following for all changes. +https://github.com/gitpython-developers/gitpython/milestone/60?closed=1 + + 3.1.29 ====== From 2625ed9fc074091c531c27ffcba7902771130261 Mon Sep 17 00:00:00 2001 From: Steve Kowalik Date: Tue, 20 Dec 2022 17:05:50 +1100 Subject: [PATCH 008/114] Forbid unsafe protocol URLs in Repo.clone{,_from}() Since the URL is passed directly to git clone, and the remote-ext helper will happily execute shell commands, so by default disallow URLs that contain a "::" unless a new unsafe_protocols kwarg is passed. (CVE-2022-24439) Fixes #1515 --- git/exc.py | 4 ++++ git/repo/base.py | 31 ++++++++++++++++++++++++++++++- test/test_repo.py | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/git/exc.py b/git/exc.py index 22fcde0d6..b696d792f 100644 --- a/git/exc.py +++ b/git/exc.py @@ -37,6 +37,10 @@ class NoSuchPathError(GitError, OSError): """Thrown if a path could not be access by the system.""" +class UnsafeOptionsUsedError(GitError): + """Thrown if unsafe protocols or options are passed without overridding.""" + + class CommandError(GitError): """Base class for exceptions thrown at every stage of `Popen()` execution. diff --git a/git/repo/base.py b/git/repo/base.py index 49a3d5a16..35ff68b0c 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -21,7 +21,12 @@ ) from git.config import GitConfigParser from git.db import GitCmdObjectDB -from git.exc import InvalidGitRepositoryError, NoSuchPathError, GitCommandError +from git.exc import ( + GitCommandError, + InvalidGitRepositoryError, + NoSuchPathError, + UnsafeOptionsUsedError, +) from git.index import IndexFile from git.objects import Submodule, RootModule, Commit from git.refs import HEAD, Head, Reference, TagReference @@ -128,6 +133,7 @@ class Repo(object): re_envvars = re.compile(r"(\$(\{\s?)?[a-zA-Z_]\w*(\}\s?)?|%\s?[a-zA-Z_]\w*\s?%)") re_author_committer_start = re.compile(r"^(author|committer)") re_tab_full_line = re.compile(r"^\t(.*)$") + re_config_protocol_option = re.compile(r"-[-]?c(|onfig)\s+protocol\.", re.I) # invariants # represents the configuration level of a configuration file @@ -1215,11 +1221,27 @@ def _clone( # END handle remote repo return repo + @classmethod + def unsafe_options( + cls, + url: str, + multi_options: Optional[List[str]] = None, + ) -> bool: + if "ext::" in url: + return True + if multi_options is not None: + if any(["--upload-pack" in m for m in multi_options]): + return True + if any([re.match(cls.re_config_protocol_option, m) for m in multi_options]): + return True + return False + def clone( self, path: PathLike, progress: Optional[Callable] = None, multi_options: Optional[List[str]] = None, + unsafe_protocols: bool = False, **kwargs: Any, ) -> "Repo": """Create a clone from this repository. @@ -1230,12 +1252,15 @@ def clone( option per list item which is passed exactly as specified to clone. For example ['--config core.filemode=false', '--config core.ignorecase', '--recurse-submodule=repo1_path', '--recurse-submodule=repo2_path'] + :param unsafe_protocols: Allow unsafe protocols to be used, like ext :param kwargs: * odbt = ObjectDatabase Type, allowing to determine the object database implementation used by the returned Repo instance * All remaining keyword arguments are given to the git-clone command :return: ``git.Repo`` (the newly cloned repo)""" + if not unsafe_protocols and self.unsafe_options(path, multi_options): + raise UnsafeOptionsUsedError(f"{path} requires unsafe_protocols flag") return self._clone( self.git, self.common_dir, @@ -1254,6 +1279,7 @@ def clone_from( progress: Optional[Callable] = None, env: Optional[Mapping[str, str]] = None, multi_options: Optional[List[str]] = None, + unsafe_protocols: bool = False, **kwargs: Any, ) -> "Repo": """Create a clone from the given URL @@ -1268,11 +1294,14 @@ def clone_from( If you want to unset some variable, consider providing empty string as its value. :param multi_options: See ``clone`` method + :param unsafe_protocols: Allow unsafe protocols to be used, like ext :param kwargs: see the ``clone`` method :return: Repo instance pointing to the cloned directory""" git = cls.GitCommandWrapperType(os.getcwd()) if env is not None: git.update_environment(**env) + if not unsafe_protocols and cls.unsafe_options(url, multi_options): + raise UnsafeOptionsUsedError(f"{url} requires unsafe_protocols flag") return cls._clone(git, url, to_path, GitCmdObjectDB, progress, multi_options, **kwargs) def archive( diff --git a/test/test_repo.py b/test/test_repo.py index 6382db7e4..53cae3cd7 100644 --- a/test/test_repo.py +++ b/test/test_repo.py @@ -13,6 +13,7 @@ import pickle import sys import tempfile +import uuid from unittest import mock, skipIf, SkipTest import pytest @@ -37,6 +38,7 @@ ) from git.exc import ( BadObject, + UnsafeOptionsUsedError, ) from git.repo.fun import touch from test.lib import TestBase, with_rw_repo, fixture @@ -263,6 +265,40 @@ def test_leaking_password_in_clone_logs(self, rw_dir): to_path=rw_dir, ) + def test_unsafe_options(self): + self.assertFalse(Repo.unsafe_options("github.com/deploy/deploy")) + + def test_unsafe_options_ext_url(self): + self.assertTrue(Repo.unsafe_options("ext::ssh")) + + def test_unsafe_options_multi_options_upload_pack(self): + self.assertTrue(Repo.unsafe_options("", ["--upload-pack='touch foo'"])) + + def test_unsafe_options_multi_options_config_user(self): + self.assertFalse(Repo.unsafe_options("", ["--config user"])) + + def test_unsafe_options_multi_options_config_protocol(self): + self.assertTrue(Repo.unsafe_options("", ["--config protocol.foo"])) + + def test_clone_from_forbids_helper_urls_by_default(self): + with self.assertRaises(UnsafeOptionsUsedError): + Repo.clone_from("ext::sh -c touch% /tmp/foo", "tmp") + + @with_rw_repo("HEAD") + def test_clone_from_allow_unsafe(self, repo): + bad_filename = pathlib.Path(f'{tempfile.gettempdir()}/{uuid.uuid4()}') + bad_url = f'ext::sh -c touch% {bad_filename}' + try: + repo.clone_from( + bad_url, 'tmp', + multi_options=["-c protocol.ext.allow=always"], + unsafe_protocols=True + ) + except GitCommandError: + pass + self.assertTrue(bad_filename.is_file()) + bad_filename.unlink() + @with_rw_repo("HEAD") def test_max_chunk_size(self, repo): class TestOutputStream(TestBase): From e6108c7997f5c8f7361b982959518e982b973230 Mon Sep 17 00:00:00 2001 From: Santos Gallegos Date: Fri, 23 Dec 2022 20:19:52 -0500 Subject: [PATCH 009/114] Block unsafe options and protocols by default --- git/cmd.py | 47 +++++++++++++++++++++++++++++- git/exc.py | 8 ++++-- git/remote.py | 73 +++++++++++++++++++++++++++++++++++++++++++---- git/repo/base.py | 63 ++++++++++++++++++++++++---------------- test/test_repo.py | 5 ++-- 5 files changed, 160 insertions(+), 36 deletions(-) diff --git a/git/cmd.py b/git/cmd.py index 3dd5aad33..9d4006b9d 100644 --- a/git/cmd.py +++ b/git/cmd.py @@ -4,6 +4,7 @@ # This module is part of GitPython and is released under # the BSD License: http://www.opensource.org/licenses/bsd-license.php from __future__ import annotations +import re from contextlib import contextmanager import io import logging @@ -24,7 +25,7 @@ from git.exc import CommandError from git.util import is_cygwin_git, cygpath, expand_path, remove_password_if_present -from .exc import GitCommandError, GitCommandNotFound +from .exc import GitCommandError, GitCommandNotFound, UnsafeOptionError, UnsafeProtocolError from .util import ( LazyMixin, stream_copy, @@ -262,6 +263,8 @@ class Git(LazyMixin): _excluded_ = ("cat_file_all", "cat_file_header", "_version_info") + re_unsafe_protocol = re.compile("(.+)::.+") + def __getstate__(self) -> Dict[str, Any]: return slots_to_dict(self, exclude=self._excluded_) @@ -454,6 +457,48 @@ def polish_url(cls, url: str, is_cygwin: Union[None, bool] = None) -> PathLike: url = url.replace("\\\\", "\\").replace("\\", "/") return url + @classmethod + def check_unsafe_protocols(cls, url: str) -> None: + """ + Check for unsafe protocols. + + Apart from the usual protocols (http, git, ssh), + Git allows "remote helpers" that have the form `::
`, + one of these helpers (`ext::`) can be used to invoke any arbitrary command. + + See: + + - https://git-scm.com/docs/gitremote-helpers + - https://git-scm.com/docs/git-remote-ext + """ + match = cls.re_unsafe_protocol.match(url) + if match: + protocol = match.group(1) + raise UnsafeProtocolError( + f"The `{protocol}::` protocol looks suspicious, use `allow_unsafe_protocols=True` to allow it." + ) + + @classmethod + def check_unsafe_options(cls, options: List[str], unsafe_options: List[str]) -> None: + """ + Check for unsafe options. + + Some options that are passed to `git ` can be used to execute + arbitrary commands, this are blocked by default. + """ + # Options can be of the form `foo` or `--foo bar` `--foo=bar`, + # so we need to check if they start with "--foo" or if they are equal to "foo". + bare_options = [ + option.lstrip("-") + for option in unsafe_options + ] + for option in options: + for unsafe_option, bare_option in zip(unsafe_options, bare_options): + if option.startswith(unsafe_option) or option == bare_option: + raise UnsafeOptionError( + f"{unsafe_option} is not allowed, use `allow_unsafe_options=True` to allow it." + ) + class AutoInterrupt(object): """Kill/Interrupt the stored process instance once this instance goes out of scope. It is used to prevent processes piling up in case iterators stop reading. diff --git a/git/exc.py b/git/exc.py index b696d792f..9b69a5889 100644 --- a/git/exc.py +++ b/git/exc.py @@ -37,8 +37,12 @@ class NoSuchPathError(GitError, OSError): """Thrown if a path could not be access by the system.""" -class UnsafeOptionsUsedError(GitError): - """Thrown if unsafe protocols or options are passed without overridding.""" +class UnsafeProtocolError(GitError): + """Thrown if unsafe protocols are passed without being explicitly allowed.""" + + +class UnsafeOptionError(GitError): + """Thrown if unsafe options are passed without being explicitly allowed.""" class CommandError(GitError): diff --git a/git/remote.py b/git/remote.py index 483d536ae..1eff00b96 100644 --- a/git/remote.py +++ b/git/remote.py @@ -535,6 +535,23 @@ class Remote(LazyMixin, IterableObj): __slots__ = ("repo", "name", "_config_reader") _id_attribute_ = "name" + unsafe_git_fetch_options = [ + # This option allows users to execute arbitrary commands. + # https://git-scm.com/docs/git-fetch#Documentation/git-fetch.txt---upload-packltupload-packgt + "--upload-pack", + ] + unsafe_git_pull_options = [ + # This option allows users to execute arbitrary commands. + # https://git-scm.com/docs/git-pull#Documentation/git-pull.txt---upload-packltupload-packgt + "--upload-pack" + ] + unsafe_git_push_options = [ + # This option allows users to execute arbitrary commands. + # https://git-scm.com/docs/git-push#Documentation/git-push.txt---execltgit-receive-packgt + "--receive-pack", + "--exec", + ] + def __init__(self, repo: "Repo", name: str) -> None: """Initialize a remote instance @@ -611,7 +628,9 @@ def iter_items(cls, repo: "Repo", *args: Any, **kwargs: Any) -> Iterator["Remote yield Remote(repo, section[lbound + 1 : rbound]) # END for each configuration section - def set_url(self, new_url: str, old_url: Optional[str] = None, **kwargs: Any) -> "Remote": + def set_url( + self, new_url: str, old_url: Optional[str] = None, allow_unsafe_protocols: bool = False, **kwargs: Any + ) -> "Remote": """Configure URLs on current remote (cf command git remote set_url) This command manages URLs on the remote. @@ -620,15 +639,17 @@ def set_url(self, new_url: str, old_url: Optional[str] = None, **kwargs: Any) -> :param old_url: when set, replaces this URL with new_url for the remote :return: self """ + if not allow_unsafe_protocols: + Git.check_unsafe_protocols(new_url) scmd = "set-url" kwargs["insert_kwargs_after"] = scmd if old_url: - self.repo.git.remote(scmd, self.name, new_url, old_url, **kwargs) + self.repo.git.remote(scmd, "--", self.name, new_url, old_url, **kwargs) else: - self.repo.git.remote(scmd, self.name, new_url, **kwargs) + self.repo.git.remote(scmd, "--", self.name, new_url, **kwargs) return self - def add_url(self, url: str, **kwargs: Any) -> "Remote": + def add_url(self, url: str, allow_unsafe_protocols: bool = False, **kwargs: Any) -> "Remote": """Adds a new url on current remote (special case of git remote set_url) This command adds new URLs to a given remote, making it possible to have @@ -637,6 +658,8 @@ def add_url(self, url: str, **kwargs: Any) -> "Remote": :param url: string being the URL to add as an extra remote URL :return: self """ + if not allow_unsafe_protocols: + Git.check_unsafe_protocols(url) return self.set_url(url, add=True) def delete_url(self, url: str, **kwargs: Any) -> "Remote": @@ -729,7 +752,7 @@ def stale_refs(self) -> IterableList[Reference]: return out_refs @classmethod - def create(cls, repo: "Repo", name: str, url: str, **kwargs: Any) -> "Remote": + def create(cls, repo: "Repo", name: str, url: str, allow_unsafe_protocols: bool = False, **kwargs: Any) -> "Remote": """Create a new remote to the given repository :param repo: Repository instance that is to receive the new remote :param name: Desired name of the remote @@ -739,7 +762,10 @@ def create(cls, repo: "Repo", name: str, url: str, **kwargs: Any) -> "Remote": :raise GitCommandError: in case an origin with that name already exists""" scmd = "add" kwargs["insert_kwargs_after"] = scmd - repo.git.remote(scmd, name, Git.polish_url(url), **kwargs) + url = Git.polish_url(url) + if not allow_unsafe_protocols: + Git.check_unsafe_protocols(url) + repo.git.remote(scmd, "--", name, url, **kwargs) return cls(repo, name) # add is an alias @@ -921,6 +947,8 @@ def fetch( progress: Union[RemoteProgress, None, "UpdateProgress"] = None, verbose: bool = True, kill_after_timeout: Union[None, float] = None, + allow_unsafe_protocols: bool = False, + allow_unsafe_options: bool = False, **kwargs: Any, ) -> IterableList[FetchInfo]: """Fetch the latest changes for this remote @@ -963,6 +991,14 @@ def fetch( else: args = [refspec] + if not allow_unsafe_protocols: + for ref in args: + if ref: + Git.check_unsafe_protocols(ref) + + if not allow_unsafe_options: + Git.check_unsafe_options(options=list(kwargs.keys()), unsafe_options=self.unsafe_git_fetch_options) + proc = self.repo.git.fetch( "--", self, *args, as_process=True, with_stdout=False, universal_newlines=True, v=verbose, **kwargs ) @@ -976,6 +1012,8 @@ def pull( refspec: Union[str, List[str], None] = None, progress: Union[RemoteProgress, "UpdateProgress", None] = None, kill_after_timeout: Union[None, float] = None, + allow_unsafe_protocols: bool = False, + allow_unsafe_options: bool = False, **kwargs: Any, ) -> IterableList[FetchInfo]: """Pull changes from the given branch, being the same as a fetch followed @@ -990,6 +1028,16 @@ def pull( # No argument refspec, then ensure the repo's config has a fetch refspec. self._assert_refspec() kwargs = add_progress(kwargs, self.repo.git, progress) + + if not allow_unsafe_protocols and refspec: + if isinstance(refspec, str): + Git.check_unsafe_protocols(refspec) + else: + for ref in refspec: + Git.check_unsafe_protocols(ref) + if not allow_unsafe_options: + Git.check_unsafe_options(options=list(kwargs.keys()), unsafe_options=self.unsafe_git_pull_options) + proc = self.repo.git.pull( "--", self, refspec, with_stdout=False, as_process=True, universal_newlines=True, v=True, **kwargs ) @@ -1003,6 +1051,8 @@ def push( refspec: Union[str, List[str], None] = None, progress: Union[RemoteProgress, "UpdateProgress", Callable[..., RemoteProgress], None] = None, kill_after_timeout: Union[None, float] = None, + allow_unsafe_protocols: bool = False, + allow_unsafe_options: bool = False, **kwargs: Any, ) -> IterableList[PushInfo]: """Push changes from source branch in refspec to target branch in refspec. @@ -1033,6 +1083,17 @@ def push( If the operation fails completely, the length of the returned IterableList will be 0.""" kwargs = add_progress(kwargs, self.repo.git, progress) + + if not allow_unsafe_protocols and refspec: + if isinstance(refspec, str): + Git.check_unsafe_protocols(refspec) + else: + for ref in refspec: + Git.check_unsafe_protocols(ref) + + if not allow_unsafe_options: + Git.check_unsafe_options(options=list(kwargs.keys()), unsafe_options=self.unsafe_git_push_options) + proc = self.repo.git.push( "--", self, diff --git a/git/repo/base.py b/git/repo/base.py index 35ff68b0c..7473c52ed 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -25,7 +25,6 @@ GitCommandError, InvalidGitRepositoryError, NoSuchPathError, - UnsafeOptionsUsedError, ) from git.index import IndexFile from git.objects import Submodule, RootModule, Commit @@ -133,7 +132,18 @@ class Repo(object): re_envvars = re.compile(r"(\$(\{\s?)?[a-zA-Z_]\w*(\}\s?)?|%\s?[a-zA-Z_]\w*\s?%)") re_author_committer_start = re.compile(r"^(author|committer)") re_tab_full_line = re.compile(r"^\t(.*)$") - re_config_protocol_option = re.compile(r"-[-]?c(|onfig)\s+protocol\.", re.I) + + unsafe_git_clone_options = [ + # This option allows users to execute arbitrary commands. + # https://git-scm.com/docs/git-clone#Documentation/git-clone.txt---upload-packltupload-packgt + "--upload-pack", + "-u", + # Users can override configuration variables + # like `protocol.allow` or `core.gitProxy` to execute arbitrary commands. + # https://git-scm.com/docs/git-clone#Documentation/git-clone.txt---configltkeygtltvaluegt + "--config", + "-c", + ] # invariants # represents the configuration level of a configuration file @@ -961,7 +971,7 @@ def blame( file: str, incremental: bool = False, rev_opts: Optional[List[str]] = None, - **kwargs: Any + **kwargs: Any, ) -> List[List[Commit | List[str | bytes] | None]] | Iterator[BlameEntry] | None: """The blame information for the given file at the given revision. @@ -1152,6 +1162,8 @@ def _clone( odb_default_type: Type[GitCmdObjectDB], progress: Union["RemoteProgress", "UpdateProgress", Callable[..., "RemoteProgress"], None] = None, multi_options: Optional[List[str]] = None, + allow_unsafe_protocols: bool = False, + allow_unsafe_options: bool = False, **kwargs: Any, ) -> "Repo": odbt = kwargs.pop("odbt", odb_default_type) @@ -1173,6 +1185,12 @@ def _clone( multi = None if multi_options: multi = shlex.split(" ".join(multi_options)) + + if not allow_unsafe_protocols: + Git.check_unsafe_protocols(str(url)) + if not allow_unsafe_options and multi_options: + Git.check_unsafe_options(options=multi_options, unsafe_options=cls.unsafe_git_clone_options) + proc = git.clone( multi, "--", @@ -1221,27 +1239,13 @@ def _clone( # END handle remote repo return repo - @classmethod - def unsafe_options( - cls, - url: str, - multi_options: Optional[List[str]] = None, - ) -> bool: - if "ext::" in url: - return True - if multi_options is not None: - if any(["--upload-pack" in m for m in multi_options]): - return True - if any([re.match(cls.re_config_protocol_option, m) for m in multi_options]): - return True - return False - def clone( self, path: PathLike, progress: Optional[Callable] = None, multi_options: Optional[List[str]] = None, - unsafe_protocols: bool = False, + allow_unsafe_protocols: bool = False, + allow_unsafe_options: bool = False, **kwargs: Any, ) -> "Repo": """Create a clone from this repository. @@ -1259,8 +1263,6 @@ def clone( * All remaining keyword arguments are given to the git-clone command :return: ``git.Repo`` (the newly cloned repo)""" - if not unsafe_protocols and self.unsafe_options(path, multi_options): - raise UnsafeOptionsUsedError(f"{path} requires unsafe_protocols flag") return self._clone( self.git, self.common_dir, @@ -1268,6 +1270,8 @@ def clone( type(self.odb), progress, multi_options, + allow_unsafe_protocols=allow_unsafe_protocols, + allow_unsafe_options=allow_unsafe_options, **kwargs, ) @@ -1279,7 +1283,8 @@ def clone_from( progress: Optional[Callable] = None, env: Optional[Mapping[str, str]] = None, multi_options: Optional[List[str]] = None, - unsafe_protocols: bool = False, + allow_unsafe_protocols: bool = False, + allow_unsafe_options: bool = False, **kwargs: Any, ) -> "Repo": """Create a clone from the given URL @@ -1300,9 +1305,17 @@ def clone_from( git = cls.GitCommandWrapperType(os.getcwd()) if env is not None: git.update_environment(**env) - if not unsafe_protocols and cls.unsafe_options(url, multi_options): - raise UnsafeOptionsUsedError(f"{url} requires unsafe_protocols flag") - return cls._clone(git, url, to_path, GitCmdObjectDB, progress, multi_options, **kwargs) + return cls._clone( + git, + url, + to_path, + GitCmdObjectDB, + progress, + multi_options, + allow_unsafe_protocols=allow_unsafe_protocols, + allow_unsafe_options=allow_unsafe_options, + **kwargs, + ) def archive( self, diff --git a/test/test_repo.py b/test/test_repo.py index 53cae3cd7..a937836f9 100644 --- a/test/test_repo.py +++ b/test/test_repo.py @@ -38,7 +38,8 @@ ) from git.exc import ( BadObject, - UnsafeOptionsUsedError, + UnsafeOptionError, + UnsafeProtocolError, ) from git.repo.fun import touch from test.lib import TestBase, with_rw_repo, fixture @@ -281,7 +282,7 @@ def test_unsafe_options_multi_options_config_protocol(self): self.assertTrue(Repo.unsafe_options("", ["--config protocol.foo"])) def test_clone_from_forbids_helper_urls_by_default(self): - with self.assertRaises(UnsafeOptionsUsedError): + with self.assertRaises(UnsafeOptionError): Repo.clone_from("ext::sh -c touch% /tmp/foo", "tmp") @with_rw_repo("HEAD") From fd2c6da5f82009398d241dc07603fbcd490ced29 Mon Sep 17 00:00:00 2001 From: Santos Gallegos Date: Tue, 27 Dec 2022 16:56:43 -0500 Subject: [PATCH 010/114] Updates from review --- git/cmd.py | 10 +++++----- git/remote.py | 21 +++++++++------------ test/test_git.py | 4 ++-- 3 files changed, 16 insertions(+), 19 deletions(-) diff --git a/git/cmd.py b/git/cmd.py index 9d4006b9d..9ef1e3a65 100644 --- a/git/cmd.py +++ b/git/cmd.py @@ -488,12 +488,12 @@ def check_unsafe_options(cls, options: List[str], unsafe_options: List[str]) -> """ # Options can be of the form `foo` or `--foo bar` `--foo=bar`, # so we need to check if they start with "--foo" or if they are equal to "foo". - bare_options = [ + bare_unsafe_options = [ option.lstrip("-") for option in unsafe_options ] for option in options: - for unsafe_option, bare_option in zip(unsafe_options, bare_options): + for unsafe_option, bare_option in zip(unsafe_options, bare_unsafe_options): if option.startswith(unsafe_option) or option == bare_option: raise UnsafeOptionError( f"{unsafe_option} is not allowed, use `allow_unsafe_options=True` to allow it." @@ -1193,12 +1193,12 @@ def transform_kwargs(self, split_single_char_options: bool = True, **kwargs: Any return args @classmethod - def __unpack_args(cls, arg_list: Sequence[str]) -> List[str]: + def _unpack_args(cls, arg_list: Sequence[str]) -> List[str]: outlist = [] if isinstance(arg_list, (list, tuple)): for arg in arg_list: - outlist.extend(cls.__unpack_args(arg)) + outlist.extend(cls._unpack_args(arg)) else: outlist.append(str(arg_list)) @@ -1283,7 +1283,7 @@ def _call_process( # Prepare the argument list opt_args = self.transform_kwargs(**opts_kwargs) - ext_args = self.__unpack_args([a for a in args if a is not None]) + ext_args = self._unpack_args([a for a in args if a is not None]) if insert_after_this_arg is None: args_list = opt_args + ext_args diff --git a/git/remote.py b/git/remote.py index 1eff00b96..520544b66 100644 --- a/git/remote.py +++ b/git/remote.py @@ -1029,12 +1029,11 @@ def pull( self._assert_refspec() kwargs = add_progress(kwargs, self.repo.git, progress) - if not allow_unsafe_protocols and refspec: - if isinstance(refspec, str): - Git.check_unsafe_protocols(refspec) - else: - for ref in refspec: - Git.check_unsafe_protocols(ref) + refspec = Git._unpack_args(refspec or []) + if not allow_unsafe_protocols: + for ref in refspec: + Git.check_unsafe_protocols(ref) + if not allow_unsafe_options: Git.check_unsafe_options(options=list(kwargs.keys()), unsafe_options=self.unsafe_git_pull_options) @@ -1084,12 +1083,10 @@ def push( be 0.""" kwargs = add_progress(kwargs, self.repo.git, progress) - if not allow_unsafe_protocols and refspec: - if isinstance(refspec, str): - Git.check_unsafe_protocols(refspec) - else: - for ref in refspec: - Git.check_unsafe_protocols(ref) + refspec = Git._unpack_args(refspec or []) + if not allow_unsafe_protocols: + for ref in refspec: + Git.check_unsafe_protocols(ref) if not allow_unsafe_options: Git.check_unsafe_options(options=list(kwargs.keys()), unsafe_options=self.unsafe_git_push_options) diff --git a/test/test_git.py b/test/test_git.py index 6ba833b4a..e7d236deb 100644 --- a/test/test_git.py +++ b/test/test_git.py @@ -39,12 +39,12 @@ def test_call_process_calls_execute(self, git): self.assertEqual(git.call_args, ((["git", "version"],), {})) def test_call_unpack_args_unicode(self): - args = Git._Git__unpack_args("Unicode€™") + args = Git._unpack_args("Unicode€™") mangled_value = "Unicode\u20ac\u2122" self.assertEqual(args, [mangled_value]) def test_call_unpack_args(self): - args = Git._Git__unpack_args(["git", "log", "--", "Unicode€™"]) + args = Git._unpack_args(["git", "log", "--", "Unicode€™"]) mangled_value = "Unicode\u20ac\u2122" self.assertEqual(args, ["git", "log", "--", mangled_value]) From b92f01a3a38fc8e171d08575c69de9733811faa6 Mon Sep 17 00:00:00 2001 From: Santos Gallegos Date: Tue, 27 Dec 2022 18:22:58 -0500 Subject: [PATCH 011/114] Update/add tests for Repo.clone* --- test/test_repo.py | 148 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 121 insertions(+), 27 deletions(-) diff --git a/test/test_repo.py b/test/test_repo.py index a937836f9..72320184f 100644 --- a/test/test_repo.py +++ b/test/test_repo.py @@ -13,7 +13,6 @@ import pickle import sys import tempfile -import uuid from unittest import mock, skipIf, SkipTest import pytest @@ -226,6 +225,7 @@ def test_clone_from_pathlib_withConfig(self, rw_dir): "--config submodule.repo.update=checkout", "--config filter.lfs.clean='git-lfs clean -- %f'", ], + allow_unsafe_options=True, ) self.assertEqual(cloned.config_reader().get_value("submodule", "active"), "repo") @@ -266,39 +266,133 @@ def test_leaking_password_in_clone_logs(self, rw_dir): to_path=rw_dir, ) - def test_unsafe_options(self): - self.assertFalse(Repo.unsafe_options("github.com/deploy/deploy")) + @with_rw_repo("HEAD") + def test_clone_unsafe_options(self, rw_repo): + tmp_dir = pathlib.Path(tempfile.mkdtemp()) + tmp_file = tmp_dir / "pwn" + unsafe_options = [ + f"--upload-pack='touch {tmp_file}'", + f"-u 'touch {tmp_file}'", + "--config=protocol.ext.allow=always", + "-c protocol.ext.allow=always", + ] + for unsafe_option in unsafe_options: + with self.assertRaises(UnsafeOptionError): + rw_repo.clone(tmp_dir, multi_options=[unsafe_option]) - def test_unsafe_options_ext_url(self): - self.assertTrue(Repo.unsafe_options("ext::ssh")) + @with_rw_repo("HEAD") + def test_clone_unsafe_options_allowed(self, rw_repo): + tmp_dir = pathlib.Path(tempfile.mkdtemp()) + tmp_file = tmp_dir / "pwn" + unsafe_options = [ + f"--upload-pack='touch {tmp_file}'", + f"-u 'touch {tmp_file}'", + ] + for i, unsafe_option in enumerate(unsafe_options): + destination = tmp_dir / str(i) + # The options will be allowed, but the command will fail. + with self.assertRaises(GitCommandError): + rw_repo.clone(destination, multi_options=[unsafe_option], allow_unsafe_options=True) + + unsafe_options = [ + "--config=protocol.ext.allow=always", + "-c protocol.ext.allow=always", + ] + for i, unsafe_option in enumerate(unsafe_options): + destination = tmp_dir / str(i) + assert not destination.exists() + rw_repo.clone(destination, multi_options=[unsafe_option], allow_unsafe_options=True) + assert destination.exists() - def test_unsafe_options_multi_options_upload_pack(self): - self.assertTrue(Repo.unsafe_options("", ["--upload-pack='touch foo'"])) + @with_rw_repo("HEAD") + def test_clone_safe_options(self, rw_repo): + tmp_dir = pathlib.Path(tempfile.mkdtemp()) + options = [ + "--depth=1", + "--single-branch", + "-q", + ] + for option in options: + destination = tmp_dir / option + assert not destination.exists() + rw_repo.clone(destination, multi_options=[option]) + assert destination.exists() - def test_unsafe_options_multi_options_config_user(self): - self.assertFalse(Repo.unsafe_options("", ["--config user"])) + @with_rw_repo("HEAD") + def test_clone_from_unsafe_options(self, rw_repo): + tmp_dir = pathlib.Path(tempfile.mkdtemp()) + tmp_file = tmp_dir / "pwn" + unsafe_options = [ + f"--upload-pack='touch {tmp_file}'", + f"-u 'touch {tmp_file}'", + "--config=protocol.ext.allow=always", + "-c protocol.ext.allow=always", + ] + for unsafe_option in unsafe_options: + with self.assertRaises(UnsafeOptionError): + Repo.clone_from(rw_repo.working_dir, tmp_dir, multi_options=[unsafe_option]) - def test_unsafe_options_multi_options_config_protocol(self): - self.assertTrue(Repo.unsafe_options("", ["--config protocol.foo"])) + @with_rw_repo("HEAD") + def test_clone_from_unsafe_options_allowed(self, rw_repo): + tmp_dir = pathlib.Path(tempfile.mkdtemp()) + tmp_file = tmp_dir / "pwn" + unsafe_options = [ + f"--upload-pack='touch {tmp_file}'", + f"-u 'touch {tmp_file}'", + ] + for i, unsafe_option in enumerate(unsafe_options): + destination = tmp_dir / str(i) + # The options will be allowed, but the command will fail. + with self.assertRaises(GitCommandError): + Repo.clone_from( + rw_repo.working_dir, destination, multi_options=[unsafe_option], allow_unsafe_options=True + ) - def test_clone_from_forbids_helper_urls_by_default(self): - with self.assertRaises(UnsafeOptionError): - Repo.clone_from("ext::sh -c touch% /tmp/foo", "tmp") + unsafe_options = [ + "--config=protocol.ext.allow=always", + "-c protocol.ext.allow=always", + ] + for i, unsafe_option in enumerate(unsafe_options): + destination = tmp_dir / str(i) + assert not destination.exists() + Repo.clone_from(rw_repo.working_dir, destination, multi_options=[unsafe_option], allow_unsafe_options=True) + assert destination.exists() @with_rw_repo("HEAD") - def test_clone_from_allow_unsafe(self, repo): - bad_filename = pathlib.Path(f'{tempfile.gettempdir()}/{uuid.uuid4()}') - bad_url = f'ext::sh -c touch% {bad_filename}' - try: - repo.clone_from( - bad_url, 'tmp', - multi_options=["-c protocol.ext.allow=always"], - unsafe_protocols=True - ) - except GitCommandError: - pass - self.assertTrue(bad_filename.is_file()) - bad_filename.unlink() + def test_clone_from_safe_options(self, rw_repo): + tmp_dir = pathlib.Path(tempfile.mkdtemp()) + options = [ + "--depth=1", + "--single-branch", + "-q", + ] + for option in options: + destination = tmp_dir / option + assert not destination.exists() + Repo.clone_from(rw_repo.common_dir, destination, multi_options=[option]) + assert destination.exists() + + def test_clone_from_unsafe_procol(self): + tmp_dir = pathlib.Path(tempfile.mkdtemp()) + urls = [ + "ext::sh -c touch% /tmp/pwn", + "fd::17/foo", + ] + for url in urls: + with self.assertRaises(UnsafeProtocolError): + Repo.clone_from(url, tmp_dir) + + def test_clone_from_unsafe_procol_allowed(self): + tmp_dir = pathlib.Path(tempfile.mkdtemp()) + urls = [ + "ext::sh -c touch% /tmp/pwn", + "fd::/foo", + ] + for url in urls: + # The URL will be allowed into the command, but the command will + # fail since we don't have that protocol enabled in the Git config file. + with self.assertRaises(GitCommandError): + Repo.clone_from(url, tmp_dir, allow_unsafe_protocols=True) @with_rw_repo("HEAD") def test_max_chunk_size(self, repo): From c8ae33b9314a7d3716827b5cb705a3cd0a2e4a46 Mon Sep 17 00:00:00 2001 From: Santos Gallegos Date: Tue, 27 Dec 2022 19:15:40 -0500 Subject: [PATCH 012/114] More tests --- git/objects/submodule/base.py | 36 +++++- git/remote.py | 4 +- test/test_remote.py | 211 ++++++++++++++++++++++++++++++++++ test/test_submodule.py | 3 +- 4 files changed, 247 insertions(+), 7 deletions(-) diff --git a/git/objects/submodule/base.py b/git/objects/submodule/base.py index bdcdf1ec5..9aa9deb27 100644 --- a/git/objects/submodule/base.py +++ b/git/objects/submodule/base.py @@ -272,7 +272,16 @@ def _module_abspath(cls, parent_repo: "Repo", path: PathLike, name: str) -> Path # end @classmethod - def _clone_repo(cls, repo: "Repo", url: str, path: PathLike, name: str, **kwargs: Any) -> "Repo": + def _clone_repo( + cls, + repo: "Repo", + url: str, + path: PathLike, + name: str, + allow_unsafe_options: bool = False, + allow_unsafe_protocols: bool = False, + **kwargs: Any, + ) -> "Repo": """:return: Repo instance of newly cloned repository :param repo: our parent repository :param url: url to clone from @@ -289,7 +298,13 @@ def _clone_repo(cls, repo: "Repo", url: str, path: PathLike, name: str, **kwargs module_checkout_path = osp.join(str(repo.working_tree_dir), path) # end - clone = git.Repo.clone_from(url, module_checkout_path, **kwargs) + clone = git.Repo.clone_from( + url, + module_checkout_path, + allow_unsafe_options=allow_unsafe_options, + allow_unsafe_protocols=allow_unsafe_protocols, + **kwargs, + ) if cls._need_gitfile_submodules(repo.git): cls._write_git_file_and_module_config(module_checkout_path, module_abspath) # end @@ -359,6 +374,8 @@ def add( depth: Union[int, None] = None, env: Union[Mapping[str, str], None] = None, clone_multi_options: Union[Sequence[TBD], None] = None, + allow_unsafe_options: bool = False, + allow_unsafe_protocols: bool = False, ) -> "Submodule": """Add a new submodule to the given repository. This will alter the index as well as the .gitmodules file, but will not create a new commit. @@ -475,7 +492,16 @@ def add( kwargs["multi_options"] = clone_multi_options # _clone_repo(cls, repo, url, path, name, **kwargs): - mrepo = cls._clone_repo(repo, url, path, name, env=env, **kwargs) + mrepo = cls._clone_repo( + repo, + url, + path, + name, + env=env, + allow_unsafe_options=allow_unsafe_options, + allow_unsafe_protocols=allow_unsafe_protocols, + **kwargs, + ) # END verify url ## See #525 for ensuring git urls in config-files valid under Windows. @@ -520,6 +546,8 @@ def update( keep_going: bool = False, env: Union[Mapping[str, str], None] = None, clone_multi_options: Union[Sequence[TBD], None] = None, + allow_unsafe_options: bool = False, + allow_unsafe_protocols: bool = False, ) -> "Submodule": """Update the repository of this submodule to point to the checkout we point at with the binsha of this instance. @@ -643,6 +671,8 @@ def update( n=True, env=env, multi_options=clone_multi_options, + allow_unsafe_options=allow_unsafe_options, + allow_unsafe_protocols=allow_unsafe_protocols, ) # END handle dry-run progress.update( diff --git a/git/remote.py b/git/remote.py index 520544b66..47a0115b0 100644 --- a/git/remote.py +++ b/git/remote.py @@ -658,9 +658,7 @@ def add_url(self, url: str, allow_unsafe_protocols: bool = False, **kwargs: Any) :param url: string being the URL to add as an extra remote URL :return: self """ - if not allow_unsafe_protocols: - Git.check_unsafe_protocols(url) - return self.set_url(url, add=True) + return self.set_url(url, add=True, allow_unsafe_protocols=allow_unsafe_protocols) def delete_url(self, url: str, **kwargs: Any) -> "Remote": """Deletes a new url on current remote (special case of git remote set_url) diff --git a/test/test_remote.py b/test/test_remote.py index 7df64c206..9583724fe 100644 --- a/test/test_remote.py +++ b/test/test_remote.py @@ -23,6 +23,8 @@ GitCommandError, ) from git.cmd import Git +from pathlib import Path +from git.exc import UnsafeOptionError, UnsafeProtocolError from test.lib import ( TestBase, with_rw_repo, @@ -690,6 +692,215 @@ def test_push_error(self, repo): with self.assertRaisesRegex(GitCommandError, "src refspec __BAD_REF__ does not match any"): rem.push("__BAD_REF__") + @with_rw_repo("HEAD") + def test_set_unsafe_url(self, rw_repo): + remote = rw_repo.remote("origin") + urls = [ + "ext::sh -c touch% /tmp/pwn", + "fd::17/foo", + ] + for url in urls: + with self.assertRaises(UnsafeProtocolError): + remote.set_url(url) + + @with_rw_repo("HEAD") + def test_set_unsafe_url_allowed(self, rw_repo): + remote = rw_repo.remote("origin") + urls = [ + "ext::sh -c touch% /tmp/pwn", + "fd::17/foo", + ] + for url in urls: + remote.set_url(url, allow_unsafe_protocols=True) + assert list(remote.urls)[-1] == url + + @with_rw_repo("HEAD") + def test_add_unsafe_url(self, rw_repo): + remote = rw_repo.remote("origin") + urls = [ + "ext::sh -c touch% /tmp/pwn", + "fd::17/foo", + ] + for url in urls: + with self.assertRaises(UnsafeProtocolError): + remote.add_url(url) + + @with_rw_repo("HEAD") + def test_add_unsafe_url_allowed(self, rw_repo): + remote = rw_repo.remote("origin") + urls = [ + "ext::sh -c touch% /tmp/pwn", + "fd::17/foo", + ] + for url in urls: + remote.add_url(url, allow_unsafe_protocols=True) + assert list(remote.urls)[-1] == url + + @with_rw_repo("HEAD") + def test_create_remote_unsafe_url(self, rw_repo): + urls = [ + "ext::sh -c touch% /tmp/pwn", + "fd::17/foo", + ] + for url in urls: + with self.assertRaises(UnsafeProtocolError): + Remote.create(rw_repo, "origin", url) + + @with_rw_repo("HEAD") + def test_create_remote_unsafe_url_allowed(self, rw_repo): + urls = [ + "ext::sh -c touch% /tmp/pwn", + "fd::17/foo", + ] + for i, url in enumerate(urls): + remote = Remote.create(rw_repo, f"origin{i}", url, allow_unsafe_protocols=True) + assert remote.url == url + + @with_rw_repo("HEAD") + def test_fetch_unsafe_url(self, rw_repo): + remote = rw_repo.remote("origin") + urls = [ + "ext::sh -c touch% /tmp/pwn", + "fd::17/foo", + ] + for url in urls: + with self.assertRaises(UnsafeProtocolError): + remote.fetch(url) + + @with_rw_repo("HEAD") + def test_fetch_unsafe_url_allowed(self, rw_repo): + remote = rw_repo.remote("origin") + urls = [ + "ext::sh -c touch% /tmp/pwn", + "fd::17/foo", + ] + for url in urls: + # The URL will be allowed into the command, but the command will + # fail since we don't have that protocol enabled in the Git config file. + with self.assertRaises(GitCommandError): + remote.fetch(url, allow_unsafe_protocols=True) + + @with_rw_repo("HEAD") + def test_fetch_unsafe_options(self, rw_repo): + remote = rw_repo.remote("origin") + tmp_dir = Path(tempfile.mkdtemp()) + tmp_file = tmp_dir / "pwn" + unsafe_options = [{"upload-pack": f"touch {tmp_file}"}] + for unsafe_option in unsafe_options: + with self.assertRaises(UnsafeOptionError): + remote.fetch(**unsafe_option) + + @with_rw_repo("HEAD") + def test_fetch_unsafe_options_allowed(self, rw_repo): + remote = rw_repo.remote("origin") + tmp_dir = Path(tempfile.mkdtemp()) + tmp_file = tmp_dir / "pwn" + unsafe_options = [{"upload-pack": f"touch {tmp_file}"}] + for unsafe_option in unsafe_options: + # The options will be allowed, but the command will fail. + with self.assertRaises(GitCommandError): + remote.fetch(**unsafe_option, allow_unsafe_options=True) + + @with_rw_repo("HEAD") + def test_pull_unsafe_url(self, rw_repo): + remote = rw_repo.remote("origin") + urls = [ + "ext::sh -c touch% /tmp/pwn", + "fd::17/foo", + ] + for url in urls: + with self.assertRaises(UnsafeProtocolError): + remote.pull(url) + + @with_rw_repo("HEAD") + def test_pull_unsafe_url_allowed(self, rw_repo): + remote = rw_repo.remote("origin") + urls = [ + "ext::sh -c touch% /tmp/pwn", + "fd::17/foo", + ] + for url in urls: + # The URL will be allowed into the command, but the command will + # fail since we don't have that protocol enabled in the Git config file. + with self.assertRaises(GitCommandError): + remote.pull(url, allow_unsafe_protocols=True) + + @with_rw_repo("HEAD") + def test_pull_unsafe_options(self, rw_repo): + remote = rw_repo.remote("origin") + tmp_dir = Path(tempfile.mkdtemp()) + tmp_file = tmp_dir / "pwn" + unsafe_options = [{"upload-pack": f"touch {tmp_file}"}] + for unsafe_option in unsafe_options: + with self.assertRaises(UnsafeOptionError): + remote.pull(**unsafe_option) + + @with_rw_repo("HEAD") + def test_pull_unsafe_options_allowed(self, rw_repo): + remote = rw_repo.remote("origin") + tmp_dir = Path(tempfile.mkdtemp()) + tmp_file = tmp_dir / "pwn" + unsafe_options = [{"upload-pack": f"touch {tmp_file}"}] + for unsafe_option in unsafe_options: + # The options will be allowed, but the command will fail. + with self.assertRaises(GitCommandError): + remote.pull(**unsafe_option, allow_unsafe_options=True) + + @with_rw_repo("HEAD") + def test_push_unsafe_url(self, rw_repo): + remote = rw_repo.remote("origin") + urls = [ + "ext::sh -c touch% /tmp/pwn", + "fd::17/foo", + ] + for url in urls: + with self.assertRaises(UnsafeProtocolError): + remote.push(url) + + @with_rw_repo("HEAD") + def test_push_unsafe_url_allowed(self, rw_repo): + remote = rw_repo.remote("origin") + urls = [ + "ext::sh -c touch% /tmp/pwn", + "fd::17/foo", + ] + for url in urls: + # The URL will be allowed into the command, but the command will + # fail since we don't have that protocol enabled in the Git config file. + with self.assertRaises(GitCommandError): + remote.push(url, allow_unsafe_protocols=True) + + @with_rw_repo("HEAD") + def test_push_unsafe_options(self, rw_repo): + remote = rw_repo.remote("origin") + tmp_dir = Path(tempfile.mkdtemp()) + tmp_file = tmp_dir / "pwn" + unsafe_options = [ + { + "receive-pack": f"touch {tmp_file}", + "exec": f"touch {tmp_file}", + } + ] + for unsafe_option in unsafe_options: + with self.assertRaises(UnsafeOptionError): + remote.push(**unsafe_option) + + @with_rw_repo("HEAD") + def test_push_unsafe_options_allowed(self, rw_repo): + remote = rw_repo.remote("origin") + tmp_dir = Path(tempfile.mkdtemp()) + tmp_file = tmp_dir / "pwn" + unsafe_options = [ + { + "receive-pack": f"touch {tmp_file}", + "exec": f"touch {tmp_file}", + } + ] + for unsafe_option in unsafe_options: + # The options will be allowed, but the command will fail. + with self.assertRaises(GitCommandError): + remote.push(**unsafe_option, allow_unsafe_options=True) + class TestTimeouts(TestBase): @with_rw_repo("HEAD", bare=False) diff --git a/test/test_submodule.py b/test/test_submodule.py index fef6bda3a..3ac29b9aa 100644 --- a/test/test_submodule.py +++ b/test/test_submodule.py @@ -1026,7 +1026,7 @@ def test_update_clone_multi_options_argument(self, rwdir): ) # Act - sm.update(init=True, clone_multi_options=["--config core.eol=true"]) + sm.update(init=True, clone_multi_options=["--config core.eol=true"], allow_unsafe_options=True) # Assert sm_config = GitConfigParser(file_or_files=osp.join(parent.git_dir, "modules", sm_name, "config")) @@ -1070,6 +1070,7 @@ def test_add_clone_multi_options_argument(self, rwdir): sm_name, url=self._small_repo_url(), clone_multi_options=["--config core.eol=true"], + allow_unsafe_options=True, ) # Assert From 9dc43926207b2205d77511c6ffd40944199f0c2d Mon Sep 17 00:00:00 2001 From: Santos Gallegos Date: Tue, 27 Dec 2022 20:07:18 -0500 Subject: [PATCH 013/114] Submodule tests --- test/test_submodule.py | 116 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 115 insertions(+), 1 deletion(-) diff --git a/test/test_submodule.py b/test/test_submodule.py index 3ac29b9aa..5b1622178 100644 --- a/test/test_submodule.py +++ b/test/test_submodule.py @@ -3,6 +3,8 @@ # the BSD License: http://www.opensource.org/licenses/bsd-license.php import os import shutil +import tempfile +from pathlib import Path import sys from unittest import skipIf @@ -12,7 +14,13 @@ from git.cmd import Git from git.compat import is_win from git.config import GitConfigParser, cp -from git.exc import InvalidGitRepositoryError, RepositoryDirtyError +from git.exc import ( + GitCommandError, + InvalidGitRepositoryError, + RepositoryDirtyError, + UnsafeOptionError, + UnsafeProtocolError, +) from git.objects.submodule.base import Submodule from git.objects.submodule.root import RootModule, RootUpdateProgress from git.repo.fun import find_submodule_git_dir, touch @@ -1090,3 +1098,109 @@ def test_add_no_clone_multi_options_argument(self, rwdir): sm_config = GitConfigParser(file_or_files=osp.join(parent.git_dir, "modules", sm_name, "config")) with self.assertRaises(cp.NoOptionError): sm_config.get_value("core", "eol") + + @with_rw_repo("HEAD") + def test_submodule_add_unsafe_url(self, rw_repo): + urls = [ + "ext::sh -c touch% /tmp/pwn", + "fd::/foo", + ] + for url in urls: + with self.assertRaises(UnsafeProtocolError): + Submodule.add(rw_repo, "new", "new", url) + + @with_rw_repo("HEAD") + def test_submodule_add_unsafe_url_allowed(self, rw_repo): + urls = [ + "ext::sh -c touch% /tmp/pwn", + "fd::/foo", + ] + for url in urls: + # The URL will be allowed into the command, but the command will + # fail since we don't have that protocol enabled in the Git config file. + with self.assertRaises(GitCommandError): + Submodule.add(rw_repo, "new", "new", url, allow_unsafe_protocols=True) + + @with_rw_repo("HEAD") + def test_submodule_add_unsafe_options(self, rw_repo): + tmp_dir = Path(tempfile.mkdtemp()) + tmp_file = tmp_dir / "pwn" + unsafe_options = [ + f"--upload-pack='touch {tmp_file}'", + f"-u 'touch {tmp_file}'", + "--config=protocol.ext.allow=always", + "-c protocol.ext.allow=always", + ] + for unsafe_option in unsafe_options: + with self.assertRaises(UnsafeOptionError): + Submodule.add(rw_repo, "new", "new", str(tmp_dir), clone_multi_options=[unsafe_option]) + + @with_rw_repo("HEAD") + def test_submodule_add_unsafe_options_allowed(self, rw_repo): + tmp_dir = Path(tempfile.mkdtemp()) + tmp_file = tmp_dir / "pwn" + unsafe_options = [ + f"--upload-pack='touch {tmp_file}'", + f"-u 'touch {tmp_file}'", + "--config=protocol.ext.allow=always", + "-c protocol.ext.allow=always", + ] + for unsafe_option in unsafe_options: + with self.assertRaises(GitCommandError): + Submodule.add( + rw_repo, "new", "new", str(tmp_dir), clone_multi_options=[unsafe_option], allow_unsafe_options=True + ) + + @with_rw_repo("HEAD") + def test_submodule_update_unsafe_url(self, rw_repo): + urls = [ + "ext::sh -c touch% /tmp/pwn", + "fd::/foo", + ] + for url in urls: + submodule = Submodule(rw_repo, b"\0" * 20, name="new", path="new", url=url) + with self.assertRaises(UnsafeProtocolError): + submodule.update() + + @with_rw_repo("HEAD") + def test_submodule_update_unsafe_url_allowed(self, rw_repo): + urls = [ + "ext::sh -c touch% /tmp/pwn", + "fd::/foo", + ] + for url in urls: + submodule = Submodule(rw_repo, b"\0" * 20, name="new", path="new", url=url) + # The URL will be allowed into the command, but the command will + # fail since we don't have that protocol enabled in the Git config file. + with self.assertRaises(GitCommandError): + submodule.update(allow_unsafe_protocols=True) + + @with_rw_repo("HEAD") + def test_submodule_update_unsafe_options(self, rw_repo): + tmp_dir = Path(tempfile.mkdtemp()) + tmp_file = tmp_dir / "pwn" + unsafe_options = [ + f"--upload-pack='touch {tmp_file}'", + f"-u 'touch {tmp_file}'", + "--config=protocol.ext.allow=always", + "-c protocol.ext.allow=always", + ] + submodule = Submodule(rw_repo, b"\0" * 20, name="new", path="new", url=str(tmp_dir)) + for unsafe_option in unsafe_options: + with self.assertRaises(UnsafeOptionError): + submodule.update(clone_multi_options=[unsafe_option]) + + @with_rw_repo("HEAD") + def test_submodule_update_unsafe_options_allowed(self, rw_repo): + tmp_dir = Path(tempfile.mkdtemp()) + tmp_file = tmp_dir / "pwn" + unsafe_options = [ + f"--upload-pack='touch {tmp_file}'", + f"-u 'touch {tmp_file}'", + "--config=protocol.ext.allow=always", + "-c protocol.ext.allow=always", + ] + submodule = Submodule(rw_repo, b"\0" * 20, name="new", path="new", url=str(tmp_dir)) + for unsafe_option in unsafe_options: + with self.assertRaises(GitCommandError): + submodule.update(clone_multi_options=[unsafe_option], allow_unsafe_options=True) From f4f2658d5d308b3fb9162e50cd4c7b346e7a0a47 Mon Sep 17 00:00:00 2001 From: Santos Gallegos Date: Wed, 28 Dec 2022 21:48:29 -0500 Subject: [PATCH 014/114] Updates from review --- AUTHORS | 1 + test/test_remote.py | 71 +++++++++++++++++++++++++++++++++++------- test/test_repo.py | 14 ++++++++- test/test_submodule.py | 41 +++++++++++++++++++++--- 4 files changed, 110 insertions(+), 17 deletions(-) diff --git a/AUTHORS b/AUTHORS index 8f3f2ccfe..8ccc09fc0 100644 --- a/AUTHORS +++ b/AUTHORS @@ -50,4 +50,5 @@ Contributors are: -Patrick Gerard -Luke Twist -Joseph Hale +-Santos Gallegos Portions derived from other open source works and are clearly marked. diff --git a/test/test_remote.py b/test/test_remote.py index 9583724fe..3a47afab5 100644 --- a/test/test_remote.py +++ b/test/test_remote.py @@ -694,84 +694,107 @@ def test_push_error(self, repo): @with_rw_repo("HEAD") def test_set_unsafe_url(self, rw_repo): + tmp_dir = Path(tempfile.mkdtemp()) + tmp_file = tmp_dir / "pwn" remote = rw_repo.remote("origin") urls = [ - "ext::sh -c touch% /tmp/pwn", + f"ext::sh -c touch% {tmp_file}", "fd::17/foo", ] for url in urls: with self.assertRaises(UnsafeProtocolError): remote.set_url(url) + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_set_unsafe_url_allowed(self, rw_repo): + tmp_dir = Path(tempfile.mkdtemp()) + tmp_file = tmp_dir / "pwn" remote = rw_repo.remote("origin") urls = [ - "ext::sh -c touch% /tmp/pwn", + f"ext::sh -c touch% {tmp_file}", "fd::17/foo", ] for url in urls: remote.set_url(url, allow_unsafe_protocols=True) assert list(remote.urls)[-1] == url + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_add_unsafe_url(self, rw_repo): + tmp_dir = Path(tempfile.mkdtemp()) + tmp_file = tmp_dir / "pwn" remote = rw_repo.remote("origin") urls = [ - "ext::sh -c touch% /tmp/pwn", + f"ext::sh -c touch% {tmp_file}", "fd::17/foo", ] for url in urls: with self.assertRaises(UnsafeProtocolError): remote.add_url(url) + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_add_unsafe_url_allowed(self, rw_repo): + tmp_dir = Path(tempfile.mkdtemp()) + tmp_file = tmp_dir / "pwn" remote = rw_repo.remote("origin") urls = [ - "ext::sh -c touch% /tmp/pwn", + f"ext::sh -c touch% {tmp_file}", "fd::17/foo", ] for url in urls: remote.add_url(url, allow_unsafe_protocols=True) assert list(remote.urls)[-1] == url + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_create_remote_unsafe_url(self, rw_repo): + tmp_dir = Path(tempfile.mkdtemp()) + tmp_file = tmp_dir / "pwn" urls = [ - "ext::sh -c touch% /tmp/pwn", + f"ext::sh -c touch% {tmp_file}", "fd::17/foo", ] for url in urls: with self.assertRaises(UnsafeProtocolError): Remote.create(rw_repo, "origin", url) + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_create_remote_unsafe_url_allowed(self, rw_repo): + tmp_dir = Path(tempfile.mkdtemp()) + tmp_file = tmp_dir / "pwn" urls = [ - "ext::sh -c touch% /tmp/pwn", + f"ext::sh -c touch% {tmp_file}", "fd::17/foo", ] for i, url in enumerate(urls): remote = Remote.create(rw_repo, f"origin{i}", url, allow_unsafe_protocols=True) assert remote.url == url + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_fetch_unsafe_url(self, rw_repo): + tmp_dir = Path(tempfile.mkdtemp()) + tmp_file = tmp_dir / "pwn" remote = rw_repo.remote("origin") urls = [ - "ext::sh -c touch% /tmp/pwn", + f"ext::sh -c touch% {tmp_file}", "fd::17/foo", ] for url in urls: with self.assertRaises(UnsafeProtocolError): remote.fetch(url) + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_fetch_unsafe_url_allowed(self, rw_repo): + tmp_dir = Path(tempfile.mkdtemp()) + tmp_file = tmp_dir / "pwn" remote = rw_repo.remote("origin") urls = [ - "ext::sh -c touch% /tmp/pwn", + f"ext::sh -c touch% {tmp_file}", "fd::17/foo", ] for url in urls: @@ -779,6 +802,7 @@ def test_fetch_unsafe_url_allowed(self, rw_repo): # fail since we don't have that protocol enabled in the Git config file. with self.assertRaises(GitCommandError): remote.fetch(url, allow_unsafe_protocols=True) + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_fetch_unsafe_options(self, rw_repo): @@ -789,6 +813,7 @@ def test_fetch_unsafe_options(self, rw_repo): for unsafe_option in unsafe_options: with self.assertRaises(UnsafeOptionError): remote.fetch(**unsafe_option) + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_fetch_unsafe_options_allowed(self, rw_repo): @@ -798,25 +823,32 @@ def test_fetch_unsafe_options_allowed(self, rw_repo): unsafe_options = [{"upload-pack": f"touch {tmp_file}"}] for unsafe_option in unsafe_options: # The options will be allowed, but the command will fail. + assert not tmp_file.exists() with self.assertRaises(GitCommandError): remote.fetch(**unsafe_option, allow_unsafe_options=True) + assert tmp_file.exists() @with_rw_repo("HEAD") def test_pull_unsafe_url(self, rw_repo): + tmp_dir = Path(tempfile.mkdtemp()) + tmp_file = tmp_dir / "pwn" remote = rw_repo.remote("origin") urls = [ - "ext::sh -c touch% /tmp/pwn", + f"ext::sh -c touch% {tmp_file}", "fd::17/foo", ] for url in urls: with self.assertRaises(UnsafeProtocolError): remote.pull(url) + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_pull_unsafe_url_allowed(self, rw_repo): + tmp_dir = Path(tempfile.mkdtemp()) + tmp_file = tmp_dir / "pwn" remote = rw_repo.remote("origin") urls = [ - "ext::sh -c touch% /tmp/pwn", + f"ext::sh -c touch% {tmp_file}", "fd::17/foo", ] for url in urls: @@ -824,6 +856,7 @@ def test_pull_unsafe_url_allowed(self, rw_repo): # fail since we don't have that protocol enabled in the Git config file. with self.assertRaises(GitCommandError): remote.pull(url, allow_unsafe_protocols=True) + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_pull_unsafe_options(self, rw_repo): @@ -834,6 +867,7 @@ def test_pull_unsafe_options(self, rw_repo): for unsafe_option in unsafe_options: with self.assertRaises(UnsafeOptionError): remote.pull(**unsafe_option) + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_pull_unsafe_options_allowed(self, rw_repo): @@ -843,25 +877,32 @@ def test_pull_unsafe_options_allowed(self, rw_repo): unsafe_options = [{"upload-pack": f"touch {tmp_file}"}] for unsafe_option in unsafe_options: # The options will be allowed, but the command will fail. + assert not tmp_file.exists() with self.assertRaises(GitCommandError): remote.pull(**unsafe_option, allow_unsafe_options=True) + assert tmp_file.exists() @with_rw_repo("HEAD") def test_push_unsafe_url(self, rw_repo): + tmp_dir = Path(tempfile.mkdtemp()) + tmp_file = tmp_dir / "pwn" remote = rw_repo.remote("origin") urls = [ - "ext::sh -c touch% /tmp/pwn", + f"ext::sh -c touch% {tmp_file}", "fd::17/foo", ] for url in urls: with self.assertRaises(UnsafeProtocolError): remote.push(url) + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_push_unsafe_url_allowed(self, rw_repo): + tmp_dir = Path(tempfile.mkdtemp()) + tmp_file = tmp_dir / "pwn" remote = rw_repo.remote("origin") urls = [ - "ext::sh -c touch% /tmp/pwn", + f"ext::sh -c touch% {tmp_file}", "fd::17/foo", ] for url in urls: @@ -869,6 +910,7 @@ def test_push_unsafe_url_allowed(self, rw_repo): # fail since we don't have that protocol enabled in the Git config file. with self.assertRaises(GitCommandError): remote.push(url, allow_unsafe_protocols=True) + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_push_unsafe_options(self, rw_repo): @@ -882,8 +924,10 @@ def test_push_unsafe_options(self, rw_repo): } ] for unsafe_option in unsafe_options: + assert not tmp_file.exists() with self.assertRaises(UnsafeOptionError): remote.push(**unsafe_option) + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_push_unsafe_options_allowed(self, rw_repo): @@ -898,8 +942,11 @@ def test_push_unsafe_options_allowed(self, rw_repo): ] for unsafe_option in unsafe_options: # The options will be allowed, but the command will fail. + assert not tmp_file.exists() with self.assertRaises(GitCommandError): remote.push(**unsafe_option, allow_unsafe_options=True) + assert tmp_file.exists() + tmp_file.unlink() class TestTimeouts(TestBase): diff --git a/test/test_repo.py b/test/test_repo.py index 72320184f..5874dbe6a 100644 --- a/test/test_repo.py +++ b/test/test_repo.py @@ -279,6 +279,7 @@ def test_clone_unsafe_options(self, rw_repo): for unsafe_option in unsafe_options: with self.assertRaises(UnsafeOptionError): rw_repo.clone(tmp_dir, multi_options=[unsafe_option]) + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_clone_unsafe_options_allowed(self, rw_repo): @@ -290,9 +291,12 @@ def test_clone_unsafe_options_allowed(self, rw_repo): ] for i, unsafe_option in enumerate(unsafe_options): destination = tmp_dir / str(i) + assert not tmp_file.exists() # The options will be allowed, but the command will fail. with self.assertRaises(GitCommandError): rw_repo.clone(destination, multi_options=[unsafe_option], allow_unsafe_options=True) + assert tmp_file.exists() + tmp_file.unlink() unsafe_options = [ "--config=protocol.ext.allow=always", @@ -331,6 +335,7 @@ def test_clone_from_unsafe_options(self, rw_repo): for unsafe_option in unsafe_options: with self.assertRaises(UnsafeOptionError): Repo.clone_from(rw_repo.working_dir, tmp_dir, multi_options=[unsafe_option]) + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_clone_from_unsafe_options_allowed(self, rw_repo): @@ -342,11 +347,14 @@ def test_clone_from_unsafe_options_allowed(self, rw_repo): ] for i, unsafe_option in enumerate(unsafe_options): destination = tmp_dir / str(i) + assert not tmp_file.exists() # The options will be allowed, but the command will fail. with self.assertRaises(GitCommandError): Repo.clone_from( rw_repo.working_dir, destination, multi_options=[unsafe_option], allow_unsafe_options=True ) + assert tmp_file.exists() + tmp_file.unlink() unsafe_options = [ "--config=protocol.ext.allow=always", @@ -374,16 +382,19 @@ def test_clone_from_safe_options(self, rw_repo): def test_clone_from_unsafe_procol(self): tmp_dir = pathlib.Path(tempfile.mkdtemp()) + tmp_file = tmp_dir / "pwn" urls = [ - "ext::sh -c touch% /tmp/pwn", + f"ext::sh -c touch% {tmp_file}", "fd::17/foo", ] for url in urls: with self.assertRaises(UnsafeProtocolError): Repo.clone_from(url, tmp_dir) + assert not tmp_file.exists() def test_clone_from_unsafe_procol_allowed(self): tmp_dir = pathlib.Path(tempfile.mkdtemp()) + tmp_file = tmp_dir / "pwn" urls = [ "ext::sh -c touch% /tmp/pwn", "fd::/foo", @@ -393,6 +404,7 @@ def test_clone_from_unsafe_procol_allowed(self): # fail since we don't have that protocol enabled in the Git config file. with self.assertRaises(GitCommandError): Repo.clone_from(url, tmp_dir, allow_unsafe_protocols=True) + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_max_chunk_size(self, repo): diff --git a/test/test_submodule.py b/test/test_submodule.py index 5b1622178..13878df28 100644 --- a/test/test_submodule.py +++ b/test/test_submodule.py @@ -1101,18 +1101,23 @@ def test_add_no_clone_multi_options_argument(self, rwdir): @with_rw_repo("HEAD") def test_submodule_add_unsafe_url(self, rw_repo): + tmp_dir = Path(tempfile.mkdtemp()) + tmp_file = tmp_dir / "pwn" urls = [ - "ext::sh -c touch% /tmp/pwn", + f"ext::sh -c touch% {tmp_file}", "fd::/foo", ] for url in urls: with self.assertRaises(UnsafeProtocolError): Submodule.add(rw_repo, "new", "new", url) + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_submodule_add_unsafe_url_allowed(self, rw_repo): + tmp_dir = Path(tempfile.mkdtemp()) + tmp_file = tmp_dir / "pwn" urls = [ - "ext::sh -c touch% /tmp/pwn", + f"ext::sh -c touch% {tmp_file}", "fd::/foo", ] for url in urls: @@ -1120,6 +1125,7 @@ def test_submodule_add_unsafe_url_allowed(self, rw_repo): # fail since we don't have that protocol enabled in the Git config file. with self.assertRaises(GitCommandError): Submodule.add(rw_repo, "new", "new", url, allow_unsafe_protocols=True) + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_submodule_add_unsafe_options(self, rw_repo): @@ -1134,6 +1140,7 @@ def test_submodule_add_unsafe_options(self, rw_repo): for unsafe_option in unsafe_options: with self.assertRaises(UnsafeOptionError): Submodule.add(rw_repo, "new", "new", str(tmp_dir), clone_multi_options=[unsafe_option]) + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_submodule_add_unsafe_options_allowed(self, rw_repo): @@ -1142,6 +1149,16 @@ def test_submodule_add_unsafe_options_allowed(self, rw_repo): unsafe_options = [ f"--upload-pack='touch {tmp_file}'", f"-u 'touch {tmp_file}'", + ] + for unsafe_option in unsafe_options: + # The options will be allowed, but the command will fail. + with self.assertRaises(GitCommandError): + Submodule.add( + rw_repo, "new", "new", str(tmp_dir), clone_multi_options=[unsafe_option], allow_unsafe_options=True + ) + assert not tmp_file.exists() + + unsafe_options = [ "--config=protocol.ext.allow=always", "-c protocol.ext.allow=always", ] @@ -1153,19 +1170,24 @@ def test_submodule_add_unsafe_options_allowed(self, rw_repo): @with_rw_repo("HEAD") def test_submodule_update_unsafe_url(self, rw_repo): + tmp_dir = Path(tempfile.mkdtemp()) + tmp_file = tmp_dir / "pwn" urls = [ - "ext::sh -c touch% /tmp/pwn", + f"ext::sh -c touch% {tmp_file}", "fd::/foo", ] for url in urls: submodule = Submodule(rw_repo, b"\0" * 20, name="new", path="new", url=url) with self.assertRaises(UnsafeProtocolError): submodule.update() + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_submodule_update_unsafe_url_allowed(self, rw_repo): + tmp_dir = Path(tempfile.mkdtemp()) + tmp_file = tmp_dir / "pwn" urls = [ - "ext::sh -c touch% /tmp/pwn", + f"ext::sh -c touch% {tmp_file}", "fd::/foo", ] for url in urls: @@ -1174,6 +1196,7 @@ def test_submodule_update_unsafe_url_allowed(self, rw_repo): # fail since we don't have that protocol enabled in the Git config file. with self.assertRaises(GitCommandError): submodule.update(allow_unsafe_protocols=True) + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_submodule_update_unsafe_options(self, rw_repo): @@ -1189,6 +1212,7 @@ def test_submodule_update_unsafe_options(self, rw_repo): for unsafe_option in unsafe_options: with self.assertRaises(UnsafeOptionError): submodule.update(clone_multi_options=[unsafe_option]) + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_submodule_update_unsafe_options_allowed(self, rw_repo): @@ -1197,6 +1221,15 @@ def test_submodule_update_unsafe_options_allowed(self, rw_repo): unsafe_options = [ f"--upload-pack='touch {tmp_file}'", f"-u 'touch {tmp_file}'", + ] + submodule = Submodule(rw_repo, b"\0" * 20, name="new", path="new", url=str(tmp_dir)) + for unsafe_option in unsafe_options: + # The options will be allowed, but the command will fail. + with self.assertRaises(GitCommandError): + submodule.update(clone_multi_options=[unsafe_option], allow_unsafe_options=True) + assert not tmp_file.exists() + + unsafe_options = [ "--config=protocol.ext.allow=always", "-c protocol.ext.allow=always", ] From 5bce9b4f7fc825d8bcd450325e6dda78c49f0ca0 Mon Sep 17 00:00:00 2001 From: timski Date: Wed, 28 Dec 2022 14:44:38 -0500 Subject: [PATCH 015/114] Document PushInfoList --- git/remote.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/git/remote.py b/git/remote.py index 483d536ae..cd1a2c537 100644 --- a/git/remote.py +++ b/git/remote.py @@ -278,6 +278,10 @@ def iter_items(cls, repo: "Repo", *args: Any, **kwargs: Any) -> NoReturn: # -> class PushInfoList(IterableList[PushInfo]): + """ + IterableList of PushInfo objects. + """ + def __new__(cls) -> "PushInfoList": return cast(PushInfoList, IterableList.__new__(cls, "push_infos")) @@ -1004,7 +1008,7 @@ def push( progress: Union[RemoteProgress, "UpdateProgress", Callable[..., RemoteProgress], None] = None, kill_after_timeout: Union[None, float] = None, **kwargs: Any, - ) -> IterableList[PushInfo]: + ) -> PushInfoList: """Push changes from source branch in refspec to target branch in refspec. :param refspec: see 'fetch' method @@ -1025,13 +1029,13 @@ def push( should be killed. It is set to None by default. :param kwargs: Additional arguments to be passed to git-push :return: - list(PushInfo, ...) list of PushInfo instances, each - one informing about an individual head which had been updated on the remote - side. + A ``PushInfoList`` object, where each list member + represents an individual head which had been updated on the remote side. If the push contains rejected heads, these will have the PushInfo.ERROR bit set in their flags. - If the operation fails completely, the length of the returned IterableList will - be 0.""" + If the operation fails completely, the length of the returned PushInfoList will + be 0. + Call ``.raise_if_error()`` on the returned object to raise on any failure.""" kwargs = add_progress(kwargs, self.repo.git, progress) proc = self.repo.git.push( "--", From ae6a6e4b088a35c0fc7b17940722c8a515f7bee7 Mon Sep 17 00:00:00 2001 From: Andrew Cassidy Date: Wed, 28 Dec 2022 21:53:50 -0800 Subject: [PATCH 016/114] Fix type hint on create_tag pycharm yells at me without this --- git/repo/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git/repo/base.py b/git/repo/base.py index 49a3d5a16..165df5fed 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -482,7 +482,7 @@ def delete_head(self, *heads: "Union[str, Head]", **kwargs: Any) -> None: def create_tag( self, path: PathLike, - ref: str = "HEAD", + ref: Union[str, 'SymbolicReference'] = "HEAD", message: Optional[str] = None, force: bool = False, **kwargs: Any, From 141cd651e459bff8919798b3ccf03dfa167757f6 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Thu, 29 Dec 2022 08:13:48 +0100 Subject: [PATCH 017/114] adjust changelog prior to release --- VERSION | 2 +- doc/source/changes.rst | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 05b41fb67..51b450da3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.1.29 +3.1.30 diff --git a/doc/source/changes.rst b/doc/source/changes.rst index a784a096a..7cd09a1c5 100644 --- a/doc/source/changes.rst +++ b/doc/source/changes.rst @@ -9,6 +9,11 @@ Changelog See https://github.com/gitpython-developers/GitPython/pull/1518 for details. Note that this might constitute a breaking change for some users, and if so please let us know and we add an opt-out to this. +- Prohibit insecure options and protocols by default, which is potentially a breaking change, + but a necessary fix for https://github.com/gitpython-developers/GitPython/issues/1515. + Please take a look at the PR for more information and how to bypass these protections + in case they cause breakage: https://github.com/gitpython-developers/GitPython/pull/1521. + See the following for all changes. https://github.com/gitpython-developers/gitpython/milestone/60?closed=1 From 44636240a08bba4a13355a5a23543d537ff15576 Mon Sep 17 00:00:00 2001 From: Stephan Creutz Date: Thu, 29 Dec 2022 14:50:43 +0100 Subject: [PATCH 018/114] Fix Sphinx rendering errors These errors are mostly fixed by either adding blank lines or single spaces for Sphinx documentation key words. The commit solely includes documentation changes, no functional changes. --- git/cmd.py | 6 ++++-- git/index/base.py | 4 ++-- git/index/fun.py | 11 +++++++---- git/objects/base.py | 1 + git/objects/fun.py | 2 ++ git/objects/tree.py | 4 +++- git/objects/util.py | 1 + git/refs/log.py | 2 ++ git/refs/reference.py | 3 ++- git/remote.py | 11 +++++++---- git/repo/base.py | 3 +++ git/util.py | 6 +++--- 12 files changed, 37 insertions(+), 17 deletions(-) diff --git a/git/cmd.py b/git/cmd.py index 9ef1e3a65..134001e22 100644 --- a/git/cmd.py +++ b/git/cmd.py @@ -735,6 +735,7 @@ def __init__(self, working_dir: Union[None, PathLike] = None): def __getattr__(self, name: str) -> Any: """A convenience method as it allows to call the command as if it was an object. + :return: Callable object that will execute call _call_process with your arguments.""" if name[0] == "_": return LazyMixin.__getattr__(self, name) @@ -915,7 +916,7 @@ def execute( render the repository incapable of accepting changes until the lock is manually removed. :param strip_newline_in_stdout: - Whether to strip the trailing `\n` of the command stdout. + Whether to strip the trailing ``\\n`` of the command stdout. :return: * str(output) if extended_output = False (Default) * tuple(int(status), str(stdout), str(stderr)) if extended_output = True @@ -1384,7 +1385,8 @@ def get_object_header(self, ref: str) -> Tuple[str, str, int]: def get_object_data(self, ref: str) -> Tuple[str, str, int, bytes]: """As get_object_header, but returns object data as well - :return: (hexsha, type_string, size_as_int,data_string) + + :return: (hexsha, type_string, size_as_int, data_string) :note: not threadsafe""" hexsha, typename, size, stream = self.stream_object_data(ref) data = stream.read(size) diff --git a/git/index/base.py b/git/index/base.py index 17d18db58..cda08de25 100644 --- a/git/index/base.py +++ b/git/index/base.py @@ -982,12 +982,12 @@ def move( Additional arguments you would like to pass to git-mv, such as dry_run or force. - :return:List(tuple(source_path_string, destination_path_string), ...) + :return: List(tuple(source_path_string, destination_path_string), ...) A list of pairs, containing the source file moved as well as its actual destination. Relative to the repository root. :raise ValueError: If only one item was given - GitCommandError: If git could not handle your request""" + :raise GitCommandError: If git could not handle your request""" args = [] if skip_errors: args.append("-k") diff --git a/git/index/fun.py b/git/index/fun.py index 4659ac898..d0925ed51 100644 --- a/git/index/fun.py +++ b/git/index/fun.py @@ -82,6 +82,7 @@ def _has_file_extension(path): def run_commit_hook(name: str, index: "IndexFile", *args: str) -> None: """Run the commit hook of the given name. Silently ignores hooks that do not exist. + :param name: name of hook, like 'pre-commit' :param index: IndexFile instance :param args: arguments passed to hook file @@ -234,11 +235,13 @@ def read_cache( stream: IO[bytes], ) -> Tuple[int, Dict[Tuple[PathLike, int], "IndexEntry"], bytes, bytes]: """Read a cache file from the given stream + :return: tuple(version, entries_dict, extension_data, content_sha) - * version is the integer version number - * entries dict is a dictionary which maps IndexEntry instances to a path at a stage - * extension_data is '' or 4 bytes of type + 4 bytes of size + size bytes - * content_sha is a 20 byte sha on all cache file contents""" + + * version is the integer version number + * entries dict is a dictionary which maps IndexEntry instances to a path at a stage + * extension_data is '' or 4 bytes of type + 4 bytes of size + size bytes + * content_sha is a 20 byte sha on all cache file contents""" version, num_entries = read_header(stream) count = 0 entries: Dict[Tuple[PathLike, int], "IndexEntry"] = {} diff --git a/git/objects/base.py b/git/objects/base.py index 9d0057254..eb9a8ac3d 100644 --- a/git/objects/base.py +++ b/git/objects/base.py @@ -143,6 +143,7 @@ def data_stream(self) -> "OStream": def stream_data(self, ostream: "OStream") -> "Object": """Writes our data directly to the given output stream + :param ostream: File object compatible stream object. :return: self""" istream = self.repo.odb.stream(self.binsha) diff --git a/git/objects/fun.py b/git/objects/fun.py index 001e10e47..e91403a8b 100644 --- a/git/objects/fun.py +++ b/git/objects/fun.py @@ -37,6 +37,7 @@ def tree_to_stream(entries: Sequence[EntryTup], write: Callable[["ReadableBuffer"], Union[int, None]]) -> None: """Write the give list of entries into a stream using its write method + :param entries: **sorted** list of tuples with (binsha, mode, name) :param write: write method which takes a data string""" ord_zero = ord("0") @@ -68,6 +69,7 @@ def tree_to_stream(entries: Sequence[EntryTup], write: Callable[["ReadableBuffer def tree_entries_from_data(data: bytes) -> List[EntryTup]: """Reads the binary representation of a tree and returns tuples of Tree items + :param data: data block with tree data (as bytes) :return: list(tuple(binsha, mode, tree_relative_path), ...)""" ord_zero = ord("0") diff --git a/git/objects/tree.py b/git/objects/tree.py index b72e88c48..a9b491e23 100644 --- a/git/objects/tree.py +++ b/git/objects/tree.py @@ -128,6 +128,7 @@ def set_done(self) -> "TreeModifier": """Call this method once you are done modifying the tree information. It may be called several times, but be aware that each call will cause a sort operation + :return self:""" merge_sort(self._cache, git_cmp) return self @@ -175,6 +176,7 @@ def add_unchecked(self, binsha: bytes, mode: int, name: str) -> None: """Add the given item to the tree, its correctness is assumed, which puts the caller into responsibility to assure the input is correct. For more information on the parameters, see ``add`` + :param binsha: 20 byte binary sha""" assert isinstance(binsha, bytes) and isinstance(mode, int) and isinstance(name, str) tree_cache = (binsha, mode, name) @@ -259,8 +261,8 @@ def _iter_convert_to_object(self, iterable: Iterable[TreeCacheTup]) -> Iterator[ def join(self, file: str) -> IndexObjUnion: """Find the named object in this tree's contents - :return: ``git.Blob`` or ``git.Tree`` or ``git.Submodule`` + :return: ``git.Blob`` or ``git.Tree`` or ``git.Submodule`` :raise KeyError: if given file or tree does not exist in tree""" msg = "Blob or Tree named %r not found" if "/" in file: diff --git a/git/objects/util.py b/git/objects/util.py index 636a58316..f405d6287 100644 --- a/git/objects/util.py +++ b/git/objects/util.py @@ -140,6 +140,7 @@ def utctz_to_altz(utctz: str) -> int: """we convert utctz to the timezone in seconds, it is the format time.altzone returns. Git stores it as UTC timezone which has the opposite sign as well, which explains the -1 * ( that was made explicit here ) + :param utctz: git utc timezone string, i.e. +0200""" return -1 * int(float(utctz) / 100 * 3600) diff --git a/git/refs/log.py b/git/refs/log.py index a5f4de58b..1f86356a4 100644 --- a/git/refs/log.py +++ b/git/refs/log.py @@ -253,6 +253,7 @@ def entry_at(cls, filepath: PathLike, index: int) -> "RefLogEntry": def to_file(self, filepath: PathLike) -> None: """Write the contents of the reflog instance to a file at the given filepath. + :param filepath: path to file, parent directories are assumed to exist""" lfd = LockedFD(filepath) assure_directory_exists(filepath, is_file=True) @@ -326,6 +327,7 @@ def append_entry( def write(self) -> "RefLog": """Write this instance's data to the file we are originating from + :return: self""" if self._path is None: raise ValueError("Instance was not initialized with a path, use to_file(...) instead") diff --git a/git/refs/reference.py b/git/refs/reference.py index ca43cc430..4f9e3a0a7 100644 --- a/git/refs/reference.py +++ b/git/refs/reference.py @@ -49,8 +49,8 @@ class Reference(SymbolicReference, LazyMixin, IterableObj): def __init__(self, repo: "Repo", path: PathLike, check_path: bool = True) -> None: """Initialize this instance - :param repo: Our parent repository + :param repo: Our parent repository :param path: Path relative to the .git/ directory pointing to the ref in question, i.e. refs/heads/master @@ -73,6 +73,7 @@ def set_object( logmsg: Union[str, None] = None, ) -> "Reference": """Special version which checks if the head-log needs an update as well + :return: self""" oldbinsha = None if logmsg is not None: diff --git a/git/remote.py b/git/remote.py index 4240223e8..3f86a297a 100644 --- a/git/remote.py +++ b/git/remote.py @@ -756,6 +756,7 @@ def stale_refs(self) -> IterableList[Reference]: @classmethod def create(cls, repo: "Repo", name: str, url: str, allow_unsafe_protocols: bool = False, **kwargs: Any) -> "Remote": """Create a new remote to the given repository + :param repo: Repository instance that is to receive the new remote :param name: Desired name of the remote :param url: URL which corresponds to the remote's name @@ -778,6 +779,7 @@ def add(cls, repo: "Repo", name: str, url: str, **kwargs: Any) -> "Remote": @classmethod def remove(cls, repo: "Repo", name: str) -> str: """Remove the remote with the given name + :return: the passed remote name to remove """ repo.git.remote("rm", name) @@ -790,6 +792,7 @@ def remove(cls, repo: "Repo", name: str) -> str: def rename(self, new_name: str) -> "Remote": """Rename self to the given new_name + :return: self""" if self.name == new_name: return self @@ -1021,11 +1024,11 @@ def pull( """Pull changes from the given branch, being the same as a fetch followed by a merge of branch with your local branch. - :param refspec: see 'fetch' method - :param progress: see 'push' method - :param kill_after_timeout: see 'fetch' method + :param refspec: see :meth:`fetch` method + :param progress: see :meth:`push` method + :param kill_after_timeout: see :meth:`fetch` method :param kwargs: Additional arguments to be passed to git-pull - :return: Please see 'fetch' method""" + :return: Please see :meth:`fetch` method""" if refspec is None: # No argument refspec, then ensure the repo's config has a fetch refspec. self._assert_refspec() diff --git a/git/repo/base.py b/git/repo/base.py index d4463f1e1..93ed0c71d 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -403,6 +403,7 @@ def head(self) -> "HEAD": @property def remotes(self) -> "IterableList[Remote]": """A list of Remote objects allowing to access and manipulate remotes + :return: ``git.IterableList(Remote, ...)``""" return Remote.list_items(self) @@ -443,6 +444,7 @@ def create_submodule(self, *args: Any, **kwargs: Any) -> Submodule: def iter_submodules(self, *args: Any, **kwargs: Any) -> Iterator[Submodule]: """An iterator yielding Submodule instances, see Traversable interface for a description of args and kwargs + :return: Iterator""" return RootModule(self).traverse(*args, **kwargs) @@ -457,6 +459,7 @@ def submodule_update(self, *args: Any, **kwargs: Any) -> Iterator[Submodule]: @property def tags(self) -> "IterableList[TagReference]": """A list of ``Tag`` objects that are available in this repo + :return: ``git.IterableList(TagReference, ...)``""" return TagReference.list_items(self) diff --git a/git/util.py b/git/util.py index 6a4a65579..30028b1c2 100644 --- a/git/util.py +++ b/git/util.py @@ -131,7 +131,7 @@ def unbare_repo(func: Callable[..., T]) -> Callable[..., T]: - """Methods with this decorator raise InvalidGitRepositoryError if they + """Methods with this decorator raise :class:`.exc.InvalidGitRepositoryError` if they encounter a bare repository""" from .exc import InvalidGitRepositoryError @@ -1152,7 +1152,7 @@ def list_items(cls, repo: "Repo", *args: Any, **kwargs: Any) -> Any: :note: Favor the iter_items method as it will - :return:list(Item,...) list of item instances""" + :return: list(Item,...) list of item instances""" out_list: Any = IterableList(cls._id_attribute_) out_list.extend(cls.iter_items(repo, *args, **kwargs)) return out_list @@ -1184,7 +1184,7 @@ def list_items(cls, repo: "Repo", *args: Any, **kwargs: Any) -> IterableList[T_I :note: Favor the iter_items method as it will - :return:list(Item,...) list of item instances""" + :return: list(Item,...) list of item instances""" out_list: IterableList = IterableList(cls._id_attribute_) out_list.extend(cls.iter_items(repo, *args, **kwargs)) return out_list From fbc36f9cefa3a7e150187dabf8758a53062c5b47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20G=C3=B3rny?= Date: Sat, 31 Dec 2022 08:07:51 +0100 Subject: [PATCH 019/114] tests: Use `command -v` instead of third-party `which` program Use `command -v` to locate the git executable instead of `which`. The former is guaranteed to always be available according to POSIX, while which(1) is a redundant third-party tool that is slowly being phased out from Linux distributions. In particular, Gentoo no longer installs it by default. --- test/test_git.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_git.py b/test/test_git.py index e7d236deb..c5d871f08 100644 --- a/test/test_git.py +++ b/test/test_git.py @@ -169,7 +169,7 @@ def test_refresh(self): self.assertRaises(GitCommandNotFound, refresh, "yada") # test a good path refresh - which_cmd = "where" if is_win else "which" + which_cmd = "where" if is_win else "command -v" path = os.popen("{0} git".format(which_cmd)).read().strip().split("\n")[0] refresh(path) From 4110b613833fb4039847ac80ca8571f4ec8ebe50 Mon Sep 17 00:00:00 2001 From: FC Stegerman Date: Sun, 8 Jan 2023 04:44:28 +0100 Subject: [PATCH 020/114] fix/add allow_unsafe_* params in docstrings + fix typo --- git/objects/submodule/base.py | 8 +++++++- git/remote.py | 9 +++++++++ git/repo/base.py | 6 ++++-- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/git/objects/submodule/base.py b/git/objects/submodule/base.py index 9aa9deb27..7db64d705 100644 --- a/git/objects/submodule/base.py +++ b/git/objects/submodule/base.py @@ -287,7 +287,9 @@ def _clone_repo( :param url: url to clone from :param path: repository - relative path to the submodule checkout location :param name: canonical of the submodule - :param kwrags: additinoal arguments given to git.clone""" + :param allow_unsafe_protocols: Allow unsafe protocols to be used, like ext + :param allow_unsafe_options: Allow unsafe options to be used, like --upload-pack + :param kwargs: additional arguments given to git.clone""" module_abspath = cls._module_abspath(repo, path, name) module_checkout_path = module_abspath if cls._need_gitfile_submodules(repo.git): @@ -411,6 +413,8 @@ def add( as its value. :param clone_multi_options: A list of Clone options. Please see ``git.repo.base.Repo.clone`` for details. + :param allow_unsafe_protocols: Allow unsafe protocols to be used, like ext + :param allow_unsafe_options: Allow unsafe options to be used, like --upload-pack :return: The newly created submodule instance :note: works atomically, such that no change will be done if the repository update fails for instance""" @@ -581,6 +585,8 @@ def update( as its value. :param clone_multi_options: list of Clone options. Please see ``git.repo.base.Repo.clone`` for details. Only take effect with `init` option. + :param allow_unsafe_protocols: Allow unsafe protocols to be used, like ext + :param allow_unsafe_options: Allow unsafe options to be used, like --upload-pack :note: does nothing in bare repositories :note: method is definitely not atomic if recurisve is True :return: self""" diff --git a/git/remote.py b/git/remote.py index 3f86a297a..5886a69f0 100644 --- a/git/remote.py +++ b/git/remote.py @@ -641,6 +641,7 @@ def set_url( :param new_url: string being the URL to add as an extra remote URL :param old_url: when set, replaces this URL with new_url for the remote + :param allow_unsafe_protocols: Allow unsafe protocols to be used, like ext :return: self """ if not allow_unsafe_protocols: @@ -660,6 +661,7 @@ def add_url(self, url: str, allow_unsafe_protocols: bool = False, **kwargs: Any) multiple URLs for a single remote. :param url: string being the URL to add as an extra remote URL + :param allow_unsafe_protocols: Allow unsafe protocols to be used, like ext :return: self """ return self.set_url(url, add=True, allow_unsafe_protocols=allow_unsafe_protocols) @@ -760,6 +762,7 @@ def create(cls, repo: "Repo", name: str, url: str, allow_unsafe_protocols: bool :param repo: Repository instance that is to receive the new remote :param name: Desired name of the remote :param url: URL which corresponds to the remote's name + :param allow_unsafe_protocols: Allow unsafe protocols to be used, like ext :param kwargs: Additional arguments to be passed to the git-remote add command :return: New Remote instance :raise GitCommandError: in case an origin with that name already exists""" @@ -978,6 +981,8 @@ def fetch( :param kill_after_timeout: To specify a timeout in seconds for the git command, after which the process should be killed. It is set to None by default. + :param allow_unsafe_protocols: Allow unsafe protocols to be used, like ext + :param allow_unsafe_options: Allow unsafe options to be used, like --upload-pack :param kwargs: Additional arguments to be passed to git-fetch :return: IterableList(FetchInfo, ...) list of FetchInfo instances providing detailed @@ -1027,6 +1032,8 @@ def pull( :param refspec: see :meth:`fetch` method :param progress: see :meth:`push` method :param kill_after_timeout: see :meth:`fetch` method + :param allow_unsafe_protocols: Allow unsafe protocols to be used, like ext + :param allow_unsafe_options: Allow unsafe options to be used, like --upload-pack :param kwargs: Additional arguments to be passed to git-pull :return: Please see :meth:`fetch` method""" if refspec is None: @@ -1077,6 +1084,8 @@ def push( :param kill_after_timeout: To specify a timeout in seconds for the git command, after which the process should be killed. It is set to None by default. + :param allow_unsafe_protocols: Allow unsafe protocols to be used, like ext + :param allow_unsafe_options: Allow unsafe options to be used, like --receive-pack :param kwargs: Additional arguments to be passed to git-push :return: A ``PushInfoList`` object, where each list member diff --git a/git/repo/base.py b/git/repo/base.py index 93ed0c71d..4a3704c0b 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -1259,7 +1259,8 @@ def clone( option per list item which is passed exactly as specified to clone. For example ['--config core.filemode=false', '--config core.ignorecase', '--recurse-submodule=repo1_path', '--recurse-submodule=repo2_path'] - :param unsafe_protocols: Allow unsafe protocols to be used, like ext + :param allow_unsafe_protocols: Allow unsafe protocols to be used, like ext + :param allow_unsafe_options: Allow unsafe options to be used, like --upload-pack :param kwargs: * odbt = ObjectDatabase Type, allowing to determine the object database implementation used by the returned Repo instance @@ -1302,7 +1303,8 @@ def clone_from( If you want to unset some variable, consider providing empty string as its value. :param multi_options: See ``clone`` method - :param unsafe_protocols: Allow unsafe protocols to be used, like ext + :param allow_unsafe_protocols: Allow unsafe protocols to be used, like ext + :param allow_unsafe_options: Allow unsafe options to be used, like --upload-pack :param kwargs: see the ``clone`` method :return: Repo instance pointing to the cloned directory""" git = cls.GitCommandWrapperType(os.getcwd()) From 36cf7c17ab50a74a86bfa939fa66345329c05749 Mon Sep 17 00:00:00 2001 From: FC Stegerman Date: Sun, 8 Jan 2023 05:42:14 +0100 Subject: [PATCH 021/114] replace tempfile.mkdtemp w/ tempfile.TemporaryDirectory --- test/test_remote.py | 424 +++++++++++++++++++++-------------------- test/test_repo.py | 276 ++++++++++++++------------- test/test_submodule.py | 238 ++++++++++++----------- 3 files changed, 488 insertions(+), 450 deletions(-) diff --git a/test/test_remote.py b/test/test_remote.py index 3a47afab5..9636ca486 100644 --- a/test/test_remote.py +++ b/test/test_remote.py @@ -694,259 +694,279 @@ def test_push_error(self, repo): @with_rw_repo("HEAD") def test_set_unsafe_url(self, rw_repo): - tmp_dir = Path(tempfile.mkdtemp()) - tmp_file = tmp_dir / "pwn" - remote = rw_repo.remote("origin") - urls = [ - f"ext::sh -c touch% {tmp_file}", - "fd::17/foo", - ] - for url in urls: - with self.assertRaises(UnsafeProtocolError): - remote.set_url(url) - assert not tmp_file.exists() + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = Path(tdir) + tmp_file = tmp_dir / "pwn" + remote = rw_repo.remote("origin") + urls = [ + f"ext::sh -c touch% {tmp_file}", + "fd::17/foo", + ] + for url in urls: + with self.assertRaises(UnsafeProtocolError): + remote.set_url(url) + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_set_unsafe_url_allowed(self, rw_repo): - tmp_dir = Path(tempfile.mkdtemp()) - tmp_file = tmp_dir / "pwn" - remote = rw_repo.remote("origin") - urls = [ - f"ext::sh -c touch% {tmp_file}", - "fd::17/foo", - ] - for url in urls: - remote.set_url(url, allow_unsafe_protocols=True) - assert list(remote.urls)[-1] == url - assert not tmp_file.exists() + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = Path(tdir) + tmp_file = tmp_dir / "pwn" + remote = rw_repo.remote("origin") + urls = [ + f"ext::sh -c touch% {tmp_file}", + "fd::17/foo", + ] + for url in urls: + remote.set_url(url, allow_unsafe_protocols=True) + assert list(remote.urls)[-1] == url + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_add_unsafe_url(self, rw_repo): - tmp_dir = Path(tempfile.mkdtemp()) - tmp_file = tmp_dir / "pwn" - remote = rw_repo.remote("origin") - urls = [ - f"ext::sh -c touch% {tmp_file}", - "fd::17/foo", - ] - for url in urls: - with self.assertRaises(UnsafeProtocolError): - remote.add_url(url) - assert not tmp_file.exists() + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = Path(tdir) + tmp_file = tmp_dir / "pwn" + remote = rw_repo.remote("origin") + urls = [ + f"ext::sh -c touch% {tmp_file}", + "fd::17/foo", + ] + for url in urls: + with self.assertRaises(UnsafeProtocolError): + remote.add_url(url) + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_add_unsafe_url_allowed(self, rw_repo): - tmp_dir = Path(tempfile.mkdtemp()) - tmp_file = tmp_dir / "pwn" - remote = rw_repo.remote("origin") - urls = [ - f"ext::sh -c touch% {tmp_file}", - "fd::17/foo", - ] - for url in urls: - remote.add_url(url, allow_unsafe_protocols=True) - assert list(remote.urls)[-1] == url - assert not tmp_file.exists() + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = Path(tdir) + tmp_file = tmp_dir / "pwn" + remote = rw_repo.remote("origin") + urls = [ + f"ext::sh -c touch% {tmp_file}", + "fd::17/foo", + ] + for url in urls: + remote.add_url(url, allow_unsafe_protocols=True) + assert list(remote.urls)[-1] == url + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_create_remote_unsafe_url(self, rw_repo): - tmp_dir = Path(tempfile.mkdtemp()) - tmp_file = tmp_dir / "pwn" - urls = [ - f"ext::sh -c touch% {tmp_file}", - "fd::17/foo", - ] - for url in urls: - with self.assertRaises(UnsafeProtocolError): - Remote.create(rw_repo, "origin", url) - assert not tmp_file.exists() + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = Path(tdir) + tmp_file = tmp_dir / "pwn" + urls = [ + f"ext::sh -c touch% {tmp_file}", + "fd::17/foo", + ] + for url in urls: + with self.assertRaises(UnsafeProtocolError): + Remote.create(rw_repo, "origin", url) + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_create_remote_unsafe_url_allowed(self, rw_repo): - tmp_dir = Path(tempfile.mkdtemp()) - tmp_file = tmp_dir / "pwn" - urls = [ - f"ext::sh -c touch% {tmp_file}", - "fd::17/foo", - ] - for i, url in enumerate(urls): - remote = Remote.create(rw_repo, f"origin{i}", url, allow_unsafe_protocols=True) - assert remote.url == url - assert not tmp_file.exists() + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = Path(tdir) + tmp_file = tmp_dir / "pwn" + urls = [ + f"ext::sh -c touch% {tmp_file}", + "fd::17/foo", + ] + for i, url in enumerate(urls): + remote = Remote.create(rw_repo, f"origin{i}", url, allow_unsafe_protocols=True) + assert remote.url == url + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_fetch_unsafe_url(self, rw_repo): - tmp_dir = Path(tempfile.mkdtemp()) - tmp_file = tmp_dir / "pwn" - remote = rw_repo.remote("origin") - urls = [ - f"ext::sh -c touch% {tmp_file}", - "fd::17/foo", - ] - for url in urls: - with self.assertRaises(UnsafeProtocolError): - remote.fetch(url) - assert not tmp_file.exists() + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = Path(tdir) + tmp_file = tmp_dir / "pwn" + remote = rw_repo.remote("origin") + urls = [ + f"ext::sh -c touch% {tmp_file}", + "fd::17/foo", + ] + for url in urls: + with self.assertRaises(UnsafeProtocolError): + remote.fetch(url) + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_fetch_unsafe_url_allowed(self, rw_repo): - tmp_dir = Path(tempfile.mkdtemp()) - tmp_file = tmp_dir / "pwn" - remote = rw_repo.remote("origin") - urls = [ - f"ext::sh -c touch% {tmp_file}", - "fd::17/foo", - ] - for url in urls: - # The URL will be allowed into the command, but the command will - # fail since we don't have that protocol enabled in the Git config file. - with self.assertRaises(GitCommandError): - remote.fetch(url, allow_unsafe_protocols=True) - assert not tmp_file.exists() + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = Path(tdir) + tmp_file = tmp_dir / "pwn" + remote = rw_repo.remote("origin") + urls = [ + f"ext::sh -c touch% {tmp_file}", + "fd::17/foo", + ] + for url in urls: + # The URL will be allowed into the command, but the command will + # fail since we don't have that protocol enabled in the Git config file. + with self.assertRaises(GitCommandError): + remote.fetch(url, allow_unsafe_protocols=True) + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_fetch_unsafe_options(self, rw_repo): - remote = rw_repo.remote("origin") - tmp_dir = Path(tempfile.mkdtemp()) - tmp_file = tmp_dir / "pwn" - unsafe_options = [{"upload-pack": f"touch {tmp_file}"}] - for unsafe_option in unsafe_options: - with self.assertRaises(UnsafeOptionError): - remote.fetch(**unsafe_option) - assert not tmp_file.exists() + with tempfile.TemporaryDirectory() as tdir: + remote = rw_repo.remote("origin") + tmp_dir = Path(tdir) + tmp_file = tmp_dir / "pwn" + unsafe_options = [{"upload-pack": f"touch {tmp_file}"}] + for unsafe_option in unsafe_options: + with self.assertRaises(UnsafeOptionError): + remote.fetch(**unsafe_option) + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_fetch_unsafe_options_allowed(self, rw_repo): - remote = rw_repo.remote("origin") - tmp_dir = Path(tempfile.mkdtemp()) - tmp_file = tmp_dir / "pwn" - unsafe_options = [{"upload-pack": f"touch {tmp_file}"}] - for unsafe_option in unsafe_options: - # The options will be allowed, but the command will fail. - assert not tmp_file.exists() - with self.assertRaises(GitCommandError): - remote.fetch(**unsafe_option, allow_unsafe_options=True) - assert tmp_file.exists() + with tempfile.TemporaryDirectory() as tdir: + remote = rw_repo.remote("origin") + tmp_dir = Path(tdir) + tmp_file = tmp_dir / "pwn" + unsafe_options = [{"upload-pack": f"touch {tmp_file}"}] + for unsafe_option in unsafe_options: + # The options will be allowed, but the command will fail. + assert not tmp_file.exists() + with self.assertRaises(GitCommandError): + remote.fetch(**unsafe_option, allow_unsafe_options=True) + assert tmp_file.exists() + tmp_file.unlink() @with_rw_repo("HEAD") def test_pull_unsafe_url(self, rw_repo): - tmp_dir = Path(tempfile.mkdtemp()) - tmp_file = tmp_dir / "pwn" - remote = rw_repo.remote("origin") - urls = [ - f"ext::sh -c touch% {tmp_file}", - "fd::17/foo", - ] - for url in urls: - with self.assertRaises(UnsafeProtocolError): - remote.pull(url) - assert not tmp_file.exists() + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = Path(tdir) + tmp_file = tmp_dir / "pwn" + remote = rw_repo.remote("origin") + urls = [ + f"ext::sh -c touch% {tmp_file}", + "fd::17/foo", + ] + for url in urls: + with self.assertRaises(UnsafeProtocolError): + remote.pull(url) + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_pull_unsafe_url_allowed(self, rw_repo): - tmp_dir = Path(tempfile.mkdtemp()) - tmp_file = tmp_dir / "pwn" - remote = rw_repo.remote("origin") - urls = [ - f"ext::sh -c touch% {tmp_file}", - "fd::17/foo", - ] - for url in urls: - # The URL will be allowed into the command, but the command will - # fail since we don't have that protocol enabled in the Git config file. - with self.assertRaises(GitCommandError): - remote.pull(url, allow_unsafe_protocols=True) - assert not tmp_file.exists() + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = Path(tdir) + tmp_file = tmp_dir / "pwn" + remote = rw_repo.remote("origin") + urls = [ + f"ext::sh -c touch% {tmp_file}", + "fd::17/foo", + ] + for url in urls: + # The URL will be allowed into the command, but the command will + # fail since we don't have that protocol enabled in the Git config file. + with self.assertRaises(GitCommandError): + remote.pull(url, allow_unsafe_protocols=True) + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_pull_unsafe_options(self, rw_repo): - remote = rw_repo.remote("origin") - tmp_dir = Path(tempfile.mkdtemp()) - tmp_file = tmp_dir / "pwn" - unsafe_options = [{"upload-pack": f"touch {tmp_file}"}] - for unsafe_option in unsafe_options: - with self.assertRaises(UnsafeOptionError): - remote.pull(**unsafe_option) - assert not tmp_file.exists() + with tempfile.TemporaryDirectory() as tdir: + remote = rw_repo.remote("origin") + tmp_dir = Path(tdir) + tmp_file = tmp_dir / "pwn" + unsafe_options = [{"upload-pack": f"touch {tmp_file}"}] + for unsafe_option in unsafe_options: + with self.assertRaises(UnsafeOptionError): + remote.pull(**unsafe_option) + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_pull_unsafe_options_allowed(self, rw_repo): - remote = rw_repo.remote("origin") - tmp_dir = Path(tempfile.mkdtemp()) - tmp_file = tmp_dir / "pwn" - unsafe_options = [{"upload-pack": f"touch {tmp_file}"}] - for unsafe_option in unsafe_options: - # The options will be allowed, but the command will fail. - assert not tmp_file.exists() - with self.assertRaises(GitCommandError): - remote.pull(**unsafe_option, allow_unsafe_options=True) - assert tmp_file.exists() + with tempfile.TemporaryDirectory() as tdir: + remote = rw_repo.remote("origin") + tmp_dir = Path(tdir) + tmp_file = tmp_dir / "pwn" + unsafe_options = [{"upload-pack": f"touch {tmp_file}"}] + for unsafe_option in unsafe_options: + # The options will be allowed, but the command will fail. + assert not tmp_file.exists() + with self.assertRaises(GitCommandError): + remote.pull(**unsafe_option, allow_unsafe_options=True) + assert tmp_file.exists() + tmp_file.unlink() @with_rw_repo("HEAD") def test_push_unsafe_url(self, rw_repo): - tmp_dir = Path(tempfile.mkdtemp()) - tmp_file = tmp_dir / "pwn" - remote = rw_repo.remote("origin") - urls = [ - f"ext::sh -c touch% {tmp_file}", - "fd::17/foo", - ] - for url in urls: - with self.assertRaises(UnsafeProtocolError): - remote.push(url) - assert not tmp_file.exists() + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = Path(tdir) + tmp_file = tmp_dir / "pwn" + remote = rw_repo.remote("origin") + urls = [ + f"ext::sh -c touch% {tmp_file}", + "fd::17/foo", + ] + for url in urls: + with self.assertRaises(UnsafeProtocolError): + remote.push(url) + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_push_unsafe_url_allowed(self, rw_repo): - tmp_dir = Path(tempfile.mkdtemp()) - tmp_file = tmp_dir / "pwn" - remote = rw_repo.remote("origin") - urls = [ - f"ext::sh -c touch% {tmp_file}", - "fd::17/foo", - ] - for url in urls: - # The URL will be allowed into the command, but the command will - # fail since we don't have that protocol enabled in the Git config file. - with self.assertRaises(GitCommandError): - remote.push(url, allow_unsafe_protocols=True) - assert not tmp_file.exists() + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = Path(tdir) + tmp_file = tmp_dir / "pwn" + remote = rw_repo.remote("origin") + urls = [ + f"ext::sh -c touch% {tmp_file}", + "fd::17/foo", + ] + for url in urls: + # The URL will be allowed into the command, but the command will + # fail since we don't have that protocol enabled in the Git config file. + with self.assertRaises(GitCommandError): + remote.push(url, allow_unsafe_protocols=True) + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_push_unsafe_options(self, rw_repo): - remote = rw_repo.remote("origin") - tmp_dir = Path(tempfile.mkdtemp()) - tmp_file = tmp_dir / "pwn" - unsafe_options = [ - { - "receive-pack": f"touch {tmp_file}", - "exec": f"touch {tmp_file}", - } - ] - for unsafe_option in unsafe_options: - assert not tmp_file.exists() - with self.assertRaises(UnsafeOptionError): - remote.push(**unsafe_option) - assert not tmp_file.exists() + with tempfile.TemporaryDirectory() as tdir: + remote = rw_repo.remote("origin") + tmp_dir = Path(tdir) + tmp_file = tmp_dir / "pwn" + unsafe_options = [ + { + "receive-pack": f"touch {tmp_file}", + "exec": f"touch {tmp_file}", + } + ] + for unsafe_option in unsafe_options: + assert not tmp_file.exists() + with self.assertRaises(UnsafeOptionError): + remote.push(**unsafe_option) + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_push_unsafe_options_allowed(self, rw_repo): - remote = rw_repo.remote("origin") - tmp_dir = Path(tempfile.mkdtemp()) - tmp_file = tmp_dir / "pwn" - unsafe_options = [ - { - "receive-pack": f"touch {tmp_file}", - "exec": f"touch {tmp_file}", - } - ] - for unsafe_option in unsafe_options: - # The options will be allowed, but the command will fail. - assert not tmp_file.exists() - with self.assertRaises(GitCommandError): - remote.push(**unsafe_option, allow_unsafe_options=True) - assert tmp_file.exists() - tmp_file.unlink() + with tempfile.TemporaryDirectory() as tdir: + remote = rw_repo.remote("origin") + tmp_dir = Path(tdir) + tmp_file = tmp_dir / "pwn" + unsafe_options = [ + { + "receive-pack": f"touch {tmp_file}", + "exec": f"touch {tmp_file}", + } + ] + for unsafe_option in unsafe_options: + # The options will be allowed, but the command will fail. + assert not tmp_file.exists() + with self.assertRaises(GitCommandError): + remote.push(**unsafe_option, allow_unsafe_options=True) + assert tmp_file.exists() + tmp_file.unlink() class TestTimeouts(TestBase): diff --git a/test/test_repo.py b/test/test_repo.py index 5874dbe6a..0985b6352 100644 --- a/test/test_repo.py +++ b/test/test_repo.py @@ -268,143 +268,151 @@ def test_leaking_password_in_clone_logs(self, rw_dir): @with_rw_repo("HEAD") def test_clone_unsafe_options(self, rw_repo): - tmp_dir = pathlib.Path(tempfile.mkdtemp()) - tmp_file = tmp_dir / "pwn" - unsafe_options = [ - f"--upload-pack='touch {tmp_file}'", - f"-u 'touch {tmp_file}'", - "--config=protocol.ext.allow=always", - "-c protocol.ext.allow=always", - ] - for unsafe_option in unsafe_options: - with self.assertRaises(UnsafeOptionError): - rw_repo.clone(tmp_dir, multi_options=[unsafe_option]) - assert not tmp_file.exists() + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = pathlib.Path(tdir) + tmp_file = tmp_dir / "pwn" + unsafe_options = [ + f"--upload-pack='touch {tmp_file}'", + f"-u 'touch {tmp_file}'", + "--config=protocol.ext.allow=always", + "-c protocol.ext.allow=always", + ] + for unsafe_option in unsafe_options: + with self.assertRaises(UnsafeOptionError): + rw_repo.clone(tmp_dir, multi_options=[unsafe_option]) + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_clone_unsafe_options_allowed(self, rw_repo): - tmp_dir = pathlib.Path(tempfile.mkdtemp()) - tmp_file = tmp_dir / "pwn" - unsafe_options = [ - f"--upload-pack='touch {tmp_file}'", - f"-u 'touch {tmp_file}'", - ] - for i, unsafe_option in enumerate(unsafe_options): - destination = tmp_dir / str(i) - assert not tmp_file.exists() - # The options will be allowed, but the command will fail. - with self.assertRaises(GitCommandError): + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = pathlib.Path(tdir) + tmp_file = tmp_dir / "pwn" + unsafe_options = [ + f"--upload-pack='touch {tmp_file}'", + f"-u 'touch {tmp_file}'", + ] + for i, unsafe_option in enumerate(unsafe_options): + destination = tmp_dir / str(i) + assert not tmp_file.exists() + # The options will be allowed, but the command will fail. + with self.assertRaises(GitCommandError): + rw_repo.clone(destination, multi_options=[unsafe_option], allow_unsafe_options=True) + assert tmp_file.exists() + tmp_file.unlink() + + unsafe_options = [ + "--config=protocol.ext.allow=always", + "-c protocol.ext.allow=always", + ] + for i, unsafe_option in enumerate(unsafe_options): + destination = tmp_dir / str(i) + assert not destination.exists() rw_repo.clone(destination, multi_options=[unsafe_option], allow_unsafe_options=True) - assert tmp_file.exists() - tmp_file.unlink() - - unsafe_options = [ - "--config=protocol.ext.allow=always", - "-c protocol.ext.allow=always", - ] - for i, unsafe_option in enumerate(unsafe_options): - destination = tmp_dir / str(i) - assert not destination.exists() - rw_repo.clone(destination, multi_options=[unsafe_option], allow_unsafe_options=True) - assert destination.exists() + assert destination.exists() @with_rw_repo("HEAD") def test_clone_safe_options(self, rw_repo): - tmp_dir = pathlib.Path(tempfile.mkdtemp()) - options = [ - "--depth=1", - "--single-branch", - "-q", - ] - for option in options: - destination = tmp_dir / option - assert not destination.exists() - rw_repo.clone(destination, multi_options=[option]) - assert destination.exists() + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = pathlib.Path(tdir) + options = [ + "--depth=1", + "--single-branch", + "-q", + ] + for option in options: + destination = tmp_dir / option + assert not destination.exists() + rw_repo.clone(destination, multi_options=[option]) + assert destination.exists() @with_rw_repo("HEAD") def test_clone_from_unsafe_options(self, rw_repo): - tmp_dir = pathlib.Path(tempfile.mkdtemp()) - tmp_file = tmp_dir / "pwn" - unsafe_options = [ - f"--upload-pack='touch {tmp_file}'", - f"-u 'touch {tmp_file}'", - "--config=protocol.ext.allow=always", - "-c protocol.ext.allow=always", - ] - for unsafe_option in unsafe_options: - with self.assertRaises(UnsafeOptionError): - Repo.clone_from(rw_repo.working_dir, tmp_dir, multi_options=[unsafe_option]) - assert not tmp_file.exists() + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = pathlib.Path(tdir) + tmp_file = tmp_dir / "pwn" + unsafe_options = [ + f"--upload-pack='touch {tmp_file}'", + f"-u 'touch {tmp_file}'", + "--config=protocol.ext.allow=always", + "-c protocol.ext.allow=always", + ] + for unsafe_option in unsafe_options: + with self.assertRaises(UnsafeOptionError): + Repo.clone_from(rw_repo.working_dir, tmp_dir, multi_options=[unsafe_option]) + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_clone_from_unsafe_options_allowed(self, rw_repo): - tmp_dir = pathlib.Path(tempfile.mkdtemp()) - tmp_file = tmp_dir / "pwn" - unsafe_options = [ - f"--upload-pack='touch {tmp_file}'", - f"-u 'touch {tmp_file}'", - ] - for i, unsafe_option in enumerate(unsafe_options): - destination = tmp_dir / str(i) - assert not tmp_file.exists() - # The options will be allowed, but the command will fail. - with self.assertRaises(GitCommandError): - Repo.clone_from( - rw_repo.working_dir, destination, multi_options=[unsafe_option], allow_unsafe_options=True - ) - assert tmp_file.exists() - tmp_file.unlink() - - unsafe_options = [ - "--config=protocol.ext.allow=always", - "-c protocol.ext.allow=always", - ] - for i, unsafe_option in enumerate(unsafe_options): - destination = tmp_dir / str(i) - assert not destination.exists() - Repo.clone_from(rw_repo.working_dir, destination, multi_options=[unsafe_option], allow_unsafe_options=True) - assert destination.exists() + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = pathlib.Path(tdir) + tmp_file = tmp_dir / "pwn" + unsafe_options = [ + f"--upload-pack='touch {tmp_file}'", + f"-u 'touch {tmp_file}'", + ] + for i, unsafe_option in enumerate(unsafe_options): + destination = tmp_dir / str(i) + assert not tmp_file.exists() + # The options will be allowed, but the command will fail. + with self.assertRaises(GitCommandError): + Repo.clone_from( + rw_repo.working_dir, destination, multi_options=[unsafe_option], allow_unsafe_options=True + ) + assert tmp_file.exists() + tmp_file.unlink() + + unsafe_options = [ + "--config=protocol.ext.allow=always", + "-c protocol.ext.allow=always", + ] + for i, unsafe_option in enumerate(unsafe_options): + destination = tmp_dir / str(i) + assert not destination.exists() + Repo.clone_from(rw_repo.working_dir, destination, multi_options=[unsafe_option], allow_unsafe_options=True) + assert destination.exists() @with_rw_repo("HEAD") def test_clone_from_safe_options(self, rw_repo): - tmp_dir = pathlib.Path(tempfile.mkdtemp()) - options = [ - "--depth=1", - "--single-branch", - "-q", - ] - for option in options: - destination = tmp_dir / option - assert not destination.exists() - Repo.clone_from(rw_repo.common_dir, destination, multi_options=[option]) - assert destination.exists() + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = pathlib.Path(tdir) + options = [ + "--depth=1", + "--single-branch", + "-q", + ] + for option in options: + destination = tmp_dir / option + assert not destination.exists() + Repo.clone_from(rw_repo.common_dir, destination, multi_options=[option]) + assert destination.exists() def test_clone_from_unsafe_procol(self): - tmp_dir = pathlib.Path(tempfile.mkdtemp()) - tmp_file = tmp_dir / "pwn" - urls = [ - f"ext::sh -c touch% {tmp_file}", - "fd::17/foo", - ] - for url in urls: - with self.assertRaises(UnsafeProtocolError): - Repo.clone_from(url, tmp_dir) - assert not tmp_file.exists() + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = pathlib.Path(tdir) + tmp_file = tmp_dir / "pwn" + urls = [ + f"ext::sh -c touch% {tmp_file}", + "fd::17/foo", + ] + for url in urls: + with self.assertRaises(UnsafeProtocolError): + Repo.clone_from(url, tmp_dir) + assert not tmp_file.exists() def test_clone_from_unsafe_procol_allowed(self): - tmp_dir = pathlib.Path(tempfile.mkdtemp()) - tmp_file = tmp_dir / "pwn" - urls = [ - "ext::sh -c touch% /tmp/pwn", - "fd::/foo", - ] - for url in urls: - # The URL will be allowed into the command, but the command will - # fail since we don't have that protocol enabled in the Git config file. - with self.assertRaises(GitCommandError): - Repo.clone_from(url, tmp_dir, allow_unsafe_protocols=True) - assert not tmp_file.exists() + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = pathlib.Path(tdir) + tmp_file = tmp_dir / "pwn" + urls = [ + "ext::sh -c touch% /tmp/pwn", + "fd::/foo", + ] + for url in urls: + # The URL will be allowed into the command, but the command will + # fail since we don't have that protocol enabled in the Git config file. + with self.assertRaises(GitCommandError): + Repo.clone_from(url, tmp_dir, allow_unsafe_protocols=True) + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_max_chunk_size(self, repo): @@ -1326,26 +1334,28 @@ def test_do_not_strip_newline_in_stdout(self, rw_dir): @with_rw_repo("HEAD") def test_clone_command_injection(self, rw_repo): - tmp_dir = pathlib.Path(tempfile.mkdtemp()) - unexpected_file = tmp_dir / "pwn" - assert not unexpected_file.exists() + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = pathlib.Path(tdir) + unexpected_file = tmp_dir / "pwn" + assert not unexpected_file.exists() - payload = f"--upload-pack=touch {unexpected_file}" - rw_repo.clone(payload) + payload = f"--upload-pack=touch {unexpected_file}" + rw_repo.clone(payload) - assert not unexpected_file.exists() - # A repo was cloned with the payload as name - assert pathlib.Path(payload).exists() + assert not unexpected_file.exists() + # A repo was cloned with the payload as name + assert pathlib.Path(payload).exists() @with_rw_repo("HEAD") def test_clone_from_command_injection(self, rw_repo): - tmp_dir = pathlib.Path(tempfile.mkdtemp()) - temp_repo = Repo.init(tmp_dir / "repo") - unexpected_file = tmp_dir / "pwn" + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = pathlib.Path(tdir) + temp_repo = Repo.init(tmp_dir / "repo") + unexpected_file = tmp_dir / "pwn" - assert not unexpected_file.exists() - payload = f"--upload-pack=touch {unexpected_file}" - with self.assertRaises(GitCommandError): - rw_repo.clone_from(payload, temp_repo.common_dir) + assert not unexpected_file.exists() + payload = f"--upload-pack=touch {unexpected_file}" + with self.assertRaises(GitCommandError): + rw_repo.clone_from(payload, temp_repo.common_dir) - assert not unexpected_file.exists() + assert not unexpected_file.exists() diff --git a/test/test_submodule.py b/test/test_submodule.py index 13878df28..982226411 100644 --- a/test/test_submodule.py +++ b/test/test_submodule.py @@ -1101,139 +1101,147 @@ def test_add_no_clone_multi_options_argument(self, rwdir): @with_rw_repo("HEAD") def test_submodule_add_unsafe_url(self, rw_repo): - tmp_dir = Path(tempfile.mkdtemp()) - tmp_file = tmp_dir / "pwn" - urls = [ - f"ext::sh -c touch% {tmp_file}", - "fd::/foo", - ] - for url in urls: - with self.assertRaises(UnsafeProtocolError): - Submodule.add(rw_repo, "new", "new", url) - assert not tmp_file.exists() + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = Path(tdir) + tmp_file = tmp_dir / "pwn" + urls = [ + f"ext::sh -c touch% {tmp_file}", + "fd::/foo", + ] + for url in urls: + with self.assertRaises(UnsafeProtocolError): + Submodule.add(rw_repo, "new", "new", url) + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_submodule_add_unsafe_url_allowed(self, rw_repo): - tmp_dir = Path(tempfile.mkdtemp()) - tmp_file = tmp_dir / "pwn" - urls = [ - f"ext::sh -c touch% {tmp_file}", - "fd::/foo", - ] - for url in urls: - # The URL will be allowed into the command, but the command will - # fail since we don't have that protocol enabled in the Git config file. - with self.assertRaises(GitCommandError): - Submodule.add(rw_repo, "new", "new", url, allow_unsafe_protocols=True) - assert not tmp_file.exists() + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = Path(tdir) + tmp_file = tmp_dir / "pwn" + urls = [ + f"ext::sh -c touch% {tmp_file}", + "fd::/foo", + ] + for url in urls: + # The URL will be allowed into the command, but the command will + # fail since we don't have that protocol enabled in the Git config file. + with self.assertRaises(GitCommandError): + Submodule.add(rw_repo, "new", "new", url, allow_unsafe_protocols=True) + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_submodule_add_unsafe_options(self, rw_repo): - tmp_dir = Path(tempfile.mkdtemp()) - tmp_file = tmp_dir / "pwn" - unsafe_options = [ - f"--upload-pack='touch {tmp_file}'", - f"-u 'touch {tmp_file}'", - "--config=protocol.ext.allow=always", - "-c protocol.ext.allow=always", - ] - for unsafe_option in unsafe_options: - with self.assertRaises(UnsafeOptionError): - Submodule.add(rw_repo, "new", "new", str(tmp_dir), clone_multi_options=[unsafe_option]) - assert not tmp_file.exists() + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = Path(tdir) + tmp_file = tmp_dir / "pwn" + unsafe_options = [ + f"--upload-pack='touch {tmp_file}'", + f"-u 'touch {tmp_file}'", + "--config=protocol.ext.allow=always", + "-c protocol.ext.allow=always", + ] + for unsafe_option in unsafe_options: + with self.assertRaises(UnsafeOptionError): + Submodule.add(rw_repo, "new", "new", str(tmp_dir), clone_multi_options=[unsafe_option]) + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_submodule_add_unsafe_options_allowed(self, rw_repo): - tmp_dir = Path(tempfile.mkdtemp()) - tmp_file = tmp_dir / "pwn" - unsafe_options = [ - f"--upload-pack='touch {tmp_file}'", - f"-u 'touch {tmp_file}'", - ] - for unsafe_option in unsafe_options: - # The options will be allowed, but the command will fail. - with self.assertRaises(GitCommandError): - Submodule.add( - rw_repo, "new", "new", str(tmp_dir), clone_multi_options=[unsafe_option], allow_unsafe_options=True - ) - assert not tmp_file.exists() - - unsafe_options = [ - "--config=protocol.ext.allow=always", - "-c protocol.ext.allow=always", - ] - for unsafe_option in unsafe_options: - with self.assertRaises(GitCommandError): - Submodule.add( - rw_repo, "new", "new", str(tmp_dir), clone_multi_options=[unsafe_option], allow_unsafe_options=True - ) + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = Path(tdir) + tmp_file = tmp_dir / "pwn" + unsafe_options = [ + f"--upload-pack='touch {tmp_file}'", + f"-u 'touch {tmp_file}'", + ] + for unsafe_option in unsafe_options: + # The options will be allowed, but the command will fail. + with self.assertRaises(GitCommandError): + Submodule.add( + rw_repo, "new", "new", str(tmp_dir), clone_multi_options=[unsafe_option], allow_unsafe_options=True + ) + assert not tmp_file.exists() + + unsafe_options = [ + "--config=protocol.ext.allow=always", + "-c protocol.ext.allow=always", + ] + for unsafe_option in unsafe_options: + with self.assertRaises(GitCommandError): + Submodule.add( + rw_repo, "new", "new", str(tmp_dir), clone_multi_options=[unsafe_option], allow_unsafe_options=True + ) @with_rw_repo("HEAD") def test_submodule_update_unsafe_url(self, rw_repo): - tmp_dir = Path(tempfile.mkdtemp()) - tmp_file = tmp_dir / "pwn" - urls = [ - f"ext::sh -c touch% {tmp_file}", - "fd::/foo", - ] - for url in urls: - submodule = Submodule(rw_repo, b"\0" * 20, name="new", path="new", url=url) - with self.assertRaises(UnsafeProtocolError): - submodule.update() - assert not tmp_file.exists() + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = Path(tdir) + tmp_file = tmp_dir / "pwn" + urls = [ + f"ext::sh -c touch% {tmp_file}", + "fd::/foo", + ] + for url in urls: + submodule = Submodule(rw_repo, b"\0" * 20, name="new", path="new", url=url) + with self.assertRaises(UnsafeProtocolError): + submodule.update() + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_submodule_update_unsafe_url_allowed(self, rw_repo): - tmp_dir = Path(tempfile.mkdtemp()) - tmp_file = tmp_dir / "pwn" - urls = [ - f"ext::sh -c touch% {tmp_file}", - "fd::/foo", - ] - for url in urls: - submodule = Submodule(rw_repo, b"\0" * 20, name="new", path="new", url=url) - # The URL will be allowed into the command, but the command will - # fail since we don't have that protocol enabled in the Git config file. - with self.assertRaises(GitCommandError): - submodule.update(allow_unsafe_protocols=True) - assert not tmp_file.exists() + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = Path(tdir) + tmp_file = tmp_dir / "pwn" + urls = [ + f"ext::sh -c touch% {tmp_file}", + "fd::/foo", + ] + for url in urls: + submodule = Submodule(rw_repo, b"\0" * 20, name="new", path="new", url=url) + # The URL will be allowed into the command, but the command will + # fail since we don't have that protocol enabled in the Git config file. + with self.assertRaises(GitCommandError): + submodule.update(allow_unsafe_protocols=True) + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_submodule_update_unsafe_options(self, rw_repo): - tmp_dir = Path(tempfile.mkdtemp()) - tmp_file = tmp_dir / "pwn" - unsafe_options = [ - f"--upload-pack='touch {tmp_file}'", - f"-u 'touch {tmp_file}'", - "--config=protocol.ext.allow=always", - "-c protocol.ext.allow=always", - ] - submodule = Submodule(rw_repo, b"\0" * 20, name="new", path="new", url=str(tmp_dir)) - for unsafe_option in unsafe_options: - with self.assertRaises(UnsafeOptionError): - submodule.update(clone_multi_options=[unsafe_option]) - assert not tmp_file.exists() + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = Path(tdir) + tmp_file = tmp_dir / "pwn" + unsafe_options = [ + f"--upload-pack='touch {tmp_file}'", + f"-u 'touch {tmp_file}'", + "--config=protocol.ext.allow=always", + "-c protocol.ext.allow=always", + ] + submodule = Submodule(rw_repo, b"\0" * 20, name="new", path="new", url=str(tmp_dir)) + for unsafe_option in unsafe_options: + with self.assertRaises(UnsafeOptionError): + submodule.update(clone_multi_options=[unsafe_option]) + assert not tmp_file.exists() @with_rw_repo("HEAD") def test_submodule_update_unsafe_options_allowed(self, rw_repo): - tmp_dir = Path(tempfile.mkdtemp()) - tmp_file = tmp_dir / "pwn" - unsafe_options = [ - f"--upload-pack='touch {tmp_file}'", - f"-u 'touch {tmp_file}'", - ] - submodule = Submodule(rw_repo, b"\0" * 20, name="new", path="new", url=str(tmp_dir)) - for unsafe_option in unsafe_options: - # The options will be allowed, but the command will fail. - with self.assertRaises(GitCommandError): - submodule.update(clone_multi_options=[unsafe_option], allow_unsafe_options=True) - assert not tmp_file.exists() - - unsafe_options = [ - "--config=protocol.ext.allow=always", - "-c protocol.ext.allow=always", - ] - submodule = Submodule(rw_repo, b"\0" * 20, name="new", path="new", url=str(tmp_dir)) - for unsafe_option in unsafe_options: - with self.assertRaises(GitCommandError): - submodule.update(clone_multi_options=[unsafe_option], allow_unsafe_options=True) + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = Path(tdir) + tmp_file = tmp_dir / "pwn" + unsafe_options = [ + f"--upload-pack='touch {tmp_file}'", + f"-u 'touch {tmp_file}'", + ] + submodule = Submodule(rw_repo, b"\0" * 20, name="new", path="new", url=str(tmp_dir)) + for unsafe_option in unsafe_options: + # The options will be allowed, but the command will fail. + with self.assertRaises(GitCommandError): + submodule.update(clone_multi_options=[unsafe_option], allow_unsafe_options=True) + assert not tmp_file.exists() + + unsafe_options = [ + "--config=protocol.ext.allow=always", + "-c protocol.ext.allow=always", + ] + submodule = Submodule(rw_repo, b"\0" * 20, name="new", path="new", url=str(tmp_dir)) + for unsafe_option in unsafe_options: + with self.assertRaises(GitCommandError): + submodule.update(clone_multi_options=[unsafe_option], allow_unsafe_options=True) From 8f41a390bf9a54db6f85032bc56b453307b95451 Mon Sep 17 00:00:00 2001 From: FC Stegerman Date: Sun, 8 Jan 2023 06:26:27 +0100 Subject: [PATCH 022/114] fix clone_from_unsafe_protocol tests --- test/test_repo.py | 35 ++++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/test/test_repo.py b/test/test_repo.py index 0985b6352..d5474353f 100644 --- a/test/test_repo.py +++ b/test/test_repo.py @@ -386,7 +386,7 @@ def test_clone_from_safe_options(self, rw_repo): Repo.clone_from(rw_repo.common_dir, destination, multi_options=[option]) assert destination.exists() - def test_clone_from_unsafe_procol(self): + def test_clone_from_unsafe_protocol(self): with tempfile.TemporaryDirectory() as tdir: tmp_dir = pathlib.Path(tdir) tmp_file = tmp_dir / "pwn" @@ -396,24 +396,49 @@ def test_clone_from_unsafe_procol(self): ] for url in urls: with self.assertRaises(UnsafeProtocolError): - Repo.clone_from(url, tmp_dir) + Repo.clone_from(url, tmp_dir / "repo") assert not tmp_file.exists() - def test_clone_from_unsafe_procol_allowed(self): + def test_clone_from_unsafe_protocol_allowed(self): with tempfile.TemporaryDirectory() as tdir: tmp_dir = pathlib.Path(tdir) tmp_file = tmp_dir / "pwn" urls = [ - "ext::sh -c touch% /tmp/pwn", + f"ext::sh -c touch% {tmp_file}", "fd::/foo", ] for url in urls: # The URL will be allowed into the command, but the command will # fail since we don't have that protocol enabled in the Git config file. with self.assertRaises(GitCommandError): - Repo.clone_from(url, tmp_dir, allow_unsafe_protocols=True) + Repo.clone_from(url, tmp_dir / "repo", allow_unsafe_protocols=True) assert not tmp_file.exists() + def test_clone_from_unsafe_protocol_allowed_and_enabled(self): + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = pathlib.Path(tdir) + tmp_file = tmp_dir / "pwn" + urls = [ + f"ext::sh -c touch% {tmp_file}", + ] + allow_ext = [ + "--config=protocol.ext.allow=always", + ] + for url in urls: + # The URL will be allowed into the command, and the protocol is enabled, + # but the command will fail since it can't read from the remote repo. + assert not tmp_file.exists() + with self.assertRaises(GitCommandError): + Repo.clone_from( + url, + tmp_dir / "repo", + multi_options=allow_ext, + allow_unsafe_protocols=True, + allow_unsafe_options=True, + ) + assert tmp_file.exists() + tmp_file.unlink() + @with_rw_repo("HEAD") def test_max_chunk_size(self, repo): class TestOutputStream(TestBase): From e50046688f734f65f452de9b8feb10189efd7c1b Mon Sep 17 00:00:00 2001 From: Martin Lambertsen Date: Sun, 8 Jan 2023 23:49:07 +0100 Subject: [PATCH 023/114] Fix some resource leaks by open file handles --- git/repo/base.py | 9 +++++++-- git/repo/fun.py | 3 ++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/git/repo/base.py b/git/repo/base.py index 4a3704c0b..30f71b0c8 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -9,6 +9,9 @@ import re import shlex import warnings + +from pathlib import Path + from gitdb.db.loose import LooseObjectDB from gitdb.exc import BadObject @@ -268,7 +271,7 @@ def __init__( pass try: - common_dir = open(osp.join(self.git_dir, "commondir"), "rt").readlines()[0].strip() + common_dir = (Path(self.git_dir) / "commondir").read_text().splitlines()[0].strip() self._common_dir = osp.join(self.git_dir, common_dir) except OSError: self._common_dir = "" @@ -1385,4 +1388,6 @@ def currently_rebasing_on(self) -> Commit | None: rebase_head_file = osp.join(self.git_dir, "REBASE_HEAD") if not osp.isfile(rebase_head_file): return None - return self.commit(open(rebase_head_file, "rt").readline().strip()) + with open(rebase_head_file, "rt") as f: + content = f.readline().strip() + return self.commit(content) diff --git a/git/repo/fun.py b/git/repo/fun.py index 2ca2e3d6f..ae35aa81e 100644 --- a/git/repo/fun.py +++ b/git/repo/fun.py @@ -2,6 +2,7 @@ from __future__ import annotations import os import stat +from pathlib import Path from string import digits from git.exc import WorkTreeRepositoryUnsupported @@ -83,7 +84,7 @@ def find_worktree_git_dir(dotgit: "PathLike") -> Optional[str]: return None try: - lines = open(dotgit, "r").readlines() + lines = Path(dotgit).read_text().splitlines() for key, value in [line.strip().split(": ") for line in lines]: if key == "gitdir": return value From cfc613a866921dab9228d5c21587e62f30ec6a57 Mon Sep 17 00:00:00 2001 From: Kent Friesen Date: Tue, 10 Jan 2023 11:02:38 -0800 Subject: [PATCH 024/114] get_values eagerly loads sections before return --- git/config.py | 1 + 1 file changed, 1 insertion(+) diff --git a/git/config.py b/git/config.py index 71d7ea689..e05a297af 100644 --- a/git/config.py +++ b/git/config.py @@ -796,6 +796,7 @@ def get_values( :raise TypeError: in case the value could not be understood Otherwise the exceptions known to the ConfigParser will be raised.""" try: + self.sections() lst = self._sections[section].getall(option) except Exception: if default is not None: From c9b44d29d656e92bb08fa41bcc2c31b2a2a2607b Mon Sep 17 00:00:00 2001 From: Matteo Croce Date: Wed, 11 Jan 2023 19:13:49 +0100 Subject: [PATCH 025/114] fix files list on file rename GitPython parses the output of `git diff --numstat` to get the files changed in a commit. This breaks when a commit contains a file rename, because the output of `git diff` is different than expected. This is the output of a normal commit: $ git diff --numstat 8f41a390bf9a^ 8f41a390bf9a 30 5 test/test_repo.py And this a commit containing a rename: $ git diff --numstat 185d847ec764^ 185d847ec764 3 1 .github/workflows/{test_pytest.yml => Future.yml} This can be triggered by this code: for commit in repo.iter_commits(): print(commit.hexsha) for file in commit.stats.files: print(file) Which will print for the normal commit: 8f41a390bf9a54db6f85032bc56b453307b95451 'test/test_repo.py' And when there is a rename: 185d847ec7647fd2642a82d9205fb3d07ea71715 '.github/workflows/{test_pytest.yml => Future.yml}' Additionally, when a path member is removed, the file list become a list of strings, breaking even more the caller. This is in the Linux kernel tree: $ git diff --numstat db401875f438^ db401875f438 1 1 tools/testing/selftests/drivers/net/mlxsw/{spectrum-2 => }/devlink_trap_tunnel_ipip6.sh and GitPython parses it as: db401875f438168c5804b295b93a28c7730bb57a ('tools/testing/selftests/drivers/net/mlxsw/{spectrum-2 => ' '}/devlink_trap_tunnel_ipip6.sh') Fix this by pasing the --no-renames option to `git diff` which ignores renames and print the same output as if the file was deleted from the old path and created in the new one: $ git diff --numstat --no-renames 185d847ec764^ 185d847ec764 57 0 .github/workflows/Future.yml 0 55 .github/workflows/test_pytest.yml --- git/objects/commit.py | 4 ++-- test/test_commit.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/git/objects/commit.py b/git/objects/commit.py index 82d2387b3..547e8fe82 100644 --- a/git/objects/commit.py +++ b/git/objects/commit.py @@ -324,14 +324,14 @@ def stats(self) -> Stats: :return: git.Stats""" if not self.parents: - text = self.repo.git.diff_tree(self.hexsha, "--", numstat=True, root=True) + text = self.repo.git.diff_tree(self.hexsha, "--", numstat=True, no_renames=True, root=True) text2 = "" for line in text.splitlines()[1:]: (insertions, deletions, filename) = line.split("\t") text2 += "%s\t%s\t%s\n" % (insertions, deletions, filename) text = text2 else: - text = self.repo.git.diff(self.parents[0].hexsha, self.hexsha, "--", numstat=True) + text = self.repo.git.diff(self.parents[0].hexsha, self.hexsha, "--", numstat=True, no_renames=True) return Stats._list_from_string(self.repo, text) @property diff --git a/test/test_commit.py b/test/test_commit.py index c5a43c94a..1efc68897 100644 --- a/test/test_commit.py +++ b/test/test_commit.py @@ -159,6 +159,37 @@ def check_entries(d): self.assertEqual(commit.committer_tz_offset, 14400, commit.committer_tz_offset) self.assertEqual(commit.message, "initial project\n") + def test_renames(self): + commit = self.rorepo.commit("185d847ec7647fd2642a82d9205fb3d07ea71715") + files = commit.stats.files + + # when a file is renamed, the output of git diff is like "dir/{old => new}" + # unless we disable rename with --no-renames, which produces two lines + # one with the old path deletes and another with the new added + self.assertEqual(len(files), 2) + + def check_entries(path, changes): + expected = { + ".github/workflows/Future.yml" : { + 'insertions': 57, + 'deletions': 0, + 'lines': 57 + }, + ".github/workflows/test_pytest.yml" : { + 'insertions': 0, + 'deletions': 55, + 'lines': 55 + }, + } + assert path in expected + assert isinstance(changes, dict) + for key in ("insertions", "deletions", "lines"): + assert changes[key] == expected[path][key] + + for path, changes in files.items(): + check_entries(path, changes) + # END for each stated file + def test_unicode_actor(self): # assure we can parse unicode actors correctly name = "Üäöß ÄußÉ" From ef697606dc3e7b70bfb944b4ebbb67f9c6638e94 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sun, 15 Jan 2023 22:44:08 +0200 Subject: [PATCH 026/114] Declare support for Python 3.11 --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index daad454d8..f64a9cb15 100755 --- a/setup.py +++ b/setup.py @@ -121,5 +121,6 @@ def build_py_modules(basedir: str, excludes: Sequence = ()) -> Sequence: "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", ], ) From 666633720e59c0af65acc36e89c5d2f83d8f8d80 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sun, 15 Jan 2023 22:45:47 +0200 Subject: [PATCH 027/114] Fix typo --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index f64a9cb15..55b0af3a8 100755 --- a/setup.py +++ b/setup.py @@ -82,7 +82,7 @@ def build_py_modules(basedir: str, excludes: Sequence = ()) -> Sequence: name="GitPython", cmdclass={"build_py": build_py, "sdist": sdist}, version=VERSION, - description="""GitPython is a python library used to interact with Git repositories""", + description="GitPython is a Python library used to interact with Git repositories", author="Sebastian Thiel, Michael Trier", author_email="byronimo@gmail.com, mtrier@gmail.com", license="BSD", @@ -95,7 +95,7 @@ def build_py_modules(basedir: str, excludes: Sequence = ()) -> Sequence: install_requires=requirements, tests_require=requirements + test_requirements, zip_safe=False, - long_description="""GitPython is a python library used to interact with Git repositories""", + long_description="""GitPython is a Python library used to interact with Git repositories""", long_description_content_type="text/markdown", classifiers=[ # Picked from From 496c69c5328b366ff3b86e25652683e528eec034 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sun, 15 Jan 2023 22:46:53 +0200 Subject: [PATCH 028/114] Upgrade Python syntax with pyupgrade --py37-plus --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 55b0af3a8..81ae0132d 100755 --- a/setup.py +++ b/setup.py @@ -44,7 +44,7 @@ def make_release_tree(self, base_dir: str, files: Sequence) -> None: def _stamp_version(filename: str) -> None: found, out = False, [] try: - with open(filename, "r") as f: + with open(filename) as f: for line in f: if "__version__ =" in line: line = line.replace("\"git\"", "'%s'" % VERSION) From 8a8047438ef9c3a7ec4ea1fff34ffa3e497c9a06 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Mon, 16 Jan 2023 17:52:25 +0200 Subject: [PATCH 029/114] Lint with Flake8 via pre-commit --- .github/workflows/cygwin-test.yml | 15 +++------------ .github/workflows/lint.yml | 14 ++++++++++++++ .github/workflows/pythonpackage.yml | 12 ++---------- .pre-commit-config.yaml | 19 +++++++++++++++++++ README.md | 2 +- test-requirements.txt | 5 +---- 6 files changed, 40 insertions(+), 27 deletions(-) create mode 100644 .github/workflows/lint.yml create mode 100644 .pre-commit-config.yaml diff --git a/.github/workflows/cygwin-test.yml b/.github/workflows/cygwin-test.yml index 16b42f89c..0018e7dfc 100644 --- a/.github/workflows/cygwin-test.yml +++ b/.github/workflows/cygwin-test.yml @@ -1,16 +1,12 @@ name: test-cygwin -on: - push: - branches: - main - pull_request: - branches: - main +on: [push, pull_request, workflow_dispatch] jobs: build: runs-on: windows-latest + strategy: + fail-fast: false env: CHERE_INVOKING: 1 SHELLOPTS: igncr @@ -47,11 +43,6 @@ jobs: # If we rewrite the user's config by accident, we will mess it up # and cause subsequent tests to fail cat test/fixtures/.gitconfig >> ~/.gitconfig - - name: Lint with flake8 - shell: bash.exe -eo pipefail -o igncr "{0}" - run: | - set -x - /usr/bin/python -m flake8 - name: Test with pytest shell: bash.exe -eo pipefail -o igncr "{0}" run: | diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 000000000..c78a4053a --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,14 @@ +name: Lint + +on: [push, pull_request, workflow_dispatch] + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: "3.x" + - uses: pre-commit/action@v3.0.0 diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 5373dace6..6d6c67952 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -3,11 +3,7 @@ name: Python package -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] +on: [push, pull_request, workflow_dispatch] permissions: contents: read @@ -17,6 +13,7 @@ jobs: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: python-version: [3.7, 3.8, 3.9, "3.10", "3.11"] @@ -47,11 +44,6 @@ jobs: # and cause subsequent tests to fail cat test/fixtures/.gitconfig >> ~/.gitconfig - - name: Lint with flake8 - run: | - set -x - flake8 - - name: Check types with mypy # With new versions of pypi 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. diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..581cb69b2 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,19 @@ +repos: + - repo: https://github.com/PyCQA/flake8 + rev: 6.0.0 + hooks: + - id: flake8 + additional_dependencies: + [ + flake8-bugbear==22.12.6, + flake8-comprehensions==3.10.1, + flake8-typing-imports==1.14.0, + ] + exclude: ^doc|^git/ext/|^test/ + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: check-merge-conflict + - id: check-toml + - id: check-yaml diff --git a/README.md b/README.md index 54a735e53..82c5c9e0f 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ with MINGW's. Ensure testing libraries are installed. In the root directory, run: `pip install -r test-requirements.txt` -To lint, run: `flake8` +To lint, run: `pre-commit run --all-files` To typecheck, run: `mypy -p git` diff --git a/test-requirements.txt b/test-requirements.txt index 6549f0fa0..6c6d57060 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -3,10 +3,7 @@ mypy black -flake8 -flake8-bugbear -flake8-comprehensions -flake8-typing-imports +pre-commit virtualenv From 7ada83a4cf83c1178159c250a11811012506058c Mon Sep 17 00:00:00 2001 From: Axel Aguado Date: Sat, 21 Jan 2023 14:06:25 -0600 Subject: [PATCH 030/114] Add test_ignored_items_reported --- test/test_repo.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/test_repo.py b/test/test_repo.py index d5474353f..c21f3270f 100644 --- a/test/test_repo.py +++ b/test/test_repo.py @@ -1384,3 +1384,19 @@ def test_clone_from_command_injection(self, rw_repo): rw_repo.clone_from(payload, temp_repo.common_dir) assert not unexpected_file.exists() + + def test_ignored_items_reported(self): + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = pathlib.Path(tdir) + temp_repo = Repo.init(tmp_dir / "repo") + + gi = tmp_dir / "repo" / ".gitignore" + + with open(gi, 'w') as file: + file.write('ignored_file.txt\n') + file.write('ignored_dir/\n') + + assert temp_repo.ignored(['included_file.txt', 'included_dir/file.txt']) == [] + assert temp_repo.ignored(['ignored_file.txt']) == ['ignored_file.txt'] + assert temp_repo.ignored(['included_file.txt', 'ignored_file.txt']) == ['ignored_file.txt'] + assert temp_repo.ignored(['included_file.txt', 'ignored_file.txt', 'included_dir/file.txt', 'ignored_dir/file.txt']) == ['ignored_file.txt', 'ignored_dir/file.txt'] \ No newline at end of file From 2ddb604ae8a7fd1069857c8194155d109565e6bb Mon Sep 17 00:00:00 2001 From: Axel Aguado Date: Sat, 21 Jan 2023 15:05:33 -0600 Subject: [PATCH 031/114] Add test to verify GitCommandError is raised when check-ignore is run against a file behind a symlink --- test/test_repo.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/test/test_repo.py b/test/test_repo.py index c21f3270f..07c1e9adf 100644 --- a/test/test_repo.py +++ b/test/test_repo.py @@ -1399,4 +1399,15 @@ def test_ignored_items_reported(self): assert temp_repo.ignored(['included_file.txt', 'included_dir/file.txt']) == [] assert temp_repo.ignored(['ignored_file.txt']) == ['ignored_file.txt'] assert temp_repo.ignored(['included_file.txt', 'ignored_file.txt']) == ['ignored_file.txt'] - assert temp_repo.ignored(['included_file.txt', 'ignored_file.txt', 'included_dir/file.txt', 'ignored_dir/file.txt']) == ['ignored_file.txt', 'ignored_dir/file.txt'] \ No newline at end of file + assert temp_repo.ignored(['included_file.txt', 'ignored_file.txt', 'included_dir/file.txt', 'ignored_dir/file.txt']) == ['ignored_file.txt', 'ignored_dir/file.txt'] + + def test_ignored_raises_error_w_symlink(self): + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = pathlib.Path(tdir) + temp_repo = Repo.init(tmp_dir / "repo") + + os.mkdir(tmp_dir / "target") + os.symlink(tmp_dir / "target", tmp_dir / "symlink") + + with pytest.raises(GitCommandError): + temp_repo.ignored(tmp_dir / "symlink/file.txt") \ No newline at end of file From df4dabb17c4e83c580d515894dbf7d57912ee554 Mon Sep 17 00:00:00 2001 From: Axel Aguado Date: Sat, 21 Jan 2023 15:38:46 -0600 Subject: [PATCH 032/114] Raise exception if return code from check-ignore is not 1 --- git/repo/base.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/git/repo/base.py b/git/repo/base.py index 30f71b0c8..9cdf673e6 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -873,8 +873,15 @@ def ignored(self, *paths: PathLike) -> List[str]: """ try: proc: str = self.git.check_ignore(*paths) - except GitCommandError: - return [] + except GitCommandError as err: + # If return code is 1, this means none of the items in *paths + # are ignored by Git, so return an empty list. Raise the + # exception on all other return codes. + if err.status == 1: + return [] + else: + raise + return proc.replace("\\\\", "\\").replace('"', "").split("\n") @property From 854a2d1f7fde596babe85ba9f76f282e9d53086d Mon Sep 17 00:00:00 2001 From: James Cowgill Date: Tue, 24 Jan 2023 12:40:29 +0000 Subject: [PATCH 033/114] Fix timezone parsing functions for non-hour timezones The `utctz_to_altz` and `altz_to_utctz_str` functions fail to handle timezones with UTC offsets that are not a multiple of one hour. Rewrite them and add some unit tests. Fixes #630 --- git/objects/util.py | 35 +++++++++++++++++++---------------- test/test_util.py | 21 +++++++++++++++++++++ 2 files changed, 40 insertions(+), 16 deletions(-) diff --git a/git/objects/util.py b/git/objects/util.py index f405d6287..af279154c 100644 --- a/git/objects/util.py +++ b/git/objects/util.py @@ -137,22 +137,25 @@ def get_object_type_by_name( def utctz_to_altz(utctz: str) -> int: - """we convert utctz to the timezone in seconds, it is the format time.altzone - returns. Git stores it as UTC timezone which has the opposite sign as well, - which explains the -1 * ( that was made explicit here ) - - :param utctz: git utc timezone string, i.e. +0200""" - return -1 * int(float(utctz) / 100 * 3600) - - -def altz_to_utctz_str(altz: float) -> str: - """As above, but inverses the operation, returning a string that can be used - in commit objects""" - utci = -1 * int((float(altz) / 3600) * 100) - utcs = str(abs(utci)) - utcs = "0" * (4 - len(utcs)) + utcs - prefix = (utci < 0 and "-") or "+" - return prefix + utcs + """Convert a git timezone offset into a timezone offset west of + UTC in seconds (compatible with time.altzone). + + :param utctz: git utc timezone string, i.e. +0200 + """ + int_utctz = int(utctz) + seconds = ((abs(int_utctz) // 100) * 3600 + (abs(int_utctz) % 100) * 60) + return seconds if int_utctz < 0 else -seconds + + +def altz_to_utctz_str(altz: int) -> str: + """Convert a timezone offset west of UTC in seconds into a git timezone offset string + + :param altz: timezone offset in seconds west of UTC + """ + hours = abs(altz) // 3600 + minutes = (abs(altz) % 3600) // 60 + sign = "-" if altz >= 60 else "+" + return "{}{:02}{:02}".format(sign, hours, minutes) def verify_utctz(offset: str) -> str: diff --git a/test/test_util.py b/test/test_util.py index 90dd89a91..c17efce35 100644 --- a/test/test_util.py +++ b/test/test_util.py @@ -333,6 +333,27 @@ def test_iterable_list(self, case): self.assertRaises(IndexError, ilist.__delitem__, 0) self.assertRaises(IndexError, ilist.__delitem__, "something") + def test_utctz_to_altz(self): + self.assertEqual(utctz_to_altz("+0000"), 0) + self.assertEqual(utctz_to_altz("+1400"), -(14 * 3600)) + self.assertEqual(utctz_to_altz("-1200"), 12 * 3600) + self.assertEqual(utctz_to_altz("+0001"), -60) + self.assertEqual(utctz_to_altz("+0530"), -(5 * 3600 + 1800)) + self.assertEqual(utctz_to_altz("-0930"), 9 * 3600 + 1800) + + def test_altz_to_utctz_str(self): + self.assertEqual(altz_to_utctz_str(0), "+0000") + self.assertEqual(altz_to_utctz_str(-(14 * 3600)), "+1400") + self.assertEqual(altz_to_utctz_str(12 * 3600), "-1200") + self.assertEqual(altz_to_utctz_str(-60), "+0001") + self.assertEqual(altz_to_utctz_str(-(5 * 3600 + 1800)), "+0530") + self.assertEqual(altz_to_utctz_str(9 * 3600 + 1800), "-0930") + + self.assertEqual(altz_to_utctz_str(1), "+0000") + self.assertEqual(altz_to_utctz_str(59), "+0000") + self.assertEqual(altz_to_utctz_str(-1), "+0000") + self.assertEqual(altz_to_utctz_str(-59), "+0000") + def test_from_timestamp(self): # Correct offset: UTC+2, should return datetime + tzoffset(+2) altz = utctz_to_altz("+0200") From cc71f02913eaa4c7c939bc3762342710b009c092 Mon Sep 17 00:00:00 2001 From: Clayton Walker Date: Fri, 27 Jan 2023 14:03:29 -0700 Subject: [PATCH 034/114] Remove optional from two member variables --- git/repo/base.py | 54 +++++++++++++++++++++++++----------------------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/git/repo/base.py b/git/repo/base.py index 9cdf673e6..a74be215f 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -123,9 +123,9 @@ class Repo(object): DAEMON_EXPORT_FILE = "git-daemon-export-ok" git = cast("Git", None) # Must exist, or __del__ will fail in case we raise on `__init__()` - working_dir: Optional[PathLike] = None + working_dir: PathLike _working_tree_dir: Optional[PathLike] = None - git_dir: PathLike = "" + git_dir: PathLike _common_dir: PathLike = "" # precompiled regex @@ -215,13 +215,14 @@ def __init__( ## Walk up the path to find the `.git` dir. # curpath = epath + git_dir = None while curpath: # ABOUT osp.NORMPATH # It's important to normalize the paths, as submodules will otherwise initialize their # repo instances with paths that depend on path-portions that will not exist after being # removed. It's just cleaner. if is_git_dir(curpath): - self.git_dir = curpath + git_dir = curpath # from man git-config : core.worktree # Set the path to the root of the working tree. If GIT_COMMON_DIR environment # variable is set, core.worktree is ignored and not used for determining the @@ -230,9 +231,9 @@ def __init__( # directory, which is either specified by GIT_DIR, or automatically discovered. # If GIT_DIR is specified but none of GIT_WORK_TREE and core.worktree is specified, # the current working directory is regarded as the top level of your working tree. - self._working_tree_dir = os.path.dirname(self.git_dir) + self._working_tree_dir = os.path.dirname(git_dir) if os.environ.get("GIT_COMMON_DIR") is None: - gitconf = self.config_reader("repository") + gitconf = self._config_reader("repository", git_dir) if gitconf.has_option("core", "worktree"): self._working_tree_dir = gitconf.get("core", "worktree") if "GIT_WORK_TREE" in os.environ: @@ -242,14 +243,14 @@ def __init__( dotgit = osp.join(curpath, ".git") sm_gitpath = find_submodule_git_dir(dotgit) if sm_gitpath is not None: - self.git_dir = osp.normpath(sm_gitpath) + git_dir = osp.normpath(sm_gitpath) sm_gitpath = find_submodule_git_dir(dotgit) if sm_gitpath is None: sm_gitpath = find_worktree_git_dir(dotgit) if sm_gitpath is not None: - self.git_dir = expand_path(sm_gitpath, expand_vars) + git_dir = expand_path(sm_gitpath, expand_vars) self._working_tree_dir = curpath break @@ -260,8 +261,9 @@ def __init__( break # END while curpath - if self.git_dir is None: + if git_dir is None: raise InvalidGitRepositoryError(epath) + self.git_dir = git_dir self._bare = False try: @@ -282,7 +284,7 @@ def __init__( self._working_tree_dir = None # END working dir handling - self.working_dir: Optional[PathLike] = self._working_tree_dir or self.common_dir + self.working_dir: PathLike = self._working_tree_dir or self.common_dir self.git = self.GitCommandWrapperType(self.working_dir) # special handling, in special times @@ -320,7 +322,7 @@ def close(self) -> None: gc.collect() def __eq__(self, rhs: object) -> bool: - if isinstance(rhs, Repo) and self.git_dir: + if isinstance(rhs, Repo): return self.git_dir == rhs.git_dir return False @@ -332,14 +334,12 @@ def __hash__(self) -> int: # Description property def _get_description(self) -> str: - if self.git_dir: - filename = osp.join(self.git_dir, "description") + filename = osp.join(self.git_dir, "description") with open(filename, "rb") as fp: return fp.read().rstrip().decode(defenc) def _set_description(self, descr: str) -> None: - if self.git_dir: - filename = osp.join(self.git_dir, "description") + filename = osp.join(self.git_dir, "description") with open(filename, "wb") as fp: fp.write((descr + "\n").encode(defenc)) @@ -357,13 +357,7 @@ def common_dir(self) -> PathLike: """ :return: The git dir that holds everything except possibly HEAD, FETCH_HEAD, ORIG_HEAD, COMMIT_EDITMSG, index, and logs/.""" - if self._common_dir: - return self._common_dir - elif self.git_dir: - return self.git_dir - else: - # or could return "" - raise InvalidGitRepositoryError() + return self._common_dir or self.git_dir @property def bare(self) -> bool: @@ -532,7 +526,9 @@ def delete_remote(self, remote: "Remote") -> str: """Delete the given remote.""" return Remote.remove(self, remote) - def _get_config_path(self, config_level: Lit_config_levels) -> str: + def _get_config_path(self, config_level: Lit_config_levels, git_dir: Optional[PathLike] = None) -> str: + if git_dir is None: + git_dir = self.git_dir # we do not support an absolute path of the gitconfig on windows , # use the global config instead if is_win and config_level == "system": @@ -546,7 +542,7 @@ def _get_config_path(self, config_level: Lit_config_levels) -> str: elif config_level == "global": return osp.normpath(osp.expanduser("~/.gitconfig")) elif config_level == "repository": - repo_dir = self._common_dir or self.git_dir + repo_dir = self._common_dir or git_dir if not repo_dir: raise NotADirectoryError else: @@ -575,15 +571,21 @@ def config_reader( you know which file you wish to read to prevent reading multiple files. :note: On windows, system configuration cannot currently be read as the path is unknown, instead the global path will be used.""" - files = None + return self._config_reader(config_level=config_level) + + def _config_reader( + self, + config_level: Optional[Lit_config_levels] = None, + git_dir: Optional[PathLike] = None, + ) -> GitConfigParser: if config_level is None: files = [ - self._get_config_path(cast(Lit_config_levels, f)) + self._get_config_path(cast(Lit_config_levels, f), git_dir) for f in self.config_level if cast(Lit_config_levels, f) ] else: - files = [self._get_config_path(config_level)] + files = [self._get_config_path(config_level, git_dir)] return GitConfigParser(files, read_only=True, repo=self) def config_writer(self, config_level: Lit_config_levels = "repository") -> GitConfigParser: From 22f515824408b502e285c80bf2da038aae5af254 Mon Sep 17 00:00:00 2001 From: Cesar Velazquez Date: Mon, 30 Jan 2023 15:57:54 -0800 Subject: [PATCH 035/114] Enable user to override default diff -M arg --- git/diff.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/git/diff.py b/git/diff.py index c4424592f..79b02f12e 100644 --- a/git/diff.py +++ b/git/diff.py @@ -144,7 +144,10 @@ def diff( args.append("--abbrev=40") # we need full shas args.append("--full-index") # get full index paths, not only filenames - args.append("-M") # check for renames, in both formats + # remove default '-M' arg (check for renames) if user is overriding it + if not any(x in kwargs for x in ('find_renames', 'no_renames','M')): + args.append("-M") + if create_patch: args.append("-p") else: From 6cf3661d6be63b19cd64a18776a2fb575522a9e2 Mon Sep 17 00:00:00 2001 From: Cesar Velazquez Date: Mon, 30 Jan 2023 17:03:45 -0800 Subject: [PATCH 036/114] fixed lint error --- git/diff.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git/diff.py b/git/diff.py index 79b02f12e..c1a5bd26f 100644 --- a/git/diff.py +++ b/git/diff.py @@ -145,7 +145,7 @@ def diff( args.append("--full-index") # get full index paths, not only filenames # remove default '-M' arg (check for renames) if user is overriding it - if not any(x in kwargs for x in ('find_renames', 'no_renames','M')): + if not any(x in kwargs for x in ('find_renames', 'no_renames', 'M')): args.append("-M") if create_patch: From 186d75c6ba283fd1bb0647ae94a3a8054197c42b Mon Sep 17 00:00:00 2001 From: Cesar Velazquez Date: Tue, 31 Jan 2023 17:59:58 -0800 Subject: [PATCH 037/114] Added diff test to disable rename detection --- test/test_diff.py | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/test/test_diff.py b/test/test_diff.py index 7065f0635..16d902097 100644 --- a/test/test_diff.py +++ b/test/test_diff.py @@ -411,3 +411,50 @@ def test_diff_interface(self): cp = c.parents[0] diff_index = c.diff(cp, ["does/not/exist"]) self.assertEqual(len(diff_index), 0) + + @with_rw_directory + def test_rename_override(self, rw_dir): + """Test disabling of diff rename detection""" + + # create and commit file_a.txt + repo = Repo.init(rw_dir) + file_a = osp.join(rw_dir, "file_a.txt") + with open(file_a, "w", encoding='utf-8') as outfile: + outfile.write("hello world") + repo.git.add(Git.polish_url(file_a)) + repo.git.commit(message="Added file_a.txt") + + # remove file_a.txt + repo.git.rm(Git.polish_url(file_a)) + + # create and commit file_b.txt + file_b = osp.join(rw_dir, "file_b.txt") + with open(file_b, "w", encoding='utf-8') as outfile: + outfile.write("hello world") + repo.git.add(Git.polish_url(file_b)) + repo.git.commit(message="Removed file_a.txt. Added file_b.txt") + + commit_a = repo.commit('HEAD') + commit_b = repo.commit('HEAD~1') + + # check default diff command with renamed files enabled + diffs = commit_b.diff(commit_a) + self.assertEqual(1, len(diffs)) + diff = diffs[0] + self.assertEqual(True, diff.renamed_file) + self.assertEqual('file_a.txt', diff.rename_from) + self.assertEqual('file_b.txt', diff.rename_to) + + # check diff with rename files disabled + diffs = commit_b.diff(commit_a, no_renames=True) + self.assertEqual(2, len(diffs)) + + # check fileA.txt deleted + diff = diffs[0] + self.assertEqual(True, diff.deleted_file) + self.assertEqual('file_a.txt', diff.a_path) + + # check fileB.txt added + diff = diffs[1] + self.assertEqual(True, diff.new_file) + self.assertEqual('file_b.txt', diff.a_path) From bc8f1191a3befd1051bd676df630b2fa005fecf0 Mon Sep 17 00:00:00 2001 From: Clayton Walker Date: Wed, 1 Feb 2023 15:19:44 -0700 Subject: [PATCH 038/114] Add check to test bare repo --- test/test_base.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test/test_base.py b/test/test_base.py index ccfdc8ed3..5c89fd4fb 100644 --- a/test/test_base.py +++ b/test/test_base.py @@ -98,6 +98,7 @@ def test_object_resolution(self): def test_with_bare_rw_repo(self, bare_rw_repo): assert bare_rw_repo.config_reader("repository").getboolean("core", "bare") assert osp.isfile(osp.join(bare_rw_repo.git_dir, "HEAD")) + assert osp.isdir(bare_rw_repo.working_dir) @with_rw_repo("0.1.6") def test_with_rw_repo(self, rw_repo): From c0e69a4263a7f42b7a7d8bb44d11ee3d14a27545 Mon Sep 17 00:00:00 2001 From: Cesar Velazquez Date: Wed, 1 Feb 2023 23:02:56 -0800 Subject: [PATCH 039/114] Updated diff test to use different similarity thresholds --- test/test_diff.py | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/test/test_diff.py b/test/test_diff.py index 16d902097..504337744 100644 --- a/test/test_diff.py +++ b/test/test_diff.py @@ -420,17 +420,17 @@ def test_rename_override(self, rw_dir): repo = Repo.init(rw_dir) file_a = osp.join(rw_dir, "file_a.txt") with open(file_a, "w", encoding='utf-8') as outfile: - outfile.write("hello world") + outfile.write("hello world\n") repo.git.add(Git.polish_url(file_a)) repo.git.commit(message="Added file_a.txt") # remove file_a.txt repo.git.rm(Git.polish_url(file_a)) - # create and commit file_b.txt + # create and commit file_b.txt with similarity index of 52 file_b = osp.join(rw_dir, "file_b.txt") with open(file_b, "w", encoding='utf-8') as outfile: - outfile.write("hello world") + outfile.write("hello world\nhello world") repo.git.add(Git.polish_url(file_b)) repo.git.commit(message="Removed file_a.txt. Added file_b.txt") @@ -458,3 +458,26 @@ def test_rename_override(self, rw_dir): diff = diffs[1] self.assertEqual(True, diff.new_file) self.assertEqual('file_b.txt', diff.a_path) + + # check diff with high similarity index + diffs = commit_b.diff(commit_a, split_single_char_options=False, M='75%') + self.assertEqual(2, len(diffs)) + + # check fileA.txt deleted + diff = diffs[0] + self.assertEqual(True, diff.deleted_file) + self.assertEqual('file_a.txt', diff.a_path) + + # check fileB.txt added + diff = diffs[1] + self.assertEqual(True, diff.new_file) + self.assertEqual('file_b.txt', diff.a_path) + + # check diff with low similarity index + diffs = commit_b.diff(commit_a, split_single_char_options=False, M='40%') + self.assertEqual(1, len(diffs)) + diff = diffs[0] + self.assertEqual(True, diff.renamed_file) + self.assertEqual('file_a.txt', diff.rename_from) + self.assertEqual('file_b.txt', diff.rename_to) + From fb6eb3d5eb56ce9f27cf23387053d440d6786fe2 Mon Sep 17 00:00:00 2001 From: Clayton Walker Date: Thu, 2 Feb 2023 10:00:18 -0700 Subject: [PATCH 040/114] Add additional assertions to test_base.py --- test/test_base.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/test/test_base.py b/test/test_base.py index 5c89fd4fb..30029367d 100644 --- a/test/test_base.py +++ b/test/test_base.py @@ -9,6 +9,7 @@ import tempfile from unittest import SkipTest, skipIf +from git import Repo from git.objects import Blob, Tree, Commit, TagObject from git.compat import is_win from git.objects.util import get_object_type_by_name @@ -95,15 +96,18 @@ def test_object_resolution(self): self.assertEqual(self.rorepo.head.reference.object, self.rorepo.active_branch.object) @with_rw_repo("HEAD", bare=True) - def test_with_bare_rw_repo(self, bare_rw_repo): + def test_with_bare_rw_repo(self, bare_rw_repo: Repo): assert bare_rw_repo.config_reader("repository").getboolean("core", "bare") assert osp.isfile(osp.join(bare_rw_repo.git_dir, "HEAD")) assert osp.isdir(bare_rw_repo.working_dir) + assert bare_rw_repo.working_tree_dir is None @with_rw_repo("0.1.6") - def test_with_rw_repo(self, rw_repo): + def test_with_rw_repo(self, rw_repo: Repo): assert not rw_repo.config_reader("repository").getboolean("core", "bare") + assert osp.isdir(rw_repo.working_tree_dir) assert osp.isdir(osp.join(rw_repo.working_tree_dir, "lib")) + assert osp.isdir(rw_repo.working_dir) @skipIf(HIDE_WINDOWS_FREEZE_ERRORS, "FIXME: Freezes! sometimes...") @with_rw_and_rw_remote_repo("0.1.6") From 4a44fdf9d90a537d821c471b1cf1ae6c9e73a2de Mon Sep 17 00:00:00 2001 From: Clayton Walker Date: Thu, 2 Feb 2023 10:00:49 -0700 Subject: [PATCH 041/114] Update docs --- git/repo/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git/repo/base.py b/git/repo/base.py index a74be215f..2fc9cf1fe 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -115,7 +115,7 @@ class Repo(object): 'working_dir' is the working directory of the git command, which is the working tree directory if available or the .git directory in case of bare repositories - 'working_tree_dir' is the working tree directory, but will raise AssertionError + 'working_tree_dir' is the working tree directory, but will return None if we are a bare repository. 'git_dir' is the .git repository directory, which is always set.""" From 5cbc1782e79783fbfedf5783fe52616e397916df Mon Sep 17 00:00:00 2001 From: Eric Wieser Date: Sun, 12 Feb 2023 15:47:36 +0000 Subject: [PATCH 042/114] Fix RecursionError when iterating streams --- git/cmd.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/git/cmd.py b/git/cmd.py index 134001e22..5dd8dd5cf 100644 --- a/git/cmd.py +++ b/git/cmd.py @@ -695,15 +695,15 @@ def __iter__(self) -> "Git.CatFileContentStream": return self def __next__(self) -> bytes: - return next(self) - - def next(self) -> bytes: line = self.readline() if not line: raise StopIteration return line + def next(self) -> bytes: + return next(self) + def __del__(self) -> None: bytes_left = self._size - self._nbr if bytes_left: From 5ab58f3a5c8b1b50a73104ddc048fd9f6b663b0b Mon Sep 17 00:00:00 2001 From: Eric Wieser Date: Sun, 12 Feb 2023 15:50:06 +0000 Subject: [PATCH 043/114] Update cmd.py --- git/cmd.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/git/cmd.py b/git/cmd.py index 5dd8dd5cf..dfce9024d 100644 --- a/git/cmd.py +++ b/git/cmd.py @@ -701,8 +701,7 @@ def __next__(self) -> bytes: return line - def next(self) -> bytes: - return next(self) + next = __next__ def __del__(self) -> None: bytes_left = self._size - self._nbr From fc843d31ddb817deb8e5e6732ebe67a5ee61db66 Mon Sep 17 00:00:00 2001 From: Cody Martin Date: Wed, 15 Feb 2023 22:07:56 -0700 Subject: [PATCH 044/114] Add test asserting that get_values works by itself As described in #1534, this test will fail in main with a KeyError about a missing section name even though the named sections do exist within the config file. This test will pass in the branch associated with #1535. This test should keep the improved behavior healthy as the code evolves by preventing future developers from removing the eager loading of sections within get_values(). --- test/test_config.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/test_config.py b/test/test_config.py index 8bb2aa306..b159ebe2d 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -398,6 +398,17 @@ def test_empty_config_value(self): with self.assertRaises(cp.NoOptionError): cr.get_value("color", "ui") + 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) + self.assertEqual(cr.get_values("section0", "option0"), ["value0"]) + file_obj.seek(0) + cr = GitConfigParser(file_obj, read_only=True) + self.assertEqual(cr.get_values("section1", "option1"), ["value1a", "value1b"]) + file_obj.seek(0) + cr = GitConfigParser(file_obj, read_only=True) + self.assertEqual(cr.get_values("section1", "other_option1"), ["other_value1"]) + def test_multiple_values(self): file_obj = self._to_memcache(fixture_path("git_config_multiple")) with GitConfigParser(file_obj, read_only=False) as cw: From f25333525425ee1497366fd300a60127aa652d79 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Thu, 16 Feb 2023 17:30:15 +0100 Subject: [PATCH 045/114] prepare next release --- VERSION | 2 +- doc/source/changes.rst | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 51b450da3..339bdc849 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.1.30 +3.1.31 diff --git a/doc/source/changes.rst b/doc/source/changes.rst index 7cd09a1c5..4ee613bcc 100644 --- a/doc/source/changes.rst +++ b/doc/source/changes.rst @@ -2,6 +2,12 @@ Changelog ========= +3.1.31 +====== + +See the following for all changes. +https://github.com/gitpython-developers/gitpython/milestone/61?closed=1 + 3.1.30 ====== From 1570f3d70e7a80e0fdc2f8edc3060b3e54a9cea3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Apr 2023 13:59:39 +0000 Subject: [PATCH 046/114] Bump cygwin/cygwin-install-action from 3 to 4 Bumps [cygwin/cygwin-install-action](https://github.com/cygwin/cygwin-install-action) from 3 to 4. - [Release notes](https://github.com/cygwin/cygwin-install-action/releases) - [Commits](https://github.com/cygwin/cygwin-install-action/compare/v3...v4) --- updated-dependencies: - dependency-name: cygwin/cygwin-install-action dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/cygwin-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cygwin-test.yml b/.github/workflows/cygwin-test.yml index 0018e7dfc..a1ecb6785 100644 --- a/.github/workflows/cygwin-test.yml +++ b/.github/workflows/cygwin-test.yml @@ -19,7 +19,7 @@ jobs: - uses: actions/checkout@v3 with: fetch-depth: 9999 - - uses: cygwin/cygwin-install-action@v3 + - uses: cygwin/cygwin-install-action@v4 with: packages: python39 python39-pip python39-virtualenv git - name: Tell git to trust this repo From d2f7284189b5c1f867ebd58b0b879fa34406eefc Mon Sep 17 00:00:00 2001 From: Twist Date: Fri, 21 Apr 2023 22:16:32 +0100 Subject: [PATCH 047/114] Add trailers_list and trailers_list methods to fix the commit trailers functionality. Update trailers tests. --- git/objects/commit.py | 101 +++++++++++++++++++++++++++++++++--------- test/test_commit.py | 83 +++++++++++++++++++--------------- 2 files changed, 126 insertions(+), 58 deletions(-) diff --git a/git/objects/commit.py b/git/objects/commit.py index 547e8fe82..50da0a105 100644 --- a/git/objects/commit.py +++ b/git/objects/commit.py @@ -26,6 +26,7 @@ import os from io import BytesIO import logging +from collections import defaultdict # typing ------------------------------------------------------------------ @@ -335,8 +336,70 @@ def stats(self) -> Stats: return Stats._list_from_string(self.repo, text) @property - def trailers(self) -> Dict: - """Get the trailers of the message as dictionary + def trailers(self) -> Dict[str, str]: + """Get the trailers of the message as a dictionary + + Git messages can contain trailer information that are similar to RFC 822 + e-mail headers (see: https://git-scm.com/docs/git-interpret-trailers). + + WARNING: This function only returns the latest instance of each trailer key + and will be deprecated soon. Please see either ``Commit.trailers_list`` or ``Commit.trailers_dict``. + + :return: + Dictionary containing whitespace stripped trailer information. + Only the latest instance of each trailer key. + """ + return { + k: v[0] for k, v in self.trailers_dict.items() + } + + @property + def trailers_list(self) -> List[str]: + """Get the trailers of the message as a list + + Git messages can contain trailer information that are similar to RFC 822 + e-mail headers (see: https://git-scm.com/docs/git-interpret-trailers). + + This functions calls ``git interpret-trailers --parse`` onto the message + to extract the trailer information, returns the raw trailer data as a list. + + Valid message with trailer:: + + Subject line + + some body information + + another information + + key1: value1.1 + key1: value1.2 + key2 : value 2 with inner spaces + + + Returned list will look like this:: + + [ + "key1: value1.1", + "key1: value1.2", + "key2 : value 2 with inner spaces", + ] + + + :return: + List containing whitespace stripped trailer information. + """ + cmd = ["git", "interpret-trailers", "--parse"] + proc: Git.AutoInterrupt = self.repo.git.execute(cmd, as_process=True, istream=PIPE) # type: ignore + trailer: str = proc.communicate(str(self.message).encode())[0].decode() + trailer = trailer.strip() + if trailer: + return [t.strip() for t in trailer.split("\n")] + + return [] + + @property + def trailers_dict(self) -> Dict[str, List[str]]: + """Get the trailers of the message as a dictionary Git messages can contain trailer information that are similar to RFC 822 e-mail headers (see: https://git-scm.com/docs/git-interpret-trailers). @@ -345,9 +408,7 @@ def trailers(self) -> Dict: to extract the trailer information. The key value pairs are stripped of leading and trailing whitespaces before they get saved into a dictionary. - Valid message with trailer: - - .. code-block:: + Valid message with trailer:: Subject line @@ -355,32 +416,28 @@ def trailers(self) -> Dict: another information - key1: value1 + key1: value1.1 + key1: value1.2 key2 : value 2 with inner spaces - dictionary will look like this: - .. code-block:: + Returned dictionary will look like this:: { - "key1": "value1", - "key2": "value 2 with inner spaces" + "key1": ["value1.1", "value1.2"], + "key2": ["value 2 with inner spaces"], } - :return: Dictionary containing whitespace stripped trailer information + :return: + Dictionary containing whitespace stripped trailer information. + Mapping trailer keys to a list of their corresponding values. """ - d = {} - cmd = ["git", "interpret-trailers", "--parse"] - proc: Git.AutoInterrupt = self.repo.git.execute(cmd, as_process=True, istream=PIPE) # type: ignore - trailer: str = proc.communicate(str(self.message).encode())[0].decode() - if trailer.endswith("\n"): - trailer = trailer[0:-1] - if trailer != "": - for line in trailer.split("\n"): - key, value = line.split(":", 1) - d[key.strip()] = value.strip() - return d + d = defaultdict(list) + for trailer in self.trailers_list: + key, value = trailer.split(":", 1) + d[key.strip()].append(value.strip()) + return dict(d) @classmethod def _iter_from_process_or_stream(cls, repo: "Repo", proc_or_stream: Union[Popen, IO]) -> Iterator["Commit"]: diff --git a/test/test_commit.py b/test/test_commit.py index 1efc68897..ca1e4752b 100644 --- a/test/test_commit.py +++ b/test/test_commit.py @@ -494,52 +494,63 @@ def test_datetimes(self): def test_trailers(self): KEY_1 = "Hello" - VALUE_1 = "World" + VALUE_1_1 = "World" + VALUE_1_2 = "Another-World" KEY_2 = "Key" VALUE_2 = "Value with inner spaces" - # Check if KEY 1 & 2 with Value 1 & 2 is extracted from multiple msg variations - msgs = [] - msgs.append(f"Subject\n\n{KEY_1}: {VALUE_1}\n{KEY_2}: {VALUE_2}\n") - msgs.append(f"Subject\n \nSome body of a function\n \n{KEY_1}: {VALUE_1}\n{KEY_2}: {VALUE_2}\n") - msgs.append( - f"Subject\n \nSome body of a function\n\nnon-key: non-value\n\n{KEY_1}: {VALUE_1}\n{KEY_2}: {VALUE_2}\n" - ) - msgs.append( - f"Subject\n \nSome multiline\n body of a function\n\nnon-key: non-value\n\n{KEY_1}: {VALUE_1}\n{KEY_2} : {VALUE_2}\n" - ) - + # Check the following trailer example is extracted from multiple msg variations + TRAILER = f"{KEY_1}: {VALUE_1_1}\n{KEY_2}: {VALUE_2}\n{KEY_1}: {VALUE_1_2}" + msgs = [ + f"Subject\n\n{TRAILER}\n", + f"Subject\n \nSome body of a function\n \n{TRAILER}\n", + f"Subject\n \nSome body of a function\n\nnon-key: non-value\n\n{TRAILER}\n", + ( + # check when trailer has inconsistent whitespace + f"Subject\n \nSome multiline\n body of a function\n\nnon-key: non-value\n\n" + f"{KEY_1}:{VALUE_1_1}\n{KEY_2} : {VALUE_2}\n{KEY_1}: {VALUE_1_2}\n" + ), + ] for msg in msgs: - commit = self.rorepo.commit("master") - commit = copy.copy(commit) + commit = copy.copy(self.rorepo.commit("master")) commit.message = msg - assert KEY_1 in commit.trailers.keys() - assert KEY_2 in commit.trailers.keys() - assert commit.trailers[KEY_1] == VALUE_1 - assert commit.trailers[KEY_2] == VALUE_2 - - # Check that trailer stays empty for multiple msg combinations - msgs = [] - msgs.append(f"Subject\n") - msgs.append(f"Subject\n\nBody with some\nText\n") - msgs.append(f"Subject\n\nBody with\nText\n\nContinuation but\n doesn't contain colon\n") - msgs.append(f"Subject\n\nBody with\nText\n\nContinuation but\n only contains one :\n") - msgs.append(f"Subject\n\nBody with\nText\n\nKey: Value\nLine without colon\n") - msgs.append(f"Subject\n\nBody with\nText\n\nLine without colon\nKey: Value\n") + assert commit.trailers_list == [ + f"{KEY_1}: {VALUE_1_1}", + f"{KEY_2}: {VALUE_2}", + f"{KEY_1}: {VALUE_1_2}", + ] + assert commit.trailers_dict == { + KEY_1: [VALUE_1_1, VALUE_1_2], + KEY_2: [VALUE_2], + } + assert commit.trailers == { + KEY_1: VALUE_1_1, + KEY_2: VALUE_2, + } + + # check that trailer stays empty for multiple msg combinations + msgs = [ + f"Subject\n", + f"Subject\n\nBody with some\nText\n", + f"Subject\n\nBody with\nText\n\nContinuation but\n doesn't contain colon\n", + f"Subject\n\nBody with\nText\n\nContinuation but\n only contains one :\n", + f"Subject\n\nBody with\nText\n\nKey: Value\nLine without colon\n", + f"Subject\n\nBody with\nText\n\nLine without colon\nKey: Value\n", + ] for msg in msgs: - commit = self.rorepo.commit("master") - commit = copy.copy(commit) + commit = copy.copy(self.rorepo.commit("master")) commit.message = msg - assert len(commit.trailers.keys()) == 0 + assert commit.trailers_list == [] + assert commit.trailers_dict == {} + assert commit.trailers == {} # check that only the last key value paragraph is evaluated - commit = self.rorepo.commit("master") - commit = copy.copy(commit) - commit.message = f"Subject\n\nMultiline\nBody\n\n{KEY_1}: {VALUE_1}\n\n{KEY_2}: {VALUE_2}\n" - assert KEY_1 not in commit.trailers.keys() - assert KEY_2 in commit.trailers.keys() - assert commit.trailers[KEY_2] == VALUE_2 + commit = copy.copy(self.rorepo.commit("master")) + commit.message = f"Subject\n\nMultiline\nBody\n\n{KEY_1}: {VALUE_1_1}\n\n{KEY_2}: {VALUE_2}\n" + assert commit.trailers_list == [f"{KEY_2}: {VALUE_2}"] + assert commit.trailers_dict == {KEY_2: [VALUE_2]} + assert commit.trailers == {KEY_2: VALUE_2} def test_commit_co_authors(self): commit = copy.copy(self.rorepo.commit("4251bd5")) From 78424b56654ad476da4bd2faf88d3875c5265e0d Mon Sep 17 00:00:00 2001 From: Twist Date: Sat, 22 Apr 2023 17:19:20 +0100 Subject: [PATCH 048/114] Deprecate Commit.trailers. --- git/objects/commit.py | 18 ------------------ test/test_commit.py | 6 ------ 2 files changed, 24 deletions(-) diff --git a/git/objects/commit.py b/git/objects/commit.py index 50da0a105..1e3f751bc 100644 --- a/git/objects/commit.py +++ b/git/objects/commit.py @@ -335,24 +335,6 @@ def stats(self) -> Stats: text = self.repo.git.diff(self.parents[0].hexsha, self.hexsha, "--", numstat=True, no_renames=True) return Stats._list_from_string(self.repo, text) - @property - def trailers(self) -> Dict[str, str]: - """Get the trailers of the message as a dictionary - - Git messages can contain trailer information that are similar to RFC 822 - e-mail headers (see: https://git-scm.com/docs/git-interpret-trailers). - - WARNING: This function only returns the latest instance of each trailer key - and will be deprecated soon. Please see either ``Commit.trailers_list`` or ``Commit.trailers_dict``. - - :return: - Dictionary containing whitespace stripped trailer information. - Only the latest instance of each trailer key. - """ - return { - k: v[0] for k, v in self.trailers_dict.items() - } - @property def trailers_list(self) -> List[str]: """Get the trailers of the message as a list diff --git a/test/test_commit.py b/test/test_commit.py index ca1e4752b..8d2ee754b 100644 --- a/test/test_commit.py +++ b/test/test_commit.py @@ -523,10 +523,6 @@ def test_trailers(self): KEY_1: [VALUE_1_1, VALUE_1_2], KEY_2: [VALUE_2], } - assert commit.trailers == { - KEY_1: VALUE_1_1, - KEY_2: VALUE_2, - } # check that trailer stays empty for multiple msg combinations msgs = [ @@ -543,14 +539,12 @@ def test_trailers(self): commit.message = msg assert commit.trailers_list == [] assert commit.trailers_dict == {} - assert commit.trailers == {} # check that only the last key value paragraph is evaluated commit = copy.copy(self.rorepo.commit("master")) commit.message = f"Subject\n\nMultiline\nBody\n\n{KEY_1}: {VALUE_1_1}\n\n{KEY_2}: {VALUE_2}\n" assert commit.trailers_list == [f"{KEY_2}: {VALUE_2}"] assert commit.trailers_dict == {KEY_2: [VALUE_2]} - assert commit.trailers == {KEY_2: VALUE_2} def test_commit_co_authors(self): commit = copy.copy(self.rorepo.commit("4251bd5")) From abde3eafd293e8fa2ef2dc22d58ba5d80f1702e9 Mon Sep 17 00:00:00 2001 From: Twist Date: Sat, 22 Apr 2023 17:47:08 +0100 Subject: [PATCH 049/114] Update Commit.trailer_list to return tuples. --- git/objects/commit.py | 27 ++++++++++++++++----------- test/test_commit.py | 8 ++++---- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/git/objects/commit.py b/git/objects/commit.py index 1e3f751bc..b41a79951 100644 --- a/git/objects/commit.py +++ b/git/objects/commit.py @@ -336,7 +336,7 @@ def stats(self) -> Stats: return Stats._list_from_string(self.repo, text) @property - def trailers_list(self) -> List[str]: + def trailers_list(self) -> List[Tuple[str, str]]: """Get the trailers of the message as a list Git messages can contain trailer information that are similar to RFC 822 @@ -361,23 +361,29 @@ def trailers_list(self) -> List[str]: Returned list will look like this:: [ - "key1: value1.1", - "key1: value1.2", - "key2 : value 2 with inner spaces", + ("key1", "value1.1"), + ("key1", "value1.2"), + ("key2", "value 2 with inner spaces"), ] :return: - List containing whitespace stripped trailer information. + List containing key-value tuples of whitespace stripped trailer information. """ cmd = ["git", "interpret-trailers", "--parse"] proc: Git.AutoInterrupt = self.repo.git.execute(cmd, as_process=True, istream=PIPE) # type: ignore trailer: str = proc.communicate(str(self.message).encode())[0].decode() trailer = trailer.strip() - if trailer: - return [t.strip() for t in trailer.split("\n")] - return [] + if not trailer: + return [] + + trailer_list = [] + for t in trailer.split("\n"): + key, val = t.split(":", 1) + trailer_list.append((key.strip(), val.strip())) + + return trailer_list @property def trailers_dict(self) -> Dict[str, List[str]]: @@ -416,9 +422,8 @@ def trailers_dict(self) -> Dict[str, List[str]]: Mapping trailer keys to a list of their corresponding values. """ d = defaultdict(list) - for trailer in self.trailers_list: - key, value = trailer.split(":", 1) - d[key.strip()].append(value.strip()) + for key, val in self.trailers_list: + d[key].append(val) return dict(d) @classmethod diff --git a/test/test_commit.py b/test/test_commit.py index 8d2ee754b..4871902ec 100644 --- a/test/test_commit.py +++ b/test/test_commit.py @@ -515,9 +515,9 @@ def test_trailers(self): commit = copy.copy(self.rorepo.commit("master")) commit.message = msg assert commit.trailers_list == [ - f"{KEY_1}: {VALUE_1_1}", - f"{KEY_2}: {VALUE_2}", - f"{KEY_1}: {VALUE_1_2}", + (KEY_1, VALUE_1_1), + (KEY_2, VALUE_2), + (KEY_1, VALUE_1_2), ] assert commit.trailers_dict == { KEY_1: [VALUE_1_1, VALUE_1_2], @@ -543,7 +543,7 @@ def test_trailers(self): # check that only the last key value paragraph is evaluated commit = copy.copy(self.rorepo.commit("master")) commit.message = f"Subject\n\nMultiline\nBody\n\n{KEY_1}: {VALUE_1_1}\n\n{KEY_2}: {VALUE_2}\n" - assert commit.trailers_list == [f"{KEY_2}: {VALUE_2}"] + assert commit.trailers_list == [(KEY_2, VALUE_2)] assert commit.trailers_dict == {KEY_2: [VALUE_2]} def test_commit_co_authors(self): From ed36bd903e1fdf45c53b52dbcb1b2d8444965d98 Mon Sep 17 00:00:00 2001 From: Twist Date: Sat, 22 Apr 2023 17:54:04 +0100 Subject: [PATCH 050/114] Specify encoding in Commit.trailer_list. --- git/objects/commit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git/objects/commit.py b/git/objects/commit.py index b41a79951..f164becbc 100644 --- a/git/objects/commit.py +++ b/git/objects/commit.py @@ -372,7 +372,7 @@ def trailers_list(self) -> List[Tuple[str, str]]: """ cmd = ["git", "interpret-trailers", "--parse"] proc: Git.AutoInterrupt = self.repo.git.execute(cmd, as_process=True, istream=PIPE) # type: ignore - trailer: str = proc.communicate(str(self.message).encode())[0].decode() + trailer: str = proc.communicate(str(self.message).encode())[0].decode("utf8") trailer = trailer.strip() if not trailer: From 9ef07a731caf39684891bce5cc92c4e91829138d Mon Sep 17 00:00:00 2001 From: Twist Date: Sun, 23 Apr 2023 14:16:53 +0100 Subject: [PATCH 051/114] Revert the removal of Commit.trailers property. --- git/objects/commit.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/git/objects/commit.py b/git/objects/commit.py index f164becbc..138db0afe 100644 --- a/git/objects/commit.py +++ b/git/objects/commit.py @@ -335,6 +335,20 @@ def stats(self) -> Stats: text = self.repo.git.diff(self.parents[0].hexsha, self.hexsha, "--", numstat=True, no_renames=True) return Stats._list_from_string(self.repo, text) + @property + def trailers(self) -> Dict[str, str]: + """Get the trailers of the message as a dictionary + + :note: This property is deprecated, please use either ``Commit.trailers_list`` or ``Commit.trailers_dict``. + + :return: + Dictionary containing whitespace stripped trailer information. + Only contains the latest instance of each trailer key. + """ + return { + k: v[0] for k, v in self.trailers_dict.items() + } + @property def trailers_list(self) -> List[Tuple[str, str]]: """Get the trailers of the message as a list From 2a0305b349f61bddae674eb8f4efe0fd3c916bbf Mon Sep 17 00:00:00 2001 From: Christopher Head Date: Mon, 15 May 2023 10:13:37 -0700 Subject: [PATCH 052/114] Name top-level exceptions as private variables `exc` is private to the module. Naming it `_exc` eliminates a collision with the `exc` submodule (one which would not be observable at runtime due to the import failing, but which confuses linters). --- git/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/git/__init__.py b/git/__init__.py index f746e1fca..cd6602bf0 100644 --- a/git/__init__.py +++ b/git/__init__.py @@ -56,8 +56,8 @@ def _init_externals() -> None: Actor, rmtree, ) -except GitError as exc: - raise ImportError("%s: %s" % (exc.__class__.__name__, exc)) from exc +except GitError as _exc: + raise ImportError("%s: %s" % (_exc.__class__.__name__, _exc)) from _exc # } END imports @@ -87,6 +87,6 @@ def refresh(path: Optional[PathLike] = None) -> None: ################# try: refresh() -except Exception as exc: - raise ImportError("Failed to initialize: {0}".format(exc)) from exc +except Exception as _exc: + raise ImportError("Failed to initialize: {0}".format(_exc)) from _exc ################# From 6fc11e6e36e524a6749e15046eca3a8601745822 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Tue, 23 May 2023 07:33:14 +0200 Subject: [PATCH 053/114] update README to reflect the status quo on `git` command usage --- README.md | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 82c5c9e0f..96801b046 100644 --- a/README.md +++ b/README.md @@ -22,12 +22,8 @@ implementation of 'git' in [Rust](https://www.rust-lang.org). GitPython is a python library used to interact with git repositories, high-level like git-porcelain, or low-level like git-plumbing. -It provides abstractions of git objects for easy access of repository data, and additionally -allows you to access the git repository more directly using either a pure python implementation, -or the faster, but more resource intensive _git command_ implementation. - -The object database implementation is optimized for handling large quantities of objects and large datasets, -which is achieved by using low-level structures and data streaming. +It provides abstractions of git objects for easy access of repository data often backed by calling the `git` +command-line program. ### DEVELOPMENT STATUS @@ -41,8 +37,7 @@ The project is open to contributions of all kinds, as well as new maintainers. ### REQUIREMENTS -GitPython needs the `git` executable to be installed on the system and available -in your `PATH` for most operations. +GitPython needs the `git` executable to be installed on the system and available in your `PATH` for most operations. If it is not in your `PATH`, you can help GitPython find it by setting the `GIT_PYTHON_GIT_EXECUTABLE=` environment variable. From 9cd7ddb96022dd30cfe7b64378e3b32a3747c1dd Mon Sep 17 00:00:00 2001 From: Jean-Luc Tibaux <90517741+eUgEntOptIc44@users.noreply.github.com> Date: Wed, 5 Jul 2023 09:23:14 +0000 Subject: [PATCH 054/114] Improve the 'long_description' displayed on pypi --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 81ae0132d..ebece64eb 100755 --- a/setup.py +++ b/setup.py @@ -95,7 +95,7 @@ def build_py_modules(basedir: str, excludes: Sequence = ()) -> Sequence: install_requires=requirements, tests_require=requirements + test_requirements, zip_safe=False, - long_description="""GitPython is a Python library used to interact with Git repositories""", + long_description=long_description, long_description_content_type="text/markdown", classifiers=[ # Picked from From 0c543cd0ddedeaee27ca5e7c4c22b25a8fd5becb Mon Sep 17 00:00:00 2001 From: Jean-Luc Tibaux <90517741+eUgEntOptIc44@users.noreply.github.com> Date: Wed, 5 Jul 2023 09:23:38 +0000 Subject: [PATCH 055/114] Improve readability of README.md --- README.md | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 96801b046..676d2c6d6 100644 --- a/README.md +++ b/README.md @@ -51,17 +51,19 @@ The installer takes care of installing them for you. If you have downloaded the source code: - python setup.py install +```bash +python setup.py install +``` or if you want to obtain a copy from the Pypi repository: - pip install GitPython +```bash +pip install GitPython +``` Both commands will install the required package dependencies. -A distribution package can be obtained for manual installation at: - - http://pypi.python.org/pypi/GitPython +A distribution package can be obtained for manual installation at: . If you like to clone from source, you can do it like so: @@ -157,7 +159,7 @@ tarballs. This script shows how to verify the tarball was indeed created by the authors of this project: -``` +```bash curl https://files.pythonhosted.org/packages/09/bc/ae32e07e89cc25b9e5c793d19a1e5454d30a8e37d95040991160f942519e/GitPython-3.1.8-py3-none-any.whl > gitpython.whl curl https://files.pythonhosted.org/packages/09/bc/ae32e07e89cc25b9e5c793d19a1e5454d30a8e37d95040991160f942519e/GitPython-3.1.8-py3-none-any.whl.asc > gitpython-signature.asc gpg --verify gitpython-signature.asc gitpython.whl @@ -165,7 +167,7 @@ gpg --verify gitpython-signature.asc gitpython.whl which outputs -``` +```bash gpg: Signature made Fr 4 Sep 10:04:50 2020 CST gpg: using RSA key 27C50E7F590947D7273A741E85194C08421980C9 gpg: Good signature from "Sebastian Thiel (YubiKey USB-C) " [ultimate] @@ -175,19 +177,19 @@ gpg: aka "Sebastian Thiel (In Rust I trust) Date: Fri, 7 Jul 2023 06:36:12 +0300 Subject: [PATCH 056/114] Don't rely on __del__ --- git/index/base.py | 17 ++++++++--------- git/index/util.py | 16 +++++++++++++--- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/git/index/base.py b/git/index/base.py index cda08de25..8523717c6 100644 --- a/git/index/base.py +++ b/git/index/base.py @@ -4,6 +4,7 @@ # This module is part of GitPython and is released under # the BSD License: http://www.opensource.org/licenses/bsd-license.php +from contextlib import ExitStack import datetime import glob from io import BytesIO @@ -360,20 +361,19 @@ def from_tree(cls, repo: "Repo", *treeish: Treeish, **kwargs: Any) -> "IndexFile # as it considers existing entries. moving it essentially clears the index. # Unfortunately there is no 'soft' way to do it. # The TemporaryFileSwap assure the original file get put back - if repo.git_dir: - index_handler = TemporaryFileSwap(join_path_native(repo.git_dir, "index")) try: - repo.git.read_tree(*arg_list, **kwargs) - index = cls(repo, tmp_index) - index.entries # force it to read the file as we will delete the temp-file - del index_handler # release as soon as possible + with ExitStack() as stack: + if repo.git_dir: + stack.enter_context(TemporaryFileSwap(join_path_native(repo.git_dir, "index"))) + repo.git.read_tree(*arg_list, **kwargs) + index = cls(repo, tmp_index) + index.entries # force it to read the file as we will delete the temp-file + return index finally: if osp.exists(tmp_index): os.remove(tmp_index) # END index merge handling - return index - # UTILITIES @unbare_repo def _iter_expand_paths(self: "IndexFile", paths: Sequence[PathLike]) -> Iterator[PathLike]: @@ -1156,7 +1156,6 @@ def checkout( unknown_lines = [] def handle_stderr(proc: "Popen[bytes]", iter_checked_out_files: Iterable[PathLike]) -> None: - stderr_IO = proc.stderr if not stderr_IO: return None # return early if stderr empty diff --git a/git/index/util.py b/git/index/util.py index bfc7fadd6..6cf838f3b 100644 --- a/git/index/util.py +++ b/git/index/util.py @@ -3,6 +3,7 @@ import os import struct import tempfile +from types import TracebackType from git.compat import is_win @@ -11,7 +12,7 @@ # typing ---------------------------------------------------------------------- -from typing import Any, Callable, TYPE_CHECKING +from typing import Any, Callable, TYPE_CHECKING, Optional, Type from git.types import PathLike, _T @@ -47,12 +48,21 @@ def __init__(self, file_path: PathLike) -> None: except OSError: pass - def __del__(self) -> None: + def __enter__(self) -> "TemporaryFileSwap": + return self + + def __exit__( + self, + exc_type: Optional[Type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ) -> bool: if osp.isfile(self.tmp_file_path): if is_win and osp.exists(self.file_path): os.remove(self.file_path) os.rename(self.tmp_file_path, self.file_path) - # END temp file exists + + return False # { Decorators From a3859ee6f72e604d46a63dcd9fa3098adcc35cb0 Mon Sep 17 00:00:00 2001 From: Roey Darwish Dror Date: Fri, 7 Jul 2023 16:47:07 +0300 Subject: [PATCH 057/114] fixes --- git/index/base.py | 36 ++++++++++++++++-------------------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/git/index/base.py b/git/index/base.py index 8523717c6..dd8f9aa2e 100644 --- a/git/index/base.py +++ b/git/index/base.py @@ -353,26 +353,22 @@ def from_tree(cls, repo: "Repo", *treeish: Treeish, **kwargs: Any) -> "IndexFile # tmp file created in git home directory to be sure renaming # works - /tmp/ dirs could be on another device - tmp_index = tempfile.mktemp("", "", repo.git_dir) - arg_list.append("--index-output=%s" % tmp_index) - arg_list.extend(treeish) - - # move current index out of the way - otherwise the merge may fail - # as it considers existing entries. moving it essentially clears the index. - # Unfortunately there is no 'soft' way to do it. - # The TemporaryFileSwap assure the original file get put back - try: - with ExitStack() as stack: - if repo.git_dir: - stack.enter_context(TemporaryFileSwap(join_path_native(repo.git_dir, "index"))) - repo.git.read_tree(*arg_list, **kwargs) - index = cls(repo, tmp_index) - index.entries # force it to read the file as we will delete the temp-file - return index - finally: - if osp.exists(tmp_index): - os.remove(tmp_index) - # END index merge handling + with ExitStack() as stack: + tmp_index = stack.enter_context(tempfile.NamedTemporaryFile(dir=repo.git_dir)) + arg_list.append("--index-output=%s" % tmp_index.name) + arg_list.extend(treeish) + + # move current index out of the way - otherwise the merge may fail + # as it considers existing entries. moving it essentially clears the index. + # Unfortunately there is no 'soft' way to do it. + # The TemporaryFileSwap assure the original file get put back + + stack.enter_context(TemporaryFileSwap(join_path_native(repo.git_dir, "index"))) + repo.git.read_tree(*arg_list, **kwargs) + index = cls(repo, tmp_index.name) + index.entries # force it to read the file as we will delete the temp-file + return index + # END index merge handling # UTILITIES @unbare_repo From 97cdb403fd11778916b006b22679f427a3c1a8ac Mon Sep 17 00:00:00 2001 From: LeoDaCoda Date: Sat, 8 Jul 2023 15:22:33 -0400 Subject: [PATCH 058/114] Made the init repo section of quickdoc --- doc/source/quickstart.rst | 37 +++++++++++++++++++++++++++++++++++++ test/test_quick_doc.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 doc/source/quickstart.rst create mode 100644 test/test_quick_doc.py diff --git a/doc/source/quickstart.rst b/doc/source/quickstart.rst new file mode 100644 index 000000000..602ca423f --- /dev/null +++ b/doc/source/quickstart.rst @@ -0,0 +1,37 @@ +.. _quickdoc_toplevel: + +.. highlight:: python + +.. _quickdoc-label: + +============================== +GitPython Quick Start Tutorial +============================== + +git.Repo +******** + +There are a few ways to create a :class:`git.Repo ` object + +An existing local path +###################### + +.. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [1-test_init_repo_object] + :end-before: # ![1-test_init_repo_object] + +Existing local git Repo +####################### + +.. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [2-test_init_repo_object] + :end-before: # ![2-test_init_repo_object] + +Clone from URL +############## + +For the rest of this tutorial we will use a clone from https://github.com \ No newline at end of file diff --git a/test/test_quick_doc.py b/test/test_quick_doc.py new file mode 100644 index 000000000..a6dfb8e53 --- /dev/null +++ b/test/test_quick_doc.py @@ -0,0 +1,33 @@ +import pytest + +import git +from test.lib import TestBase +from test.lib.helper import with_rw_directory + + +class QuickDoc(TestBase): + def tearDown(self): + import gc + + gc.collect() + + @with_rw_directory + def test_init_repo_object(self, rw_dir): + path_to_dir = rw_dir + + # [1-test_init_repo_object] + from git import Repo + + repo = Repo.init(path_to_dir) + assert repo.__class__ is Repo # Test to confirm repo was initialized + # ![1-test_init_repo_object] + + # [2-test_init_repo_object] + try: + repo = Repo(path_to_dir) + except git.NoSuchPathError: + assert False, f"No such path {path_to_dir}" + # ! [2-test_init_repo_object] + + # [3 - test_init_repo_object] + From 6a9154b1bfcebe7ee28edebec6617993ad6a5569 Mon Sep 17 00:00:00 2001 From: LeoDaCoda Date: Sat, 8 Jul 2023 20:52:04 -0400 Subject: [PATCH 059/114] Added git clone & git add --- doc/source/quickstart.rst | 27 +++++++++++++++++++++++++- test/test_quick_doc.py | 40 ++++++++++++++++++++++++++++++++++----- 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/doc/source/quickstart.rst b/doc/source/quickstart.rst index 602ca423f..ebdb2520a 100644 --- a/doc/source/quickstart.rst +++ b/doc/source/quickstart.rst @@ -16,6 +16,8 @@ There are a few ways to create a :class:`git.Repo ` object An existing local path ###################### +$ git init path/to/dir + .. literalinclude:: ../../test/test_quick_doc.py :language: python :dedent: 8 @@ -34,4 +36,27 @@ Existing local git Repo Clone from URL ############## -For the rest of this tutorial we will use a clone from https://github.com \ No newline at end of file +For the rest of this tutorial we will use a clone from https://github.com/LeoDaCoda/GitPython-TestFileSys.git + +git clone https://some_repo_url + +.. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [1-test_cloned_repo_object] + :end-before: # ![1-test_cloned_repo_object] + +Usage +**************** + +* git add filepath + + + + +* git commit -m message +* git log file +* git status + + + diff --git a/test/test_quick_doc.py b/test/test_quick_doc.py index a6dfb8e53..0188367cf 100644 --- a/test/test_quick_doc.py +++ b/test/test_quick_doc.py @@ -1,6 +1,6 @@ import pytest -import git + from test.lib import TestBase from test.lib.helper import with_rw_directory @@ -18,16 +18,46 @@ def test_init_repo_object(self, rw_dir): # [1-test_init_repo_object] from git import Repo - repo = Repo.init(path_to_dir) - assert repo.__class__ is Repo # Test to confirm repo was initialized + repo = Repo.init(path_to_dir) # git init path/to/dir + assert repo.__class__ is Repo # Test to confirm repo was initialized # ![1-test_init_repo_object] # [2-test_init_repo_object] + import git + try: repo = Repo(path_to_dir) except git.NoSuchPathError: assert False, f"No such path {path_to_dir}" - # ! [2-test_init_repo_object] + # ![2-test_init_repo_object] + + @with_rw_directory + def test_cloned_repo_object(self, rw_dir): + local_dir = rw_dir - # [3 - test_init_repo_object] + from git import Repo + import git + # code to clone from url + # [1-test_cloned_repo_object] + repo_url = "https://github.com/LeoDaCoda/GitPython-TestFileSys.git" + + try: + repo = Repo.clone_from(repo_url, local_dir) + except git.CommandError: + assert False, f"Invalid address {repo_url}" + # ![1-test_cloned_repo_object] + + # code to add files + # [2-test_cloned_repo_object] + # We must make a change to a file so that we can add the update to git + + update_file = 'dir1/file2.txt' # we'll use /dir1/file2.txt + with open(f"{local_dir}/{update_file}", 'a') as f: + f.write('\nUpdate version 2') + # ![2-test_cloned_repo_object] + + # [3-test_cloned_repo_object] + add_file = [f"{local_dir}/{update_file}"] + repo.index.add(add_file) # notice the add function requires a list of paths + # [3-test_cloned_repo_object] From 3c42baebf5bd7c509b9962d1490f59e8874f1323 Mon Sep 17 00:00:00 2001 From: LeoDaCoda Date: Sun, 9 Jul 2023 05:34:09 -0400 Subject: [PATCH 060/114] Finishing touches for Repo quickstart --- doc/source/quickstart.rst | 85 +++++++++++++++++++++++++++++++++++++-- test/test_quick_doc.py | 71 ++++++++++++++++++++++++++++++-- 2 files changed, 149 insertions(+), 7 deletions(-) diff --git a/doc/source/quickstart.rst b/doc/source/quickstart.rst index ebdb2520a..0a728e485 100644 --- a/doc/source/quickstart.rst +++ b/doc/source/quickstart.rst @@ -49,14 +49,91 @@ git clone https://some_repo_url Usage **************** -* git add filepath +* $ git add filepath +.. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [2-test_cloned_repo_object] + :end-before: # ![2-test_cloned_repo_object] + +Now lets add the updated file to git + +.. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [3-test_cloned_repo_object] + :end-before: # ![3-test_cloned_repo_object] + +Notice the add method requires a list as a parameter + +* $ git commit -m message + +.. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [4-test_cloned_repo_object] + :end-before: # ![4-test_cloned_repo_object] + +* $ git log file + +A list of commits associated with a file + +.. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [5-test_cloned_repo_object] + :end-before: # ![5-test_cloned_repo_object] + +Notice this returns a generator object + +.. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [6-test_cloned_repo_object] + :end-before: # ![6-test_cloned_repo_object] + +returns list of :class:`Commit ` objects + +* $ git status + + * Untracked files + + Lets create a new file + + .. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [7-test_cloned_repo_object] + :end-before: # ![7-test_cloned_repo_object] + + .. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [8-test_cloned_repo_object] + :end-before: # ![8-test_cloned_repo_object] + + * Modified files + + .. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [9-test_cloned_repo_object] + :end-before: # ![9-test_cloned_repo_object] + .. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [10-test_cloned_repo_object] + :end-before: # ![10-test_cloned_repo_object] + returns a list of :class:`Diff ` objects -* git commit -m message -* git log file -* git status + .. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [11-test_cloned_repo_object] + :end-before: # ![11-test_cloned_repo_object] diff --git a/test/test_quick_doc.py b/test/test_quick_doc.py index 0188367cf..bb3372905 100644 --- a/test/test_quick_doc.py +++ b/test/test_quick_doc.py @@ -51,13 +51,78 @@ def test_cloned_repo_object(self, rw_dir): # [2-test_cloned_repo_object] # We must make a change to a file so that we can add the update to git - update_file = 'dir1/file2.txt' # we'll use /dir1/file2.txt + update_file = 'dir1/file2.txt' # we'll use ./dir1/file2.txt with open(f"{local_dir}/{update_file}", 'a') as f: f.write('\nUpdate version 2') # ![2-test_cloned_repo_object] # [3-test_cloned_repo_object] - add_file = [f"{local_dir}/{update_file}"] + add_file = [f"{update_file}"] # relative path from git root repo.index.add(add_file) # notice the add function requires a list of paths - # [3-test_cloned_repo_object] + # ![3-test_cloned_repo_object] + + # code to commit - not sure how to test this + # [4-test_cloned_repo_object] + repo.index.commit("Update to file2") + # ![4-test_cloned_repo_object] + + # [5-test_cloned_repo_object] + file = 'dir1/file2.txt' # relative path from git root + repo.iter_commits('--all', max_count=100, paths=file) + + # Outputs: + + # ![5-test_cloned_repo_object] + + # [6-test_cloned_repo_object] + commits_for_file_generator = repo.iter_commits('--all', max_count=100, paths=file) + commits_for_file = [c for c in commits_for_file_generator] + commits_for_file + + # Outputs: [, + # ] + # ![6-test_cloned_repo_object] + + # Untracked files - create new file + # [7-test_cloned_repo_object] + # We'll create a file5.txt + + file5 = f'{local_dir}/file5.txt' + with open(file5, 'w') as f: + f.write('file5 version 1') + # ![7-test_cloned_repo_object] + + # [8-test_cloned_repo_object] + repo.untracked_files + # Output: ['file5.txt'] + # ![8-test_cloned_repo_object] + + # Modified files + # [9-test_cloned_repo_object] + # Lets modify one of our tracked files + file3 = f'{local_dir}/Downloads/file3.txt' + with open(file3, 'w') as f: + f.write('file3 version 2') # overwrite file 3 + # ![9-test_cloned_repo_object] + + # [10-test_cloned_repo_object] + repo.index.diff(None) + # Output: [, + # ] + # ![10-test_cloned_repo_object] + + # [11-test_cloned_repo_object] + diffs = repo.index.diff(None) + for d in diffs: + print(d.a_path) + + # Downloads/file3.txt + # file4.txt + # ![11-test_cloned_repo_object] + + + + + + From 10ea113ca6141b8a74e78858e6ff6f52bfd04d9f Mon Sep 17 00:00:00 2001 From: LeoDaCoda Date: Sun, 9 Jul 2023 21:29:26 -0400 Subject: [PATCH 061/114] finished code for quickstart --- doc/source/quickstart.rst | 70 +++++++++++++++++++++++++++++++++++++++ test/test_quick_doc.py | 46 +++++++++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/doc/source/quickstart.rst b/doc/source/quickstart.rst index 0a728e485..9d63c5674 100644 --- a/doc/source/quickstart.rst +++ b/doc/source/quickstart.rst @@ -136,4 +136,74 @@ returns list of :class:`Commit ` objects :end-before: # ![11-test_cloned_repo_object] +Trees & Blobs +************** +Latest Commit Tree +################## + +.. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [12-test_cloned_repo_object] + :end-before: # ![12-test_cloned_repo_object] + +Any Commit Tree +############### + +.. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [13-test_cloned_repo_object] + :end-before: # ![13-test_cloned_repo_object] + +Display level 1 Contents +######################## + +.. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [14-test_cloned_repo_object] + :end-before: # ![14-test_cloned_repo_object] + +Recurse through the Tree +######################## + +.. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [15-test_cloned_repo_object] + :end-before: # ![15-test_cloned_repo_object] + +.. code-block:: python + + print_files_from_git(tree) + +.. code-block:: python + + # Output + | Downloads, tree + ----| Downloads/file3.txt, blob + | dir1, tree + ----| dir1/file1.txt, blob + ----| dir1/file2.txt, blob + | file4.txt, blob + + +Print file version +################## + +.. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [16-test_cloned_repo_object] + :end-before: # ![16-test_cloned_repo_object] + +.. code-block:: python + + blob = tree[print_file] + print(blob.data_stream.read().decode()) + + # Output + # file 2 version 1 + # Update version 2 diff --git a/test/test_quick_doc.py b/test/test_quick_doc.py index bb3372905..2a95bfff5 100644 --- a/test/test_quick_doc.py +++ b/test/test_quick_doc.py @@ -120,7 +120,53 @@ def test_cloned_repo_object(self, rw_dir): # file4.txt # ![11-test_cloned_repo_object] + '''Trees and Blobs''' + # Latest commit tree + # [12-test_cloned_repo_object] + tree = repo.tree() + # ![12-test_cloned_repo_object] + + # Previous commit tree + # [13-test_cloned_repo_object] + prev_commits = [c for c in repo.iter_commits('--all', max_count=10)] + tree = prev_commits[0].tree + # ![13-test_cloned_repo_object] + + # Iterating through tree + # [14-test_cloned_repo_object] + tree = repo.tree() + files_dirs = [fd for fd in tree] + files_dirs + + # Output + # [, + # , + # ] + + # ![14-test_cloned_repo_object] + + # [15-test_cloned_repo_object] + def print_files_from_git(tree, delim='-', i=0): + files_dirs = [fd for fd in tree] + for fd in files_dirs: + print(f'{delim if i != 0 else ""}| {fd.path}, {fd.type}') + if fd.type == "tree": + print_files_from_git(fd, delim * 4, i + 1) + + # ![15-test_cloned_repo_object] + + # Printing text files + # [16-test_cloned_repo_object] + print_file = 'dir1/file2.txt' + tree[print_file] + + # Output + # ![16-test_cloned_repo_object] + + # [17-test_cloned_repo_object] + + # ![17-test_cloned_repo_object] From b0da0a9e53a30dfcefaa7d77fe0bd0104b3a814e Mon Sep 17 00:00:00 2001 From: LeoDaCoda Date: Sun, 9 Jul 2023 21:29:26 -0400 Subject: [PATCH 062/114] finished code for quickstart --- doc/source/quickstart.rst | 70 +++++++++++++++++++++++++++++++++++++++ test/test_quick_doc.py | 46 +++++++++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/doc/source/quickstart.rst b/doc/source/quickstart.rst index 0a728e485..9d63c5674 100644 --- a/doc/source/quickstart.rst +++ b/doc/source/quickstart.rst @@ -136,4 +136,74 @@ returns list of :class:`Commit ` objects :end-before: # ![11-test_cloned_repo_object] +Trees & Blobs +************** +Latest Commit Tree +################## + +.. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [12-test_cloned_repo_object] + :end-before: # ![12-test_cloned_repo_object] + +Any Commit Tree +############### + +.. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [13-test_cloned_repo_object] + :end-before: # ![13-test_cloned_repo_object] + +Display level 1 Contents +######################## + +.. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [14-test_cloned_repo_object] + :end-before: # ![14-test_cloned_repo_object] + +Recurse through the Tree +######################## + +.. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [15-test_cloned_repo_object] + :end-before: # ![15-test_cloned_repo_object] + +.. code-block:: python + + print_files_from_git(tree) + +.. code-block:: python + + # Output + | Downloads, tree + ----| Downloads/file3.txt, blob + | dir1, tree + ----| dir1/file1.txt, blob + ----| dir1/file2.txt, blob + | file4.txt, blob + + +Print file version +################## + +.. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [16-test_cloned_repo_object] + :end-before: # ![16-test_cloned_repo_object] + +.. code-block:: python + + blob = tree[print_file] + print(blob.data_stream.read().decode()) + + # Output + # file 2 version 1 + # Update version 2 diff --git a/test/test_quick_doc.py b/test/test_quick_doc.py index bb3372905..2a95bfff5 100644 --- a/test/test_quick_doc.py +++ b/test/test_quick_doc.py @@ -120,7 +120,53 @@ def test_cloned_repo_object(self, rw_dir): # file4.txt # ![11-test_cloned_repo_object] + '''Trees and Blobs''' + # Latest commit tree + # [12-test_cloned_repo_object] + tree = repo.tree() + # ![12-test_cloned_repo_object] + + # Previous commit tree + # [13-test_cloned_repo_object] + prev_commits = [c for c in repo.iter_commits('--all', max_count=10)] + tree = prev_commits[0].tree + # ![13-test_cloned_repo_object] + + # Iterating through tree + # [14-test_cloned_repo_object] + tree = repo.tree() + files_dirs = [fd for fd in tree] + files_dirs + + # Output + # [, + # , + # ] + + # ![14-test_cloned_repo_object] + + # [15-test_cloned_repo_object] + def print_files_from_git(tree, delim='-', i=0): + files_dirs = [fd for fd in tree] + for fd in files_dirs: + print(f'{delim if i != 0 else ""}| {fd.path}, {fd.type}') + if fd.type == "tree": + print_files_from_git(fd, delim * 4, i + 1) + + # ![15-test_cloned_repo_object] + + # Printing text files + # [16-test_cloned_repo_object] + print_file = 'dir1/file2.txt' + tree[print_file] + + # Output + # ![16-test_cloned_repo_object] + + # [17-test_cloned_repo_object] + + # ![17-test_cloned_repo_object] From fb35ed1d611113637c52a559d6f77aaadb6d403d Mon Sep 17 00:00:00 2001 From: LeoDaCoda Date: Sun, 9 Jul 2023 21:36:28 -0400 Subject: [PATCH 063/114] fixed some indentation --- doc/source/quickstart.rst | 60 +++++++++++++++++++-------------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/doc/source/quickstart.rst b/doc/source/quickstart.rst index 9d63c5674..1c0832ed5 100644 --- a/doc/source/quickstart.rst +++ b/doc/source/quickstart.rst @@ -99,41 +99,41 @@ returns list of :class:`Commit ` objects * Untracked files - Lets create a new file + Lets create a new file - .. literalinclude:: ../../test/test_quick_doc.py - :language: python - :dedent: 8 - :start-after: # [7-test_cloned_repo_object] - :end-before: # ![7-test_cloned_repo_object] + .. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [7-test_cloned_repo_object] + :end-before: # ![7-test_cloned_repo_object] - .. literalinclude:: ../../test/test_quick_doc.py - :language: python - :dedent: 8 - :start-after: # [8-test_cloned_repo_object] - :end-before: # ![8-test_cloned_repo_object] + .. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [8-test_cloned_repo_object] + :end-before: # ![8-test_cloned_repo_object] * Modified files - .. literalinclude:: ../../test/test_quick_doc.py - :language: python - :dedent: 8 - :start-after: # [9-test_cloned_repo_object] - :end-before: # ![9-test_cloned_repo_object] - - .. literalinclude:: ../../test/test_quick_doc.py - :language: python - :dedent: 8 - :start-after: # [10-test_cloned_repo_object] - :end-before: # ![10-test_cloned_repo_object] - - returns a list of :class:`Diff ` objects - - .. literalinclude:: ../../test/test_quick_doc.py - :language: python - :dedent: 8 - :start-after: # [11-test_cloned_repo_object] - :end-before: # ![11-test_cloned_repo_object] + .. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [9-test_cloned_repo_object] + :end-before: # ![9-test_cloned_repo_object] + + .. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [10-test_cloned_repo_object] + :end-before: # ![10-test_cloned_repo_object] + + returns a list of :class:`Diff ` objects + + .. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [11-test_cloned_repo_object] + :end-before: # ![11-test_cloned_repo_object] Trees & Blobs From 47c83629cfa0550fae71f2c266bd8b236b63fdc6 Mon Sep 17 00:00:00 2001 From: LeoDaCoda Date: Sun, 9 Jul 2023 22:17:03 -0400 Subject: [PATCH 064/114] added quickstart to toctree and fixed sphinx warning --- doc/source/index.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/source/index.rst b/doc/source/index.rst index 69fb573a4..72db8ee5a 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -9,6 +9,7 @@ GitPython Documentation :maxdepth: 2 intro + quickstart tutorial reference roadmap From b7955ed1f1511dd7d873e4198b3372c104102b4f Mon Sep 17 00:00:00 2001 From: LeoDaCoda Date: Sun, 9 Jul 2023 22:17:03 -0400 Subject: [PATCH 065/114] added quickstart to toctree to fix sphinx warning --- doc/source/index.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/source/index.rst b/doc/source/index.rst index 69fb573a4..72db8ee5a 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -9,6 +9,7 @@ GitPython Documentation :maxdepth: 2 intro + quickstart tutorial reference roadmap From 5c59e0d63da6180db8a0b349f0ad36fef42aceed Mon Sep 17 00:00:00 2001 From: Sylvain Beucler Date: Mon, 10 Jul 2023 16:10:10 +0200 Subject: [PATCH 066/114] Block insecure non-multi options in clone/clone_from Follow-up to #1521 --- git/repo/base.py | 2 ++ test/test_repo.py | 24 +++++++++++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/git/repo/base.py b/git/repo/base.py index 2fc9cf1fe..1fa98d8c7 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -1203,6 +1203,8 @@ def _clone( if not allow_unsafe_protocols: Git.check_unsafe_protocols(str(url)) + if not allow_unsafe_options: + Git.check_unsafe_options(options=list(kwargs.keys()), unsafe_options=cls.unsafe_git_clone_options) if not allow_unsafe_options and multi_options: Git.check_unsafe_options(options=multi_options, unsafe_options=cls.unsafe_git_clone_options) diff --git a/test/test_repo.py b/test/test_repo.py index 07c1e9adf..5c66aeeb1 100644 --- a/test/test_repo.py +++ b/test/test_repo.py @@ -282,6 +282,17 @@ def test_clone_unsafe_options(self, rw_repo): rw_repo.clone(tmp_dir, multi_options=[unsafe_option]) assert not tmp_file.exists() + unsafe_options = [ + {"upload-pack": f"touch {tmp_file}"}, + {"u": f"touch {tmp_file}"}, + {"config": "protocol.ext.allow=always"}, + {"c": "protocol.ext.allow=always"}, + ] + for unsafe_option in unsafe_options: + with self.assertRaises(UnsafeOptionError): + rw_repo.clone(tmp_dir, **unsafe_option) + assert not tmp_file.exists() + @with_rw_repo("HEAD") def test_clone_unsafe_options_allowed(self, rw_repo): with tempfile.TemporaryDirectory() as tdir: @@ -341,6 +352,17 @@ def test_clone_from_unsafe_options(self, rw_repo): Repo.clone_from(rw_repo.working_dir, tmp_dir, multi_options=[unsafe_option]) assert not tmp_file.exists() + unsafe_options = [ + {"upload-pack": f"touch {tmp_file}"}, + {"u": f"touch {tmp_file}"}, + {"config": "protocol.ext.allow=always"}, + {"c": "protocol.ext.allow=always"}, + ] + for unsafe_option in unsafe_options: + with self.assertRaises(UnsafeOptionError): + Repo.clone_from(rw_repo.working_dir, tmp_dir, **unsafe_option) + assert not tmp_file.exists() + @with_rw_repo("HEAD") def test_clone_from_unsafe_options_allowed(self, rw_repo): with tempfile.TemporaryDirectory() as tdir: @@ -1410,4 +1432,4 @@ def test_ignored_raises_error_w_symlink(self): os.symlink(tmp_dir / "target", tmp_dir / "symlink") with pytest.raises(GitCommandError): - temp_repo.ignored(tmp_dir / "symlink/file.txt") \ No newline at end of file + temp_repo.ignored(tmp_dir / "symlink/file.txt") From 03d26f0c92055759e296a36f2bde1ff9fb439b29 Mon Sep 17 00:00:00 2001 From: LeoDaCoda Date: Mon, 10 Jul 2023 13:51:40 -0400 Subject: [PATCH 067/114] Removed code from RST --- doc/source/quickstart.rst | 34 ++++++++++++++-------------------- test/test_quick_doc.py | 25 ++++++++++++++++++++----- 2 files changed, 34 insertions(+), 25 deletions(-) diff --git a/doc/source/quickstart.rst b/doc/source/quickstart.rst index 1c0832ed5..5845bf9e2 100644 --- a/doc/source/quickstart.rst +++ b/doc/source/quickstart.rst @@ -175,35 +175,29 @@ Recurse through the Tree :start-after: # [15-test_cloned_repo_object] :end-before: # ![15-test_cloned_repo_object] -.. code-block:: python +.. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [16-test_cloned_repo_object] + :end-before: # ![16-test_cloned_repo_object] - print_files_from_git(tree) -.. code-block:: python - # Output - | Downloads, tree - ----| Downloads/file3.txt, blob - | dir1, tree - ----| dir1/file1.txt, blob - ----| dir1/file2.txt, blob - | file4.txt, blob +Printing text files +#################### -Print file version -################## +.. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [17-test_cloned_repo_object] + :end-before: # ![17-test_cloned_repo_object] .. literalinclude:: ../../test/test_quick_doc.py :language: python :dedent: 8 - :start-after: # [16-test_cloned_repo_object] - :end-before: # ![16-test_cloned_repo_object] + :start-after: # [18-test_cloned_repo_object] + :end-before: # ![18-test_cloned_repo_object] -.. code-block:: python - blob = tree[print_file] - print(blob.data_stream.read().decode()) - # Output - # file 2 version 1 - # Update version 2 diff --git a/test/test_quick_doc.py b/test/test_quick_doc.py index 2a95bfff5..f1d644382 100644 --- a/test/test_quick_doc.py +++ b/test/test_quick_doc.py @@ -156,19 +156,34 @@ def print_files_from_git(tree, delim='-', i=0): # ![15-test_cloned_repo_object] - # Printing text files # [16-test_cloned_repo_object] - print_file = 'dir1/file2.txt' - tree[print_file] + print_files_from_git(tree) - # Output - # ![16-test_cloned_repo_object] + # Output + # | Downloads, tree + # ---- | Downloads / file3.txt, blob + # | dir1, tree + # ---- | dir1 / file1.txt, blob + # ---- | dir1 / file2.txt, blob + # | file4.txt, blob + # # ![16-test_cloned_repo_object] + # Printing text files # [17-test_cloned_repo_object] + print_file = 'dir1/file2.txt' + tree[print_file] + # Output # ![17-test_cloned_repo_object] + # [18-test_cloned_repo_object] + blob = tree[print_file] + print(blob.data_stream.read().decode()) + # Output + # file 2 version 1 + # Update version 2 + # ![18-test_cloned_repo_object] From 5d45ce243a12669724e969442e6725a894e30fd4 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Mon, 10 Jul 2023 19:53:19 +0200 Subject: [PATCH 068/114] prepare 3.1.32 release --- VERSION | 2 +- doc/source/changes.rst | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 339bdc849..381c34a62 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.1.31 +3.1.32 diff --git a/doc/source/changes.rst b/doc/source/changes.rst index 4ee613bcc..3bc02e770 100644 --- a/doc/source/changes.rst +++ b/doc/source/changes.rst @@ -2,6 +2,12 @@ Changelog ========= +3.1.32 +====== + +See the following for all changes. +https://github.com/gitpython-developers/gitpython/milestone/62?closed=1 + 3.1.31 ====== From 947b8b7330a6a26c1503b0b40bb2fd2bf489e8e8 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Mon, 10 Jul 2023 20:13:48 +0200 Subject: [PATCH 069/114] try to fix CI by making it deal with tags forcefully. Question is why this is a problem in the first place. --- .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 6d6c67952..2d95e6ffa 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -32,7 +32,7 @@ jobs: python -m pip install --upgrade pip setuptools wheel python --version; git --version git submodule update --init --recursive - git fetch --tags + git fetch --tags --force pip install -r requirements.txt pip install -r test-requirements.txt From a0045d8844b937e703156adfbeb496ebc70c8950 Mon Sep 17 00:00:00 2001 From: LeoDaCoda Date: Mon, 10 Jul 2023 15:00:06 -0400 Subject: [PATCH 070/114] Made variable names more intuitive --- test/test_quick_doc.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/test/test_quick_doc.py b/test/test_quick_doc.py index f1d644382..49d9a0f36 100644 --- a/test/test_quick_doc.py +++ b/test/test_quick_doc.py @@ -136,8 +136,8 @@ def test_cloned_repo_object(self, rw_dir): # Iterating through tree # [14-test_cloned_repo_object] tree = repo.tree() - files_dirs = [fd for fd in tree] - files_dirs + files_and_dirs = [entry for entry in tree] + files_and_dirs # Output # [, @@ -147,12 +147,11 @@ def test_cloned_repo_object(self, rw_dir): # ![14-test_cloned_repo_object] # [15-test_cloned_repo_object] - def print_files_from_git(tree, delim='-', i=0): - files_dirs = [fd for fd in tree] - for fd in files_dirs: - print(f'{delim if i != 0 else ""}| {fd.path}, {fd.type}') - if fd.type == "tree": - print_files_from_git(fd, delim * 4, i + 1) + def print_files_from_git(root, delim='-', i=0): + for entry in root: + print(f'{delim if i != 0 else ""}| {entry.path}, {entry.type}') + if entry.type == "tree": + print_files_from_git(entry, delim * 4, i + 1) # ![15-test_cloned_repo_object] From 98336551260f0b3093f5be085573b193198a4271 Mon Sep 17 00:00:00 2001 From: LeoDaCoda Date: Mon, 10 Jul 2023 15:19:00 -0400 Subject: [PATCH 071/114] Updated the sample repo URL --- test/test_quick_doc.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/test/test_quick_doc.py b/test/test_quick_doc.py index 49d9a0f36..e76bf3a12 100644 --- a/test/test_quick_doc.py +++ b/test/test_quick_doc.py @@ -12,8 +12,7 @@ def tearDown(self): gc.collect() @with_rw_directory - def test_init_repo_object(self, rw_dir): - path_to_dir = rw_dir + def test_init_repo_object(self, path_to_dir): # [1-test_init_repo_object] from git import Repo @@ -32,19 +31,15 @@ def test_init_repo_object(self, rw_dir): # ![2-test_init_repo_object] @with_rw_directory - def test_cloned_repo_object(self, rw_dir): - local_dir = rw_dir + def test_cloned_repo_object(self, local_dir): from git import Repo import git # code to clone from url # [1-test_cloned_repo_object] - repo_url = "https://github.com/LeoDaCoda/GitPython-TestFileSys.git" + repo_url = "https://github.com/gitpython-developers/QuickStartTutorialFiles.git" - try: - repo = Repo.clone_from(repo_url, local_dir) - except git.CommandError: - assert False, f"Invalid address {repo_url}" + repo = Repo.clone_from(repo_url, local_dir) # ![1-test_cloned_repo_object] # code to add files From 3cda530b1fc1e5ae3c2403a43a7270f6a73f07fb Mon Sep 17 00:00:00 2001 From: LeoDaCoda Date: Mon, 10 Jul 2023 15:24:05 -0400 Subject: [PATCH 072/114] removed try/except and updated sample url --- doc/source/quickstart.rst | 2 +- test/test_quick_doc.py | 10 ++-------- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/doc/source/quickstart.rst b/doc/source/quickstart.rst index 5845bf9e2..5c1c18701 100644 --- a/doc/source/quickstart.rst +++ b/doc/source/quickstart.rst @@ -38,7 +38,7 @@ Clone from URL For the rest of this tutorial we will use a clone from https://github.com/LeoDaCoda/GitPython-TestFileSys.git -git clone https://some_repo_url +$ git clone https://github.com/gitpython-developers/QuickStartTutorialFiles.git .. literalinclude:: ../../test/test_quick_doc.py :language: python diff --git a/test/test_quick_doc.py b/test/test_quick_doc.py index e76bf3a12..64586f186 100644 --- a/test/test_quick_doc.py +++ b/test/test_quick_doc.py @@ -18,16 +18,10 @@ def test_init_repo_object(self, path_to_dir): from git import Repo repo = Repo.init(path_to_dir) # git init path/to/dir - assert repo.__class__ is Repo # Test to confirm repo was initialized - # ![1-test_init_repo_object] + # ![1-test_init_repo_object] # [2-test_init_repo_object] - import git - - try: - repo = Repo(path_to_dir) - except git.NoSuchPathError: - assert False, f"No such path {path_to_dir}" + repo = Repo(path_to_dir) # ![2-test_init_repo_object] @with_rw_directory From e4bbc7a520d83b7e5db208d0fe901cec0125c2f9 Mon Sep 17 00:00:00 2001 From: LeoDaCoda Date: Mon, 10 Jul 2023 17:13:44 -0400 Subject: [PATCH 073/114] correct way to get the latest commit tree --- test/test_quick_doc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_quick_doc.py b/test/test_quick_doc.py index 64586f186..f8c973bad 100644 --- a/test/test_quick_doc.py +++ b/test/test_quick_doc.py @@ -113,7 +113,7 @@ def test_cloned_repo_object(self, local_dir): # Latest commit tree # [12-test_cloned_repo_object] - tree = repo.tree() + tree = repo.head.commit.tree # ![12-test_cloned_repo_object] # Previous commit tree From a1dfd4ade535242bb535cbda9b2f02153d2a423e Mon Sep 17 00:00:00 2001 From: LeoDaCoda Date: Mon, 10 Jul 2023 23:56:06 -0400 Subject: [PATCH 074/114] convert from --all flag to all=True --- test/test_quick_doc.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/test_quick_doc.py b/test/test_quick_doc.py index f8c973bad..701d3994a 100644 --- a/test/test_quick_doc.py +++ b/test/test_quick_doc.py @@ -57,14 +57,14 @@ def test_cloned_repo_object(self, local_dir): # [5-test_cloned_repo_object] file = 'dir1/file2.txt' # relative path from git root - repo.iter_commits('--all', max_count=100, paths=file) + repo.iter_commits(all=True, max_count=10, paths=file) # gets the last 10 commits from all branches # Outputs: # ![5-test_cloned_repo_object] # [6-test_cloned_repo_object] - commits_for_file_generator = repo.iter_commits('--all', max_count=100, paths=file) + commits_for_file_generator = repo.iter_commits(all=True, max_count=10, paths=file) commits_for_file = [c for c in commits_for_file_generator] commits_for_file @@ -95,7 +95,8 @@ def test_cloned_repo_object(self, local_dir): # ![9-test_cloned_repo_object] # [10-test_cloned_repo_object] - repo.index.diff(None) + repo.index.diff(None) # compares staging area to working directory + repo.index.diff(repo.head.commit) # compares staging area to last commit # Output: [, # ] # ![10-test_cloned_repo_object] @@ -118,7 +119,7 @@ def test_cloned_repo_object(self, local_dir): # Previous commit tree # [13-test_cloned_repo_object] - prev_commits = [c for c in repo.iter_commits('--all', max_count=10)] + prev_commits = [c for c in repo.iter_commits(all=True, max_count=10)] # last 10 commits from all branches tree = prev_commits[0].tree # ![13-test_cloned_repo_object] From a8b58639f57f5a4952f98ee097def5ad9543b566 Mon Sep 17 00:00:00 2001 From: LeoDaCoda Date: Tue, 11 Jul 2023 00:02:20 -0400 Subject: [PATCH 075/114] removed unnecessary variables --- test/test_quick_doc.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/test/test_quick_doc.py b/test/test_quick_doc.py index 701d3994a..9756f0da5 100644 --- a/test/test_quick_doc.py +++ b/test/test_quick_doc.py @@ -46,7 +46,7 @@ def test_cloned_repo_object(self, local_dir): # ![2-test_cloned_repo_object] # [3-test_cloned_repo_object] - add_file = [f"{update_file}"] # relative path from git root + add_file = [update_file] # relative path from git root repo.index.add(add_file) # notice the add function requires a list of paths # ![3-test_cloned_repo_object] @@ -56,15 +56,15 @@ def test_cloned_repo_object(self, local_dir): # ![4-test_cloned_repo_object] # [5-test_cloned_repo_object] - file = 'dir1/file2.txt' # relative path from git root - repo.iter_commits(all=True, max_count=10, paths=file) # gets the last 10 commits from all branches + # relative path from git root + repo.iter_commits(all=True, max_count=10, paths=update_file) # gets the last 10 commits from all branches # Outputs: # ![5-test_cloned_repo_object] # [6-test_cloned_repo_object] - commits_for_file_generator = repo.iter_commits(all=True, max_count=10, paths=file) + commits_for_file_generator = repo.iter_commits(all=True, max_count=10, paths=update_file) commits_for_file = [c for c in commits_for_file_generator] commits_for_file @@ -76,8 +76,7 @@ def test_cloned_repo_object(self, local_dir): # [7-test_cloned_repo_object] # We'll create a file5.txt - file5 = f'{local_dir}/file5.txt' - with open(file5, 'w') as f: + with open(f'{local_dir}/file5.txt', 'w') as f: f.write('file5 version 1') # ![7-test_cloned_repo_object] @@ -89,8 +88,8 @@ def test_cloned_repo_object(self, local_dir): # Modified files # [9-test_cloned_repo_object] # Lets modify one of our tracked files - file3 = f'{local_dir}/Downloads/file3.txt' - with open(file3, 'w') as f: + + with open(f'{local_dir}/Downloads/file3.txt', 'w') as f: f.write('file3 version 2') # overwrite file 3 # ![9-test_cloned_repo_object] From abe7e6e5075ba4b9ea4cfc74b6121ad977dc7e4f Mon Sep 17 00:00:00 2001 From: LeoDaCoda Date: Tue, 11 Jul 2023 00:23:18 -0400 Subject: [PATCH 076/114] replaced output cell to generic commit ID --- test/test_quick_doc.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/test/test_quick_doc.py b/test/test_quick_doc.py index 9756f0da5..0f09d4ed9 100644 --- a/test/test_quick_doc.py +++ b/test/test_quick_doc.py @@ -68,8 +68,8 @@ def test_cloned_repo_object(self, local_dir): commits_for_file = [c for c in commits_for_file_generator] commits_for_file - # Outputs: [, - # ] + # Outputs: [, + # ] # ![6-test_cloned_repo_object] # Untracked files - create new file @@ -124,14 +124,13 @@ def test_cloned_repo_object(self, local_dir): # Iterating through tree # [14-test_cloned_repo_object] - tree = repo.tree() files_and_dirs = [entry for entry in tree] files_and_dirs # Output - # [, - # , - # ] + # [, + # , + # ] # ![14-test_cloned_repo_object] From 1369bdc6d7d06e473b7c211a4070dcee94438e64 Mon Sep 17 00:00:00 2001 From: LeoDaCoda Date: Thu, 13 Jul 2023 01:35:41 -0400 Subject: [PATCH 077/114] replaced hash with generic --- test/test_quick_doc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_quick_doc.py b/test/test_quick_doc.py index 0f09d4ed9..d1aea44e7 100644 --- a/test/test_quick_doc.py +++ b/test/test_quick_doc.py @@ -160,7 +160,7 @@ def print_files_from_git(root, delim='-', i=0): print_file = 'dir1/file2.txt' tree[print_file] - # Output + # Output # ![17-test_cloned_repo_object] # [18-test_cloned_repo_object] From 9cd9431906acff137e441a2dd82d1d6d4e6322d7 Mon Sep 17 00:00:00 2001 From: LeoDaCoda Date: Thu, 13 Jul 2023 01:36:17 -0400 Subject: [PATCH 078/114] draft of description --- doc/source/quickstart.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/doc/source/quickstart.rst b/doc/source/quickstart.rst index 5c1c18701..018e13a76 100644 --- a/doc/source/quickstart.rst +++ b/doc/source/quickstart.rst @@ -7,6 +7,9 @@ ============================== GitPython Quick Start Tutorial ============================== +Welcome to the GitPython Quickstart Guide! Designed for developers seeking a practical and interactive learning experience, this concise resource offers step-by-step code snippets to swiftly initialize/clone repositories, perform essential Git operations, and explore GitPython's capabilities. Get ready to dive in, experiment, and unleash the power of GitPython in your projects! + +All code presented here originated from `***** insert link **** `_ to assure correctness. Knowing this should also allow you to more easily run the code for your own testing purposes. All you need is a developer installation of git-python. git.Repo ******** From 393bae5184abda11cdaab128049fccba2fcb213f Mon Sep 17 00:00:00 2001 From: LeoDaCoda Date: Thu, 13 Jul 2023 01:42:24 -0400 Subject: [PATCH 079/114] clarified comment --- doc/source/quickstart.rst | 2 +- test/test_quick_doc.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/source/quickstart.rst b/doc/source/quickstart.rst index 018e13a76..bd5ddd7b8 100644 --- a/doc/source/quickstart.rst +++ b/doc/source/quickstart.rst @@ -39,7 +39,7 @@ Existing local git Repo Clone from URL ############## -For the rest of this tutorial we will use a clone from https://github.com/LeoDaCoda/GitPython-TestFileSys.git +For the rest of this tutorial we will use a clone from https://github.com/gitpython-developers/QuickStartTutorialFiles.git $ git clone https://github.com/gitpython-developers/QuickStartTutorialFiles.git diff --git a/test/test_quick_doc.py b/test/test_quick_doc.py index d1aea44e7..3dc29a22c 100644 --- a/test/test_quick_doc.py +++ b/test/test_quick_doc.py @@ -40,7 +40,7 @@ def test_cloned_repo_object(self, local_dir): # [2-test_cloned_repo_object] # We must make a change to a file so that we can add the update to git - update_file = 'dir1/file2.txt' # we'll use ./dir1/file2.txt + update_file = 'dir1/file2.txt' # we'll use local_dir/dir1/file2.txt with open(f"{local_dir}/{update_file}", 'a') as f: f.write('\nUpdate version 2') # ![2-test_cloned_repo_object] From aa6d27f9204d68b21cd24366c8a58fb4f9578553 Mon Sep 17 00:00:00 2001 From: LeoDaCoda Date: Thu, 13 Jul 2023 16:15:16 -0400 Subject: [PATCH 080/114] refactored print git tree --- test/test_quick_doc.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/test_quick_doc.py b/test/test_quick_doc.py index 3dc29a22c..0aa27a361 100644 --- a/test/test_quick_doc.py +++ b/test/test_quick_doc.py @@ -135,11 +135,11 @@ def test_cloned_repo_object(self, local_dir): # ![14-test_cloned_repo_object] # [15-test_cloned_repo_object] - def print_files_from_git(root, delim='-', i=0): + def print_files_from_git(root, level=0): for entry in root: - print(f'{delim if i != 0 else ""}| {entry.path}, {entry.type}') + print(f'{"-" * 4 * level}| {entry.path}, {entry.type}') if entry.type == "tree": - print_files_from_git(entry, delim * 4, i + 1) + print_files_from_git(entry, level + 1) # ![15-test_cloned_repo_object] @@ -148,10 +148,10 @@ def print_files_from_git(root, delim='-', i=0): # Output # | Downloads, tree - # ---- | Downloads / file3.txt, blob + # ----| Downloads / file3.txt, blob # | dir1, tree - # ---- | dir1 / file1.txt, blob - # ---- | dir1 / file2.txt, blob + # ----| dir1 / file1.txt, blob + # ----| dir1 / file2.txt, blob # | file4.txt, blob # # ![16-test_cloned_repo_object] From 6d78ff1ac33fa2adeb0518feb33a634c09b0b5b5 Mon Sep 17 00:00:00 2001 From: LeoDaCoda Date: Sun, 16 Jul 2023 11:58:46 -0400 Subject: [PATCH 081/114] Made trees and blobs the first section --- doc/source/quickstart.rst | 133 +++++++++++++++++++------------------- 1 file changed, 68 insertions(+), 65 deletions(-) diff --git a/doc/source/quickstart.rst b/doc/source/quickstart.rst index bd5ddd7b8..11f8123bb 100644 --- a/doc/source/quickstart.rst +++ b/doc/source/quickstart.rst @@ -11,6 +11,74 @@ Welcome to the GitPython Quickstart Guide! Designed for developers seeking a pra All code presented here originated from `***** insert link **** `_ to assure correctness. Knowing this should also allow you to more easily run the code for your own testing purposes. All you need is a developer installation of git-python. + +Trees & Blobs +************** + +Latest Commit Tree +################## + +.. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [12-test_cloned_repo_object] + :end-before: # ![12-test_cloned_repo_object] + +Any Commit Tree +############### + +.. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [13-test_cloned_repo_object] + :end-before: # ![13-test_cloned_repo_object] + +Display level 1 Contents +######################## + +.. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [14-test_cloned_repo_object] + :end-before: # ![14-test_cloned_repo_object] + +Recurse through the Tree +######################## + +.. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [15-test_cloned_repo_object] + :end-before: # ![15-test_cloned_repo_object] + +.. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [16-test_cloned_repo_object] + :end-before: # ![16-test_cloned_repo_object] + + + + +Printing text files +#################### + +.. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [17-test_cloned_repo_object] + :end-before: # ![17-test_cloned_repo_object] + +.. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [18-test_cloned_repo_object] + :end-before: # ![18-test_cloned_repo_object] + + + + + git.Repo ******** @@ -139,68 +207,3 @@ returns list of :class:`Commit ` objects :end-before: # ![11-test_cloned_repo_object] -Trees & Blobs -************** - -Latest Commit Tree -################## - -.. literalinclude:: ../../test/test_quick_doc.py - :language: python - :dedent: 8 - :start-after: # [12-test_cloned_repo_object] - :end-before: # ![12-test_cloned_repo_object] - -Any Commit Tree -############### - -.. literalinclude:: ../../test/test_quick_doc.py - :language: python - :dedent: 8 - :start-after: # [13-test_cloned_repo_object] - :end-before: # ![13-test_cloned_repo_object] - -Display level 1 Contents -######################## - -.. literalinclude:: ../../test/test_quick_doc.py - :language: python - :dedent: 8 - :start-after: # [14-test_cloned_repo_object] - :end-before: # ![14-test_cloned_repo_object] - -Recurse through the Tree -######################## - -.. literalinclude:: ../../test/test_quick_doc.py - :language: python - :dedent: 8 - :start-after: # [15-test_cloned_repo_object] - :end-before: # ![15-test_cloned_repo_object] - -.. literalinclude:: ../../test/test_quick_doc.py - :language: python - :dedent: 8 - :start-after: # [16-test_cloned_repo_object] - :end-before: # ![16-test_cloned_repo_object] - - - - -Printing text files -#################### - -.. literalinclude:: ../../test/test_quick_doc.py - :language: python - :dedent: 8 - :start-after: # [17-test_cloned_repo_object] - :end-before: # ![17-test_cloned_repo_object] - -.. literalinclude:: ../../test/test_quick_doc.py - :language: python - :dedent: 8 - :start-after: # [18-test_cloned_repo_object] - :end-before: # ![18-test_cloned_repo_object] - - - From 2c9c0c122d7dddce62d593f564d2b0c6f7a33e69 Mon Sep 17 00:00:00 2001 From: LeoDaCoda Date: Sun, 16 Jul 2023 12:05:18 -0400 Subject: [PATCH 082/114] Added warning about index add --- doc/source/quickstart.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/doc/source/quickstart.rst b/doc/source/quickstart.rst index 11f8123bb..693562b17 100644 --- a/doc/source/quickstart.rst +++ b/doc/source/quickstart.rst @@ -138,6 +138,8 @@ Now lets add the updated file to git Notice the add method requires a list as a parameter +Warning: If you experience any trouble with this, try to invoke :class:`git ` instead via repo.git.add(path) + * $ git commit -m message .. literalinclude:: ../../test/test_quick_doc.py From d276107039d69bb3ad32595756b70fd4e51267d1 Mon Sep 17 00:00:00 2001 From: LeoDaCoda Date: Sun, 16 Jul 2023 12:08:19 -0400 Subject: [PATCH 083/114] Updated generic sha hash --- test/test_quick_doc.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/test_quick_doc.py b/test/test_quick_doc.py index 0aa27a361..5aa5664bc 100644 --- a/test/test_quick_doc.py +++ b/test/test_quick_doc.py @@ -68,8 +68,8 @@ def test_cloned_repo_object(self, local_dir): commits_for_file = [c for c in commits_for_file_generator] commits_for_file - # Outputs: [, - # ] + # Outputs: [, + # ] # ![6-test_cloned_repo_object] # Untracked files - create new file @@ -128,9 +128,9 @@ def test_cloned_repo_object(self, local_dir): files_and_dirs # Output - # [, - # , - # ] + # [, + # , + # ] # ![14-test_cloned_repo_object] From f3968f2e34467735935ee7a39a7d2b2f07229e7d Mon Sep 17 00:00:00 2001 From: LeoDaCoda Date: Sun, 16 Jul 2023 12:19:44 -0400 Subject: [PATCH 084/114] Removed all reference to source code --- doc/source/quickstart.rst | 2 -- 1 file changed, 2 deletions(-) diff --git a/doc/source/quickstart.rst b/doc/source/quickstart.rst index 693562b17..0cbb4f45c 100644 --- a/doc/source/quickstart.rst +++ b/doc/source/quickstart.rst @@ -9,8 +9,6 @@ GitPython Quick Start Tutorial ============================== Welcome to the GitPython Quickstart Guide! Designed for developers seeking a practical and interactive learning experience, this concise resource offers step-by-step code snippets to swiftly initialize/clone repositories, perform essential Git operations, and explore GitPython's capabilities. Get ready to dive in, experiment, and unleash the power of GitPython in your projects! -All code presented here originated from `***** insert link **** `_ to assure correctness. Knowing this should also allow you to more easily run the code for your own testing purposes. All you need is a developer installation of git-python. - Trees & Blobs ************** From 9ca25d767e554681ad9863138911800868c29b49 Mon Sep 17 00:00:00 2001 From: LeoDaCoda Date: Sun, 16 Jul 2023 13:30:09 -0400 Subject: [PATCH 085/114] WIP major changes to structure to improve readability --- doc/source/quickstart.rst | 122 ++++++++++++++++++++------------------ test/test_quick_doc.py | 27 ++++++++- 2 files changed, 87 insertions(+), 62 deletions(-) diff --git a/doc/source/quickstart.rst b/doc/source/quickstart.rst index 0cbb4f45c..f33d51600 100644 --- a/doc/source/quickstart.rst +++ b/doc/source/quickstart.rst @@ -10,6 +10,41 @@ GitPython Quick Start Tutorial Welcome to the GitPython Quickstart Guide! Designed for developers seeking a practical and interactive learning experience, this concise resource offers step-by-step code snippets to swiftly initialize/clone repositories, perform essential Git operations, and explore GitPython's capabilities. Get ready to dive in, experiment, and unleash the power of GitPython in your projects! +git.Repo +******** + +There are a few ways to create a :class:`git.Repo ` object + +Initialize a new git Repo +######################### + +.. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [1-test_init_repo_object] + :end-before: # ![1-test_init_repo_object] + +Existing local git Repo +####################### + +.. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [2-test_init_repo_object] + :end-before: # ![2-test_init_repo_object] + +Clone from URL +############## + +For the rest of this tutorial we will use a clone from https://github.com/gitpython-developers/QuickStartTutorialFiles.git + +.. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [1-test_cloned_repo_object] + :end-before: # ![1-test_cloned_repo_object] + + Trees & Blobs ************** @@ -40,6 +75,12 @@ Display level 1 Contents :start-after: # [14-test_cloned_repo_object] :end-before: # ![14-test_cloned_repo_object] +.. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [14.1-test_cloned_repo_object] + :end-before: # ![14.1-test_cloned_repo_object] + Recurse through the Tree ######################## @@ -58,67 +99,10 @@ Recurse through the Tree -Printing text files -#################### - -.. literalinclude:: ../../test/test_quick_doc.py - :language: python - :dedent: 8 - :start-after: # [17-test_cloned_repo_object] - :end-before: # ![17-test_cloned_repo_object] - -.. literalinclude:: ../../test/test_quick_doc.py - :language: python - :dedent: 8 - :start-after: # [18-test_cloned_repo_object] - :end-before: # ![18-test_cloned_repo_object] - - - - - -git.Repo -******** - -There are a few ways to create a :class:`git.Repo ` object - -An existing local path -###################### - -$ git init path/to/dir - -.. literalinclude:: ../../test/test_quick_doc.py - :language: python - :dedent: 8 - :start-after: # [1-test_init_repo_object] - :end-before: # ![1-test_init_repo_object] - -Existing local git Repo -####################### - -.. literalinclude:: ../../test/test_quick_doc.py - :language: python - :dedent: 8 - :start-after: # [2-test_init_repo_object] - :end-before: # ![2-test_init_repo_object] - -Clone from URL -############## - -For the rest of this tutorial we will use a clone from https://github.com/gitpython-developers/QuickStartTutorialFiles.git - -$ git clone https://github.com/gitpython-developers/QuickStartTutorialFiles.git - -.. literalinclude:: ../../test/test_quick_doc.py - :language: python - :dedent: 8 - :start-after: # [1-test_cloned_repo_object] - :end-before: # ![1-test_cloned_repo_object] - Usage **************** -* $ git add filepath +* $ git add .. literalinclude:: ../../test/test_quick_doc.py :language: python @@ -146,7 +130,7 @@ Warning: If you experience any trouble with this, try to invoke :class:`git A list of commits associated with a file @@ -166,6 +150,24 @@ Notice this returns a generator object returns list of :class:`Commit ` objects +Printing text files +#################### +Lets print the latest version of ` dir1/file2.txt` + +.. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [17-test_cloned_repo_object] + :end-before: # ![17-test_cloned_repo_object] + +.. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [18-test_cloned_repo_object] + :end-before: # ![18-test_cloned_repo_object] + +Previous version of `/dir1/file2.txt` + * $ git status * Untracked files @@ -207,3 +209,5 @@ returns list of :class:`Commit ` objects :end-before: # ![11-test_cloned_repo_object] + + diff --git a/test/test_quick_doc.py b/test/test_quick_doc.py index 5aa5664bc..61b8082d0 100644 --- a/test/test_quick_doc.py +++ b/test/test_quick_doc.py @@ -15,6 +15,8 @@ def tearDown(self): def test_init_repo_object(self, path_to_dir): # [1-test_init_repo_object] + # $ git init + from git import Repo repo = Repo.init(path_to_dir) # git init path/to/dir @@ -31,6 +33,8 @@ def test_cloned_repo_object(self, local_dir): import git # code to clone from url # [1-test_cloned_repo_object] + # $ git clone + repo_url = "https://github.com/gitpython-developers/QuickStartTutorialFiles.git" repo = Repo.clone_from(repo_url, local_dir) @@ -128,12 +132,22 @@ def test_cloned_repo_object(self, local_dir): files_and_dirs # Output - # [, - # , - # ] + # [, + # , + # ] # ![14-test_cloned_repo_object] + # [14.1-test_cloned_repo_object] + files_and_dirs = [(entry, entry.name) for entry in tree] + files_and_dirs + + # Output + # [(< git.Tree "SHA1-HEX_HASH" >, 'Downloads', 'tree'), + # (< git.Tree "SHA1-HEX_HASH" >, 'dir1', 'tree'), + # (< git.Blob "SHA1-HEX_HASH" >, 'file4.txt', 'blob')] + # ![14.1-test_cloned_repo_object] + # [15-test_cloned_repo_object] def print_files_from_git(root, level=0): for entry in root: @@ -163,6 +177,13 @@ def print_files_from_git(root, level=0): # Output # ![17-test_cloned_repo_object] + # print pre + # [17.1-test_cloned_repo_object] + commits_for_file = [c for c in repo.iter_commits(all=True, paths=print_file)] + blob = tree[print_file] + + # ![17.1-test_cloned_repo_object] + # [18-test_cloned_repo_object] blob = tree[print_file] print(blob.data_stream.read().decode()) From 7fa57e5e30e56e7aa247cf77d1b84ddf5d08d1e7 Mon Sep 17 00:00:00 2001 From: LeoDaCoda Date: Sun, 16 Jul 2023 18:33:24 -0400 Subject: [PATCH 086/114] Added new section to print prev file --- doc/source/quickstart.rst | 6 ++++++ test/test_quick_doc.py | 19 +++++++++++-------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/doc/source/quickstart.rst b/doc/source/quickstart.rst index f33d51600..29c500a72 100644 --- a/doc/source/quickstart.rst +++ b/doc/source/quickstart.rst @@ -168,6 +168,12 @@ Lets print the latest version of ` dir1/file2.txt` Previous version of `/dir1/file2.txt` +.. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [18.1-test_cloned_repo_object] + :end-before: # ![18.1-test_cloned_repo_object] + * $ git status * Untracked files diff --git a/test/test_quick_doc.py b/test/test_quick_doc.py index 61b8082d0..0397eb6d9 100644 --- a/test/test_quick_doc.py +++ b/test/test_quick_doc.py @@ -177,13 +177,7 @@ def print_files_from_git(root, level=0): # Output # ![17-test_cloned_repo_object] - # print pre - # [17.1-test_cloned_repo_object] - commits_for_file = [c for c in repo.iter_commits(all=True, paths=print_file)] - blob = tree[print_file] - - # ![17.1-test_cloned_repo_object] - + # print latest file # [18-test_cloned_repo_object] blob = tree[print_file] print(blob.data_stream.read().decode()) @@ -191,7 +185,16 @@ def print_files_from_git(root, level=0): # Output # file 2 version 1 # Update version 2 - # ![18-test_cloned_repo_object] + # print previous tree + # [18.1-test_cloned_repo_object] + commits_for_file = [c for c in repo.iter_commits(all=True, paths=print_file)] + tree = commits_for_file[-1].tree # gets the first commit tree + blob = tree[print_file] + + print(blob.data_stream.read().decode()) + # Output + # file 2 version 1 + # ![18.1-test_cloned_repo_object] \ No newline at end of file From 9d878af964947a09e74f29e3a13b5a26d606e86f Mon Sep 17 00:00:00 2001 From: LeoDaCoda Date: Mon, 17 Jul 2023 14:21:17 -0400 Subject: [PATCH 087/114] change to formatting - removed = bash cmds --- doc/source/quickstart.rst | 166 +++++++++++++++++++------------------- test/test_quick_doc.py | 12 +-- 2 files changed, 91 insertions(+), 87 deletions(-) diff --git a/doc/source/quickstart.rst b/doc/source/quickstart.rst index 29c500a72..01a664e9c 100644 --- a/doc/source/quickstart.rst +++ b/doc/source/quickstart.rst @@ -102,117 +102,119 @@ Recurse through the Tree Usage **************** -* $ git add - -.. literalinclude:: ../../test/test_quick_doc.py - :language: python - :dedent: 8 - :start-after: # [2-test_cloned_repo_object] - :end-before: # ![2-test_cloned_repo_object] - -Now lets add the updated file to git - -.. literalinclude:: ../../test/test_quick_doc.py - :language: python - :dedent: 8 - :start-after: # [3-test_cloned_repo_object] - :end-before: # ![3-test_cloned_repo_object] - -Notice the add method requires a list as a parameter - -Warning: If you experience any trouble with this, try to invoke :class:`git ` instead via repo.git.add(path) - -* $ git commit -m message - -.. literalinclude:: ../../test/test_quick_doc.py - :language: python - :dedent: 8 - :start-after: # [4-test_cloned_repo_object] - :end-before: # ![4-test_cloned_repo_object] - -* $ git log - -A list of commits associated with a file - -.. literalinclude:: ../../test/test_quick_doc.py - :language: python - :dedent: 8 - :start-after: # [5-test_cloned_repo_object] - :end-before: # ![5-test_cloned_repo_object] - -Notice this returns a generator object - -.. literalinclude:: ../../test/test_quick_doc.py - :language: python - :dedent: 8 - :start-after: # [6-test_cloned_repo_object] - :end-before: # ![6-test_cloned_repo_object] - -returns list of :class:`Commit ` objects - -Printing text files -#################### -Lets print the latest version of ` dir1/file2.txt` +Add file to staging area +######################## -.. literalinclude:: ../../test/test_quick_doc.py - :language: python - :dedent: 8 - :start-after: # [17-test_cloned_repo_object] - :end-before: # ![17-test_cloned_repo_object] -.. literalinclude:: ../../test/test_quick_doc.py + .. literalinclude:: ../../test/test_quick_doc.py :language: python :dedent: 8 - :start-after: # [18-test_cloned_repo_object] - :end-before: # ![18-test_cloned_repo_object] + :start-after: # [2-test_cloned_repo_object] + :end-before: # ![2-test_cloned_repo_object] -Previous version of `/dir1/file2.txt` + Now lets add the updated file to git -.. literalinclude:: ../../test/test_quick_doc.py + .. literalinclude:: ../../test/test_quick_doc.py :language: python :dedent: 8 - :start-after: # [18.1-test_cloned_repo_object] - :end-before: # ![18.1-test_cloned_repo_object] + :start-after: # [3-test_cloned_repo_object] + :end-before: # ![3-test_cloned_repo_object] -* $ git status + Notice the add method requires a list as a parameter - * Untracked files + Warning: If you experience any trouble with this, try to invoke :class:`git ` instead via repo.git.add(path) - Lets create a new file +Commit +###### .. literalinclude:: ../../test/test_quick_doc.py :language: python :dedent: 8 - :start-after: # [7-test_cloned_repo_object] - :end-before: # ![7-test_cloned_repo_object] + :start-after: # [4-test_cloned_repo_object] + :end-before: # ![4-test_cloned_repo_object] + +List of commits associated with a file +####################################### .. literalinclude:: ../../test/test_quick_doc.py :language: python :dedent: 8 - :start-after: # [8-test_cloned_repo_object] - :end-before: # ![8-test_cloned_repo_object] + :start-after: # [5-test_cloned_repo_object] + :end-before: # ![5-test_cloned_repo_object] - * Modified files + Notice this returns a generator object .. literalinclude:: ../../test/test_quick_doc.py :language: python :dedent: 8 - :start-after: # [9-test_cloned_repo_object] - :end-before: # ![9-test_cloned_repo_object] + :start-after: # [6-test_cloned_repo_object] + :end-before: # ![6-test_cloned_repo_object] + + returns list of :class:`Commit ` objects + +Printing text files +#################### +Lets print the latest version of ` dir1/file2.txt` .. literalinclude:: ../../test/test_quick_doc.py - :language: python - :dedent: 8 - :start-after: # [10-test_cloned_repo_object] - :end-before: # ![10-test_cloned_repo_object] + :language: python + :dedent: 8 + :start-after: # [17-test_cloned_repo_object] + :end-before: # ![17-test_cloned_repo_object] + + .. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [18-test_cloned_repo_object] + :end-before: # ![18-test_cloned_repo_object] - returns a list of :class:`Diff ` objects + Previous version of `/dir1/file2.txt` .. literalinclude:: ../../test/test_quick_doc.py - :language: python - :dedent: 8 - :start-after: # [11-test_cloned_repo_object] - :end-before: # ![11-test_cloned_repo_object] + :language: python + :dedent: 8 + :start-after: # [18.1-test_cloned_repo_object] + :end-before: # ![18.1-test_cloned_repo_object] + +Status +###### + * Untracked files + + Lets create a new file + + .. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [7-test_cloned_repo_object] + :end-before: # ![7-test_cloned_repo_object] + + .. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [8-test_cloned_repo_object] + :end-before: # ![8-test_cloned_repo_object] + + * Modified files + + .. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [9-test_cloned_repo_object] + :end-before: # ![9-test_cloned_repo_object] + + .. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [10-test_cloned_repo_object] + :end-before: # ![10-test_cloned_repo_object] + + returns a list of :class:`Diff ` objects + + .. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [11-test_cloned_repo_object] + :end-before: # ![11-test_cloned_repo_object] diff --git a/test/test_quick_doc.py b/test/test_quick_doc.py index 0397eb6d9..4ab2a59a6 100644 --- a/test/test_quick_doc.py +++ b/test/test_quick_doc.py @@ -50,16 +50,20 @@ def test_cloned_repo_object(self, local_dir): # ![2-test_cloned_repo_object] # [3-test_cloned_repo_object] + # $ git add add_file = [update_file] # relative path from git root repo.index.add(add_file) # notice the add function requires a list of paths # ![3-test_cloned_repo_object] # code to commit - not sure how to test this # [4-test_cloned_repo_object] + # $ git commit -m repo.index.commit("Update to file2") # ![4-test_cloned_repo_object] # [5-test_cloned_repo_object] + # $ git log + # relative path from git root repo.iter_commits(all=True, max_count=10, paths=update_file) # gets the last 10 commits from all branches @@ -78,15 +82,13 @@ def test_cloned_repo_object(self, local_dir): # Untracked files - create new file # [7-test_cloned_repo_object] - # We'll create a file5.txt - - with open(f'{local_dir}/file5.txt', 'w') as f: - f.write('file5 version 1') + f = open(f'{local_dir}/untracked.txt', 'w') # creates an empty file + f.close() # ![7-test_cloned_repo_object] # [8-test_cloned_repo_object] repo.untracked_files - # Output: ['file5.txt'] + # Output: ['untracked.txt'] # ![8-test_cloned_repo_object] # Modified files From 315405d9395ff94348d43912d15471e6dd465100 Mon Sep 17 00:00:00 2001 From: LeoDaCoda Date: Mon, 17 Jul 2023 18:49:34 -0400 Subject: [PATCH 088/114] formatting wip --- test/test_quick_doc.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/test_quick_doc.py b/test/test_quick_doc.py index 4ab2a59a6..c09845a6a 100644 --- a/test/test_quick_doc.py +++ b/test/test_quick_doc.py @@ -93,7 +93,7 @@ def test_cloned_repo_object(self, local_dir): # Modified files # [9-test_cloned_repo_object] - # Lets modify one of our tracked files + # Let's modify one of our tracked files with open(f'{local_dir}/Downloads/file3.txt', 'w') as f: f.write('file3 version 2') # overwrite file 3 @@ -174,7 +174,7 @@ def print_files_from_git(root, level=0): # Printing text files # [17-test_cloned_repo_object] print_file = 'dir1/file2.txt' - tree[print_file] + tree[print_file] # the head commit tree # Output # ![17-test_cloned_repo_object] From bccf8bc3ee2384048548e717e64a5d42156ba236 Mon Sep 17 00:00:00 2001 From: LeoDaCoda Date: Tue, 18 Jul 2023 00:16:07 -0400 Subject: [PATCH 089/114] added new section for diffs and formatting --- doc/source/quickstart.rst | 24 ++++++++++++++++++++++++ test/test_quick_doc.py | 39 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/doc/source/quickstart.rst b/doc/source/quickstart.rst index 01a664e9c..0826dec29 100644 --- a/doc/source/quickstart.rst +++ b/doc/source/quickstart.rst @@ -216,6 +216,30 @@ Status :start-after: # [11-test_cloned_repo_object] :end-before: # ![11-test_cloned_repo_object] +Diffs +###### + + Compare staging area to head commit + + .. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [11.1-test_cloned_repo_object] + :end-before: # ![11.1-test_cloned_repo_object] + + .. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [11.2-test_cloned_repo_object] + :end-before: # ![11.2-test_cloned_repo_object] + + Compare commit to commit + + .. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [11.3-test_cloned_repo_object] + :end-before: # ![11.3-test_cloned_repo_object] diff --git a/test/test_quick_doc.py b/test/test_quick_doc.py index c09845a6a..f79a9645e 100644 --- a/test/test_quick_doc.py +++ b/test/test_quick_doc.py @@ -19,7 +19,7 @@ def test_init_repo_object(self, path_to_dir): from git import Repo - repo = Repo.init(path_to_dir) # git init path/to/dir + repo = Repo.init(path_to_dir) # ![1-test_init_repo_object] # [2-test_init_repo_object] @@ -111,10 +111,43 @@ def test_cloned_repo_object(self, local_dir): for d in diffs: print(d.a_path) + # Output # Downloads/file3.txt - # file4.txt # ![11-test_cloned_repo_object] + # compares staging area to head commit + # [11.1-test_cloned_repo_object] + diffs = repo.index.diff(repo.head.commit) + for d in diffs: + print(d.a_path) + + # Output + + # ![11.1-test_cloned_repo_object] + # [11.2-test_cloned_repo_object] + # lets add untracked.txt + repo.index.add(['untracked.txt']) + diffs = repo.index.diff(repo.head.commit) + for d in diffs: + print(d.a_path) + + # Output + # untracked.txt + # ![11.2-test_cloned_repo_object] + + # Compare commit to commit + # [11.3-test_cloned_repo_object] + first_commit = [c for c in repo.iter_commits(all=True)][-1] + diffs = repo.head.commit.diff(first_commit) + for d in diffs: + print(d.a_path) + + # Output + # dir1/file2.txt + # ![11.3-test_cloned_repo_object] + + + '''Trees and Blobs''' # Latest commit tree @@ -141,7 +174,7 @@ def test_cloned_repo_object(self, local_dir): # ![14-test_cloned_repo_object] # [14.1-test_cloned_repo_object] - files_and_dirs = [(entry, entry.name) for entry in tree] + files_and_dirs = [(entry, entry.name, entry.type) for entry in tree] files_and_dirs # Output From cad1e2e835b0b7876277c0514bcba2ac6fedab81 Mon Sep 17 00:00:00 2001 From: LeoDaCoda Date: Tue, 18 Jul 2023 00:19:21 -0400 Subject: [PATCH 090/114] tabbed all code-blocks --- doc/source/quickstart.rst | 90 +++++++++++++++++++-------------------- 1 file changed, 45 insertions(+), 45 deletions(-) diff --git a/doc/source/quickstart.rst b/doc/source/quickstart.rst index 0826dec29..2b6c1c99f 100644 --- a/doc/source/quickstart.rst +++ b/doc/source/quickstart.rst @@ -18,31 +18,31 @@ There are a few ways to create a :class:`git.Repo ` object Initialize a new git Repo ######################### -.. literalinclude:: ../../test/test_quick_doc.py - :language: python - :dedent: 8 - :start-after: # [1-test_init_repo_object] - :end-before: # ![1-test_init_repo_object] + .. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [1-test_init_repo_object] + :end-before: # ![1-test_init_repo_object] Existing local git Repo ####################### -.. literalinclude:: ../../test/test_quick_doc.py - :language: python - :dedent: 8 - :start-after: # [2-test_init_repo_object] - :end-before: # ![2-test_init_repo_object] + .. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [2-test_init_repo_object] + :end-before: # ![2-test_init_repo_object] Clone from URL ############## For the rest of this tutorial we will use a clone from https://github.com/gitpython-developers/QuickStartTutorialFiles.git -.. literalinclude:: ../../test/test_quick_doc.py - :language: python - :dedent: 8 - :start-after: # [1-test_cloned_repo_object] - :end-before: # ![1-test_cloned_repo_object] + .. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [1-test_cloned_repo_object] + :end-before: # ![1-test_cloned_repo_object] Trees & Blobs @@ -51,50 +51,50 @@ Trees & Blobs Latest Commit Tree ################## -.. literalinclude:: ../../test/test_quick_doc.py - :language: python - :dedent: 8 - :start-after: # [12-test_cloned_repo_object] - :end-before: # ![12-test_cloned_repo_object] + .. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [12-test_cloned_repo_object] + :end-before: # ![12-test_cloned_repo_object] Any Commit Tree ############### -.. literalinclude:: ../../test/test_quick_doc.py - :language: python - :dedent: 8 - :start-after: # [13-test_cloned_repo_object] - :end-before: # ![13-test_cloned_repo_object] + .. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [13-test_cloned_repo_object] + :end-before: # ![13-test_cloned_repo_object] Display level 1 Contents ######################## -.. literalinclude:: ../../test/test_quick_doc.py - :language: python - :dedent: 8 - :start-after: # [14-test_cloned_repo_object] - :end-before: # ![14-test_cloned_repo_object] + .. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [14-test_cloned_repo_object] + :end-before: # ![14-test_cloned_repo_object] -.. literalinclude:: ../../test/test_quick_doc.py - :language: python - :dedent: 8 - :start-after: # [14.1-test_cloned_repo_object] - :end-before: # ![14.1-test_cloned_repo_object] + .. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [14.1-test_cloned_repo_object] + :end-before: # ![14.1-test_cloned_repo_object] Recurse through the Tree ######################## -.. literalinclude:: ../../test/test_quick_doc.py - :language: python - :dedent: 8 - :start-after: # [15-test_cloned_repo_object] - :end-before: # ![15-test_cloned_repo_object] + .. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [15-test_cloned_repo_object] + :end-before: # ![15-test_cloned_repo_object] -.. literalinclude:: ../../test/test_quick_doc.py - :language: python - :dedent: 8 - :start-after: # [16-test_cloned_repo_object] - :end-before: # ![16-test_cloned_repo_object] + .. literalinclude:: ../../test/test_quick_doc.py + :language: python + :dedent: 8 + :start-after: # [16-test_cloned_repo_object] + :end-before: # ![16-test_cloned_repo_object] From 7e589f3d852461e2c143035c1cc3ceb1a81ecd61 Mon Sep 17 00:00:00 2001 From: LeoDaCoda Date: Tue, 18 Jul 2023 00:29:44 -0400 Subject: [PATCH 091/114] fixed tabbing --- doc/source/quickstart.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/source/quickstart.rst b/doc/source/quickstart.rst index 2b6c1c99f..ebebc37d1 100644 --- a/doc/source/quickstart.rst +++ b/doc/source/quickstart.rst @@ -219,7 +219,7 @@ Status Diffs ###### - Compare staging area to head commit +Compare staging area to head commit .. literalinclude:: ../../test/test_quick_doc.py :language: python @@ -233,7 +233,7 @@ Diffs :start-after: # [11.2-test_cloned_repo_object] :end-before: # ![11.2-test_cloned_repo_object] - Compare commit to commit +Compare commit to commit .. literalinclude:: ../../test/test_quick_doc.py :language: python From 2a45f94d976e3cb91a7e700649eeea12f6655f7c Mon Sep 17 00:00:00 2001 From: LeoDaCoda Date: Tue, 18 Jul 2023 00:38:38 -0400 Subject: [PATCH 092/114] redundant line --- test/test_quick_doc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_quick_doc.py b/test/test_quick_doc.py index f79a9645e..cea96690e 100644 --- a/test/test_quick_doc.py +++ b/test/test_quick_doc.py @@ -101,7 +101,7 @@ def test_cloned_repo_object(self, local_dir): # [10-test_cloned_repo_object] repo.index.diff(None) # compares staging area to working directory - repo.index.diff(repo.head.commit) # compares staging area to last commit + # Output: [, # ] # ![10-test_cloned_repo_object] From ef4d6d52fe02b7006224765cb65c824b8eca91e5 Mon Sep 17 00:00:00 2001 From: LeoDaCoda Date: Tue, 18 Jul 2023 15:18:24 -0400 Subject: [PATCH 093/114] redundant code cell --- doc/source/quickstart.rst | 7 ------- test/test_quick_doc.py | 13 +------------ 2 files changed, 1 insertion(+), 19 deletions(-) diff --git a/doc/source/quickstart.rst b/doc/source/quickstart.rst index ebebc37d1..33ddf5901 100644 --- a/doc/source/quickstart.rst +++ b/doc/source/quickstart.rst @@ -75,12 +75,6 @@ Display level 1 Contents :start-after: # [14-test_cloned_repo_object] :end-before: # ![14-test_cloned_repo_object] - .. literalinclude:: ../../test/test_quick_doc.py - :language: python - :dedent: 8 - :start-after: # [14.1-test_cloned_repo_object] - :end-before: # ![14.1-test_cloned_repo_object] - Recurse through the Tree ######################## @@ -242,4 +236,3 @@ Compare commit to commit :end-before: # ![11.3-test_cloned_repo_object] - diff --git a/test/test_quick_doc.py b/test/test_quick_doc.py index cea96690e..cb782aa3c 100644 --- a/test/test_quick_doc.py +++ b/test/test_quick_doc.py @@ -163,17 +163,6 @@ def test_cloned_repo_object(self, local_dir): # Iterating through tree # [14-test_cloned_repo_object] - files_and_dirs = [entry for entry in tree] - files_and_dirs - - # Output - # [, - # , - # ] - - # ![14-test_cloned_repo_object] - - # [14.1-test_cloned_repo_object] files_and_dirs = [(entry, entry.name, entry.type) for entry in tree] files_and_dirs @@ -181,7 +170,7 @@ def test_cloned_repo_object(self, local_dir): # [(< git.Tree "SHA1-HEX_HASH" >, 'Downloads', 'tree'), # (< git.Tree "SHA1-HEX_HASH" >, 'dir1', 'tree'), # (< git.Blob "SHA1-HEX_HASH" >, 'file4.txt', 'blob')] - # ![14.1-test_cloned_repo_object] + # ![14-test_cloned_repo_object] # [15-test_cloned_repo_object] def print_files_from_git(root, level=0): From 8138b3a56d16f68cfe6a5d9371e2fde3d587161c Mon Sep 17 00:00:00 2001 From: LeoDaCoda Date: Tue, 18 Jul 2023 15:25:43 -0400 Subject: [PATCH 094/114] generic hash --- test/test_quick_doc.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/test_quick_doc.py b/test/test_quick_doc.py index cb782aa3c..eaee4e581 100644 --- a/test/test_quick_doc.py +++ b/test/test_quick_doc.py @@ -77,7 +77,7 @@ def test_cloned_repo_object(self, local_dir): commits_for_file # Outputs: [, - # ] + # ] # ![6-test_cloned_repo_object] # Untracked files - create new file @@ -198,7 +198,7 @@ def print_files_from_git(root, level=0): print_file = 'dir1/file2.txt' tree[print_file] # the head commit tree - # Output + # Output # ![17-test_cloned_repo_object] # print latest file From 84885a3ea412261adf457aee1c6471606ba7095c Mon Sep 17 00:00:00 2001 From: LeoDaCoda Date: Wed, 19 Jul 2023 13:47:28 -0400 Subject: [PATCH 095/114] added more resources section --- doc/source/quickstart.rst | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/doc/source/quickstart.rst b/doc/source/quickstart.rst index 33ddf5901..2a9e41e62 100644 --- a/doc/source/quickstart.rst +++ b/doc/source/quickstart.rst @@ -222,17 +222,23 @@ Compare staging area to head commit :end-before: # ![11.1-test_cloned_repo_object] .. literalinclude:: ../../test/test_quick_doc.py - :language: python - :dedent: 8 - :start-after: # [11.2-test_cloned_repo_object] - :end-before: # ![11.2-test_cloned_repo_object] + :language: python + :dedent: 8 + :start-after: # [11.2-test_cloned_repo_object] + :end-before: # ![11.2-test_cloned_repo_object] Compare commit to commit .. literalinclude:: ../../test/test_quick_doc.py - :language: python - :dedent: 8 - :start-after: # [11.3-test_cloned_repo_object] - :end-before: # ![11.3-test_cloned_repo_object] + :language: python + :dedent: 8 + :start-after: # [11.3-test_cloned_repo_object] + :end-before: # ![11.3-test_cloned_repo_object] + +More Resources +**************** +Remember, this is just the beginning! There's a lot more you can achieve with GitPython in your development workflow. +To explore further possibilities and discover advanced features, check out the full :ref:`GitPython tutorial ` +and the :ref:`API Reference `. Happy coding! From cf3a899ebd498bd8053bc17dab1ff4c36edc005e Mon Sep 17 00:00:00 2001 From: LeoDaCoda Date: Wed, 19 Jul 2023 13:50:25 -0400 Subject: [PATCH 096/114] typo --- doc/source/quickstart.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/source/quickstart.rst b/doc/source/quickstart.rst index 2a9e41e62..c5930eb8a 100644 --- a/doc/source/quickstart.rst +++ b/doc/source/quickstart.rst @@ -148,7 +148,7 @@ List of commits associated with a file Printing text files #################### -Lets print the latest version of ` dir1/file2.txt` +Lets print the latest version of `/dir1/file2.txt` .. literalinclude:: ../../test/test_quick_doc.py :language: python From 31ac93b2f38f2410e41bf90ad28dff31e79b114e Mon Sep 17 00:00:00 2001 From: Bodo Graumann Date: Thu, 20 Jul 2023 16:25:10 +0200 Subject: [PATCH 097/114] Do not typecheck submodule It has too many errors. Fixing them should be done in the separate project. --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 0d5ebf012..4d2014afb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ implicit_reexport = true # strict = true # TODO: remove when 'gitdb' is fully annotated +exclude = ["^git/ext/gitdb"] [[tool.mypy.overrides]] module = "gitdb.*" ignore_missing_imports = true From b55cf65cc96740c6128987ab0c07b43112bdfe31 Mon Sep 17 00:00:00 2001 From: Bodo Graumann Date: Thu, 20 Jul 2023 16:34:39 +0200 Subject: [PATCH 098/114] Define supported version for mypy --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 4d2014afb..57988372a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ filterwarnings = 'ignore::DeprecationWarning' # filterwarnings ignore::WarningType # ignores those warnings [tool.mypy] +python_version = "3.7" disallow_untyped_defs = true no_implicit_optional = true warn_redundant_casts = true From 76394d42ce2a33b4db71fd64763c1e9dae136747 Mon Sep 17 00:00:00 2001 From: Bodo Graumann Date: Thu, 20 Jul 2023 16:39:32 +0200 Subject: [PATCH 099/114] Ignore remaining [unreachable] type errors --- git/__init__.py | 2 +- git/config.py | 4 ++-- git/repo/base.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/git/__init__.py b/git/__init__.py index cd6602bf0..6196a42d7 100644 --- a/git/__init__.py +++ b/git/__init__.py @@ -76,7 +76,7 @@ def refresh(path: Optional[PathLike] = None) -> None: if not Git.refresh(path=path): return if not FetchInfo.refresh(): - return + return # type: ignore [unreachable] GIT_OK = True diff --git a/git/config.py b/git/config.py index e05a297af..caf1f6241 100644 --- a/git/config.py +++ b/git/config.py @@ -265,8 +265,8 @@ def get_config_path(config_level: Lit_config_levels) -> str: raise ValueError("No repo to get repository configuration from. Use Repo._get_config_path") else: # Should not reach here. Will raise ValueError if does. Static typing will warn missing elifs - assert_never( - config_level, # type: ignore[unreachable] + assert_never( # type: ignore[unreachable] + config_level, ValueError(f"Invalid configuration level: {config_level!r}"), ) diff --git a/git/repo/base.py b/git/repo/base.py index 1fa98d8c7..4bfead46f 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -549,8 +549,8 @@ def _get_config_path(self, config_level: Lit_config_levels, git_dir: Optional[Pa return osp.normpath(osp.join(repo_dir, "config")) else: - assert_never( - config_level, # type:ignore[unreachable] + assert_never( # type:ignore[unreachable] + config_level, ValueError(f"Invalid configuration level: {config_level!r}"), ) From c6dab191d1f96373aaae5c6c117f13c1006631de Mon Sep 17 00:00:00 2001 From: Bodo Graumann Date: Thu, 20 Jul 2023 16:49:02 +0200 Subject: [PATCH 100/114] Allow explicit casting even when slightly redundant --- git/cmd.py | 2 +- git/objects/commit.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/git/cmd.py b/git/cmd.py index dfce9024d..5b0b6b816 100644 --- a/git/cmd.py +++ b/git/cmd.py @@ -154,7 +154,7 @@ def pump_stream( p_stdout = process.proc.stdout if process.proc else None p_stderr = process.proc.stderr if process.proc else None else: - process = cast(Popen, process) + process = cast(Popen, process) # type: ignore [redundant-cast] cmdline = getattr(process, "args", "") p_stdout = process.stdout p_stderr = process.stderr diff --git a/git/objects/commit.py b/git/objects/commit.py index 138db0afe..6cca65c1f 100644 --- a/git/objects/commit.py +++ b/git/objects/commit.py @@ -460,7 +460,7 @@ def _iter_from_process_or_stream(cls, repo: "Repo", proc_or_stream: Union[Popen, if proc_or_stream.stdout is not None: stream = proc_or_stream.stdout elif hasattr(proc_or_stream, "readline"): - proc_or_stream = cast(IO, proc_or_stream) + proc_or_stream = cast(IO, proc_or_stream) # type: ignore [redundant-cast] stream = proc_or_stream readline = stream.readline From 6035db092decf72e6a01e175d044f0343818b51c Mon Sep 17 00:00:00 2001 From: Bodo Graumann Date: Thu, 20 Jul 2023 16:51:50 +0200 Subject: [PATCH 101/114] Run black and exclude submodule --- README.md | 2 ++ git/cmd.py | 7 ++----- git/config.py | 3 +-- git/diff.py | 3 +-- git/exc.py | 2 -- git/index/fun.py | 1 - git/objects/commit.py | 4 +--- git/objects/fun.py | 1 - git/objects/util.py | 2 +- git/remote.py | 1 - git/repo/base.py | 3 +-- git/util.py | 2 -- pyproject.toml | 1 + 13 files changed, 10 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 676d2c6d6..30c54b57f 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,8 @@ To typecheck, run: `mypy -p git` To test, run: `pytest` +For automatic code formatting run: `black git` + Configuration for flake8 is in the ./.flake8 file. Configurations for mypy, pytest and coverage.py are in ./pyproject.toml. diff --git a/git/cmd.py b/git/cmd.py index 5b0b6b816..84d888494 100644 --- a/git/cmd.py +++ b/git/cmd.py @@ -122,6 +122,7 @@ def handle_process_output( To specify a timeout in seconds for the git command, after which the process should be killed. """ + # Use 2 "pump" threads and wait for both to finish. def pump_stream( cmdline: List[str], @@ -488,10 +489,7 @@ def check_unsafe_options(cls, options: List[str], unsafe_options: List[str]) -> """ # Options can be of the form `foo` or `--foo bar` `--foo=bar`, # so we need to check if they start with "--foo" or if they are equal to "foo". - bare_unsafe_options = [ - option.lstrip("-") - for option in unsafe_options - ] + bare_unsafe_options = [option.lstrip("-") for option in unsafe_options] for option in options: for unsafe_option, bare_option in zip(unsafe_options, bare_unsafe_options): if option.startswith(unsafe_option) or option == bare_option: @@ -1194,7 +1192,6 @@ def transform_kwargs(self, split_single_char_options: bool = True, **kwargs: Any @classmethod def _unpack_args(cls, arg_list: Sequence[str]) -> List[str]: - outlist = [] if isinstance(arg_list, (list, tuple)): for arg in arg_list: diff --git a/git/config.py b/git/config.py index caf1f6241..1973111eb 100644 --- a/git/config.py +++ b/git/config.py @@ -248,7 +248,6 @@ def items_all(self) -> List[Tuple[str, List[_T]]]: def get_config_path(config_level: Lit_config_levels) -> str: - # we do not support an absolute path of the gitconfig on windows , # use the global config instead if is_win and config_level == "system": @@ -655,7 +654,7 @@ def write_section(name: str, section_dict: _OMD) -> None: values: Sequence[str] # runtime only gets str in tests, but should be whatever _OMD stores v: str - for (key, values) in section_dict.items_all(): + for key, values in section_dict.items_all(): if key == "__name__": continue diff --git a/git/diff.py b/git/diff.py index c1a5bd26f..1424ff3ad 100644 --- a/git/diff.py +++ b/git/diff.py @@ -145,7 +145,7 @@ def diff( args.append("--full-index") # get full index paths, not only filenames # remove default '-M' arg (check for renames) if user is overriding it - if not any(x in kwargs for x in ('find_renames', 'no_renames', 'M')): + if not any(x in kwargs for x in ("find_renames", "no_renames", "M")): args.append("-M") if create_patch: @@ -338,7 +338,6 @@ def __init__( change_type: Optional[Lit_change_type], score: Optional[int], ) -> None: - assert a_rawpath is None or isinstance(a_rawpath, bytes) assert b_rawpath is None or isinstance(b_rawpath, bytes) self.a_rawpath = a_rawpath diff --git a/git/exc.py b/git/exc.py index 9b69a5889..775528bf6 100644 --- a/git/exc.py +++ b/git/exc.py @@ -139,7 +139,6 @@ def __init__( valid_files: Sequence[PathLike], failed_reasons: List[str], ) -> None: - Exception.__init__(self, message) self.failed_files = failed_files self.failed_reasons = failed_reasons @@ -170,7 +169,6 @@ def __init__( stderr: Union[bytes, str, None] = None, stdout: Union[bytes, str, None] = None, ) -> None: - super(HookExecutionError, self).__init__(command, status, stderr, stdout) self._msg = "Hook('%s') failed%s" diff --git a/git/index/fun.py b/git/index/fun.py index d0925ed51..3dc5e96d2 100644 --- a/git/index/fun.py +++ b/git/index/fun.py @@ -394,7 +394,6 @@ def aggressive_tree_merge(odb: "GitCmdObjectDB", tree_shas: Sequence[bytes]) -> out.append(_tree_entry_to_baseindexentry(theirs, 0)) # END handle modification else: - if ours[0] != base[0] or ours[1] != base[1]: # they deleted it, we changed it, conflict out.append(_tree_entry_to_baseindexentry(base, 1)) diff --git a/git/objects/commit.py b/git/objects/commit.py index 6cca65c1f..6db3ea0f3 100644 --- a/git/objects/commit.py +++ b/git/objects/commit.py @@ -345,9 +345,7 @@ def trailers(self) -> Dict[str, str]: Dictionary containing whitespace stripped trailer information. Only contains the latest instance of each trailer key. """ - return { - k: v[0] for k, v in self.trailers_dict.items() - } + return {k: v[0] for k, v in self.trailers_dict.items()} @property def trailers_list(self) -> List[Tuple[str, str]]: diff --git a/git/objects/fun.py b/git/objects/fun.py index e91403a8b..043eec721 100644 --- a/git/objects/fun.py +++ b/git/objects/fun.py @@ -190,7 +190,6 @@ def traverse_trees_recursive( # is a tree. If the match is a non-tree item, put it into the result. # Processed items will be set None for ti, tree_data in enumerate(trees_data): - for ii, item in enumerate(tree_data): if not item: continue diff --git a/git/objects/util.py b/git/objects/util.py index af279154c..d72c04d17 100644 --- a/git/objects/util.py +++ b/git/objects/util.py @@ -143,7 +143,7 @@ def utctz_to_altz(utctz: str) -> int: :param utctz: git utc timezone string, i.e. +0200 """ int_utctz = int(utctz) - seconds = ((abs(int_utctz) // 100) * 3600 + (abs(int_utctz) % 100) * 60) + seconds = (abs(int_utctz) // 100) * 3600 + (abs(int_utctz) % 100) * 60 return seconds if int_utctz < 0 else -seconds diff --git a/git/remote.py b/git/remote.py index 5886a69f0..95a2b8ac6 100644 --- a/git/remote.py +++ b/git/remote.py @@ -826,7 +826,6 @@ def _get_fetch_info_from_stderr( progress: Union[Callable[..., Any], RemoteProgress, None], kill_after_timeout: Union[None, float] = None, ) -> IterableList["FetchInfo"]: - progress = to_progress_instance(progress) # skip first line as it is some remote info we are not interested in diff --git a/git/repo/base.py b/git/repo/base.py index 4bfead46f..723613c6f 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -498,7 +498,7 @@ def delete_head(self, *heads: "Union[str, Head]", **kwargs: Any) -> None: def create_tag( self, path: PathLike, - ref: Union[str, 'SymbolicReference'] = "HEAD", + ref: Union[str, "SymbolicReference"] = "HEAD", message: Optional[str] = None, force: bool = False, **kwargs: Any, @@ -548,7 +548,6 @@ def _get_config_path(self, config_level: Lit_config_levels, git_dir: Optional[Pa else: return osp.normpath(osp.join(repo_dir, "config")) else: - assert_never( # type:ignore[unreachable] config_level, ValueError(f"Invalid configuration level: {config_level!r}"), diff --git a/git/util.py b/git/util.py index 30028b1c2..5bfe11cd8 100644 --- a/git/util.py +++ b/git/util.py @@ -1083,7 +1083,6 @@ def __getattr__(self, attr: str) -> T_IterableObj: return list.__getattribute__(self, attr) def __getitem__(self, index: Union[SupportsIndex, int, slice, str]) -> T_IterableObj: # type: ignore - assert isinstance(index, (int, str, slice)), "Index of IterableList should be an int or str" if isinstance(index, int): @@ -1098,7 +1097,6 @@ def __getitem__(self, index: Union[SupportsIndex, int, slice, str]) -> T_Iterabl # END handle getattr def __delitem__(self, index: Union[SupportsIndex, int, slice, str]) -> None: - assert isinstance(index, (int, str)), "Index of IterableList should be an int or str" delindex = cast(int, index) diff --git a/pyproject.toml b/pyproject.toml index 57988372a..32c9d4a26 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,3 +45,4 @@ omit = ["*/git/ext/*"] [tool.black] line-length = 120 target-version = ['py37'] +exclude = "git/ext/gitdb" From 3908e79baf27b3d65265ca75db216f9368748351 Mon Sep 17 00:00:00 2001 From: Bodo Graumann Date: Thu, 20 Jul 2023 16:54:10 +0200 Subject: [PATCH 102/114] Add missing type annotation --- git/index/fun.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git/index/fun.py b/git/index/fun.py index 3dc5e96d2..4a2f3cb6d 100644 --- a/git/index/fun.py +++ b/git/index/fun.py @@ -76,7 +76,7 @@ def hook_path(name: str, git_dir: PathLike) -> str: return osp.join(git_dir, "hooks", name) -def _has_file_extension(path): +def _has_file_extension(path: str) -> str: return osp.splitext(path)[1] From f01ee4f8d0b83f06fc7ba5458ac896ac3b81184a Mon Sep 17 00:00:00 2001 From: Bodo Graumann Date: Thu, 20 Jul 2023 17:21:41 +0200 Subject: [PATCH 103/114] Apply straight-forward typing fixes --- git/cmd.py | 2 +- git/index/base.py | 2 +- git/index/fun.py | 8 ++++---- git/objects/util.py | 2 +- git/util.py | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/git/cmd.py b/git/cmd.py index 84d888494..3d170facd 100644 --- a/git/cmd.py +++ b/git/cmd.py @@ -211,7 +211,7 @@ def dashify(string: str) -> str: return string.replace("_", "-") -def slots_to_dict(self: object, exclude: Sequence[str] = ()) -> Dict[str, Any]: +def slots_to_dict(self: "Git", exclude: Sequence[str] = ()) -> Dict[str, Any]: return {s: getattr(self, s) for s in self.__slots__ if s not in exclude} diff --git a/git/index/base.py b/git/index/base.py index dd8f9aa2e..193baf3ad 100644 --- a/git/index/base.py +++ b/git/index/base.py @@ -656,7 +656,7 @@ def _store_path(self, filepath: PathLike, fprogress: Callable) -> BaseIndexEntry def _entries_for_paths( self, paths: List[str], - path_rewriter: Callable, + path_rewriter: Union[Callable, None], fprogress: Callable, entries: List[BaseIndexEntry], ) -> List[BaseIndexEntry]: diff --git a/git/index/fun.py b/git/index/fun.py index 4a2f3cb6d..b50f1f465 100644 --- a/git/index/fun.py +++ b/git/index/fun.py @@ -102,7 +102,7 @@ def run_commit_hook(name: str, index: "IndexFile", *args: str) -> None: relative_hp = Path(hp).relative_to(index.repo.working_dir).as_posix() cmd = ["bash.exe", relative_hp] - cmd = subprocess.Popen( + process = subprocess.Popen( cmd + list(args), env=env, stdout=subprocess.PIPE, @@ -116,13 +116,13 @@ def run_commit_hook(name: str, index: "IndexFile", *args: str) -> None: else: stdout_list: List[str] = [] stderr_list: List[str] = [] - handle_process_output(cmd, stdout_list.append, stderr_list.append, finalize_process) + handle_process_output(process, stdout_list.append, stderr_list.append, finalize_process) stdout = "".join(stdout_list) stderr = "".join(stderr_list) - if cmd.returncode != 0: + if process.returncode != 0: stdout = force_text(stdout, defenc) stderr = force_text(stderr, defenc) - raise HookExecutionError(hp, cmd.returncode, stderr, stdout) + raise HookExecutionError(hp, process.returncode, stderr, stdout) # end handle return code diff --git a/git/objects/util.py b/git/objects/util.py index d72c04d17..56938507e 100644 --- a/git/objects/util.py +++ b/git/objects/util.py @@ -147,7 +147,7 @@ def utctz_to_altz(utctz: str) -> int: return seconds if int_utctz < 0 else -seconds -def altz_to_utctz_str(altz: int) -> str: +def altz_to_utctz_str(altz: float) -> str: """Convert a timezone offset west of UTC in seconds into a git timezone offset string :param altz: timezone offset in seconds west of UTC diff --git a/git/util.py b/git/util.py index 5bfe11cd8..0ef8bdeb7 100644 --- a/git/util.py +++ b/git/util.py @@ -1049,7 +1049,7 @@ class IterableList(List[T_IterableObj]): __slots__ = ("_id_attr", "_prefix") - def __new__(cls, id_attr: str, prefix: str = "") -> "IterableList[IterableObj]": + def __new__(cls, id_attr: str, prefix: str = "") -> "IterableList[T_IterableObj]": return super(IterableList, cls).__new__(cls) def __init__(self, id_attr: str, prefix: str = "") -> None: From 41ecc6a4f80ecaa07ceac59861820c4b88dd5d1e Mon Sep 17 00:00:00 2001 From: Bodo Graumann Date: Fri, 21 Jul 2023 09:21:46 +0200 Subject: [PATCH 104/114] Disable merge_includes in config writers --- git/repo/base.py | 2 +- test/test_refs.py | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/git/repo/base.py b/git/repo/base.py index 723613c6f..ab2026549 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -600,7 +600,7 @@ def config_writer(self, config_level: Lit_config_levels = "repository") -> GitCo system = system wide configuration file global = user level configuration file repository = configuration file for this repository only""" - return GitConfigParser(self._get_config_path(config_level), read_only=False, repo=self) + return GitConfigParser(self._get_config_path(config_level), read_only=False, repo=self, merge_includes=False) def commit(self, rev: Union[str, Commit_ish, None] = None) -> Commit: """The Commit object for the specified revision diff --git a/test/test_refs.py b/test/test_refs.py index 5bb83100e..4c421767e 100644 --- a/test/test_refs.py +++ b/test/test_refs.py @@ -15,6 +15,7 @@ SymbolicReference, GitCommandError, RefLog, + GitConfigParser, ) from git.objects.tag import TagObject from test.lib import TestBase, with_rw_repo @@ -172,6 +173,26 @@ def test_heads(self, rwrepo): assert log[0].oldhexsha == pcommit.NULL_HEX_SHA assert log[0].newhexsha == pcommit.hexsha + @with_rw_repo("HEAD", bare=False) + def test_set_tracking_branch_with_import(self, rwrepo): + # prepare included config file + included_config = osp.join(rwrepo.git_dir, "config.include") + with GitConfigParser(included_config, read_only=False) as writer: + writer.set_value("test", "value", "test") + assert osp.exists(included_config) + + with rwrepo.config_writer() as writer: + writer.set_value("include", "path", included_config) + + for head in rwrepo.heads: + head.set_tracking_branch(None) + assert head.tracking_branch() is None + remote_ref = rwrepo.remotes[0].refs[0] + assert head.set_tracking_branch(remote_ref) is head + assert head.tracking_branch() == remote_ref + head.set_tracking_branch(None) + assert head.tracking_branch() is None + def test_refs(self): types_found = set() for ref in self.rorepo.refs: From 186c1ae12be1bb76087dd4fa53a1ac0979c8aa9f Mon Sep 17 00:00:00 2001 From: Patrick Hagemeister Date: Thu, 27 Jul 2023 06:24:20 +0000 Subject: [PATCH 105/114] Creating a lock now uses python built-in "open()" method to work around docker virtiofs issue --- git/util.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/git/util.py b/git/util.py index 0ef8bdeb7..a3748f0fe 100644 --- a/git/util.py +++ b/git/util.py @@ -935,11 +935,7 @@ def _obtain_lock_or_raise(self) -> None: ) try: - flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL - if is_win: - flags |= os.O_SHORT_LIVED - fd = os.open(lock_file, flags, 0) - os.close(fd) + open(lock_file, mode='w', closefd=True) except OSError as e: raise IOError(str(e)) from e From 9f74c05b7d0f224bb170ad77675d0d2b0ff82a0d Mon Sep 17 00:00:00 2001 From: Lydia Date: Sun, 27 Aug 2023 18:28:59 +0200 Subject: [PATCH 106/114] feat: full typing for "progress" parameter This commit also fix a few inconsistency, most especially: - How mypy/Pylance are checking for if-statements - Tools complaining about not subscriptable class - Exporting imports --- git/repo/base.py | 5 +++-- git/types.py | 22 +++++++++++----------- requirements-dev.txt | 2 +- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/git/repo/base.py b/git/repo/base.py index ab2026549..113fca459 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -60,6 +60,7 @@ PathLike, Lit_config_levels, Commit_ish, + CallableProgress, Tree_ish, assert_never, ) @@ -1258,7 +1259,7 @@ def _clone( def clone( self, path: PathLike, - progress: Optional[Callable] = None, + progress: Optional[CallableProgress] = None, multi_options: Optional[List[str]] = None, allow_unsafe_protocols: bool = False, allow_unsafe_options: bool = False, @@ -1297,7 +1298,7 @@ def clone_from( cls, url: PathLike, to_path: PathLike, - progress: Optional[Callable] = None, + progress: CallableProgress = None, env: Optional[Mapping[str, str]] = None, multi_options: Optional[List[str]] = None, allow_unsafe_protocols: bool = False, diff --git a/git/types.py b/git/types.py index 9064ecbf9..9f8621721 100644 --- a/git/types.py +++ b/git/types.py @@ -8,42 +8,39 @@ from typing import ( Dict, NoReturn, - Sequence, + Sequence as Sequence, Tuple, Union, Any, + Optional, + Callable, TYPE_CHECKING, TypeVar, ) # noqa: F401 -if sys.version_info[:2] >= (3, 8): +if sys.version_info >= (3, 8): from typing import ( Literal, - SupportsIndex, TypedDict, Protocol, + SupportsIndex as SupportsIndex, runtime_checkable, ) # noqa: F401 else: from typing_extensions import ( Literal, - SupportsIndex, # noqa: F401 + SupportsIndex as SupportsIndex, TypedDict, Protocol, runtime_checkable, ) # noqa: F401 -# if sys.version_info[:2] >= (3, 10): +# if sys.version_info >= (3, 10): # from typing import TypeGuard # noqa: F401 # else: # from typing_extensions import TypeGuard # noqa: F401 - -if sys.version_info[:2] < (3, 9): - PathLike = Union[str, os.PathLike] -else: - # os.PathLike only becomes subscriptable from Python 3.9 onwards - PathLike = Union[str, os.PathLike[str]] +PathLike = Union[str, "os.PathLike[str]"] if TYPE_CHECKING: from git.repo import Repo @@ -62,6 +59,9 @@ Lit_config_levels = Literal["system", "global", "user", "repository"] +# Progress parameter type alias ----------------------------------------- + +CallableProgress = Optional[Callable[[int, Union[str, float], Union[str, float, None], str], None]] # def is_config_level(inp: str) -> TypeGuard[Lit_config_levels]: # # return inp in get_args(Lit_config_level) # only py >= 3.8 diff --git a/requirements-dev.txt b/requirements-dev.txt index bacde3498..946b4c94f 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -10,4 +10,4 @@ pytest-icdiff # pytest-profiling -tox \ No newline at end of file +tox From 6029211d729a0dd81e08fcc9c1a3ab7fe9af85c9 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Wed, 30 Aug 2023 09:32:11 -0400 Subject: [PATCH 107/114] Fix CVE-2023-40590 This fixes the path search bug where the current directory is included on Windows, by setting NoDefaultCurrentDirectoryInExePath for the caller. (Setting for the callee env would not work.) This sets it only on Windows, only for the duration of the Popen call, and then automatically unsets it or restores its old value. NoDefaultCurrentDirectoryInExePath is documented at: https://learn.microsoft.com/en-us/windows/win32/api/processenv/nf-processenv-needcurrentdirectoryforexepathw It automatically affects the behavior of subprocess.Popen on Windows, due to the way Popen uses the Windows API. (In contrast, it does not, at least currently on CPython, affect the behavior of shutil.which. But shutil.which is not being used to find git.exe.) --- git/cmd.py | 38 +++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/git/cmd.py b/git/cmd.py index 3d170facd..3665eb029 100644 --- a/git/cmd.py +++ b/git/cmd.py @@ -5,7 +5,7 @@ # the BSD License: http://www.opensource.org/licenses/bsd-license.php from __future__ import annotations import re -from contextlib import contextmanager +import contextlib import io import logging import os @@ -14,6 +14,7 @@ import subprocess import threading from textwrap import dedent +import unittest.mock from git.compat import ( defenc, @@ -963,8 +964,11 @@ def execute( redacted_command, '"kill_after_timeout" feature is not supported on Windows.', ) + # Only search PATH, not CWD. This must be in the *caller* environment. The "1" can be any value. + patch_caller_env = unittest.mock.patch.dict(os.environ, {"NoDefaultCurrentDirectoryInExePath": "1"}) else: cmd_not_found_exception = FileNotFoundError # NOQA # exists, flake8 unknown @UndefinedVariable + patch_caller_env = contextlib.nullcontext() # end handle stdout_sink = PIPE if with_stdout else getattr(subprocess, "DEVNULL", None) or open(os.devnull, "wb") @@ -980,21 +984,21 @@ def execute( istream_ok, ) try: - proc = Popen( - command, - env=env, - cwd=cwd, - bufsize=-1, - stdin=istream or DEVNULL, - stderr=PIPE, - stdout=stdout_sink, - shell=shell is not None and shell or self.USE_SHELL, - close_fds=is_posix, # unsupported on windows - universal_newlines=universal_newlines, - creationflags=PROC_CREATIONFLAGS, - **subprocess_kwargs, - ) - + with patch_caller_env: + proc = Popen( + command, + env=env, + cwd=cwd, + bufsize=-1, + stdin=istream or DEVNULL, + stderr=PIPE, + stdout=stdout_sink, + shell=shell is not None and shell or self.USE_SHELL, + close_fds=is_posix, # unsupported on windows + universal_newlines=universal_newlines, + creationflags=PROC_CREATIONFLAGS, + **subprocess_kwargs, + ) except cmd_not_found_exception as err: raise GitCommandNotFound(redacted_command, err) from err else: @@ -1144,7 +1148,7 @@ def update_environment(self, **kwargs: Any) -> Dict[str, Union[str, None]]: del self._environment[key] return old_env - @contextmanager + @contextlib.contextmanager def custom_environment(self, **kwargs: Any) -> Iterator[None]: """ A context manager around the above ``update_environment`` method to restore the From 94e0fb0794b88b78ceed94ff18ee7d68587d890d Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Wed, 30 Aug 2023 11:53:29 -0400 Subject: [PATCH 108/114] Add a unit test for CVE-2023-40590 This adds test_it_executes_git_not_from_cwd to verify that the execute method does not use "git.exe" in the current directory on Windows, nor "git" in the current directory on Unix-like systems, when those files are executable. It adds a _chdir helper context manager to support this, because contextlib.chdir is only available on Python 3.11 and later. --- test/test_git.py | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/test/test_git.py b/test/test_git.py index c5d871f08..01572dc24 100644 --- a/test/test_git.py +++ b/test/test_git.py @@ -4,10 +4,12 @@ # # This module is part of GitPython and is released under # the BSD License: http://www.opensource.org/licenses/bsd-license.php +import contextlib import os +import shutil import subprocess import sys -from tempfile import TemporaryFile +from tempfile import TemporaryDirectory, TemporaryFile from unittest import mock from git import Git, refresh, GitCommandError, GitCommandNotFound, Repo, cmd @@ -20,6 +22,17 @@ from git.compat import is_win +@contextlib.contextmanager +def _chdir(new_dir): + """Context manager to temporarily change directory. Not reentrant.""" + old_dir = os.getcwd() + os.chdir(new_dir) + try: + yield + finally: + os.chdir(old_dir) + + class TestGit(TestBase): @classmethod def setUpClass(cls): @@ -75,6 +88,23 @@ def test_it_transforms_kwargs_into_git_command_arguments(self): def test_it_executes_git_to_shell_and_returns_result(self): self.assertRegex(self.git.execute(["git", "version"]), r"^git version [\d\.]{2}.*$") + def test_it_executes_git_not_from_cwd(self): + with TemporaryDirectory() as tmpdir: + if is_win: + # Copy an actual binary executable that is not git. + other_exe_path = os.path.join(os.getenv("WINDIR"), "system32", "hostname.exe") + impostor_path = os.path.join(tmpdir, "git.exe") + shutil.copy(other_exe_path, impostor_path) + else: + # Create a shell script that doesn't do anything. + impostor_path = os.path.join(tmpdir, "git") + with open(impostor_path, mode="w", encoding="utf-8") as file: + print("#!/bin/sh", file=file) + os.chmod(impostor_path, 0o755) + + with _chdir(tmpdir): + self.assertRegex(self.git.execute(["git", "version"]), r"^git version [\d\.]{2}.*$") + def test_it_accepts_stdin(self): filename = fixture_path("cat_file_blob") with open(filename, "r") as fh: From 7611cd909b890b971d23bce3bd4244ad1c381f22 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Wed, 30 Aug 2023 12:34:22 -0400 Subject: [PATCH 109/114] Don't check form of version number This changes the regex in test_it_executes_git_not_from_cwd so that (unlike test_it_executes_git_to_shell_and_returns_result) it only checks that the output starts with the words "git version", and not the form of whatever follows those words. --- test/test_git.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_git.py b/test/test_git.py index 01572dc24..540ea9f41 100644 --- a/test/test_git.py +++ b/test/test_git.py @@ -103,7 +103,7 @@ def test_it_executes_git_not_from_cwd(self): os.chmod(impostor_path, 0o755) with _chdir(tmpdir): - self.assertRegex(self.git.execute(["git", "version"]), r"^git version [\d\.]{2}.*$") + self.assertRegex(self.git.execute(["git", "version"]), r"^git version\b") def test_it_accepts_stdin(self): filename = fixture_path("cat_file_blob") From 70924c4265c2d3629d978dd7bfc9ab1678d91e7d Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Fri, 1 Sep 2023 08:11:22 +0200 Subject: [PATCH 110/114] Skip now permanently failing test with note on how to fix it --- test/test_repo.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/test_repo.py b/test/test_repo.py index 5c66aeeb1..08ed13a00 100644 --- a/test/test_repo.py +++ b/test/test_repo.py @@ -13,7 +13,7 @@ import pickle import sys import tempfile -from unittest import mock, skipIf, SkipTest +from unittest import mock, skipIf, SkipTest, skip import pytest @@ -251,6 +251,7 @@ def test_clone_from_with_path_contains_unicode(self): self.fail("Raised UnicodeEncodeError") @with_rw_directory + @skip("the referenced repository was removed, and one needs to setup a new password controlled repo under the orgs control") def test_leaking_password_in_clone_logs(self, rw_dir): password = "fakepassword1234" try: From 993f04588aa362fdce7c7f2f0848b5daedd8cb72 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Fri, 1 Sep 2023 08:12:54 +0200 Subject: [PATCH 111/114] prepare for next release --- VERSION | 2 +- doc/source/changes.rst | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 381c34a62..f8d874e2b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.1.32 +3.1.33 diff --git a/doc/source/changes.rst b/doc/source/changes.rst index 3bc02e770..45062ac13 100644 --- a/doc/source/changes.rst +++ b/doc/source/changes.rst @@ -2,6 +2,12 @@ Changelog ========= +3.1.33 +====== + +See the following for all changes. +https://github.com/gitpython-developers/gitpython/milestone/63?closed=1 + 3.1.32 ====== From f882cd8422fbb2517eebbf45824eb07951b948f3 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Fri, 1 Sep 2023 08:48:02 +0200 Subject: [PATCH 112/114] update instructions for how to create a release --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index 30c54b57f..1743bd3d2 100644 --- a/README.md +++ b/README.md @@ -144,9 +144,7 @@ Please have a look at the [contributions file][contributing]. - Run `git tag -s ` to tag the version in Git - Run `make release` - Close the milestone mentioned in the _changelog_ and create a new one. _Do not reuse milestones by renaming them_. -- set the upcoming version in the `VERSION` file, usually be - incrementing the patch level, and possibly by appending `-dev`. Probably you - want to `git push` once more. +- Got to [GitHub Releases](https://github.com/gitpython-developers/GitPython/releases) and publish a new one with the recently pushed tag. Generate the changelog. ### How to verify a release (DEPRECATED) From 3e829eb516a60212bae81a6549361be4748e22d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Saugat=20Pachhai=20=28=E0=A4=B8=E0=A5=8C=E0=A4=97=E0=A4=BE?= =?UTF-8?q?=E0=A4=A4=29?= Date: Sat, 2 Sep 2023 14:21:03 +0545 Subject: [PATCH 113/114] util: close lockfile after opening successfully Otherwise, this will leak file handles and can be a problem in Windows. Also, `closefd=true` is the default here, so need to pass it explicitly. Regression from #1619. I noticed after [our tests started raising `ResourceWarning`][1]. ```python Traceback (most recent call last): File "/opt/hostedtoolcache/Python/3.8.17/x64/lib/python3.8/site-packages/git/util.py", line 938, in _obtain_lock_or_raise open(lock_file, mode='w', closefd=True) ResourceWarning: unclosed file <_io.TextIOWrapper name='/tmp/pytest-of-runner/pytest-0/popen-gw0/external0/project.git/.git/config.lock' mode='w' encoding='UTF-8'> ``` [1]: https://github.com/iterative/dvc/actions/runs/6055520480/job/16434544764#step:6:869 --- git/util.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/git/util.py b/git/util.py index a3748f0fe..f6dedf0f2 100644 --- a/git/util.py +++ b/git/util.py @@ -935,7 +935,8 @@ def _obtain_lock_or_raise(self) -> None: ) try: - open(lock_file, mode='w', closefd=True) + with open(lock_file, mode='w'): + pass except OSError as e: raise IOError(str(e)) from e From 2a2ae776825f249a3bb7efd9b08650486226b027 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Sat, 2 Sep 2023 11:29:43 +0200 Subject: [PATCH 114/114] prepare patch release --- VERSION | 2 +- doc/source/changes.rst | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/VERSION b/VERSION index f8d874e2b..e03213a2b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.1.33 +3.1.34 diff --git a/doc/source/changes.rst b/doc/source/changes.rst index 45062ac13..38e673f3f 100644 --- a/doc/source/changes.rst +++ b/doc/source/changes.rst @@ -2,6 +2,12 @@ Changelog ========= +3.1.34 +====== + +See the following for all changes. +https://github.com/gitpython-developers/gitpython/milestone/64?closed=1 + 3.1.33 ======