repository.py
1054 lines
| 38.9 KiB
| text/x-python
|
PythonLexer
r5608 | # Copyright (C) 2014-2024 RhodeCode GmbH | |||
r1 | # | |||
# This program is free software: you can redistribute it and/or modify | ||||
# it under the terms of the GNU Affero General Public License, version 3 | ||||
# (only), as published by the Free Software Foundation. | ||||
# | ||||
# This program is distributed in the hope that it will be useful, | ||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||||
# GNU General Public License for more details. | ||||
# | ||||
# You should have received a copy of the GNU Affero General Public License | ||||
# along with this program. If not, see <http://www.gnu.org/licenses/>. | ||||
# | ||||
# This program is dual-licensed. If you wish to learn more about the | ||||
# RhodeCode Enterprise Edition, including its added features, Support services, | ||||
# and proprietary license terms, please see https://rhodecode.com/licenses/ | ||||
""" | ||||
GIT repository module | ||||
""" | ||||
import logging | ||||
import os | ||||
import re | ||||
from zope.cachedescriptors.property import Lazy as LazyProperty | ||||
r4928 | from collections import OrderedDict | |||
r1349 | from rhodecode.lib.datelib import ( | |||
utcdate_fromtimestamp, makedate, date_astimestamp) | ||||
r5074 | from rhodecode.lib.hash_utils import safe_str | |||
r3842 | from rhodecode.lib.utils2 import CachedProperty | |||
r1 | from rhodecode.lib.vcs import connection, path as vcspath | |||
from rhodecode.lib.vcs.backends.base import ( | ||||
BaseRepository, CollectionGenerator, Config, MergeResponse, | ||||
Martin Bornhold
|
r1051 | MergeFailureReason, Reference) | ||
r1 | from rhodecode.lib.vcs.backends.git.commit import GitCommit | |||
from rhodecode.lib.vcs.backends.git.diff import GitDiff | ||||
from rhodecode.lib.vcs.backends.git.inmemory import GitInMemoryCommit | ||||
from rhodecode.lib.vcs.exceptions import ( | ||||
CommitDoesNotExistError, EmptyRepositoryError, | ||||
r4080 | RepositoryError, TagAlreadyExistError, TagDoesNotExistError, VCSError, UnresolvedFilesInRepo) | |||
r1 | ||||
r4940 | SHA_PATTERN = re.compile(r'^([0-9a-fA-F]{12}|[0-9a-fA-F]{40})$') | |||
r1 | ||||
log = logging.getLogger(__name__) | ||||
class GitRepository(BaseRepository): | ||||
""" | ||||
Git repository backend. | ||||
""" | ||||
r4754 | DEFAULT_BRANCH_NAME = os.environ.get('GIT_DEFAULT_BRANCH_NAME') or 'master' | |||
r5091 | DEFAULT_REF = f'branch:{DEFAULT_BRANCH_NAME}' | |||
r1 | ||||
contact = BaseRepository.DEFAULT_CONTACT | ||||
def __init__(self, repo_path, config=None, create=False, src_url=None, | ||||
r3078 | do_workspace_checkout=False, with_wire=None, bare=False): | |||
r1 | ||||
self.path = safe_str(os.path.abspath(repo_path)) | ||||
r2518 | self.config = config if config else self.get_default_config() | |||
r3848 | self.with_wire = with_wire or {"cache": False} # default should not use cache | |||
r1 | ||||
r3078 | self._init_repo(create, src_url, do_workspace_checkout, bare) | |||
r1 | ||||
# caches | ||||
self._commit_ids = {} | ||||
@LazyProperty | ||||
r2946 | def _remote(self): | |||
r3848 | repo_id = self.path | |||
return connection.Git(self.path, repo_id, self.config, with_wire=self.with_wire) | ||||
r2946 | ||||
@LazyProperty | ||||
r1 | def bare(self): | |||
return self._remote.bare() | ||||
@LazyProperty | ||||
def head(self): | ||||
return self._remote.head() | ||||
r3842 | @CachedProperty | |||
r1 | def commit_ids(self): | |||
""" | ||||
Returns list of commit ids, in ascending order. Being lazy | ||||
attribute allows external tools to inject commit ids from cache. | ||||
""" | ||||
commit_ids = self._get_all_commit_ids() | ||||
self._rebuild_cache(commit_ids) | ||||
return commit_ids | ||||
def _rebuild_cache(self, commit_ids): | ||||
r5091 | self._commit_ids = {commit_id: index | |||
for index, commit_id in enumerate(commit_ids)} | ||||
r1 | ||||
def run_git_command(self, cmd, **opts): | ||||
""" | ||||
Runs given ``cmd`` as git command and returns tuple | ||||
(stdout, stderr). | ||||
:param cmd: git command to be executed | ||||
:param opts: env options to pass into Subprocess command | ||||
""" | ||||
if not isinstance(cmd, list): | ||||
r5074 | raise ValueError(f'cmd must be a list, got {type(cmd)} instead') | |||
r1 | ||||
r2782 | skip_stderr_log = opts.pop('skip_stderr_log', False) | |||
r1 | out, err = self._remote.run_git_command(cmd, **opts) | |||
r2782 | if err and not skip_stderr_log: | |||
r1290 | log.debug('Stderr output of git command "%s":\n%s', cmd, err) | |||
r1 | return out, err | |||
@staticmethod | ||||
def check_url(url, config): | ||||
""" | ||||
Function will check given url and try to verify if it's a valid | ||||
link. Sometimes it may happened that git will issue basic | ||||
auth request that can cause whole API to hang when used from python | ||||
or other external calls. | ||||
On failures it'll raise urllib2.HTTPError, exception is also thrown | ||||
when the return code is non 200 | ||||
""" | ||||
# check first if it's not an url | ||||
if os.path.isdir(url) or url.startswith('file:'): | ||||
return True | ||||
if '+' in url.split('://', 1)[0]: | ||||
url = url.split('+', 1)[1] | ||||
# Request the _remote to verify the url | ||||
return connection.Git.check_url(url, config.serialize()) | ||||
@staticmethod | ||||
def is_valid_repository(path): | ||||
if os.path.isdir(os.path.join(path, '.git')): | ||||
return True | ||||
# check case of bare repository | ||||
try: | ||||
GitRepository(path) | ||||
return True | ||||
except VCSError: | ||||
pass | ||||
return False | ||||
r3078 | def _init_repo(self, create, src_url=None, do_workspace_checkout=False, | |||
r1 | bare=False): | |||
if create and os.path.exists(self.path): | ||||
raise RepositoryError( | ||||
r5216 | f"Cannot create repository at {self.path}, location already exist") | |||
r1 | ||||
r3078 | if bare and do_workspace_checkout: | |||
raise RepositoryError("Cannot update a bare repository") | ||||
r1 | try: | |||
r3078 | ||||
if src_url: | ||||
# check URL before any actions | ||||
r1 | GitRepository.check_url(src_url, self.config) | |||
r3078 | ||||
if create: | ||||
r1 | if bare: | |||
self._remote.init_bare() | ||||
else: | ||||
self._remote.init() | ||||
r3078 | ||||
if src_url and bare: | ||||
# bare repository only allows a fetch and checkout is not allowed | ||||
self.fetch(src_url, commit_ids=None) | ||||
elif src_url: | ||||
self.pull(src_url, commit_ids=None, | ||||
update_after=do_workspace_checkout) | ||||
r1 | else: | |||
r2452 | if not self._remote.assert_correct_path(): | |||
raise RepositoryError( | ||||
r5216 | f'Path "{self.path}" does not contain a Git repository') | |||
r2452 | ||||
r1 | # TODO: johbo: check if we have to translate the OSError here | |||
except OSError as err: | ||||
raise RepositoryError(err) | ||||
r3842 | def _get_all_commit_ids(self): | |||
return self._remote.get_all_commit_ids() | ||||
def _get_commit_ids(self, filters=None): | ||||
r1 | # we must check if this repo is not empty, since later command | |||
# fails if it is. And it's cheaper to ask than throw the subprocess | ||||
# errors | ||||
r2955 | ||||
head = self._remote.head(show_exc=False) | ||||
r3842 | ||||
r2955 | if not head: | |||
r1 | return [] | |||
rev_filter = ['--branches', '--tags'] | ||||
extra_filter = [] | ||||
if filters: | ||||
if filters.get('since'): | ||||
extra_filter.append('--since=%s' % (filters['since'])) | ||||
if filters.get('until'): | ||||
extra_filter.append('--until=%s' % (filters['until'])) | ||||
if filters.get('branch_name'): | ||||
r3842 | rev_filter = [] | |||
r1 | extra_filter.append(filters['branch_name']) | |||
rev_filter.extend(extra_filter) | ||||
# if filters.get('start') or filters.get('end'): | ||||
# # skip is offset, max-count is limit | ||||
# if filters.get('start'): | ||||
# extra_filter += ' --skip=%s' % filters['start'] | ||||
# if filters.get('end'): | ||||
# extra_filter += ' --max-count=%s' % (filters['end'] - (filters['start'] or 0)) | ||||
cmd = ['rev-list', '--reverse', '--date-order'] + rev_filter | ||||
try: | ||||
output, __ = self.run_git_command(cmd) | ||||
except RepositoryError: | ||||
# Can be raised for empty repositories | ||||
return [] | ||||
return output.splitlines() | ||||
r4653 | def _lookup_commit(self, commit_id_or_idx, translate_tag=True, maybe_unreachable=False, reference_obj=None): | |||
r1 | def is_null(value): | |||
return len(value) == commit_id_or_idx.count('0') | ||||
if commit_id_or_idx in (None, '', 'tip', 'HEAD', 'head', -1): | ||||
return self.commit_ids[-1] | ||||
r4299 | ||||
r3842 | commit_missing_err = "Commit {} does not exist for `{}`".format( | |||
*map(safe_str, [commit_id_or_idx, self.name])) | ||||
r1 | ||||
r4952 | is_bstr = isinstance(commit_id_or_idx, str) | |||
r4653 | is_branch = reference_obj and reference_obj.branch | |||
r4656 | lookup_ok = False | |||
if is_bstr: | ||||
r4653 | # Need to call remote to translate id for tagging scenarios, | |||
# or branch that are numeric | ||||
r3842 | try: | |||
r4299 | remote_data = self._remote.get_object(commit_id_or_idx, | |||
maybe_unreachable=maybe_unreachable) | ||||
r3842 | commit_id_or_idx = remote_data["commit_id"] | |||
r4656 | lookup_ok = True | |||
r3842 | except (CommitDoesNotExistError,): | |||
r4656 | lookup_ok = False | |||
if lookup_ok is False: | ||||
is_numeric_idx = \ | ||||
(is_bstr and commit_id_or_idx.isdigit() and len(commit_id_or_idx) < 12) \ | ||||
or isinstance(commit_id_or_idx, int) | ||||
if not is_branch and (is_numeric_idx or is_null(commit_id_or_idx)): | ||||
try: | ||||
commit_id_or_idx = self.commit_ids[int(commit_id_or_idx)] | ||||
lookup_ok = True | ||||
except Exception: | ||||
raise CommitDoesNotExistError(commit_missing_err) | ||||
# we failed regular lookup, and by integer number lookup | ||||
if lookup_ok is False: | ||||
raise CommitDoesNotExistError(commit_missing_err) | ||||
r1 | ||||
# Ensure we return full id | ||||
if not SHA_PATTERN.match(str(commit_id_or_idx)): | ||||
raise CommitDoesNotExistError( | ||||
"Given commit id %s not recognized" % commit_id_or_idx) | ||||
return commit_id_or_idx | ||||
def get_hook_location(self): | ||||
""" | ||||
returns absolute path to location where hooks are stored | ||||
""" | ||||
loc = os.path.join(self.path, 'hooks') | ||||
if not self.bare: | ||||
loc = os.path.join(self.path, '.git', 'hooks') | ||||
return loc | ||||
@LazyProperty | ||||
def last_change(self): | ||||
""" | ||||
Returns last change made on this repository as | ||||
`datetime.datetime` object. | ||||
""" | ||||
try: | ||||
r1349 | return self.get_commit().date | |||
r1 | except RepositoryError: | |||
r1349 | tzoffset = makedate()[1] | |||
return utcdate_fromtimestamp(self._get_fs_mtime(), tzoffset) | ||||
def _get_fs_mtime(self): | ||||
idx_loc = '' if self.bare else '.git' | ||||
# fallback to filesystem | ||||
in_path = os.path.join(self.path, idx_loc, "index") | ||||
he_path = os.path.join(self.path, idx_loc, "HEAD") | ||||
if os.path.exists(in_path): | ||||
return os.stat(in_path).st_mtime | ||||
else: | ||||
return os.stat(he_path).st_mtime | ||||
r1 | ||||
@LazyProperty | ||||
def description(self): | ||||
description = self._remote.get_description() | ||||
r5074 | return safe_str(description or self.DEFAULT_DESCRIPTION) | |||
r1 | ||||
r784 | def _get_refs_entries(self, prefix='', reverse=False, strip_prefix=True): | |||
r1 | if self.is_empty(): | |||
r784 | return OrderedDict() | |||
r1 | ||||
r784 | result = [] | |||
r4932 | for ref, sha in self._refs.items(): | |||
r784 | if ref.startswith(prefix): | |||
ref_name = ref | ||||
if strip_prefix: | ||||
ref_name = ref[len(prefix):] | ||||
r5074 | result.append((safe_str(ref_name), sha)) | |||
r1 | ||||
r784 | def get_name(entry): | |||
return entry[0] | ||||
return OrderedDict(sorted(result, key=get_name, reverse=reverse)) | ||||
r1 | ||||
def _get_branches(self): | ||||
r784 | return self._get_refs_entries(prefix='refs/heads/', strip_prefix=True) | |||
r1 | ||||
r5428 | def delete_branch(self, branch_name): | |||
return self._remote.delete_branch(branch_name) | ||||
r3842 | @CachedProperty | |||
r1 | def branches(self): | |||
return self._get_branches() | ||||
r3842 | @CachedProperty | |||
r1 | def branches_closed(self): | |||
return {} | ||||
r3842 | @CachedProperty | |||
r2617 | def bookmarks(self): | |||
return {} | ||||
r3842 | @CachedProperty | |||
r1 | def branches_all(self): | |||
all_branches = {} | ||||
all_branches.update(self.branches) | ||||
all_branches.update(self.branches_closed) | ||||
return all_branches | ||||
r3842 | @CachedProperty | |||
r1 | def tags(self): | |||
return self._get_tags() | ||||
def _get_tags(self): | ||||
r3842 | return self._get_refs_entries(prefix='refs/tags/', strip_prefix=True, reverse=True) | |||
r1 | ||||
def tag(self, name, user, commit_id=None, message=None, date=None, | ||||
**kwargs): | ||||
r784 | # TODO: fix this method to apply annotated tags correct with message | |||
r1 | """ | |||
Creates and returns a tag for the given ``commit_id``. | ||||
:param name: name for new tag | ||||
:param user: full username, i.e.: "Joe Doe <joe.doe@example.com>" | ||||
:param commit_id: commit id for which new tag would be created | ||||
:param message: message of the tag's commit | ||||
:param date: date of tag's commit | ||||
:raises TagAlreadyExistError: if tag with same name already exists | ||||
""" | ||||
if name in self.tags: | ||||
raise TagAlreadyExistError("Tag %s already exists" % name) | ||||
commit = self.get_commit(commit_id=commit_id) | ||||
r5096 | message = message or f"Added tag {name} for commit {commit.raw_id}" | |||
r3842 | ||||
self._remote.set_refs('refs/tags/%s' % name, commit.raw_id) | ||||
r1 | ||||
r3842 | self._invalidate_prop_cache('tags') | |||
self._invalidate_prop_cache('_refs') | ||||
r1 | return commit | |||
def remove_tag(self, name, user, message=None, date=None): | ||||
""" | ||||
Removes tag with the given ``name``. | ||||
:param name: name of the tag to be removed | ||||
:param user: full username, i.e.: "Joe Doe <joe.doe@example.com>" | ||||
:param message: message of the tag's removal commit | ||||
:param date: date of tag's removal commit | ||||
:raises TagDoesNotExistError: if tag with given name does not exists | ||||
""" | ||||
if name not in self.tags: | ||||
raise TagDoesNotExistError("Tag %s does not exist" % name) | ||||
r3842 | ||||
self._remote.tag_remove(name) | ||||
self._invalidate_prop_cache('tags') | ||||
self._invalidate_prop_cache('_refs') | ||||
r1 | ||||
r784 | def _get_refs(self): | |||
return self._remote.get_refs() | ||||
r3842 | @CachedProperty | |||
r784 | def _refs(self): | |||
return self._get_refs() | ||||
r1 | ||||
r784 | @property | |||
def _ref_tree(self): | ||||
node = tree = {} | ||||
r4932 | for ref, sha in self._refs.items(): | |||
r784 | path = ref.split('/') | |||
for bit in path[:-1]: | ||||
node = node.setdefault(bit, {}) | ||||
node[path[-1]] = sha | ||||
node = tree | ||||
return tree | ||||
r1 | ||||
r2471 | def get_remote_ref(self, ref_name): | |||
r5091 | ref_key = f'refs/remotes/origin/{safe_str(ref_name)}' | |||
r2471 | try: | |||
r2472 | return self._refs[ref_key] | |||
r2471 | except Exception: | |||
return | ||||
r4299 | def get_commit(self, commit_id=None, commit_idx=None, pre_load=None, | |||
r4653 | translate_tag=True, maybe_unreachable=False, reference_obj=None): | |||
r1 | """ | |||
Returns `GitCommit` object representing commit from git repository | ||||
at the given `commit_id` or head (most recent commit) if None given. | ||||
""" | ||||
r4653 | ||||
r3740 | if self.is_empty(): | |||
raise EmptyRepositoryError("There are no commits yet") | ||||
r1 | if commit_id is not None: | |||
self._validate_commit_id(commit_id) | ||||
r3740 | try: | |||
# we have cached idx, use it without contacting the remote | ||||
idx = self._commit_ids[commit_id] | ||||
return GitCommit(self, commit_id, idx, pre_load=pre_load) | ||||
except KeyError: | ||||
pass | ||||
r1 | elif commit_idx is not None: | |||
self._validate_commit_idx(commit_idx) | ||||
r3740 | try: | |||
_commit_id = self.commit_ids[commit_idx] | ||||
if commit_idx < 0: | ||||
commit_idx = self.commit_ids.index(_commit_id) | ||||
return GitCommit(self, _commit_id, commit_idx, pre_load=pre_load) | ||||
except IndexError: | ||||
commit_id = commit_idx | ||||
else: | ||||
commit_id = "tip" | ||||
if translate_tag: | ||||
r4653 | commit_id = self._lookup_commit( | |||
commit_id, maybe_unreachable=maybe_unreachable, | ||||
reference_obj=reference_obj) | ||||
r3740 | ||||
r1 | try: | |||
idx = self._commit_ids[commit_id] | ||||
except KeyError: | ||||
r3842 | idx = -1 | |||
r1 | ||||
return GitCommit(self, commit_id, idx, pre_load=pre_load) | ||||
def get_commits( | ||||
self, start_id=None, end_id=None, start_date=None, end_date=None, | ||||
r3468 | branch_name=None, show_hidden=False, pre_load=None, translate_tags=True): | |||
r1 | """ | |||
Returns generator of `GitCommit` objects from start to end (both | ||||
are inclusive), in ascending date order. | ||||
:param start_id: None, str(commit_id) | ||||
:param end_id: None, str(commit_id) | ||||
:param start_date: if specified, commits with commit date less than | ||||
``start_date`` would be filtered out from returned set | ||||
:param end_date: if specified, commits with commit date greater than | ||||
``end_date`` would be filtered out from returned set | ||||
:param branch_name: if specified, commits not reachable from given | ||||
branch would be filtered out from returned set | ||||
r2144 | :param show_hidden: Show hidden commits such as obsolete or hidden from | |||
Mercurial evolve | ||||
r1 | :raise BranchDoesNotExistError: If given `branch_name` does not | |||
exist. | ||||
:raise CommitDoesNotExistError: If commits for given `start` or | ||||
`end` could not be found. | ||||
""" | ||||
if self.is_empty(): | ||||
raise EmptyRepositoryError("There are no commits yet") | ||||
r3740 | ||||
r1 | self._validate_branch_name(branch_name) | |||
if start_id is not None: | ||||
self._validate_commit_id(start_id) | ||||
if end_id is not None: | ||||
self._validate_commit_id(end_id) | ||||
r3740 | start_raw_id = self._lookup_commit(start_id) | |||
r1 | start_pos = self._commit_ids[start_raw_id] if start_id else None | |||
r3740 | end_raw_id = self._lookup_commit(end_id) | |||
r1 | end_pos = max(0, self._commit_ids[end_raw_id]) if end_id else None | |||
if None not in [start_id, end_id] and start_pos > end_pos: | ||||
raise RepositoryError( | ||||
"Start commit '%s' cannot be after end commit '%s'" % | ||||
(start_id, end_id)) | ||||
if end_pos is not None: | ||||
end_pos += 1 | ||||
filter_ = [] | ||||
if branch_name: | ||||
filter_.append({'branch_name': branch_name}) | ||||
if start_date and not end_date: | ||||
filter_.append({'since': start_date}) | ||||
if end_date and not start_date: | ||||
filter_.append({'until': end_date}) | ||||
if start_date and end_date: | ||||
filter_.append({'since': start_date}) | ||||
filter_.append({'until': end_date}) | ||||
# if start_pos or end_pos: | ||||
# filter_.append({'start': start_pos}) | ||||
# filter_.append({'end': end_pos}) | ||||
if filter_: | ||||
revfilters = { | ||||
'branch_name': branch_name, | ||||
'since': start_date.strftime('%m/%d/%y %H:%M:%S') if start_date else None, | ||||
'until': end_date.strftime('%m/%d/%y %H:%M:%S') if end_date else None, | ||||
'start': start_pos, | ||||
'end': end_pos, | ||||
} | ||||
r3842 | commit_ids = self._get_commit_ids(filters=revfilters) | |||
r1 | ||||
else: | ||||
commit_ids = self.commit_ids | ||||
if start_pos or end_pos: | ||||
commit_ids = commit_ids[start_pos: end_pos] | ||||
r3468 | return CollectionGenerator(self, commit_ids, pre_load=pre_load, | |||
translate_tag=translate_tags) | ||||
r1 | ||||
def get_diff( | ||||
self, commit1, commit2, path='', ignore_whitespace=False, | ||||
context=3, path1=None): | ||||
""" | ||||
Returns (git like) *diff*, as plain text. Shows changes introduced by | ||||
``commit2`` since ``commit1``. | ||||
:param commit1: Entry point from which diff is shown. Can be | ||||
``self.EMPTY_COMMIT`` - in this case, patch showing all | ||||
the changes since empty state of the repository until ``commit2`` | ||||
:param commit2: Until which commits changes should be shown. | ||||
r5074 | :param path: | |||
r1 | :param ignore_whitespace: If set to ``True``, would not show whitespace | |||
changes. Defaults to ``False``. | ||||
:param context: How many lines before/after changed lines should be | ||||
shown. Defaults to ``3``. | ||||
r5074 | :param path1: | |||
r1 | """ | |||
self._validate_diff_commits(commit1, commit2) | ||||
if path1 is not None and path1 != path: | ||||
raise ValueError("Diff of two different paths not supported.") | ||||
r3862 | if path: | |||
file_filter = path | ||||
r1 | else: | |||
r3862 | file_filter = None | |||
r1 | ||||
r3862 | diff = self._remote.diff( | |||
commit1.raw_id, commit2.raw_id, file_filter=file_filter, | ||||
opt_ignorews=ignore_whitespace, | ||||
context=context) | ||||
r5074 | ||||
r3862 | return GitDiff(diff) | |||
r1 | ||||
def strip(self, commit_id, branch_name): | ||||
commit = self.get_commit(commit_id=commit_id) | ||||
if commit.merge: | ||||
raise Exception('Cannot reset to merge commit') | ||||
r5253 | if not branch_name: | |||
raise ValueError(f'git strip requires a valid branch name, got {branch_name} instead') | ||||
r1 | # parent is going to be the new head now | |||
commit = commit.parents[0] | ||||
r5253 | self._remote.update_refs(f'refs/heads/{branch_name}', commit.raw_id) | |||
r1 | ||||
r3842 | # clear cached properties | |||
self._invalidate_prop_cache('commit_ids') | ||||
self._invalidate_prop_cache('_refs') | ||||
self._invalidate_prop_cache('branches') | ||||
r3743 | return len(self.commit_ids) | |||
r1 | ||||
def get_common_ancestor(self, commit_id1, commit_id2, repo2): | ||||
r4346 | log.debug('Calculating common ancestor between %sc1:%s and %sc2:%s', | |||
self, commit_id1, repo2, commit_id2) | ||||
r1 | if commit_id1 == commit_id2: | |||
return commit_id1 | ||||
if self != repo2: | ||||
r5624 | commits = self._remote.get_missing_revs(commit_id1, commit_id2, repo2.path) | |||
r1 | if commits: | |||
commit = repo2.get_commit(commits[-1]) | ||||
if commit.parents: | ||||
ancestor_id = commit.parents[0].raw_id | ||||
else: | ||||
ancestor_id = None | ||||
else: | ||||
# no commits from other repo, ancestor_id is the commit_id2 | ||||
ancestor_id = commit_id2 | ||||
else: | ||||
r5624 | ancestor_id = self._remote.get_common_ancestor(commit_id1, commit_id2) | |||
r1 | ||||
r4346 | log.debug('Found common ancestor with sha: %s', ancestor_id) | |||
r1 | return ancestor_id | |||
def compare(self, commit_id1, commit_id2, repo2, merge, pre_load=None): | ||||
repo1 = self | ||||
if commit_id1 == commit_id2: | ||||
commits = [] | ||||
elif repo1 != repo2: | ||||
r5624 | missing_ids = self._remote.get_missing_revs(commit_id1, commit_id2, repo2.path) | |||
r1 | commits = [ | |||
repo2.get_commit(commit_id=commit_id, pre_load=pre_load) | ||||
for commit_id in reversed(missing_ids)] | ||||
else: | ||||
r5624 | compare_commits = self._remote.compare_commits( | |||
commit_id1, commit_id2 | ||||
) | ||||
r1 | commits = [ | |||
repo1.get_commit(commit_id=commit_id, pre_load=pre_load) | ||||
r5624 | for commit_id in compare_commits] | |||
r1 | ||||
return commits | ||||
@LazyProperty | ||||
def in_memory_commit(self): | ||||
""" | ||||
Returns ``GitInMemoryCommit`` object for this repository. | ||||
""" | ||||
return GitInMemoryCommit(self) | ||||
r3078 | def pull(self, url, commit_ids=None, update_after=False): | |||
r1 | """ | |||
r3078 | Pull changes from external location. Pull is different in GIT | |||
that fetch since it's doing a checkout | ||||
r1 | ||||
r3078 | :param commit_ids: Optional. Can be set to a list of commit ids | |||
which shall be pulled from the other repository. | ||||
r1 | """ | |||
r3078 | refs = None | |||
if commit_ids is not None: | ||||
remote_refs = self._remote.get_remote_refs(url) | ||||
refs = [ref for ref in remote_refs if remote_refs[ref] in commit_ids] | ||||
self._remote.pull(url, refs=refs, update_after=update_after) | ||||
self._remote.invalidate_vcs_cache() | ||||
r1 | ||||
r5256 | def fetch(self, url, commit_ids=None, **kwargs): | |||
r1 | """ | |||
r3078 | Fetch all git objects from external location. | |||
r1 | """ | |||
r5256 | self._remote.sync_fetch(url, refs=commit_ids, **kwargs) | |||
r3078 | self._remote.invalidate_vcs_cache() | |||
r1 | ||||
r5258 | def push(self, url, **kwargs): | |||
r2492 | refs = None | |||
r5258 | self._remote.sync_push(url, refs=refs, **kwargs) | |||
r2492 | ||||
r1 | def set_refs(self, ref_name, commit_id): | |||
self._remote.set_refs(ref_name, commit_id) | ||||
r3842 | self._invalidate_prop_cache('_refs') | |||
r1 | ||||
def remove_ref(self, ref_name): | ||||
self._remote.remove_ref(ref_name) | ||||
r3842 | self._invalidate_prop_cache('_refs') | |||
r1 | ||||
r4299 | def run_gc(self, prune=True): | |||
cmd = ['gc', '--aggressive'] | ||||
if prune: | ||||
cmd += ['--prune=now'] | ||||
_stdout, stderr = self.run_git_command(cmd, fail_on_stderr=False) | ||||
return stderr | ||||
r5276 | def _update_server_info(self, force=False): | |||
r1 | """ | |||
runs gits update-server-info command in this repo instance | ||||
""" | ||||
r5276 | self._remote.update_server_info(force=force) | |||
r1 | ||||
def _current_branch(self): | ||||
""" | ||||
Return the name of the current branch. | ||||
It only works for non bare repositories (i.e. repositories with a | ||||
working copy) | ||||
""" | ||||
if self.bare: | ||||
raise RepositoryError('Bare git repos do not have active branches') | ||||
if self.is_empty(): | ||||
return None | ||||
stdout, _ = self.run_git_command(['rev-parse', '--abbrev-ref', 'HEAD']) | ||||
return stdout.strip() | ||||
r2501 | def _checkout(self, branch_name, create=False, force=False): | |||
r1 | """ | |||
Checkout a branch in the working directory. | ||||
It tries to create the branch if create is True, failing if the branch | ||||
already exists. | ||||
It only works for non bare repositories (i.e. repositories with a | ||||
working copy) | ||||
""" | ||||
if self.bare: | ||||
raise RepositoryError('Cannot checkout branches in a bare git repo') | ||||
cmd = ['checkout'] | ||||
r2501 | if force: | |||
cmd.append('-f') | ||||
r1 | if create: | |||
cmd.append('-b') | ||||
cmd.append(branch_name) | ||||
self.run_git_command(cmd, fail_on_stderr=False) | ||||
r3842 | def _create_branch(self, branch_name, commit_id): | |||
""" | ||||
creates a branch in a GIT repo | ||||
""" | ||||
self._remote.create_branch(branch_name, commit_id) | ||||
r2057 | def _identify(self): | |||
""" | ||||
Return the current state of the working directory. | ||||
""" | ||||
if self.bare: | ||||
raise RepositoryError('Bare git repos do not have active branches') | ||||
if self.is_empty(): | ||||
return None | ||||
stdout, _ = self.run_git_command(['rev-parse', 'HEAD']) | ||||
return stdout.strip() | ||||
r2471 | def _local_clone(self, clone_path, branch_name, source_branch=None): | |||
r1 | """ | |||
Create a local clone of the current repo. | ||||
""" | ||||
# N.B.(skreft): the --branch option is required as otherwise the shallow | ||||
# clone will only fetch the active branch. | ||||
r2471 | cmd = ['clone', '--branch', branch_name, | |||
r1 | self.path, os.path.abspath(clone_path)] | |||
r2471 | ||||
r1 | self.run_git_command(cmd, fail_on_stderr=False) | |||
r2471 | # if we get the different source branch, make sure we also fetch it for | |||
# merge conditions | ||||
if source_branch and source_branch != branch_name: | ||||
# check if the ref exists. | ||||
shadow_repo = GitRepository(os.path.abspath(clone_path)) | ||||
if shadow_repo.get_remote_ref(source_branch): | ||||
cmd = ['fetch', self.path, source_branch] | ||||
self.run_git_command(cmd, fail_on_stderr=False) | ||||
r2784 | def _local_fetch(self, repository_path, branch_name, use_origin=False): | |||
r1 | """ | |||
Fetch a branch from a local repository. | ||||
""" | ||||
repository_path = os.path.abspath(repository_path) | ||||
if repository_path == self.path: | ||||
raise ValueError('Cannot fetch from the same repository') | ||||
r2784 | if use_origin: | |||
branch_name = '+{branch}:refs/heads/{branch}'.format( | ||||
branch=branch_name) | ||||
cmd = ['fetch', '--no-tags', '--update-head-ok', | ||||
repository_path, branch_name] | ||||
self.run_git_command(cmd, fail_on_stderr=False) | ||||
def _local_reset(self, branch_name): | ||||
r5091 | branch_name = f'{branch_name}' | |||
r3127 | cmd = ['reset', '--hard', branch_name, '--'] | |||
r1 | self.run_git_command(cmd, fail_on_stderr=False) | |||
def _last_fetch_heads(self): | ||||
""" | ||||
Return the last fetched heads that need merging. | ||||
The algorithm is defined at | ||||
https://github.com/git/git/blob/v2.1.3/git-pull.sh#L283 | ||||
""" | ||||
if not self.bare: | ||||
fetch_heads_path = os.path.join(self.path, '.git', 'FETCH_HEAD') | ||||
else: | ||||
fetch_heads_path = os.path.join(self.path, 'FETCH_HEAD') | ||||
heads = [] | ||||
with open(fetch_heads_path) as f: | ||||
for line in f: | ||||
if ' not-for-merge ' in line: | ||||
continue | ||||
line = re.sub('\t.*', '', line, flags=re.DOTALL) | ||||
heads.append(line) | ||||
return heads | ||||
r3848 | def get_shadow_instance(self, shadow_repository_path, enable_hooks=False, cache=False): | |||
return GitRepository(shadow_repository_path, with_wire={"cache": cache}) | ||||
r1363 | ||||
r2543 | def _local_pull(self, repository_path, branch_name, ff_only=True): | |||
r1 | """ | |||
Pull a branch from a local repository. | ||||
""" | ||||
if self.bare: | ||||
raise RepositoryError('Cannot pull into a bare git repository') | ||||
# N.B.(skreft): The --ff-only option is to make sure this is a | ||||
# fast-forward (i.e., we are only pulling new changes and there are no | ||||
# conflicts with our current branch) | ||||
# Additionally, that option needs to go before --no-tags, otherwise git | ||||
# pull complains about it being an unknown flag. | ||||
r2543 | cmd = ['pull'] | |||
if ff_only: | ||||
cmd.append('--ff-only') | ||||
cmd.extend(['--no-tags', repository_path, branch_name]) | ||||
r1 | self.run_git_command(cmd, fail_on_stderr=False) | |||
def _local_merge(self, merge_message, user_name, user_email, heads): | ||||
""" | ||||
Merge the given head into the checked out branch. | ||||
It will force a merge commit. | ||||
Currently it raises an error if the repo is empty, as it is not possible | ||||
to create a merge commit in an empty repo. | ||||
:param merge_message: The message to use for the merge commit. | ||||
:param heads: the heads to merge. | ||||
""" | ||||
if self.bare: | ||||
raise RepositoryError('Cannot merge into a bare git repository') | ||||
if not heads: | ||||
return | ||||
if self.is_empty(): | ||||
r4080 | # TODO(skreft): do something more robust in this case. | |||
r4299 | raise RepositoryError('Do not know how to merge into empty repositories yet') | |||
r4080 | unresolved = None | |||
r1 | ||||
# N.B.(skreft): the --no-ff option is used to enforce the creation of a | ||||
# commit message. We also specify the user who is doing the merge. | ||||
r5074 | cmd = ['-c', f'user.name="{user_name}"', | |||
'-c', f'user.email={user_email}', | ||||
r1 | 'merge', '--no-ff', '-m', safe_str(merge_message)] | |||
r4299 | ||||
merge_cmd = cmd + heads | ||||
r1 | try: | |||
r4299 | self.run_git_command(merge_cmd, fail_on_stderr=False) | |||
r1 | except RepositoryError: | |||
r4080 | files = self.run_git_command(['diff', '--name-only', '--diff-filter', 'U'], | |||
fail_on_stderr=False)[0].splitlines() | ||||
# NOTE(marcink): we add U notation for consistent with HG backend output | ||||
r5091 | unresolved = [f'U {f}' for f in files] | |||
r4080 | ||||
r1 | # Cleanup any merge leftovers | |||
r4299 | self._remote.invalidate_vcs_cache() | |||
r1 | self.run_git_command(['merge', '--abort'], fail_on_stderr=False) | |||
r4080 | ||||
if unresolved: | ||||
raise UnresolvedFilesInRepo(unresolved) | ||||
else: | ||||
raise | ||||
r1 | ||||
def _local_push( | ||||
self, source_branch, repository_path, target_branch, | ||||
enable_hooks=False, rc_scm_data=None): | ||||
""" | ||||
Push the source_branch to the given repository and target_branch. | ||||
Currently it if the target_branch is not master and the target repo is | ||||
empty, the push will work, but then GitRepository won't be able to find | ||||
the pushed branch or the commits. As the HEAD will be corrupted (i.e., | ||||
pointing to master, which does not exist). | ||||
It does not run the hooks in the target repo. | ||||
""" | ||||
# TODO(skreft): deal with the case in which the target repo is empty, | ||||
# and the target_branch is not master. | ||||
target_repo = GitRepository(repository_path) | ||||
if (not target_repo.bare and | ||||
target_repo._current_branch() == target_branch): | ||||
# Git prevents pushing to the checked out branch, so simulate it by | ||||
# pulling into the target repository. | ||||
target_repo._local_pull(self.path, source_branch) | ||||
else: | ||||
cmd = ['push', os.path.abspath(repository_path), | ||||
r5096 | f'{source_branch}:{target_branch}'] | |||
r1 | gitenv = {} | |||
if rc_scm_data: | ||||
gitenv.update({'RC_SCM_DATA': rc_scm_data}) | ||||
if not enable_hooks: | ||||
gitenv['RC_SKIP_HOOKS'] = '1' | ||||
self.run_git_command(cmd, fail_on_stderr=False, extra_env=gitenv) | ||||
def _get_new_pr_branch(self, source_branch, target_branch): | ||||
r5096 | prefix = f'pr_{source_branch}-{target_branch}_' | |||
r1 | pr_branches = [] | |||
for branch in self.branches: | ||||
if branch.startswith(prefix): | ||||
pr_branches.append(int(branch[len(prefix):])) | ||||
if not pr_branches: | ||||
branch_id = 0 | ||||
else: | ||||
branch_id = max(pr_branches) + 1 | ||||
return '%s%d' % (prefix, branch_id) | ||||
r2810 | def _maybe_prepare_merge_workspace( | |||
self, repo_id, workspace_id, target_ref, source_ref): | ||||
shadow_repository_path = self._get_shadow_repository_path( | ||||
r3931 | self.path, repo_id, workspace_id) | |||
r2810 | if not os.path.exists(shadow_repository_path): | |||
self._local_clone( | ||||
shadow_repository_path, target_ref.name, source_ref.name) | ||||
r3848 | log.debug('Prepared %s shadow repository in %s', | |||
self.alias, shadow_repository_path) | ||||
r2810 | ||||
return shadow_repository_path | ||||
def _merge_repo(self, repo_id, workspace_id, target_ref, | ||||
r1 | source_repo, source_ref, merge_message, | |||
Martin Bornhold
|
r360 | merger_name, merger_email, dry_run=False, | ||
Mathieu Cantin
|
r2055 | use_rebase=False, close_branch=False): | ||
r3339 | ||||
log.debug('Executing merge_repo with %s strategy, dry_run mode:%s', | ||||
'rebase' if use_rebase else 'merge', dry_run) | ||||
r5178 | ||||
r1 | if target_ref.commit_id != self.branches[target_ref.name]: | |||
r2784 | log.warning('Target ref %s commit mismatch %s vs %s', target_ref, | |||
target_ref.commit_id, self.branches[target_ref.name]) | ||||
r1 | return MergeResponse( | |||
r3339 | False, False, None, MergeFailureReason.TARGET_IS_NOT_HEAD, | |||
metadata={'target_ref': target_ref}) | ||||
r1 | ||||
r2810 | shadow_repository_path = self._maybe_prepare_merge_workspace( | |||
repo_id, workspace_id, target_ref, source_ref) | ||||
r3848 | shadow_repo = self.get_shadow_instance(shadow_repository_path) | |||
r2810 | ||||
r2471 | # checkout source, if it's different. Otherwise we could not | |||
# fetch proper commits for merge testing | ||||
if source_ref.name != target_ref.name: | ||||
if shadow_repo.get_remote_ref(source_ref.name): | ||||
r2502 | shadow_repo._checkout(source_ref.name, force=True) | |||
r2471 | ||||
r2784 | # checkout target, and fetch changes | |||
r2502 | shadow_repo._checkout(target_ref.name, force=True) | |||
r2784 | ||||
# fetch/reset pull the target, in case it is changed | ||||
# this handles even force changes | ||||
shadow_repo._local_fetch(self.path, target_ref.name, use_origin=True) | ||||
shadow_repo._local_reset(target_ref.name) | ||||
r2471 | ||||
r1 | # Need to reload repo to invalidate the cache, or otherwise we cannot | |||
# retrieve the last target commit. | ||||
r3848 | shadow_repo = self.get_shadow_instance(shadow_repository_path) | |||
r1 | if target_ref.commit_id != shadow_repo.branches[target_ref.name]: | |||
r2784 | log.warning('Shadow Target ref %s commit mismatch %s vs %s', | |||
target_ref, target_ref.commit_id, | ||||
shadow_repo.branches[target_ref.name]) | ||||
r1 | return MergeResponse( | |||
r3339 | False, False, None, MergeFailureReason.TARGET_IS_NOT_HEAD, | |||
metadata={'target_ref': target_ref}) | ||||
r1 | ||||
r2784 | # calculate new branch | |||
r1 | pr_branch = shadow_repo._get_new_pr_branch( | |||
source_ref.name, target_ref.name) | ||||
r2543 | log.debug('using pull-request merge branch: `%s`', pr_branch) | |||
r2784 | # checkout to temp branch, and fetch changes | |||
r1 | shadow_repo._checkout(pr_branch, create=True) | |||
try: | ||||
shadow_repo._local_fetch(source_repo.path, source_ref.name) | ||||
Martin Bornhold
|
r1050 | except RepositoryError: | ||
r3339 | log.exception('Failure when doing local fetch on ' | |||
'shadow repo: %s', shadow_repo) | ||||
r1 | return MergeResponse( | |||
r3339 | False, False, None, MergeFailureReason.MISSING_SOURCE_REF, | |||
metadata={'source_ref': source_ref}) | ||||
r1 | ||||
Martin Bornhold
|
r1051 | merge_ref = None | ||
r1 | merge_failure_reason = MergeFailureReason.NONE | |||
r3339 | metadata = {} | |||
r1 | try: | |||
shadow_repo._local_merge(merge_message, merger_name, merger_email, | ||||
[source_ref.commit_id]) | ||||
merge_possible = True | ||||
Martin Bornhold
|
r1039 | |||
r3848 | # Need to invalidate the cache, or otherwise we | |||
Martin Bornhold
|
r1039 | # cannot retrieve the merge commit. | ||
r3848 | shadow_repo = shadow_repo.get_shadow_instance(shadow_repository_path) | |||
Martin Bornhold
|
r1039 | merge_commit_id = shadow_repo.branches[pr_branch] | ||
# Set a reference pointing to the merge commit. This reference may | ||||
# be used to easily identify the last successful merge commit in | ||||
# the shadow repository. | ||||
shadow_repo.set_refs('refs/heads/pr-merge', merge_commit_id) | ||||
Martin Bornhold
|
r1051 | merge_ref = Reference('branch', 'pr-merge', merge_commit_id) | ||
r4080 | except RepositoryError as e: | |||
r63 | log.exception('Failure when doing local merge on git shadow repo') | |||
r4080 | if isinstance(e, UnresolvedFilesInRepo): | |||
r4087 | metadata['unresolved_files'] = '\n* conflict: ' + ('\n * conflict: '.join(e.args[0])) | |||
r4080 | ||||
r1 | merge_possible = False | |||
merge_failure_reason = MergeFailureReason.MERGE_FAILED | ||||
if merge_possible and not dry_run: | ||||
try: | ||||
shadow_repo._local_push( | ||||
pr_branch, self.path, target_ref.name, enable_hooks=True, | ||||
rc_scm_data=self.config.get('rhodecode', 'RC_SCM_DATA')) | ||||
merge_succeeded = True | ||||
r5449 | if close_branch and source_ref.name != target_ref.name and not dry_run and source_ref.type == 'branch': | |||
self.delete_branch(source_ref.name) | ||||
Martin Bornhold
|
r1050 | except RepositoryError: | ||
r63 | log.exception( | |||
r3339 | 'Failure when doing local push from the shadow ' | |||
'repository to the target repository at %s.', self.path) | ||||
r1 | merge_succeeded = False | |||
merge_failure_reason = MergeFailureReason.PUSH_FAILED | ||||
r3339 | metadata['target'] = 'git shadow repo' | |||
metadata['merge_commit'] = pr_branch | ||||
r1 | else: | |||
merge_succeeded = False | ||||
return MergeResponse( | ||||
r3339 | merge_possible, merge_succeeded, merge_ref, merge_failure_reason, | |||
metadata=metadata) | ||||