Files
dvc/dvc/scm/git/__init__.py
T
b652670cbf Dependabot/pip/pylint 2.11.1 (#6843)
* build(deps): Bump pylint from 2.9.6 to 2.11.1

Bumps [pylint](https://github.com/PyCQA/pylint) from 2.9.6 to 2.11.1.
- [Release notes](https://github.com/PyCQA/pylint/releases)
- [Changelog](https://github.com/PyCQA/pylint/blob/main/ChangeLog)
- [Commits](https://github.com/PyCQA/pylint/compare/v2.9.6...v2.11.1)

---
updated-dependencies:
- dependency-name: pylint
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>

* fix pylint problems

* Updation after rebase.

* system: use binary mode

* Set remote execution script encoding type locale

* Update dvc/machine/__init__.py

Co-authored-by: Peter Rowlands (변기호) <[email protected]>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ruslan Kuprieiev <[email protected]>
Co-authored-by: Peter Rowlands (변기호) <[email protected]>
2021-10-20 14:34:10 +03:00

485 lines
16 KiB
Python

"""Manages Git."""
import logging
import os
import re
import shlex
from collections.abc import Mapping
from contextlib import contextmanager
from functools import partialmethod
from typing import Dict, Iterable, List, Optional, Set, Tuple, Type
from funcy import cached_property, first
from pathspec.patterns import GitWildMatchPattern
from dvc.exceptions import GitHookAlreadyExistsError
from dvc.scm.base import Base, FileNotInRepoError, RevError
from dvc.utils import relpath
from dvc.utils.fs import path_isin
from .backend.base import BaseGitBackend, NoGitBackendError
from .backend.dulwich import DulwichBackend
from .backend.gitpython import GitPythonBackend
from .backend.pygit2 import Pygit2Backend
from .stash import Stash
logger = logging.getLogger(__name__)
BackendCls = Type[BaseGitBackend]
class GitBackends(Mapping):
DEFAULT: Dict[str, BackendCls] = {
"dulwich": DulwichBackend,
"pygit2": Pygit2Backend,
"gitpython": GitPythonBackend,
}
def __getitem__(self, key: str) -> BaseGitBackend:
"""Lazily initialize backends and cache it afterwards"""
initialized = self.initialized.get(key)
if not initialized:
backend = self.backends[key]
initialized = backend(*self.args, **self.kwargs)
self.initialized[key] = initialized
return initialized
def __init__(
self, selected: Optional[Iterable[str]], *args, **kwargs
) -> None:
selected = selected or list(self.DEFAULT)
self.backends = {key: self.DEFAULT[key] for key in selected}
self.initialized: Dict[str, BaseGitBackend] = {}
self.args = args
self.kwargs = kwargs
def __iter__(self):
return iter(self.backends)
def __len__(self) -> int:
return len(self.backends)
def close_initialized(self) -> None:
for backend in self.initialized.values():
backend.close()
def reset_all(self) -> None:
for backend in self.initialized.values():
backend._reset() # pylint: disable=protected-access
class Git(Base):
"""Class for managing Git."""
GITIGNORE = ".gitignore"
GIT_DIR = ".git"
LOCAL_BRANCH_PREFIX = "refs/heads/"
RE_HEXSHA = re.compile(r"^[0-9A-Fa-f]{4,40}$")
BAD_REF_CHARS_RE = re.compile("[\177\\s~^:?*\\[]")
def __init__(
self, *args, backends: Optional[Iterable[str]] = None, **kwargs
):
self.ignored_paths: List[str] = []
self.files_to_track: Set[str] = set()
self.quiet: bool = False
self.backends = GitBackends(backends, *args, **kwargs)
first_ = first(self.backends.values())
super().__init__(first_.root_dir)
@property
def dir(self):
return first(self.backends.values()).dir
@property
def gitpython(self):
return self.backends["gitpython"]
@property
def dulwich(self):
return self.backends["dulwich"]
@property
def pygit2(self):
return self.backends["pygit2"]
@cached_property
def stash(self):
return Stash(self)
@classmethod
def clone(cls, url, to_path, **kwargs):
for _, backend in GitBackends.DEFAULT.items():
try:
backend.clone(url, to_path, **kwargs)
return Git(to_path)
except NotImplementedError:
pass
raise NoGitBackendError("clone")
@classmethod
def is_sha(cls, rev):
return rev and cls.RE_HEXSHA.search(rev)
@classmethod
def split_ref_pattern(cls, ref: str) -> Tuple[str, str]:
name = cls.BAD_REF_CHARS_RE.split(ref, maxsplit=1)[0]
return name, ref[len(name) :]
@staticmethod
def _get_git_dir(root_dir):
return os.path.join(root_dir, Git.GIT_DIR)
@property
def ignore_file(self):
return self.GITIGNORE
def _get_gitignore(self, path):
ignore_file_dir = os.path.dirname(path)
assert os.path.isabs(path)
assert os.path.isabs(ignore_file_dir)
entry = relpath(path, ignore_file_dir).replace(os.sep, "/")
# NOTE: using '/' prefix to make path unambiguous
if len(entry) > 0 and entry[0] != "/":
entry = "/" + entry
gitignore = os.path.join(ignore_file_dir, self.GITIGNORE)
if not path_isin(os.path.realpath(gitignore), self.root_dir):
raise FileNotInRepoError(
f"'{path}' is outside of git repository '{self.root_dir}'"
)
return entry, gitignore
def ignore(self, path):
entry, gitignore = self._get_gitignore(path)
if self.is_ignored(path):
return
msg = f"Adding '{relpath(path)}' to '{relpath(gitignore)}'."
logger.debug(msg)
self._add_entry_to_gitignore(entry, gitignore)
self.track_file(relpath(gitignore))
self.ignored_paths.append(path)
def _add_entry_to_gitignore(self, entry, gitignore):
entry = GitWildMatchPattern.escape(entry)
with open(gitignore, "a+", encoding="utf-8") as fobj:
unique_lines = set(fobj.readlines())
fobj.seek(0, os.SEEK_END)
if fobj.tell() == 0:
# Empty file
prefix = ""
else:
fobj.seek(fobj.tell() - 1, os.SEEK_SET)
last = fobj.read(1)
prefix = "" if last == "\n" else "\n"
new_entry = f"{prefix}{entry}\n"
if new_entry not in unique_lines:
fobj.write(new_entry)
def ignore_remove(self, path):
entry, gitignore = self._get_gitignore(path)
if not os.path.exists(gitignore):
return
with open(gitignore, encoding="utf-8") as fobj:
lines = fobj.readlines()
filtered = list(filter(lambda x: x.strip() != entry.strip(), lines))
if not filtered:
os.unlink(gitignore)
return
with open(gitignore, "w", encoding="utf-8") as fobj:
fobj.writelines(filtered)
self.track_file(relpath(gitignore))
def _install_hook(self, name):
hook = self._hook_path(name)
with open(hook, "w+", encoding="utf-8") as fobj:
fobj.write(f"#!/bin/sh\nexec dvc git-hook {name} $@\n")
os.chmod(hook, 0o777)
def _install_merge_driver(self):
self.gitpython.repo.git.config("merge.dvc.name", "DVC merge driver")
self.gitpython.repo.git.config(
"merge.dvc.driver",
(
"dvc git-hook merge-driver "
"--ancestor %O "
"--our %A "
"--their %B "
),
)
def install(self, use_pre_commit_tool=False):
self._install_merge_driver()
if not use_pre_commit_tool:
self._verify_dvc_hooks()
self._install_hook("post-checkout")
self._install_hook("pre-commit")
self._install_hook("pre-push")
return
from dvc.utils.serialize import modify_yaml
config_path = os.path.join(self.root_dir, ".pre-commit-config.yaml")
with modify_yaml(config_path) as config:
entry = {
"repo": "https://github.com/iterative/dvc",
"rev": "master",
"hooks": [
{
"id": "dvc-pre-commit",
"language_version": "python3",
"stages": ["commit"],
},
{
"id": "dvc-pre-push",
"language_version": "python3",
"stages": ["push"],
},
{
"id": "dvc-post-checkout",
"language_version": "python3",
"stages": ["post-checkout"],
"always_run": True,
},
],
}
if "repos" not in config:
config["repos"] = []
if entry not in config["repos"]:
config["repos"].append(entry)
def cleanup_ignores(self):
for path in self.ignored_paths:
self.ignore_remove(path)
self.reset_ignores()
def reset_ignores(self):
self.ignored_paths = []
def reset_tracked_files(self):
self.files_to_track = set()
def remind_to_track(self):
if self.quiet or not self.files_to_track:
return
files = " ".join(shlex.quote(path) for path in self.files_to_track)
logger.info(
"\n"
"To track the changes with git, run:\n"
"\n"
"\tgit add {files}".format(files=files)
)
def track_changed_files(self):
if not self.files_to_track:
return
self.add(self.files_to_track)
def track_file(self, path: str):
self.files_to_track.add(path)
def belongs_to_scm(self, path):
basename = os.path.basename(path)
path_parts = os.path.normpath(path).split(os.path.sep)
return basename == self.ignore_file or Git.GIT_DIR in path_parts
def has_rev(self, rev):
try:
self.resolve_rev(rev)
return True
except RevError:
return False
def close(self):
self.backends.close_initialized()
@cached_property
def _hooks_home(self):
return os.path.join(self.root_dir, self.GIT_DIR, "hooks")
def _hook_path(self, name):
return os.path.join(self._hooks_home, name)
def _verify_hook(self, name):
if os.path.exists(self._hook_path(name)):
raise GitHookAlreadyExistsError(name)
def _verify_dvc_hooks(self):
self._verify_hook("post-checkout")
self._verify_hook("pre-commit")
self._verify_hook("pre-push")
@property
def no_commits(self):
return not bool(self.get_ref("HEAD"))
def _backend_func(self, name, *args, **kwargs):
for backend in self.backends.values():
try:
func = getattr(backend, name)
return func(*args, **kwargs)
except NotImplementedError:
pass
raise NoGitBackendError(name)
def get_fs(self, rev: str):
from dvc.fs.git import GitFileSystem
from .objects import GitTrie
resolved = self.resolve_rev(rev)
tree_obj = self.pygit2.get_tree_obj(rev=resolved)
trie = GitTrie(tree_obj, resolved)
return GitFileSystem(self.root_dir, trie)
is_ignored = partialmethod(_backend_func, "is_ignored")
add = partialmethod(_backend_func, "add")
commit = partialmethod(_backend_func, "commit")
checkout = partialmethod(_backend_func, "checkout")
pull = partialmethod(_backend_func, "pull")
push = partialmethod(_backend_func, "push")
branch = partialmethod(_backend_func, "branch")
tag = partialmethod(_backend_func, "tag")
untracked_files = partialmethod(_backend_func, "untracked_files")
is_tracked = partialmethod(_backend_func, "is_tracked")
is_dirty = partialmethod(_backend_func, "is_dirty")
active_branch = partialmethod(_backend_func, "active_branch")
list_branches = partialmethod(_backend_func, "list_branches")
list_tags = partialmethod(_backend_func, "list_tags")
list_all_commits = partialmethod(_backend_func, "list_all_commits")
get_rev = partialmethod(_backend_func, "get_rev")
_resolve_rev = partialmethod(_backend_func, "resolve_rev")
resolve_commit = partialmethod(_backend_func, "resolve_commit")
set_ref = partialmethod(_backend_func, "set_ref")
get_ref = partialmethod(_backend_func, "get_ref")
remove_ref = partialmethod(_backend_func, "remove_ref")
iter_refs = partialmethod(_backend_func, "iter_refs")
iter_remote_refs = partialmethod(_backend_func, "iter_remote_refs")
get_refs_containing = partialmethod(_backend_func, "get_refs_containing")
push_refspec = partialmethod(_backend_func, "push_refspec")
fetch_refspecs = partialmethod(_backend_func, "fetch_refspecs")
_stash_iter = partialmethod(_backend_func, "_stash_iter")
_stash_push = partialmethod(_backend_func, "_stash_push")
_stash_apply = partialmethod(_backend_func, "_stash_apply")
_stash_drop = partialmethod(_backend_func, "_stash_drop")
describe = partialmethod(_backend_func, "describe")
diff = partialmethod(_backend_func, "diff")
reset = partialmethod(_backend_func, "reset")
checkout_index = partialmethod(_backend_func, "checkout_index")
status = partialmethod(_backend_func, "status")
merge = partialmethod(_backend_func, "merge")
validate_git_remote = partialmethod(_backend_func, "validate_git_remote")
def resolve_rev(self, rev: str) -> str:
from dvc.repo.experiments.utils import exp_refs_by_name
try:
return self._resolve_rev(rev)
except RevError:
# backends will only resolve git branch and tag names,
# if rev is not a sha it may be an abbreviated experiment name
if not self.is_sha(rev) and not rev.startswith("refs/"):
ref_infos = list(exp_refs_by_name(self, rev))
if len(ref_infos) == 1:
return self.get_ref(str(ref_infos[0]))
if len(ref_infos) > 1:
raise RevError(f"ambiguous Git revision '{rev}'")
raise
def branch_revs(
self, branch: str, end_rev: Optional[str] = None
) -> Iterable[str]:
"""Iterate over revisions in a given branch (from newest to oldest).
If end_rev is set, iterator will stop when the specified revision is
reached.
"""
commit = self.resolve_commit(branch)
while commit is not None:
yield commit.hexsha
parent = first(commit.parents)
if parent is None or parent == end_rev:
return
commit = self.resolve_commit(parent)
@contextmanager
def detach_head(self, rev: Optional[str] = None, force: bool = False):
"""Context manager for performing detached HEAD SCM operations.
Detaches and restores HEAD similar to interactive git rebase.
Restore is equivalent to 'reset --soft', meaning the caller is
is responsible for preserving & restoring working tree state
(i.e. via stash) when applicable.
Yields revision of detached head.
"""
if not rev:
rev = "HEAD"
orig_head = self.get_ref("HEAD", follow=False)
logger.debug("Detaching HEAD at '%s'", rev)
self.checkout(rev, detach=True, force=force)
try:
yield self.get_ref("HEAD")
finally:
prefix = self.LOCAL_BRANCH_PREFIX
if orig_head.startswith(prefix):
symbolic = True
name = orig_head[len(prefix) :]
else:
symbolic = False
name = orig_head
self.set_ref(
"HEAD",
orig_head,
symbolic=symbolic,
message=f"dvc: Restore HEAD to '{name}'",
)
logger.debug("Restore HEAD to '%s'", name)
self.reset()
@contextmanager
def stash_workspace(self, **kwargs):
"""Stash and restore any workspace changes.
Yields revision of the stash commit. Yields None if there were no
changes to stash.
"""
logger.debug("Stashing workspace")
rev = self.stash.push(**kwargs)
try:
yield rev
finally:
if rev:
logger.debug("Restoring stashed workspace")
self.stash.pop()
def _reset(self) -> None:
self.backends.reset_all()