# Copyright Spack Project Developers. See COPYRIGHT file for details.
#
# SPDX-License-Identifier: (Apache-2.0 OR MIT)
"""Single util module where Spack should get a git executable."""
import os
import re
import sys
from typing import List, Optional, overload
from spack.vendor.typing_extensions import Literal
import spack.llnl.util.lang
import spack.util.executable as exe
# regex for a commit version
COMMIT_VERSION = re.compile(r"^[a-f0-9]{40}$")
[docs]
def is_git_commit_sha(string: str) -> bool:
return len(string) == 40 and bool(COMMIT_VERSION.match(string))
@spack.llnl.util.lang.memoized
def _find_git() -> Optional[str]:
"""Find the git executable in the system path."""
return exe.which_string("git", required=False)
@overload
def git(required: Literal[True]) -> exe.Executable: ...
@overload
def git(required: bool = ...) -> Optional[exe.Executable]: ...
[docs]
def git(required: bool = False) -> Optional[exe.Executable]:
"""Get a git executable. Raises CommandNotFoundError if ``required`` and git is not found."""
git_path = _find_git()
if not git_path:
if required:
raise exe.CommandNotFoundError("spack requires 'git'. Make sure it is in your path.")
return None
git = exe.Executable(git_path)
# If we're running under pytest, add this to ignore the fix for CVE-2022-39253 in
# git 2.38.1+. Do this in one place; we need git to do this in all parts of Spack.
if git and "pytest" in sys.modules:
git.add_default_arg("-c", "protocol.file.allow=always")
return git
[docs]
def init_git_repo(
repository: str, remote: str = "origin", git_exe: Optional[exe.Executable] = None
):
"""Initialize a new Git repository and configure it with a remote."""
git_exe = git_exe or git(required=True)
git_exe("init", "--quiet", output=str)
git_exe("remote", "add", remote, repository)
# versions of git prior to v2.24 may not have the manyFiles feature
# so we should ignore errors here on older versions of git
git_exe("config", "feature.manyFiles", "true", ignore_errors=True)
[docs]
def pull_checkout_commit(
commit: str,
remote: Optional[str] = None,
depth: Optional[int] = None,
git_exe: Optional[exe.Executable] = None,
):
"""Checkout the specified commit (fetched if necessary)."""
git_exe = git_exe or git(required=True)
# Do not do any fetching if the commit is already present.
try:
git_exe("checkout", "--quiet", commit, error=os.devnull)
return
except exe.ProcessError:
pass
# First try to fetch the specific commit from a specific remote. This allows fixed depth, but
# the server needs to support it.
if remote is not None:
try:
flags = [] if depth is None else [f"--depth={depth}"]
git_exe("fetch", "--quiet", "--progress", *flags, remote, commit, error=os.devnull)
git_exe("checkout", "--quiet", commit)
return
except exe.ProcessError:
pass
# Fall back to fetching all while unshallowing, to guarantee we get the commit. The depth flag
# is equivalent to --unshallow, and needed cause git can pedantically error with
# "--unshallow on a complete repository does not make sense".
remote_flag = "--all" if remote is None else remote
git_exe("fetch", "--quiet", "--progress", "--depth=2147483647", remote_flag)
git_exe("checkout", "--quiet", commit)
[docs]
def pull_checkout_tag(
tag: str,
remote: str = "origin",
depth: Optional[int] = None,
git_exe: Optional[exe.Executable] = None,
):
"""Fetch tags with specified depth and checkout the given tag."""
git_exe = git_exe or git(required=True)
fetch_args = ["--quiet", "--progress", "--tags"]
if depth is not None:
if depth <= 0:
raise ValueError("depth must be a positive integer")
fetch_args.append(f"--depth={depth}")
git_exe("fetch", *fetch_args, remote)
git_exe("checkout", tag)
[docs]
def pull_checkout_branch(
branch: str,
remote: str = "origin",
depth: Optional[int] = None,
git_exe: Optional[exe.Executable] = None,
):
"""Fetch and checkout branch, then rebase with remote tracking branch."""
git_exe = git_exe or git(required=True)
fetch_args = ["--quiet", "--progress"]
if depth:
if depth <= 0:
raise ValueError("depth must be a positive integer")
fetch_args.append(f"--depth={depth}")
git_exe("fetch", *fetch_args, remote, f"{branch}:refs/remotes/{remote}/{branch}")
git_exe("checkout", "--quiet", branch)
try:
git_exe("rebase", "--quiet", f"{remote}/{branch}")
except exe.ProcessError:
git_exe("rebase", "--abort", fail_on_error=False, error=str, output=str)
raise
[docs]
def get_modified_files(
from_ref: str = "HEAD~1", to_ref: str = "HEAD", git_exe: Optional[exe.Executable] = None
) -> List[str]:
"""Get a list of files modified between ``from_ref`` and ``to_ref``
Args:
from_ref (str): oldest git ref, defaults to ``HEAD~1``
to_ref (str): newer git ref, defaults to ``HEAD``
Returns: list of file paths
"""
git_exe = git_exe or git(required=True)
stdout = git_exe("diff", "--name-only", from_ref, to_ref, output=str)
return stdout.split()
[docs]
def get_commit_sha(path: str, ref: str) -> Optional[str]:
"""Get a commit sha for an arbitrary ref using ls-remote"""
# search for matching branch, annotated tag's commit, then lightweight tag
ref_list = [f"refs/heads/{ref}", f"refs/tags/{ref}^{{}}", f"refs/tags/{ref}"]
if os.path.isdir(path):
# for the filesystem an unpacked mirror could be in a detached state from a depth 1 clone
# only reference there will be HEAD
ref_list.append("HEAD")
for try_ref in ref_list:
# this command enabled in git@1.7 so no version checking supplied (1.7 released in 2009)
try:
query = git(required=True)(
"ls-remote",
path,
try_ref,
output=str,
error=os.devnull,
extra_env={"GIT_TERMINAL_PROMPT": "0"},
)
if query:
return query.strip().split()[0]
except spack.util.executable.ProcessError:
continue
return None