diff --git a/.basedpyright/baseline.json b/.basedpyright/baseline.json index 013b097ae..14bf1024e 100644 --- a/.basedpyright/baseline.json +++ b/.basedpyright/baseline.json @@ -183,14 +183,6 @@ "lineCount": 1 } }, - { - "code": "reportPossiblyUnboundVariable", - "range": { - "startColumn": 52, - "endColumn": 59, - "lineCount": 1 - } - }, { "code": "reportSelfClsParameterName", "range": { @@ -207,14 +199,6 @@ "lineCount": 1 } }, - { - "code": "reportPossiblyUnboundVariable", - "range": { - "startColumn": 41, - "endColumn": 44, - "lineCount": 1 - } - }, { "code": "reportArgumentType", "range": { @@ -393,14 +377,6 @@ "lineCount": 1 } }, - { - "code": "reportPossiblyUnboundVariable", - "range": { - "startColumn": 19, - "endColumn": 25, - "lineCount": 1 - } - }, { "code": "reportArgumentType", "range": { @@ -467,38 +443,6 @@ "lineCount": 1 } }, - { - "code": "reportPossiblyUnboundVariable", - "range": { - "startColumn": 23, - "endColumn": 34, - "lineCount": 1 - } - }, - { - "code": "reportPossiblyUnboundVariable", - "range": { - "startColumn": 32, - "endColumn": 43, - "lineCount": 1 - } - }, - { - "code": "reportPossiblyUnboundVariable", - "range": { - "startColumn": 23, - "endColumn": 34, - "lineCount": 1 - } - }, - { - "code": "reportPossiblyUnboundVariable", - "range": { - "startColumn": 15, - "endColumn": 20, - "lineCount": 1 - } - }, { "code": "reportArgumentType", "range": { @@ -507,14 +451,6 @@ "lineCount": 1 } }, - { - "code": "reportPossiblyUnboundVariable", - "range": { - "startColumn": 28, - "endColumn": 32, - "lineCount": 1 - } - }, { "code": "reportAttributeAccessIssue", "range": { @@ -596,16 +532,6 @@ } } ], - "./git/objects/submodule/root.py": [ - { - "code": "reportPossiblyUnboundVariable", - "range": { - "startColumn": 18, - "endColumn": 21, - "lineCount": 1 - } - } - ], "./git/objects/tag.py": [ { "code": "reportIncompatibleVariableOverride", @@ -709,14 +635,6 @@ "lineCount": 1 } }, - { - "code": "reportPossiblyUnboundVariable", - "range": { - "startColumn": 41, - "endColumn": 45, - "lineCount": 1 - } - }, { "code": "reportArgumentType", "range": { @@ -863,54 +781,6 @@ "lineCount": 1 } }, - { - "code": "reportPossiblyUnboundVariable", - "range": { - "startColumn": 26, - "endColumn": 34, - "lineCount": 1 - } - }, - { - "code": "reportPossiblyUnboundVariable", - "range": { - "startColumn": 32, - "endColumn": 40, - "lineCount": 1 - } - }, - { - "code": "reportPossiblyUnboundVariable", - "range": { - "startColumn": 18, - "endColumn": 26, - "lineCount": 1 - } - }, - { - "code": "reportPossiblyUnboundVariable", - "range": { - "startColumn": 22, - "endColumn": 30, - "lineCount": 1 - } - }, - { - "code": "reportPossiblyUnboundVariable", - "range": { - "startColumn": 22, - "endColumn": 37, - "lineCount": 1 - } - }, - { - "code": "reportPossiblyUnboundVariable", - "range": { - "startColumn": 22, - "endColumn": 37, - "lineCount": 1 - } - }, { "code": "reportReturnType", "range": { @@ -991,22 +861,6 @@ "lineCount": 1 } }, - { - "code": "reportPossiblyUnboundVariable", - "range": { - "startColumn": 39, - "endColumn": 47, - "lineCount": 1 - } - }, - { - "code": "reportPossiblyUnboundVariable", - "range": { - "startColumn": 43, - "endColumn": 51, - "lineCount": 1 - } - }, { "code": "reportArgumentType", "range": { @@ -1022,22 +876,6 @@ "endColumn": 38, "lineCount": 1 } - }, - { - "code": "reportPossiblyUnboundVariable", - "range": { - "startColumn": 26, - "endColumn": 42, - "lineCount": 1 - } - }, - { - "code": "reportPossiblyUnboundVariable", - "range": { - "startColumn": 18, - "endColumn": 34, - "lineCount": 1 - } } ], "./git/repo/fun.py": [ diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 88099f15c..7dfa2e8f2 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -25,16 +25,6 @@ jobs: python-version: "3.14t" - os-type: macos python-version: "3.15t" - - os-type: windows - python-version: "3.13" # FIXME: Fix and enable Python 3.13-3.15 on Windows (#1955). - - os-type: windows - python-version: "3.14" - - os-type: windows - python-version: "3.14t" - - os-type: windows - python-version: "3.15" - - os-type: windows - python-version: "3.15t" include: - os-ver: latest - os-type: ubuntu diff --git a/VERSION b/VERSION index cb1a6470a..dfabc766a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.1.57 +3.1.58 diff --git a/doc/source/changes.rst b/doc/source/changes.rst index 2bce3058e..ffddf56a1 100644 --- a/doc/source/changes.rst +++ b/doc/source/changes.rst @@ -2,6 +2,24 @@ Changelog ========= +3.1.58 +====== + +Security fixes for + +* https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-hh9p-6wh2-4mfc +* https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-9rj7-rf2p-w77r +* https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-4gmw-gg2m-w46p +* https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-wvpp-8hx9-p66j +* https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-jm78-9fvv-mhgr +* https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-hmq2-w58f-27jc + +If you can, also try and provide feedback on the upcoming v4 branch +https://github.com/gitpython-developers/GitPython/pull/2177 - patches welcome. + +See the following for all changes. +https://github.com/gitpython-developers/GitPython/releases/tag/3.1.58 + 3.1.57 ====== @@ -14,7 +32,7 @@ If you can, also try and provide feedback on the upcoming v4 branch https://github.com/gitpython-developers/GitPython/pull/2177 - patches welcome. See the following for all changes. -https://github.com/gitpython-developers/GitPython/releases/tag/3.1.55 +https://github.com/gitpython-developers/GitPython/releases/tag/3.1.57 3.1.56 ====== @@ -27,7 +45,7 @@ If you can, also try and provide feedback on the upcoming v4 branch https://github.com/gitpython-developers/GitPython/pull/2177 - patches welcome. See the following for all changes. -https://github.com/gitpython-developers/GitPython/releases/tag/3.1.55 +https://github.com/gitpython-developers/GitPython/releases/tag/3.1.56 3.1.55 ====== diff --git a/git/cmd.py b/git/cmd.py index 4f7c3b443..03ecd13f5 100644 --- a/git/cmd.py +++ b/git/cmd.py @@ -654,6 +654,12 @@ class Git(metaclass=_GitMeta): "--upload-pack", ] + unsafe_git_pathspec_from_file_options = [ + # Reads pathspecs from a caller-controlled file. Some commands include an + # unmatched pathspec in their error output, which can disclose the file. + "--pathspec-from-file", + ] + def __getstate__(self) -> Dict[str, Any]: return slots_to_dict(self, exclude=self._excluded_) @@ -1044,13 +1050,21 @@ def _option_candidates(cls, args: Sequence[Any] = (), kwargs: Optional[Mapping[s values = value if isinstance(value, (list, tuple)) else (value,) if any(value is True or (value is not False and value is not None) for value in values): key = str(key) - options.append(f"-{key}" if len(key) == 1 else f"--{dashify(key)}") - if len(key) == 1 and split_single_char_options: + if len(key) != 1: + options.append(f"--{dashify(key)}") + elif split_single_char_options: + options.append(f"-{key}") options.extend( str(value) for value in values if value is not True and value not in (False, None) and str(value).startswith("-") ) + else: + options.extend( + f"-{key}" if value is True else f"-{key}{value}" + for value in values + if value is True or (value is not False and value is not None) + ) return options AutoInterrupt: TypeAlias = _AutoInterrupt diff --git a/git/config.py b/git/config.py index 821710ea3..c300de499 100644 --- a/git/config.py +++ b/git/config.py @@ -75,6 +75,9 @@ UNSAFE_CONFIG_CHARS_RE = re.compile(r"[\r\n\x00]") """Characters that cannot be safely written in config names or values.""" +VALID_CONFIG_OPTION_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]+$") +"""Pattern for option names that can be written without changing config syntax.""" + class MetaParserBuilder(abc.ABCMeta): # noqa: B024 """Utility class wrapping base-class methods into decorators that assure read-only @@ -577,8 +580,12 @@ def _all_items(section: str) -> List[Tuple[str, str]]: if keyword in ["gitdir", "gitdir/i"]: value = osp.expanduser(value) + git_dir = os.fspath(self._repo.git_dir) if self._repo.git_dir else None + if sys.platform == "win32": + git_dir = git_dir.replace("\\", "/") if git_dir else None - if not any(value.startswith(s) for s in ["./", "/"]): + drive, _tail = osp.splitdrive(value) + if not drive and not any(value.startswith(s) for s in ["./", "/"]): value = "**/" + value if value.endswith("/"): value += "**" @@ -590,9 +597,8 @@ def _all_items(section: str) -> List[Tuple[str, str]]: lambda m: f"[{m.group().lower()!r}{m.group().upper()!r}]", value, ) - if self._repo.git_dir: - if fnmatch.fnmatchcase(os.fspath(self._repo.git_dir), value): - paths += _all_items(section) + if git_dir and fnmatch.fnmatchcase(git_dir, value): + paths += _all_items(section) elif keyword == "onbranch": try: @@ -897,6 +903,8 @@ def _value_to_string_safe(self, value: Union[str, bytes, int, float, bool]) -> s def _assure_config_name_safe(self, name: "cp._SectionName", label: str) -> None: if isinstance(name, str) and UNSAFE_CONFIG_CHARS_RE.search(name): raise ValueError("Git config %s names must not contain CR, LF, or NUL" % label) + if label == "option" and isinstance(name, str) and not VALID_CONFIG_OPTION_NAME_RE.fullmatch(name): + raise ValueError("Git config option names may contain only letters, digits, '-', '_', or '.'") if label == "section" and isinstance(name, str): in_quotes = False escaped = False diff --git a/git/index/base.py b/git/index/base.py index e5b1e72f8..0e7b5f918 100644 --- a/git/index/base.py +++ b/git/index/base.py @@ -34,6 +34,8 @@ LockedFD, join_path_native, file_contents_ro, + _is_path_rooted, + _to_relative_path, to_native_path_linux, unbare_repo, to_bin_sha, @@ -58,6 +60,7 @@ Any, BinaryIO, Callable, + cast, Dict, Generator, IO, @@ -131,6 +134,7 @@ class IndexFile(LazyMixin, git_diff.Diffable, Serializable): """ unsafe_git_checkout_index_options = ["--prefix"] + unsafe_git_read_tree_options = ["--index-output"] __slots__ = ("repo", "version", "entries", "_extension_data", "_file_path") @@ -256,7 +260,12 @@ def write( @post_clear_cache @default_index - def merge_tree(self, rhs: Treeish, base: Union[None, Treeish] = None) -> "IndexFile": + def merge_tree( + self, + rhs: Treeish, + base: Union[None, Treeish] = None, + allow_unsafe_options: bool = False, + ) -> "IndexFile": """Merge the given `rhs` treeish into the current index, possibly taking a common base treeish into account. @@ -270,6 +279,9 @@ def merge_tree(self, rhs: Treeish, base: Union[None, Treeish] = None) -> "IndexF Optional treeish reference pointing to the common base of `rhs` and this index which equals lhs. + :param allow_unsafe_options: + Allow options that may write to arbitrary paths. + :return: self (containing the merge and possibly unmerged entries in case of conflicts) @@ -280,6 +292,12 @@ def merge_tree(self, rhs: Treeish, base: Union[None, Treeish] = None) -> "IndexF yourself, you have to commit the changed index (or make a valid tree from it) and retry with a three-way :meth:`index.from_tree ` call. """ + if not allow_unsafe_options: + Git.check_unsafe_options( + options=Git._option_candidates([base, rhs]), + unsafe_options=self.unsafe_git_read_tree_options, + ) + # -i : ignore working tree status # --aggressive : handle more merge cases # -m : do an actual merge @@ -324,7 +342,13 @@ def new(cls, repo: "Repo", *tree_sha: Union[str, Tree]) -> "IndexFile": return inst @classmethod - def from_tree(cls, repo: "Repo", *treeish: Treeish, **kwargs: Any) -> "IndexFile": + def from_tree( + cls, + repo: "Repo", + *treeish: Treeish, + allow_unsafe_options: bool = False, + **kwargs: Any, + ) -> "IndexFile": R"""Merge the given treeish revisions into a new index which is returned. The original index will remain unaltered. @@ -348,6 +372,9 @@ def from_tree(cls, repo: "Repo", *treeish: Treeish, **kwargs: Any) -> "IndexFile :param kwargs: Additional arguments passed to :manpage:`git-read-tree(1)`. + :param allow_unsafe_options: + Allow options that may write to arbitrary paths. + :return: New :class:`IndexFile` instance. It will point to a temporary index location which does not exist anymore. If you intend to write such a merged Index, @@ -365,6 +392,12 @@ def from_tree(cls, repo: "Repo", *treeish: Treeish, **kwargs: Any) -> "IndexFile if len(treeish) == 0 or len(treeish) > 3: raise ValueError("Please specify between 1 and 3 treeish, got %i" % len(treeish)) + if not allow_unsafe_options: + Git.check_unsafe_options( + options=Git._option_candidates(treeish, kwargs), + unsafe_options=cls.unsafe_git_read_tree_options, + ) + arg_list: List[Union[Treeish, str]] = [] # Ignore that the working tree and index possibly are out of date. if len(treeish) > 1: @@ -655,16 +688,12 @@ def _to_relative_path(self, path: PathLike) -> PathLike: :raise ValueError: """ - if not osp.isabs(path): - return path if self.repo.bare: - raise InvalidGitRepositoryError("require non-bare repository") - if not osp.normpath(path).startswith(str(self.repo.working_tree_dir)): - raise ValueError("Absolute path %r is not in git repository at %r" % (path, self.repo.working_tree_dir)) - result = os.path.relpath(path, self.repo.working_tree_dir) - if os.fspath(path).endswith(os.sep) and not result.endswith(os.sep): - result += os.sep - return result + drive, _tail = osp.splitdrive(os.fspath(path)) + if drive or _is_path_rooted(path): + raise InvalidGitRepositoryError("paths with a drive or root require a non-bare repository") + return path + return _to_relative_path(cast(PathLike, self.repo.working_tree_dir), path) def _preprocess_add_items( self, items: Union[PathLike, Sequence[Union[PathLike, Blob, BaseIndexEntry, "Submodule"]]] @@ -731,14 +760,17 @@ def _entries_for_paths( ) -> List[BaseIndexEntry]: entries_added: List[BaseIndexEntry] = [] if path_rewriter: + working_tree_dir = self.repo.working_tree_dir + if working_tree_dir is None: + raise InvalidGitRepositoryError("Cannot rewrite paths without a working tree") + working_tree_dir = str(working_tree_dir) for path in paths: if osp.isabs(path): abspath = path - gitrelative_path = path[len(str(self.repo.working_tree_dir)) + 1 :] + gitrelative_path = path[len(working_tree_dir) + 1 :] else: gitrelative_path = path - if self.repo.working_tree_dir: - abspath = osp.join(self.repo.working_tree_dir, gitrelative_path) + abspath = osp.join(working_tree_dir, gitrelative_path) # END obtain relative and absolute paths blob = Blob( @@ -992,6 +1024,7 @@ def remove( self, items: Union[PathLike, Sequence[Union[PathLike, Blob, BaseIndexEntry, "Submodule"]]], working_tree: bool = False, + allow_unsafe_options: bool = False, **kwargs: Any, ) -> List[str]: R"""Remove the given items from the index and optionally from the working tree @@ -1022,6 +1055,10 @@ def remove( physically removing the respective file. This may fail if there are uncommitted changes in it. + :param allow_unsafe_options: + Allow unsafe options such as ``--pathspec-from-file`` to be passed to + :manpage:`git-rm(1)`. + :param kwargs: Additional keyword arguments to be passed to :manpage:`git-rm(1)`, such as ``r`` to allow recursive removal. @@ -1033,6 +1070,11 @@ def remove( This is interesting to know in case you have provided a directory or globs. Paths are relative to the repository. """ + if not allow_unsafe_options: + Git.check_unsafe_options( + options=Git._option_candidates([], kwargs), + unsafe_options=Git.unsafe_git_pathspec_from_file_options, + ) args = [] if not working_tree: args.append("--cached") @@ -1414,6 +1456,7 @@ def reset( working_tree: bool = False, paths: Union[None, Iterable[PathLike]] = None, head: bool = False, + allow_unsafe_options: bool = False, **kwargs: Any, ) -> "IndexFile": """Reset the index to reflect the tree at the given commit. This will not adjust @@ -1445,6 +1488,9 @@ def reset( The paths need to exist at the commit, otherwise an exception will be raised. + :param allow_unsafe_options: + Allow options that may write to arbitrary paths. + :param kwargs: Additional keyword arguments passed to :manpage:`git-reset(1)`. @@ -1461,15 +1507,15 @@ def reset( """ # What we actually want to do is to merge the tree into our existing index, # which is what git-read-tree does. - new_inst = type(self).from_tree(self.repo, commit) + new_inst = type(self).from_tree(self.repo, commit, allow_unsafe_options=allow_unsafe_options) if not paths: self.entries = new_inst.entries else: nie = new_inst.entries for path in paths: path = self._to_relative_path(path) + key = entry_key(path, 0) try: - key = entry_key(path, 0) self.entries[key] = nie[key] except KeyError: # If key is not in theirs, it mustn't be in ours. diff --git a/git/index/fun.py b/git/index/fun.py index 5d52486f9..886e10de9 100644 --- a/git/index/fun.py +++ b/git/index/fun.py @@ -26,7 +26,7 @@ from gitdb.base import IStream from gitdb.typ import str_tree_type -from git.cmd import handle_process_output, safer_popen +from git.cmd import Git, handle_process_output, safer_popen from git.compat import defenc, force_bytes, force_text, safe_decode from git.exc import HookExecutionError, UnmergedEntriesError from git.objects.fun import ( @@ -79,6 +79,99 @@ def _has_file_extension(path: str) -> str: return osp.splitext(path)[1] +def _is_in_windows_system_root(path: str) -> bool: + """Return whether ``path`` is inside the Windows installation directory.""" + system_root = os.environ.get("SystemRoot") + if not system_root: + return False + + system_root = osp.normcase(osp.realpath(system_root)) + path = osp.normcase(osp.realpath(path)) + try: + return osp.commonpath((system_root, path)) == system_root + except ValueError: + # Paths on different drives have no common path on Windows. + return False + + +def _which_from_path(command: str) -> Union[str, None]: + """Resolve ``command`` from PATH, excluding the Windows installation.""" + for directory in os.get_exec_path(): + # Unlike POSIX, Windows does not define an empty PATH entry as the current + # directory. Skip it rather than letting abspath() turn it into one. + if not directory: + continue + directory = osp.abspath(directory) + candidate = osp.join(directory, command) + # SystemRoot contains the WSL launcher stubs. They are valid executables but + # not suitable for running a Windows Git hook: the hook path and environment + # were prepared for Git for Windows, and WSL may have no distribution at all. + if _is_in_windows_system_root(candidate): + continue + if osp.isfile(candidate) and os.access(candidate, os.X_OK): + return candidate + return None + + +_GIT_FOR_WINDOWS_PREFIXES = ("mingw64", "mingw32", "clangarm64", "clang64", "clang32", "ucrt64") + + +def _git_for_windows_root() -> Union[str, None]: + """Infer a standard Git for Windows root from GitPython's selected executable.""" + git_executable = os.fspath(Git.GIT_PYTHON_GIT_EXECUTABLE or Git.git_exec_name) + if osp.dirname(git_executable): + # CreateProcess resolves a relative executable path containing a directory + # from the parent process cwd, even when Popen supplies a different child cwd. + git_executable = osp.abspath(git_executable) + else: + # GitPython deliberately retains a bare executable name so later PATH changes + # affect Git commands. Resolve it with the same PATH snapshot used for Bash. + names = (git_executable,) if _has_file_extension(git_executable) else (git_executable, f"{git_executable}.exe") + for name in names: + resolved = _which_from_path(name) + if resolved is not None: + git_executable = resolved + break + else: + git_executable = "" + if not git_executable: + return None + if osp.basename(git_executable).lower() not in ("git", "git.exe"): + return None + + executable_dir = osp.dirname(git_executable) + directory_name = osp.basename(executable_dir).lower() + if directory_name == "cmd": + # The normal system-wide PATH entry is /cmd. + return osp.dirname(executable_dir) + if directory_name == "bin": + prefix = osp.dirname(executable_dir) + if osp.basename(prefix).lower() in _GIT_FOR_WINDOWS_PREFIXES: + # Git Bash commonly exposes //bin/git.exe. + return osp.dirname(prefix) + if osp.basename(prefix).lower() != "usr": + # An explicitly configured Git may be the root-level bin/git.exe. Do + # not make the same inference from usr/bin: unlike the recognized + # platform prefixes, "usr" has no reliably bounded parent layout. + return prefix + return None + + +def _git_for_windows_bash() -> Union[str, None]: + """Return Bash from the Git for Windows installation selected by GitPython.""" + git_root = _git_for_windows_root() + if git_root is None: + return None + + # Match gix-path's precedence: prefer the lightweight bin shim, then the + # underlying usr/bin executable. Both belong to the same installation as Git. + for relative_path in ("bin/bash.exe", "usr/bin/bash.exe"): + candidate = osp.join(git_root, *relative_path.split("/")) + if osp.isfile(candidate) and os.access(candidate, os.X_OK): + return candidate + return None + + def run_commit_hook(name: str, index: "IndexFile", *args: str) -> None: """Run the commit hook of the given name. Silently ignore hooks that do not exist. @@ -112,7 +205,12 @@ def run_commit_hook(name: str, index: "IndexFile", *args: str) -> None: # an absolute path in this form, although a relative path is preferable # because it also works with the Windows Subsystem for Linux wrapper. bash_hp = hp - cmd = ["bash.exe", Path(bash_hp).as_posix()] + # Prefer Bash associated with GitPython's selected Git installation. If + # that layout is not recognized, use an explicitly configured non-system + # PATH entry. Preserve the bare fallback for installations that previously + # relied on WSL or another CreateProcess-resolved Bash. + bash_executable = _git_for_windows_bash() or _which_from_path("bash.exe") or "bash.exe" + cmd = [bash_executable, Path(bash_hp).as_posix()] process = safer_popen( cmd + list(args), diff --git a/git/objects/commit.py b/git/objects/commit.py index 3e435453d..45843eac2 100644 --- a/git/objects/commit.py +++ b/git/objects/commit.py @@ -575,11 +575,14 @@ def _iter_from_process_or_stream(cls, repo: "Repo", proc_or_stream: Union[Popen, if hasattr(proc_or_stream, "wait"): proc_or_stream = cast(Popen, proc_or_stream) - if proc_or_stream.stdout is not None: - stream = proc_or_stream.stdout + stream = proc_or_stream.stdout + if stream is None: + raise ValueError("Process has no stdout stream") elif hasattr(proc_or_stream, "readline"): proc_or_stream = cast(IO, proc_or_stream) # type: ignore[redundant-cast] stream = proc_or_stream + else: + raise TypeError("Expected a process or stream") readline = stream.readline while True: diff --git a/git/objects/submodule/base.py b/git/objects/submodule/base.py index 97fc9e111..da0e09af4 100644 --- a/git/objects/submodule/base.py +++ b/git/objects/submodule/base.py @@ -6,6 +6,7 @@ import gc from io import BytesIO import logging +import ntpath import os import os.path as osp import stat @@ -28,6 +29,7 @@ from git.util import ( IterableList, RemoteProgress, + _to_relative_path, join_path_native, rmtree, to_native_path_linux, @@ -302,8 +304,20 @@ def _config_parser_constrained(self, read_only: bool) -> SectionConstraint: parser.set_submodule(self) return SectionConstraint(parser, sm_section(self.name)) + @classmethod + def _validated_name(cls, name: str) -> str: + if ( + not name + or name.startswith(("/", "\\")) + or ntpath.splitdrive(name)[0] + or ".." in name.replace("\\", "/").split("/") + ): + raise ValueError("Invalid submodule name %r" % name) + return name + @classmethod def _module_abspath(cls, parent_repo: "Repo", path: PathLike, name: str) -> PathLike: + name = cls._validated_name(name) if cls._need_gitfile_submodules(parent_repo.git): return osp.join(parent_repo.git_dir, "modules", name) if parent_repo.working_tree_dir: @@ -379,23 +393,14 @@ def _to_relative_path(cls, parent_repo: "Repo", path: PathLike) -> PathLike: :raise ValueError: If path is not contained in the parent repository's working tree. """ - path = to_native_path_linux(path) + if parent_repo.working_tree_dir: + path = _to_relative_path(parent_repo.working_tree_dir, path) + else: + path = to_native_path_linux(path) if path.endswith("/"): path = path[:-1] - # END handle trailing slash - - if osp.isabs(path) and parent_repo.working_tree_dir: - working_tree_linux = to_native_path_linux(parent_repo.working_tree_dir) - if not path.startswith(working_tree_linux): - raise ValueError( - "Submodule checkout path '%s' needs to be within the parents repository at '%s'" - % (working_tree_linux, path) - ) - path = path[len(working_tree_linux.rstrip("/")) + 1 :] - if not path: - raise ValueError("Absolute submodule path '%s' didn't yield a valid relative path" % path) - # END verify converted relative path makes sense - # END convert to a relative path + if not path or path == ".": + raise ValueError("Submodule checkout path must not be the repository root") return path @@ -523,6 +528,7 @@ def add( raise InvalidGitRepositoryError("Cannot add submodules to bare repositories") # END handle bare repos + cls._validated_name(name) path = cls._to_relative_path(repo, path) # Ensure we never put backslashes into the URL, as might happen on Windows. @@ -734,9 +740,7 @@ def update( prefix = "DRY-RUN: " # END handle prefix - # To keep things plausible in dry-run mode. - if dry_run: - mrepo = None + mrepo = None # END init mrepo def fetch_remotes(module_repo: "Repo") -> None: @@ -771,6 +775,8 @@ def fetch_remotes(module_repo: "Repo") -> None: # END fetch new data try: + self._validated_name(self.name) + # ENSURE REPO IS PRESENT AND UP-TO-DATE ####################################### try: @@ -871,6 +877,7 @@ def fetch_remotes(module_repo: "Repo") -> None: ############################# binsha = self.binsha hexsha = self.hexsha + is_detached = False if mrepo is not None: # mrepo is only set if we are not in dry-run mode or if the module # existed. @@ -1020,6 +1027,7 @@ def move(self, module_path: PathLike, configuration: bool = True, module: bool = raise ValueError("You must specify to move at least the module or the configuration of the submodule") # END handle input + self._validated_name(self.name) module_checkout_path = self._to_relative_path(self.repo, module_path) # VERIFY DESTINATION @@ -1160,6 +1168,7 @@ def remove( raise ValueError("Need to specify to delete at least the module, or the configuration") # END handle parameters + self._validated_name(self.name) # Recursively remove children of this submodule. nc = 0 for csm in self.children(): @@ -1213,6 +1222,7 @@ def remove( for remote in mod.remotes: num_branches_with_new_commits = 0 rrefs = remote.refs + rref = None for rref in rrefs: num_branches_with_new_commits += len(mod.git.cherry(rref)) != 0 # END for each remote ref @@ -1416,6 +1426,9 @@ def rename(self, new_name: str) -> "Submodule": if self.name == new_name: return self + self._validated_name(self.name) + self._validated_name(new_name) + # .git/config with self.repo.config_writer() as pw: # As we ourselves didn't write anything about submodules into the parent @@ -1463,6 +1476,7 @@ def module(self) -> "Repo": If a repository was not available. This could also mean that it was not yet initialized. """ + self._validated_name(self.name) module_checkout_abspath = self.abspath try: repo = git.Repo(module_checkout_abspath) diff --git a/git/objects/submodule/root.py b/git/objects/submodule/root.py index d93193fa3..d068049c1 100644 --- a/git/objects/submodule/root.py +++ b/git/objects/submodule/root.py @@ -7,6 +7,7 @@ import git from git.exc import InvalidGitRepositoryError +from git.util import IterableList from .base import Submodule, UpdateProgress from .util import find_first_remote_branch @@ -19,7 +20,6 @@ if TYPE_CHECKING: from git.repo import Repo - from git.util import IterableList # ---------------------------------------------------------------------------- @@ -162,6 +162,7 @@ def update( # type: ignore[override] prefix = "DRY-RUN: " repo = self.repo + sms: "IterableList[Submodule]" = IterableList("name") try: # SETUP BASE COMMIT @@ -182,7 +183,7 @@ def update( # type: ignore[override] # END handle previous commit psms: "IterableList[Submodule]" = self.list_items(repo, parent_commit=previous_commit) - sms: "IterableList[Submodule]" = self.list_items(repo) + sms = self.list_items(repo) spsms = set(psms) ssms = set(sms) diff --git a/git/refs/head.py b/git/refs/head.py index 3c43993e7..7f563374e 100644 --- a/git/refs/head.py +++ b/git/refs/head.py @@ -19,6 +19,7 @@ from typing import Any, Sequence, TYPE_CHECKING, Union +from git.cmd import Git from git.types import Commit_ish, PathLike if TYPE_CHECKING: @@ -62,6 +63,7 @@ def reset( index: bool = True, working_tree: bool = False, paths: Union[PathLike, Sequence[PathLike], None] = None, + allow_unsafe_options: bool = False, **kwargs: Any, ) -> "HEAD": """Reset our HEAD to the given commit optionally synchronizing the index and @@ -84,12 +86,21 @@ def reset( Single path or list of paths relative to the git root directory that are to be reset. This allows to partially reset individual files. + :param allow_unsafe_options: + Allow unsafe options such as ``--pathspec-from-file`` to be passed to + :manpage:`git-reset(1)`. + :param kwargs: Additional arguments passed to :manpage:`git-reset(1)`. :return: self """ + if not allow_unsafe_options: + Git.check_unsafe_options( + options=Git._option_candidates([commit], kwargs), + unsafe_options=Git.unsafe_git_pathspec_from_file_options, + ) mode: Union[str, None] mode = "--soft" if index: @@ -234,7 +245,12 @@ def rename(self, new_path: PathLike, force: bool = False) -> "Head": self.path = "%s/%s" % (self._common_path_default, new_path) return self - def checkout(self, force: bool = False, **kwargs: Any) -> Union["HEAD", "Head"]: + def checkout( + self, + force: bool = False, + allow_unsafe_options: bool = False, + **kwargs: Any, + ) -> Union["HEAD", "Head"]: """Check out this head by setting the HEAD to this reference, by updating the index to reflect the tree we point to and by updating the working tree to reflect the latest index. @@ -246,6 +262,10 @@ def checkout(self, force: bool = False, **kwargs: Any) -> Union["HEAD", "Head"]: If ``False``, :exc:`~git.exc.GitCommandError` will be raised in that situation. + :param allow_unsafe_options: + Allow unsafe options such as ``--pathspec-from-file`` to be passed to + :manpage:`git-checkout(1)`. + :param kwargs: Additional keyword arguments to be passed to git checkout, e.g. ``b="new_branch"`` to create a new branch at the given spot. @@ -261,6 +281,11 @@ def checkout(self, force: bool = False, **kwargs: Any) -> Union["HEAD", "Head"]: the HEAD detached which is allowed and possible, but remains a special state that some tools might not be able to handle. """ + if not allow_unsafe_options: + Git.check_unsafe_options( + options=Git._option_candidates([], kwargs), + unsafe_options=Git.unsafe_git_pathspec_from_file_options, + ) kwargs["f"] = force if kwargs["f"] is False: kwargs.pop("f") diff --git a/git/refs/log.py b/git/refs/log.py index fbbe66b22..0681943bf 100644 --- a/git/refs/log.py +++ b/git/refs/log.py @@ -269,6 +269,7 @@ def entry_at(cls, filepath: PathLike, index: int) -> "RefLogEntry": return RefLogEntry.from_line(fp.readlines()[index].strip()) # Read until index is reached. + line = b"" for i in range(index + 1): line = fp.readline() if not line: diff --git a/git/refs/symbolic.py b/git/refs/symbolic.py index 020de5e13..824d0c46c 100644 --- a/git/refs/symbolic.py +++ b/git/refs/symbolic.py @@ -119,7 +119,7 @@ def _get_validated_path(base: PathLike, path: PathLike) -> str: common_path = os.path.commonpath([base_path, abs_path]) except ValueError as e: raise ValueError("Reference path %r escapes the repository" % path) from e - if os.path.normcase(common_path) != os.path.normcase(base_path): + if common_path != base_path: raise ValueError("Reference path %r escapes the repository" % path) return abs_path diff --git a/git/repo/base.py b/git/repo/base.py index 6594101f3..df61e2d28 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -142,6 +142,14 @@ class Repo: re_author_committer_start = re.compile(r"^(author|committer)") re_tab_full_line = re.compile(r"^\t(.*)$") + unsafe_git_init_options = [ + # Can install hooks that execute during later Git commands: + "--template", + # Redirects the repository metadata to a caller-controlled path: + "--separate-git-dir", + ] + """Options to :manpage:`git-init(1)` that permit unsafe code execution or I/O.""" + unsafe_git_clone_options = [ # Executes arbitrary commands: "--upload-pack", @@ -933,13 +941,17 @@ def is_valid_object(self, sha: str, object_type: Union[str, None] = None) -> boo return False def _get_daemon_export(self) -> bool: - if self.git_dir: - filename = osp.join(self.git_dir, self.DAEMON_EXPORT_FILE) + git_dir = getattr(self, "git_dir", None) + if git_dir is None: + return False + filename = osp.join(git_dir, self.DAEMON_EXPORT_FILE) return osp.exists(filename) def _set_daemon_export(self, value: object) -> None: - if self.git_dir: - filename = osp.join(self.git_dir, self.DAEMON_EXPORT_FILE) + git_dir = getattr(self, "git_dir", None) + if git_dir is None: + return + filename = osp.join(git_dir, self.DAEMON_EXPORT_FILE) fileexists = osp.exists(filename) if value and not fileexists: touch(filename) @@ -961,8 +973,7 @@ def _get_alternates(self) -> List[str]: :return: List of strings being pathnames of alternates """ - if self.git_dir: - alternates_path = osp.join(self.git_dir, "objects", "info", "alternates") + alternates_path = osp.join(self.common_dir, "objects", "info", "alternates") if osp.exists(alternates_path): with open(alternates_path, "rb") as f: @@ -1280,6 +1291,7 @@ class InfoTD(TypedDict, total=False): keepends = True for line_bytes in data.splitlines(keepends): + line_str = "" try: line_str = line_bytes.rstrip().decode(defenc) except UnicodeDecodeError: @@ -1394,6 +1406,7 @@ def init( mkdir: bool = True, odbt: Type[GitCmdObjectDB] = GitCmdObjectDB, expand_vars: bool = True, + allow_unsafe_options: bool = False, **kwargs: Any, ) -> "Repo": """Initialize a git repository at the given path if specified. @@ -1418,6 +1431,10 @@ def init( information disclosure, allowing attackers to access the contents of environment variables. + :param allow_unsafe_options: + Allow unsafe options to be used, such as ``--template`` and + ``--separate-git-dir``. + :param kwargs: Keyword arguments serving as additional options to the :manpage:`git-init(1)` command. @@ -1425,6 +1442,11 @@ def init( :return: :class:`Repo` (the newly created repo) """ + if not allow_unsafe_options: + Git.check_unsafe_options( + options=Git._option_candidates([], kwargs), + unsafe_options=cls.unsafe_git_init_options, + ) if path: path = expand_path(path, expand_vars) if mkdir and path and not osp.exists(path): @@ -1738,8 +1760,9 @@ def currently_rebasing_on(self) -> Commit | None: ``None`` if we are not currently rebasing. """ - if self.git_dir: - rebase_head_file = osp.join(self.git_dir, "REBASE_HEAD") + if not self.git_dir: + return None + rebase_head_file = osp.join(self.git_dir, "REBASE_HEAD") if not osp.isfile(rebase_head_file): return None with open(rebase_head_file, "rt") as f: diff --git a/git/util.py b/git/util.py index 6f002d98a..02f57c132 100644 --- a/git/util.py +++ b/git/util.py @@ -315,6 +315,68 @@ def join_path_native(a: PathLike, *p: PathLike) -> PathLike: return to_native_path(join_path(a, *p)) +def _is_path_rooted(path: PathLike) -> bool: + r"""Whether ``path`` has a root, including one encoded in a UNC drive. + + On Windows, ``\directory`` is rooted on the current drive without being + absolute, while ``C:\directory`` has both a drive and a root. In contrast, + ``directory`` and the drive-relative ``C:directory`` have no root. + UNC paths are rooted: ``\\server\share`` stores the share in the drive + returned by :func:`os.path.splitdrive`, while ``\\server\share\directory`` + additionally has a rooted tail. + On POSIX, which has no drive concept, this simply distinguishes absolute + paths such as ``/directory`` from relative paths such as ``directory``. + """ + drive, tail = osp.splitdrive(os.fspath(path)) + separators = (os.sep,) if os.altsep is None else (os.sep, os.altsep) + return tail.startswith(separators) or drive.startswith(separators) + + +def _to_relative_path(root: PathLike, path: PathLike) -> str: + r"""Return a normalized Git-style path confined to ``root``. + + A Windows path such as ``\directory`` is rooted but not absolute. Resolve it + against the drive of ``root`` rather than treating it as relative to ``root``. + Drive-relative paths such as ``C:directory`` are rejected because their meaning + depends on process-global per-drive state. + + For example, with ``root`` set to ``C:\repo`` on Windows: + + * ``directory\file`` -> ``directory/file`` + * ``directory\`` -> ``directory/`` + * ``C:\repo\directory\file`` -> ``directory/file`` + * ``\repo\directory\file`` -> ``directory/file`` + * ``C:directory\file`` -> :exc:`ValueError` + * ``C:\other\file`` -> :exc:`ValueError` + + On POSIX, ``/repo/directory/file`` under ``/repo`` similarly becomes + ``directory/file``. A trailing separator is preserved as a Git-style ``/``. + """ + path_str = os.fspath(path) + if not path_str: + return path_str + + drive, _tail = osp.splitdrive(path_str) + rooted = _is_path_rooted(path_str) + if drive and not rooted: + raise ValueError("Drive-relative path %r is not supported" % path_str) + + root_abs = osp.abspath(os.fspath(root)) + path_abs = osp.abspath(osp.join(root_abs, path_str)) + try: + common_path = osp.commonpath([root_abs, path_abs]) + except ValueError as e: + raise ValueError("Path %r is not in repository at %r" % (path_str, root_abs)) from e + if common_path != root_abs: + raise ValueError("Path %r is not in repository at %r" % (path_str, root_abs)) + + relative_path = to_native_path_linux(osp.relpath(path_abs, root_abs)) + separators = (os.sep,) if os.altsep is None else (os.sep, os.altsep) + if path_str.endswith(separators) and relative_path != "." and not relative_path.endswith("/"): + relative_path += "/" + return relative_path + + def assure_directory_exists(path: PathLike, is_file: bool = False) -> bool: """Make sure that the directory pointed to by path exists. diff --git a/pyproject.toml b/pyproject.toml index 117f044d6..b7c437bf3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools"] build-backend = "setuptools.build_meta" [tool.pytest.ini_options] -addopts = "--cov=git --cov-report=term -ra" +addopts = "--cov=git --cov-report=term -rfEX" filterwarnings = "ignore::DeprecationWarning" python_files = "test_*.py" tmp_path_retention_policy = "failed" @@ -13,7 +13,7 @@ testpaths = "test" # Space separated list of paths from root e.g test tests doc # --cov-report term-missing # to terminal with line numbers # --cov-report html:path # html file at path # --maxfail # number of errors before giving up -# -rfE # default test summary: list fail and error +# -rfEX # test summary: list failures, errors, and unexpected passes; omit expected failures # -ra # test summary: list all non-passing (fail, error, skip, xfail, xpass) # --ignore-glob=**/gitdb/* # ignore glob paths # filterwarnings ignore::WarningType # ignores those warnings diff --git a/test/lib/helper.py b/test/lib/helper.py index deddced2b..58923eaef 100644 --- a/test/lib/helper.py +++ b/test/lib/helper.py @@ -19,6 +19,8 @@ "GIT_REPO", "GIT_DAEMON_PORT", "xfail_if_raises", + "symlinks_supported", + "requires_symlinks", ] import contextlib @@ -29,6 +31,7 @@ import logging import os import os.path as osp +from stat import S_ISLNK, ST_MODE import subprocess import sys import tempfile @@ -491,6 +494,28 @@ def _executable(self, basename): raise RuntimeError(f"no regular file or symlink {path!r}") +def symlinks_supported() -> bool: + """Check whether this process can actually create a symlink. + + On Windows the platform alone doesn't decide it: creating a symlink needs either + Developer Mode or SeCreateSymbolicLinkPrivilege, and an unprivileged process gets + OSError (WinError 1314) instead. + """ + with tempfile.TemporaryDirectory(prefix="gitpython-symlink-check-") as temp_dir: + link_path = osp.join(temp_dir, "link") + try: + os.symlink("missing-target", link_path) + except (NotImplementedError, OSError): + return False + return S_ISLNK(os.lstat(link_path)[ST_MODE]) + + +requires_symlinks = pytest.mark.skipif( + not symlinks_supported(), + reason="symlinks are unavailable, or need privileges this process doesn't have", +) + + @contextlib.contextmanager def xfail_if_raises( condition: bool, diff --git a/test/test_commit.py b/test/test_commit.py index cb0427740..431269b29 100644 --- a/test/test_commit.py +++ b/test/test_commit.py @@ -276,14 +276,14 @@ def test_iteration(self): assert ltd_commits and len(ltd_commits) < len(all_commits) # Show commits of multiple paths, resulting in a union of commits. - less_ltd_commits = list(Commit.iter_items(self.rorepo, "master", paths=("CHANGES", "AUTHORS"))) + less_ltd_commits = list(Commit.iter_items(self.rorepo, "HEAD", paths=("CHANGES", "AUTHORS"))) assert len(ltd_commits) < len(less_ltd_commits) class Child(Commit): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - child_commits = list(Child.iter_items(self.rorepo, "master", paths=("CHANGES", "AUTHORS"))) + child_commits = list(Child.iter_items(self.rorepo, "HEAD", paths=("CHANGES", "AUTHORS"))) assert type(child_commits[0]) is Child def test_iter_items(self): @@ -327,6 +327,11 @@ def test_rev_list_bisect_all(self): for sha1, commit in zip(expected_ids, commits): self.assertEqual(sha1, commit.hexsha) + def test_iter_from_invalid_process_or_stream(self): + for source, error in ((Mock(wait=Mock(), stdout=None), ValueError), (object(), TypeError)): + with self.assertRaises(error): + list(Commit._iter_from_process_or_stream(self.rorepo, source)) + @with_rw_directory def test_ambiguous_arg_iteration(self, rw_dir): rw_repo = Repo.init(osp.join(rw_dir, "test_ambiguous_arg")) @@ -536,7 +541,7 @@ def test_trailers(self): ), ] for msg in msgs: - commit = copy.copy(self.rorepo.commit("master")) + commit = copy.copy(self.rorepo.commit("HEAD")) commit.message = msg assert commit.trailers_list == [ (KEY_1, VALUE_1_1), @@ -559,13 +564,13 @@ def test_trailers(self): ] for msg in msgs: - commit = copy.copy(self.rorepo.commit("master")) + commit = copy.copy(self.rorepo.commit("HEAD")) commit.message = msg assert commit.trailers_list == [] assert commit.trailers_dict == {} # Check that only the last key value paragraph is evaluated. - commit = copy.copy(self.rorepo.commit("master")) + commit = copy.copy(self.rorepo.commit("HEAD")) 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 == [(KEY_2, VALUE_2)] assert commit.trailers_dict == {KEY_2: [VALUE_2]} diff --git a/test/test_config.py b/test/test_config.py index 361a51fa9..fd0d347a4 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -14,7 +14,7 @@ from git import GitConfigParser from git.config import _OMD, cp -from git.util import rmfile +from git.util import cwd, rmfile from test.lib import SkipTest, TestCase, fixture_path, with_rw_directory @@ -194,6 +194,43 @@ def test_set_value_rejects_unsafe_section_and_option_names(self, rw_dir): self.assertEqual(git_config.get_value("user", "name"), "safe") self.assertFalse(git_config.has_section("core")) + @with_rw_directory + def test_writer_rejects_invalid_option_names(self, rw_dir): + config_path = osp.join(rw_dir, "config") + bad_options = ( + "name=value", + "name#comment", + "name;comment", + "name with space", + "name\twith-tab", + "name[section", + "name]section", + "name:colon", + 'name"quote', + "name\\escape", + ) + + with GitConfigParser(config_path, read_only=False) as git_config: + git_config.add_section("user") + for bad_option in bad_options: + with pytest.raises(ValueError, match="option name"): + git_config.set("user", bad_option, "unsafe") + with pytest.raises(ValueError, match="option name"): + git_config.set_value("user", bad_option, "unsafe") + with pytest.raises(ValueError, match="option name"): + git_config.add_value("user", bad_option, "unsafe") + + git_config.set_value("user", "safe-option1", "safe") + git_config.set_value("user", "safe_option2", "safe") + git_config.set_value("user", "3safe_option", "safe") + git_config.set_value("user", "safe.option3", "safe") + + with GitConfigParser(config_path, read_only=True) as git_config: + self.assertEqual(git_config.get_value("user", "safe-option1"), "safe") + self.assertEqual(git_config.get_value("user", "safe_option2"), "safe") + self.assertEqual(git_config.get_value("user", "3safe_option"), "safe") + self.assertEqual(git_config.get_value("user", "safe.option3"), "safe") + @with_rw_directory def test_writer_rejects_unquoted_section_terminators(self, rw_dir): config_path = osp.join(rw_dir, "config") @@ -374,6 +411,22 @@ def test_config_relative_path_include(self, rw_dir): with GitConfigParser(relative_config_path, read_only=True) as cr: assert cr.get_value("included", "value") == "included" + @pytest.mark.skipif(os.name != "nt", reason="Specifically for Windows drive-rooted paths.") + @with_rw_directory + def test_config_drive_rooted_path_include(self, rw_dir): + with cwd(rw_dir): + included_path = osp.join(rw_dir, "included") + with GitConfigParser(included_path, read_only=False) as cw: + cw.set_value("included", "value", "included") + + _drive, rooted_included_path = osp.splitdrive(included_path) + config_path = osp.join(rw_dir, "config") + with GitConfigParser(config_path, read_only=False) as cw: + cw.set_value("include", "path", rooted_included_path) + + with GitConfigParser(config_path, read_only=True) as cr: + assert cr.get_value("included", "value") == "included" + @with_rw_directory def test_multiple_include_paths_with_same_key(self, rw_dir): """Test that multiple 'path' entries under [include] are all respected. @@ -411,15 +464,11 @@ def test_multiple_include_paths_with_same_key(self, rw_dir): assert cr.get_value("user", "name") == "from-inc1" assert cr.get_value("core", "bar") == "from-inc2" - @pytest.mark.xfail( - sys.platform == "win32", - reason='Second config._has_includes() assertion fails (for "config is included if path is matching git_dir")', - raises=AssertionError, - ) @with_rw_directory def test_conditional_includes_from_git_dir(self, rw_dir): # Initiate repository path. git_dir = osp.join(rw_dir, "target1", "repo1") + git_dir_pattern = git_dir.replace("\\", "/") os.makedirs(git_dir) # Initiate mocked repository. @@ -431,6 +480,7 @@ def test_conditional_includes_from_git_dir(self, rw_dir): template = '[includeIf "{}:{}"]\n path={}\n' with open(path1, "w") as stream: + # on Windows, this writes a backslash pattern. stream.write(template.format("gitdir", git_dir, path2)) # Ensure that config is ignored if no repo is set. @@ -438,14 +488,26 @@ def test_conditional_includes_from_git_dir(self, rw_dir): assert not config._has_includes() assert config._included_paths() == [] - # Ensure that config is included if path is matching git_dir. + # Git uses forward slashes in gitdir patterns on every platform: + # backslashes escape the next pattern character rather than separate + # path components. On Windows, GitPython therefore normalizes git_dir + # to forward slashes but leaves this backslash pattern unchanged, so + # the two do not match and no path is included. + with GitConfigParser(path1, repo=repo, merge_includes=False) as config: + expected_paths = [] if sys.platform == "win32" else [("path", path2)] + assert config._included_paths() == expected_paths + + # Ensure that Git's forward-slash syntax matches native Windows paths. + with open(path1, "w") as stream: + stream.write(template.format("gitdir", git_dir_pattern, path2)) + with GitConfigParser(path1, repo=repo) as config: assert config._has_includes() assert config._included_paths() == [("path", path2)] # Ensure that config is ignored if case is incorrect. with open(path1, "w") as stream: - stream.write(template.format("gitdir", git_dir.upper(), path2)) + stream.write(template.format("gitdir", git_dir_pattern.upper(), path2)) with GitConfigParser(path1, repo=repo) as config: assert not config._has_includes() @@ -453,7 +515,7 @@ def test_conditional_includes_from_git_dir(self, rw_dir): # Ensure that config is included if case is ignored. with open(path1, "w") as stream: - stream.write(template.format("gitdir/i", git_dir.upper(), path2)) + stream.write(template.format("gitdir/i", git_dir_pattern.upper(), path2)) with GitConfigParser(path1, repo=repo) as config: assert config._has_includes() @@ -483,6 +545,20 @@ def test_conditional_includes_from_git_dir(self, rw_dir): assert config._has_includes() assert config._included_paths() == [("path", path2)] + @with_rw_directory + def test_conditional_includes_do_not_treat_backslashes_as_separators(self, rw_dir): + git_dir = osp.join(rw_dir, "target", "repo") + repo = mock.Mock(git_dir=git_dir) + config_path = osp.join(rw_dir, "config") + included_path = osp.join(rw_dir, "included") + pattern = git_dir.replace("\\", "/").replace("/target/repo", R"/target\repo") + + with open(config_path, "w") as stream: + stream.write(f'[includeIf "gitdir:{pattern}"]\n path={included_path}\n') + + with GitConfigParser(config_path, repo=repo, merge_includes=False) as config: + assert config._included_paths() == [] + @with_rw_directory def test_conditional_includes_from_branch_name(self, rw_dir): # Initiate mocked branch. diff --git a/test/test_docs.py b/test/test_docs.py index c3cfec3e0..0810d954d 100644 --- a/test/test_docs.py +++ b/test/test_docs.py @@ -72,21 +72,20 @@ def test_init_repo_object(self, rw_dir): # heads, tags and references # heads are branches in git-speak # [8-test_init_repo_object] - self.assertEqual( - repo.head.ref, - repo.heads.master, # head is a sym-ref pointing to master. - "It's ok if TC not running from `master`.", - ) + active_branch = repo.active_branch + self.assertEqual(repo.head.ref, active_branch) # HEAD is a sym-ref pointing to the active branch. self.assertEqual(repo.tags["0.3.5"], repo.tag("refs/tags/0.3.5")) # You can access tags in various ways too. - self.assertEqual(repo.refs.master, repo.heads["master"]) # .refs provides all refs, i.e. heads... + self.assertEqual(repo.refs[active_branch.name], repo.heads[active_branch.name]) # .refs provides all refs... if "TRAVIS" not in os.environ: - self.assertEqual(repo.refs["origin/master"], repo.remotes.origin.refs.master) # ... remotes ... + remote_branch = next(ref for ref in repo.remotes.origin.refs if ref.remote_head != "HEAD") + self.assertEqual(repo.refs[remote_branch.name], remote_branch) # ...remotes... self.assertEqual(repo.refs["0.3.5"], repo.tags["0.3.5"]) # ... and tags. # ![8-test_init_repo_object] # Create a new head/branch. # [9-test_init_repo_object] + original_branch = cloned_repo.active_branch new_branch = cloned_repo.create_head("feature") # Create a new branch ... assert cloned_repo.active_branch != new_branch # which wasn't checked out yet ... self.assertEqual(new_branch.commit, cloned_repo.active_branch.commit) # pointing to the checked-out commit. @@ -146,10 +145,10 @@ def update(self, op_code, cur_count, max_count=None, message=""): assert origin.exists() for fetch_info in origin.fetch(progress=MyProgressPrinter()): print("Updated %s to %s" % (fetch_info.ref, fetch_info.commit)) - # Create a local branch at the latest fetched master. We specify the name - # statically, but you have all information to do it programmatically as well. - bare_master = bare_repo.create_head("master", origin.refs.master) - bare_repo.head.set_reference(bare_master) + # Create a local branch at one of the remote's branches. + remote_branch = next(ref for ref in origin.refs if ref.remote_head != "HEAD") + bare_branch = bare_repo.create_head(remote_branch.remote_head, remote_branch) + bare_repo.head.set_reference(bare_branch) assert not bare_repo.delete_remote(origin).exists() # push and pull behave very similarly. # ![12-test_init_repo_object] @@ -162,35 +161,39 @@ def update(self, op_code, cur_count, max_count=None, message=""): new_file_path = os.path.join(cloned_repo.working_tree_dir, "my-new-file") open(new_file_path, "wb").close() # Create new file in working tree. cloned_repo.index.add([new_file_path]) # Add it to the index. - # Commit the changes to deviate masters history. + # Commit the changes to deviate from the original branch's history. cloned_repo.index.commit("Added a new file in the past - for later merge") # Prepare a merge. - master = cloned_repo.heads.master # Right-hand side is ahead of us, in the future. - merge_base = cloned_repo.merge_base(new_branch, master) # Allows for a three-way merge. - cloned_repo.index.merge_tree(master, base=merge_base) # Write the merge result into index. + merge_base = cloned_repo.merge_base(new_branch, original_branch) # Allows for a three-way merge. + cloned_repo.index.merge_tree(original_branch, base=merge_base) # Write the merge result into index. cloned_repo.index.commit( "Merged past and now into future ;)", - parent_commits=(new_branch.commit, master.commit), + parent_commits=(new_branch.commit, original_branch.commit), ) - # Now new_branch is ahead of master, which probably should be checked out and reset softly. + # Now new_branch is ahead of the original branch, which probably should be checked out and reset softly. # Note that all these operations didn't touch the working tree, as we managed it ourselves. # This definitely requires you to know what you are doing! :) assert os.path.basename(new_file_path) in new_branch.commit.tree # New file is now in tree. - master.commit = new_branch.commit # Let master point to most recent commit. - cloned_repo.head.reference = master # We adjusted just the reference, not the working tree or index. + original_branch.commit = new_branch.commit # Let the original branch point to the most recent commit. + cloned_repo.head.reference = original_branch # We adjusted just the reference, not the working tree or index. # ![13-test_init_repo_object] # submodules # [14-test_init_repo_object] - # Create a new submodule and check it out on the spot, setup to track master - # branch of `bare_repo`. As our GitPython repository has submodules already that - # point to GitHub, make sure we don't interact with them. + # Create a new submodule and check it out on the spot, set up to track the + # default branch of `bare_repo`. As our GitPython repository has submodules + # already that point to GitHub, make sure we don't interact with them. for sm in cloned_repo.submodules: assert not sm.remove().exists() # after removal, the sm doesn't exist anymore - sm = cloned_repo.create_submodule("mysubrepo", "path/to/subrepo", url=bare_repo.git_dir, branch="master") + sm = cloned_repo.create_submodule( + "mysubrepo", + "path/to/subrepo", + url=bare_repo.git_dir, + branch=bare_branch.name, + ) # .gitmodules was written and added to the index, which is now being committed. cloned_repo.index.commit("Added submodule") diff --git a/test/test_git.py b/test/test_git.py index 7256e9bb5..a88d980fb 100644 --- a/test/test_git.py +++ b/test/test_git.py @@ -215,6 +215,18 @@ def test_option_candidates_ignore_untransformed_kwargs(self): self.assertEqual(options, ["--max-count"]) + def test_option_candidates_include_falsey_non_boolean_values(self): + kwargs = {"pathspec_from_file": 0} + candidates = Git._option_candidates(kwargs=kwargs) + + self.assertEqual(candidates, ["--pathspec-from-file"]) + self.assertEqual(self.git.transform_kwargs(**kwargs), ["--pathspec-from-file=0"]) + with self.assertRaises(UnsafeOptionError): + Git.check_unsafe_options( + options=candidates, + unsafe_options=Git.unsafe_git_pathspec_from_file_options, + ) + def test_option_candidates_include_split_single_char_option_values(self): cases = [ ({"n": "--upload-pack=helper"}, ["-n", "--upload-pack=helper"], ["--upload-pack"]), @@ -230,7 +242,15 @@ def test_option_candidates_include_split_single_char_option_values(self): unsplit_kwargs = {"n": "--upload-pack=helper", "split_single_char_options": False} self.assertEqual(self.git.transform_kwargs(**unsplit_kwargs), ["-n--upload-pack=helper"]) - self.assertEqual(Git._option_candidates(kwargs=unsplit_kwargs), ["-n"]) + self.assertEqual(Git._option_candidates(kwargs=unsplit_kwargs), ["-n--upload-pack=helper"]) + + def test_option_candidates_include_joined_single_char_option_values(self): + kwargs = {"n": "uhelper", "split_single_char_options": False} + candidates = Git._option_candidates(kwargs=kwargs) + + self.assertEqual(candidates, ["-nuhelper"]) + with self.assertRaises(UnsafeOptionError): + Git.check_unsafe_options(options=candidates, unsafe_options=["-u"]) _shell_cases = ( # value_in_call, value_from_class, expected_popen_arg @@ -302,7 +322,7 @@ def test_it_honors_kill_after_timeout_with_output_stream(self): status, _, stderr = self.git.execute( command, output_stream=output_stream, - kill_after_timeout=0.1, + kill_after_timeout=1, with_exceptions=False, with_extended_output=True, ) diff --git a/test/test_index.py b/test/test_index.py index 3ad5a457f..469a9dc17 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -33,14 +33,14 @@ UnmergedEntriesError, UnsafeOptionError, ) -from git.index.fun import hook_path, run_commit_hook +from git.index.fun import _git_for_windows_bash, _which_from_path, hook_path, run_commit_hook from git.index.typ import BaseIndexEntry, IndexEntry from git.index.util import TemporaryFileSwap from git.objects import Blob from git.util import Actor, cwd, hex_to_bin, rmtree from test.lib import TestBase, VirtualEnvironment, fixture, fixture_path, with_rw_directory, with_rw_repo, PathLikeMock -from test.lib.helper import xfail_if_raises +from test.lib.helper import symlinks_supported, xfail_if_raises HOOKS_SHEBANG = "#!/usr/bin/env sh\n" @@ -175,19 +175,6 @@ def _decode(stdout): _win_bash_status = WinBashStatus.check() -def _windows_supports_symlinks(): - if sys.platform != "win32": - return False - - with tempfile.TemporaryDirectory(prefix="gitpython-symlink-check-") as temp_dir: - link_path = osp.join(temp_dir, "link") - try: - os.symlink("missing-target", link_path) - except (NotImplementedError, OSError): - return False - return S_ISLNK(os.lstat(link_path)[ST_MODE]) - - def _make_hook(git_dir, name, content, make_exec=True): """A helper to create a hook""" hp = hook_path(name, git_dir) @@ -212,6 +199,32 @@ def test_checkout_rejects_unsafe_prefix(self, rw_repo): rw_repo.index.checkout(prefix=f"{target}/", allow_unsafe_options=True) self.assertTrue(osp.isfile(osp.join(target, "CHANGES"))) + @with_rw_repo("HEAD") + def test_remove_rejects_pathspec_from_file(self, rw_repo): + with tempfile.TemporaryDirectory() as tdir: + pathspecs = Path(tdir) / "pathspecs" + pathspecs.write_bytes(b"unmatched-path-one\nunmatched-path-two") + for option_name in ("pathspec_from_file", "pathspec_from"): + with self.assertRaises(UnsafeOptionError): + rw_repo.index.remove( + [], + pathspec_file_nul=True, + **{option_name: str(pathspecs)}, + ) + + @with_rw_repo("HEAD") + def test_remove_allows_explicit_pathspec_from_file(self, rw_repo): + with tempfile.TemporaryDirectory() as tdir: + pathspecs = Path(tdir) / "pathspecs" + pathspecs.write_bytes(b"CHANGES\0") + removed = rw_repo.index.remove( + [], + pathspec_from_file=str(pathspecs), + pathspec_file_nul=True, + allow_unsafe_options=True, + ) + assert "CHANGES" in removed + def __init__(self, *args): super().__init__(*args) self._reset_progress() @@ -327,6 +340,22 @@ def add_bad_blob(): except Exception as ex: assert "index.lock' could not be obtained" not in str(ex) + @with_rw_repo("0.1.6") + def test_read_tree_methods_reject_index_output(self, rw_repo): + output_path = (Path(rw_repo.working_tree_dir) / "alternate-index").as_posix() + unsafe_option = f"--index-output={output_path}" + + with pytest.raises(UnsafeOptionError): + IndexFile.from_tree(rw_repo, unsafe_option) + with pytest.raises(UnsafeOptionError): + IndexFile.from_tree(rw_repo, "HEAD", index_output=output_path) + with pytest.raises(UnsafeOptionError): + rw_repo.index.reset(unsafe_option) + with pytest.raises(UnsafeOptionError): + rw_repo.index.merge_tree(unsafe_option) + with pytest.raises(UnsafeOptionError): + rw_repo.index.merge_tree("HEAD", base=unsafe_option) + @with_rw_repo("0.1.6") def test_index_file_from_tree(self, rw_repo): common_ancestor_sha = "5117c9c8a4d3af19a9958677e45cda9269de1541" @@ -613,7 +642,7 @@ def _count_existing(self, repo, files): @with_rw_repo("0.1.6") def test_index_mutation(self, rw_repo): with xfail_if_raises( - sys.platform == "win32" and (Git().config("core.symlinks") == "true" or _windows_supports_symlinks()), + sys.platform == "win32" and (Git().config("core.symlinks") == "true" or symlinks_supported()), raises=(FileNotFoundError, GitCommandError), reason="Assumes symlinks are not created on Windows and opens a symlink to a nonexistent target.", ): @@ -1086,14 +1115,52 @@ class Mocked: path = os.path.join(repo_root, f"directory2{os.sep}") index = IndexFile(repo) - expected_path = f"directory2{os.sep}" + expected_path = "directory2/" actual_path = index._to_relative_path(path) self.assertEqual(expected_path, actual_path) - with mock.patch("git.index.base.os.path") as ospath_mock: - ospath_mock.relpath.return_value = f"directory2{os.sep}" - actual_path = index._to_relative_path(path) - self.assertEqual(expected_path, actual_path) + @pytest.mark.skipif(sys.platform != "win32", reason="Specifically for Windows.") + def test__to_relative_path_windows_unc_share_root(self): + for repo_root in [R"\\server\share", R"\\?\UNC\server\share"]: + with self.subTest(repo_root=repo_root): + repo = mock.Mock(bare=False, git_dir=repo_root, working_tree_dir=repo_root) + index = IndexFile(repo) + + self.assertEqual(index._to_relative_path(repo_root), ".") + + @pytest.mark.skipif(sys.platform != "win32", reason="Specifically for Windows.") + @with_rw_directory + def test__to_relative_path_windows_path_kinds(self, rw_dir): + repo_root = osp.join(rw_dir, "repo") + os.makedirs(osp.join(repo_root, "nested")) + + class Mocked: + bare = False + git_dir = osp.join(repo_root, ".git") + working_tree_dir = repo_root + + index = IndexFile(Mocked()) + inside_path = osp.join(repo_root, "nested", "file") + _drive, rooted_inside_path = osp.splitdrive(inside_path) + + self.assertEqual(index._to_relative_path(inside_path), "nested/file") + self.assertEqual(index._to_relative_path(rooted_inside_path), "nested/file") + self.assertEqual(index._to_relative_path(rooted_inside_path.replace("\\", "/")), "nested/file") + self.assertEqual(index._to_relative_path(PathLikeMock(inside_path)), "nested/file") + self.assertEqual(index._to_relative_path(inside_path.upper()), "NESTED/FILE") + self.assertRaises(ValueError, index._to_relative_path, osp.join(repo_root + "-other", "file")) + self.assertRaises(ValueError, index._to_relative_path, osp.join("..", "outside")) + self.assertRaises(ValueError, index._to_relative_path, f"{osp.splitdrive(repo_root)[0]}relative") + self.assertRaises(ValueError, index._to_relative_path, R"Z:\outside") + self.assertRaises(ValueError, index._to_relative_path, R"\\server\share\outside") + self.assertRaises(ValueError, index._to_relative_path, R"\\?\C:\outside") + + Mocked.bare = True + bare_index = IndexFile(Mocked()) + self.assertRaises(InvalidGitRepositoryError, bare_index._to_relative_path, rooted_inside_path) + self.assertRaises( + InvalidGitRepositoryError, bare_index._to_relative_path, f"{osp.splitdrive(repo_root)[0]}relative" + ) @pytest.mark.xfail( type(_win_bash_status) is WinBashStatus.Absent, @@ -1128,16 +1195,80 @@ def test_run_commit_hook_outside_worktree_on_windows(self, rw_dir): repo = Repo.init(root / "repo") hooks_dir = root / "hooks" _make_hook(root, "fake-hook", "exit 0") + system_root = root / "Windows" + system_bash = system_root / "System32" / "bash.exe" + git_executable = root / "Git" / "cmd" / "git.exe" + git_bash = root / "Git" / "bin" / "bash.exe" + for executable in (system_bash, git_executable, git_bash): + executable.parent.mkdir(parents=True) + executable.touch() + executable.chmod(0o755) with repo.config_writer() as writer: writer.set_value("core", "hooksPath", str(hooks_dir)) - with mock.patch("git.index.fun.sys.platform", "win32"): + # Model a normal Windows PATH: System32 (containing the WSL launcher) comes + # before Git's cmd directory, while Git's Bash is not itself on PATH. This + # exercises both Git-installation discovery and shell selection without + # mocking either resolver's answer. + with mock.patch("git.index.fun.sys.platform", "win32"), mock.patch.object( + Git, "GIT_PYTHON_GIT_EXECUTABLE", "git" + ), mock.patch.dict(os.environ, {"SystemRoot": str(system_root)}), mock.patch( + "git.index.fun.os.get_exec_path", return_value=["", str(system_bash.parent), str(git_executable.parent)] + ): with mock.patch("git.index.fun.safer_popen") as popen, mock.patch("git.index.fun.handle_process_output"): popen.return_value.returncode = 0 run_commit_hook("fake-hook", repo.index) command = popen.call_args[0][0] - self.assertEqual(command, ["bash.exe", "../hooks/fake-hook"]) + self.assertEqual(command, [str(git_bash), "../hooks/fake-hook"]) + + @with_rw_directory + def test_windows_bash_lookup_respects_explicit_current_directory_in_path(self, rw_dir): + root = Path(rw_dir).resolve() + bash = root / "bash.exe" + bash.touch() + bash.chmod(0o755) + + # An explicitly listed directory is trusted PATH configuration, even when + # it happens to be the current directory. This differs from an empty entry, + # which Windows requires PATH lookup to ignore. + with cwd(root), mock.patch("git.index.fun.os.get_exec_path", return_value=[str(root)]): + self.assertEqual(_which_from_path("bash.exe"), str(bash)) + + @with_rw_directory + def test_windows_bash_lookup_from_explicit_git_bin(self, rw_dir): + git_root = Path(rw_dir).resolve() / "Git" + git_executable = git_root / "bin" / "git.exe" + bash = git_root / "bin" / "bash.exe" + git_executable.parent.mkdir(parents=True) + for executable in (git_executable, bash): + executable.touch() + executable.chmod(0o755) + + # A relative executable containing a directory is resolved by CreateProcess + # from the parent process cwd, not the separately supplied child cwd. Enter the + # temporary root first because Windows cannot express a relative path between + # drives, and CI may keep the checkout and its temporary directory on different + # drives. + with cwd(Path(rw_dir).resolve()): + relative_git = osp.relpath(git_executable, os.curdir) + with mock.patch.object(Git, "GIT_PYTHON_GIT_EXECUTABLE", relative_git): + self.assertEqual(_git_for_windows_bash(), str(bash)) + + @with_rw_directory + def test_windows_bash_lookup_ignores_custom_git_executable(self, rw_dir): + root = Path(rw_dir).resolve() + for directory_name in ("cmd", "bin"): + executable = root / directory_name / "mygit.exe" + bash = root / "bin" / "bash.exe" + executable.parent.mkdir(parents=True, exist_ok=True) + bash.parent.mkdir(parents=True, exist_ok=True) + executable.touch() + bash.touch() + executable.chmod(0o755) + bash.chmod(0o755) + with mock.patch.object(Git, "GIT_PYTHON_GIT_EXECUTABLE", str(executable)): + self.assertIsNone(_git_for_windows_bash()) @ddt.data((False,), (True,)) @with_rw_directory diff --git a/test/test_installation.py b/test/test_installation.py index 656ef9787..e8956d5cf 100644 --- a/test/test_installation.py +++ b/test/test_installation.py @@ -6,10 +6,11 @@ import os import subprocess -from test.lib import TestBase, VirtualEnvironment, with_rw_directory +from test.lib import TestBase, VirtualEnvironment, requires_symlinks, with_rw_directory class TestInstallation(TestBase): + @requires_symlinks @with_rw_directory def test_installation(self, rw_dir): venv, run = self._set_up_venv(rw_dir) diff --git a/test/test_refs.py b/test/test_refs.py index 6481b54a8..a87134ab8 100644 --- a/test/test_refs.py +++ b/test/test_refs.py @@ -28,7 +28,7 @@ import git.refs as refs from git.util import Actor -from test.lib import TestBase, with_rw_repo, PathLikeMock +from test.lib import TestBase, requires_symlinks, with_rw_repo, PathLikeMock class TestRefs(TestBase): @@ -258,10 +258,55 @@ def test_orig_head(self): @with_rw_repo("0.1.6") def test_head_checkout_detached_head(self, rw_repo): - res = rw_repo.remotes.origin.refs.master.checkout() + res = rw_repo.remotes.origin.refs.HEAD.reference.checkout() assert isinstance(res, SymbolicReference) assert res.name == "HEAD" + @with_rw_repo("HEAD") + def test_head_checkout_rejects_pathspec_from_file(self, rw_repo): + with tempfile.TemporaryDirectory() as tdir: + pathspecs = Path(tdir) / "pathspecs" + pathspecs.write_bytes(b"unmatched-path-one\nunmatched-path-two") + for option_name in ("pathspec_from_file", "pathspec_from"): + with self.assertRaises(UnsafeOptionError): + rw_repo.active_branch.checkout( + pathspec_file_nul=True, + **{option_name: str(pathspecs)}, + ) + + @with_rw_repo("HEAD") + def test_head_reset_rejects_pathspec_from_file(self, rw_repo): + with tempfile.TemporaryDirectory() as tdir: + pathspecs = Path(tdir) / "pathspecs" + pathspecs.write_bytes(b"unmatched-path-one\nunmatched-path-two") + for option_name in ("pathspec_from_file", "pathspec_from"): + with self.assertRaises(UnsafeOptionError): + rw_repo.head.reset( + pathspec_file_nul=True, + **{option_name: str(pathspecs)}, + ) + for option_name in ("--pathspec-from-file", "--pathspec-from"): + with self.assertRaises(UnsafeOptionError): + rw_repo.head.reset( + f"{option_name}={pathspecs}", + pathspec_file_nul=True, + ) + + @with_rw_repo("HEAD") + def test_head_commands_allow_explicit_pathspec_from_file(self, rw_repo): + with tempfile.TemporaryDirectory() as tdir: + pathspecs = Path(tdir) / "pathspecs" + pathspecs.write_bytes(b"CHANGES\0") + options = { + "pathspec_from_file": str(pathspecs), + "pathspec_file_nul": True, + "allow_unsafe_options": True, + } + head = rw_repo.head + branch = rw_repo.active_branch + assert head.reset(**options) is head + assert branch.checkout(**options) == branch + @with_rw_repo("0.1.6") def test_head_reset(self, rw_repo): cur_head = rw_repo.head @@ -661,7 +706,7 @@ def test_dereference_recursive(self): assert SymbolicReference.dereference_recursive(self.rorepo, "HEAD") def test_reflog(self): - assert isinstance(self.rorepo.heads.master.log(), RefLog) + assert isinstance(self.rorepo.active_branch.log(), RefLog) def test_refs_outside_repo(self): # Create a file containing a valid reference outside the repository. Attempting @@ -735,6 +780,7 @@ def test_symbolic_reference_log_append_rejects_path_traversal(self): ) assert not outside_path.exists() + @requires_symlinks def test_symbolic_reference_set_reference_rejects_symlink_escape(self): with tempfile.TemporaryDirectory() as tmp_dir: base_dir = Path(tmp_dir) @@ -746,10 +792,7 @@ def test_symbolic_reference_set_reference_rejects_symlink_escape(self): refs_heads_dir = Path(repo.common_dir) / "refs" / "heads" refs_heads_dir.mkdir(parents=True, exist_ok=True) symlink_path = refs_heads_dir / "link_out" - try: - symlink_path.symlink_to(outside_dir, target_is_directory=True) - except (OSError, NotImplementedError) as ex: - self.skipTest("symlinks unavailable on this platform: %s" % ex) + symlink_path.symlink_to(outside_dir, target_is_directory=True) if osp.realpath(symlink_path / "escaped") == osp.abspath(symlink_path / "escaped"): self.skipTest("realpath does not resolve directory symlinks on this platform") diff --git a/test/test_repo.py b/test/test_repo.py index 7c7f1dd34..0c97041f9 100644 --- a/test/test_repo.py +++ b/test/test_repo.py @@ -43,7 +43,7 @@ from git.repo.fun import touch from git.util import bin_to_hex, cwd, cygpath, join_path_native, rmfile, rmtree -from test.lib import TestBase, fixture, with_rw_directory, with_rw_repo, PathLikeMock +from test.lib import TestBase, fixture, requires_symlinks, with_rw_directory, with_rw_repo, PathLikeMock def iter_flatten(lol): @@ -82,6 +82,33 @@ def test_new_should_raise_on_invalid_repo_location(self): with tempfile.TemporaryDirectory() as tdir: self.assertRaises(InvalidGitRepositoryError, Repo, tdir) + def test_init_rejects_unsafe_options(self): + with tempfile.TemporaryDirectory() as tdir: + template_dir = osp.join(tdir, "template") + os.mkdir(template_dir) + unsafe_options = [ + {"template": template_dir}, + {"templa": template_dir}, + {"separate_git_dir": osp.join(tdir, "git-dir")}, + {"separate_git_di": osp.join(tdir, "git-dir")}, + ] + for index, kwargs in enumerate(unsafe_options): + repo_dir = osp.join(tdir, f"repo-{index}") + with self.assertRaises(UnsafeOptionError): + Repo.init(repo_dir, **kwargs) + assert not osp.exists(repo_dir) + + def test_init_allows_explicitly_unsafe_options(self): + with tempfile.TemporaryDirectory() as tdir: + template_dir = osp.join(tdir, "template") + os.mkdir(template_dir) + repo = Repo.init( + osp.join(tdir, "repo"), + template=template_dir, + allow_unsafe_options=True, + ) + assert repo.git_dir + @with_rw_directory def test_new_should_raise_on_invalid_repo_location_within_repo(self, rw_dir): repo_dir = osp.join(rw_dir, "repo") @@ -128,8 +155,9 @@ def test_heads_should_populate_head_data(self): self.assertIsInstance(head.commit, Commit) # END for each head - self.assertIsInstance(self.rorepo.heads.master, Head) - self.assertIsInstance(self.rorepo.heads["master"], Head) + active_branch = self.rorepo.active_branch + self.assertIsInstance(self.rorepo.heads[active_branch.name], Head) + self.assertEqual(self.rorepo.heads[active_branch.name], active_branch) def test_tree_from_revision(self): tree = self.rorepo.tree("0.1.6") @@ -233,6 +261,18 @@ def test_clone_from_keeps_env(self, rw_dir): self.assertEqual(environment, cloned.git.environment()) + @pytest.mark.skipif(os.name != "nt", reason="Specifically for Windows drive-rooted paths.") + @with_rw_directory + def test_clone_from_drive_rooted_destination(self, rw_dir): + original_repo = Repo.init(osp.join(rw_dir, "repo")) + with cwd(rw_dir): + destination = osp.join(rw_dir, "clone") + _drive, rooted_destination = osp.splitdrive(destination) + + cloned = Repo.clone_from(original_repo.git_dir, rooted_destination) + + assert osp.samefile(cloned.working_tree_dir, destination) + @with_rw_directory def test_date_format(self, rw_dir): repo = Repo.init(osp.join(rw_dir, "repo")) @@ -324,15 +364,37 @@ def test_daemon_export(self): self.rorepo.daemon_export = orig_val self.assertEqual(self.rorepo.daemon_export, orig_val) + def test_daemon_export_without_git_dir(self): + repo = Repo.__new__(Repo) + repo.git_dir = None + self.assertFalse(repo._get_daemon_export()) + repo._set_daemon_export(True) + def test_alternates(self): cur_alternates = self.rorepo.alternates - # empty alternates - self.rorepo.alternates = [] - self.assertEqual(self.rorepo.alternates, []) + try: + # Empty alternates. + self.rorepo.alternates = [] + self.assertEqual(self.rorepo.alternates, []) + alts = ["other/location", "this/location"] + self.rorepo.alternates = alts + self.assertEqual(alts, self.rorepo.alternates) + finally: + self.rorepo.alternates = cur_alternates + + @with_rw_directory + def test_alternates_use_common_dir(self, rw_dir): + common_dir = osp.join(rw_dir, "common") + git_dir = osp.join(rw_dir, "worktrees", "linked") + os.makedirs(osp.join(common_dir, "objects", "info")) + os.makedirs(osp.join(git_dir, "objects", "info")) + repo = mock.Mock(common_dir=common_dir, git_dir=git_dir) + alts = ["other/location", "this/location"] - self.rorepo.alternates = alts - self.assertEqual(alts, self.rorepo.alternates) - self.rorepo.alternates = cur_alternates + Repo._set_alternates(repo, alts) + + self.assertEqual(Repo._get_alternates(repo), alts) + self.assertFalse(osp.exists(osp.join(git_dir, "objects", "info", "alternates"))) def test_repr(self): assert repr(self.rorepo).startswith("=4 -env_list = py{37,38,39,310,311,312}, ruff, format, mypy, html, misc +env_list = py{37,38,39,310,311,312,313,314,315}, ruff, format, mypy, html, misc [testenv] description = Run unit tests