repository.py
1035 lines
| 39.2 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/ | ||||
""" | ||||
HG repository module | ||||
""" | ||||
r2623 | import os | |||
r66 | import logging | |||
r1 | import binascii | |||
r4927 | import configparser | |||
r5074 | import urllib.request | |||
import urllib.parse | ||||
import urllib.error | ||||
r1 | ||||
from zope.cachedescriptors.property import Lazy as LazyProperty | ||||
r4928 | from collections import OrderedDict | |||
Martin Bornhold
|
r402 | from rhodecode.lib.datelib import ( | ||
r2623 | date_to_timestamp_plus_offset, utcdate_fromtimestamp, makedate) | |||
r5074 | from rhodecode.lib.str_utils import safe_str | |||
r3842 | from rhodecode.lib.utils2 import CachedProperty | |||
r2622 | from rhodecode.lib.vcs import connection, exceptions | |||
r5334 | from rhodecode.lib.vcs.conf import settings as vcs_settings | |||
r1 | from rhodecode.lib.vcs.backends.base import ( | |||
BaseRepository, CollectionGenerator, Config, MergeResponse, | ||||
r2619 | MergeFailureReason, Reference, BasePathPermissionChecker) | |||
r1 | from rhodecode.lib.vcs.backends.hg.commit import MercurialCommit | |||
from rhodecode.lib.vcs.backends.hg.diff import MercurialDiff | ||||
from rhodecode.lib.vcs.backends.hg.inmemory import MercurialInMemoryCommit | ||||
from rhodecode.lib.vcs.exceptions import ( | ||||
EmptyRepositoryError, RepositoryError, TagAlreadyExistError, | ||||
r4080 | TagDoesNotExistError, CommitDoesNotExistError, SubrepoMergeError, UnresolvedFilesInRepo) | |||
r1 | ||||
hexlify = binascii.hexlify | ||||
nullid = "\0" * 20 | ||||
r66 | log = logging.getLogger(__name__) | |||
r1 | ||||
class MercurialRepository(BaseRepository): | ||||
""" | ||||
Mercurial repository backend | ||||
""" | ||||
DEFAULT_BRANCH_NAME = 'default' | ||||
def __init__(self, repo_path, config=None, create=False, src_url=None, | ||||
r3078 | do_workspace_checkout=False, with_wire=None, bare=False): | |||
r1 | """ | |||
Raises RepositoryError if repository could not be find at the given | ||||
``repo_path``. | ||||
:param repo_path: local path of the repository | ||||
:param config: config object containing the repo configuration | ||||
:param create=False: if set to True, would try to create repository if | ||||
it does not exist rather than raising exception | ||||
:param src_url=None: would try to clone repository from given location | ||||
r3078 | :param do_workspace_checkout=False: sets update of working copy after | |||
r1 | making a clone | |||
r3078 | :param bare: not used, compatible with other VCS | |||
r1 | """ | |||
r2518 | ||||
r1 | self.path = safe_str(os.path.abspath(repo_path)) | |||
r2518 | # mercurial since 4.4.X requires certain configuration to be present | |||
# because sometimes we init the repos with config we need to meet | ||||
# special requirements | ||||
self.config = config if config else self.get_default_config( | ||||
r5188 | default=[('extensions', 'largefiles', '')]) | |||
r5250 | ||||
# NOTE(marcink): since python3 hgsubversion is deprecated. | ||||
# From old installations we might still have this set enabled | ||||
# we explicitly remove this now here to make sure it wont propagate further | ||||
r5251 | if config and config.get('extensions', 'hgsubversion') is not None: | |||
r5250 | config.drop_option('extensions', 'hgsubversion') | |||
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) | |||
r1 | ||||
# caches | ||||
self._commit_ids = {} | ||||
@LazyProperty | ||||
r2946 | def _remote(self): | |||
r3848 | repo_id = self.path | |||
return connection.Hg(self.path, repo_id, self.config, with_wire=self.with_wire) | ||||
r2946 | ||||
r3842 | @CachedProperty | |||
r1 | def commit_ids(self): | |||
""" | ||||
Returns list of commit ids, in ascending order. Being lazy | ||||
attribute allows external tools to inject shas 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 | ||||
r3842 | @CachedProperty | |||
r1 | def branches(self): | |||
return self._get_branches() | ||||
r3842 | @CachedProperty | |||
r1 | def branches_closed(self): | |||
return self._get_branches(active=False, closed=True) | ||||
r3842 | @CachedProperty | |||
r1 | def branches_all(self): | |||
all_branches = {} | ||||
all_branches.update(self.branches) | ||||
all_branches.update(self.branches_closed) | ||||
return all_branches | ||||
def _get_branches(self, active=True, closed=False): | ||||
""" | ||||
Gets branches for this repository | ||||
Returns only not closed active branches by default | ||||
:param active: return also active branches | ||||
:param closed: return also closed branches | ||||
""" | ||||
if self.is_empty(): | ||||
return {} | ||||
def get_name(ctx): | ||||
return ctx[0] | ||||
r5074 | _branches = [(n, h,) for n, h in | |||
r1 | self._remote.branches(active, closed).items()] | |||
return OrderedDict(sorted(_branches, key=get_name, reverse=False)) | ||||
r3842 | @CachedProperty | |||
r1 | def tags(self): | |||
""" | ||||
Gets tags for this repository | ||||
""" | ||||
return self._get_tags() | ||||
def _get_tags(self): | ||||
if self.is_empty(): | ||||
return {} | ||||
def get_name(ctx): | ||||
return ctx[0] | ||||
r5074 | _tags = [(n, h,) for n, h in | |||
r1 | self._remote.tags().items()] | |||
return OrderedDict(sorted(_tags, key=get_name, reverse=True)) | ||||
r3743 | def tag(self, name, user, commit_id=None, message=None, date=None, **kwargs): | |||
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) | ||||
r3743 | ||||
r1 | commit = self.get_commit(commit_id=commit_id) | |||
local = kwargs.setdefault('local', False) | ||||
if message is None: | ||||
r5096 | message = f"Added tag {name} for commit {commit.short_id}" | |||
r1 | ||||
date, tz = date_to_timestamp_plus_offset(date) | ||||
r3743 | self._remote.tag(name, commit.raw_id, message, local, user, date, tz) | |||
Martin Bornhold
|
r407 | self._remote.invalidate_vcs_cache() | ||
r1 | ||||
# Reinitialize tags | ||||
r3848 | self._invalidate_prop_cache('tags') | |||
r1 | tag_id = self.tags[name] | |||
return self.get_commit(commit_id=tag_id) | ||||
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) | ||||
r3743 | ||||
r1 | if message is None: | |||
message = "Removed tag %s" % name | ||||
local = False | ||||
date, tz = date_to_timestamp_plus_offset(date) | ||||
self._remote.tag(name, nullid, message, local, user, date, tz) | ||||
Martin Bornhold
|
r407 | self._remote.invalidate_vcs_cache() | ||
r3848 | self._invalidate_prop_cache('tags') | |||
r1 | ||||
@LazyProperty | ||||
def bookmarks(self): | ||||
""" | ||||
Gets bookmarks for this repository | ||||
""" | ||||
return self._get_bookmarks() | ||||
def _get_bookmarks(self): | ||||
if self.is_empty(): | ||||
return {} | ||||
def get_name(ctx): | ||||
return ctx[0] | ||||
_bookmarks = [ | ||||
r5074 | (n, h) for n, h in | |||
r1 | self._remote.bookmarks().items()] | |||
return OrderedDict(sorted(_bookmarks, key=get_name)) | ||||
def _get_all_commit_ids(self): | ||||
return self._remote.get_all_commit_ids('visible') | ||||
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 commit changes should be shown. | ||||
: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``. | ||||
""" | ||||
self._validate_diff_commits(commit1, commit2) | ||||
if path1 is not None and path1 != path: | ||||
raise ValueError("Diff of two different paths not supported.") | ||||
if path: | ||||
file_filter = [self.path, path] | ||||
else: | ||||
file_filter = None | ||||
diff = self._remote.diff( | ||||
commit1.raw_id, commit2.raw_id, file_filter=file_filter, | ||||
opt_git=True, opt_ignorews=ignore_whitespace, | ||||
context=context) | ||||
return MercurialDiff(diff) | ||||
def strip(self, commit_id, branch=None): | ||||
r5158 | self._remote.strip(commit_id, update=False, backup=False) | |||
r1 | ||||
Martin Bornhold
|
r407 | self._remote.invalidate_vcs_cache() | ||
r3842 | # clear cache | |||
self._invalidate_prop_cache('commit_ids') | ||||
r3743 | return len(self.commit_ids) | |||
r1 | ||||
r1553 | def verify(self): | |||
verify = self._remote.verify() | ||||
self._remote.invalidate_vcs_cache() | ||||
return verify | ||||
r3928 | def hg_update_cache(self): | |||
update_cache = self._remote.hg_update_cache() | ||||
self._remote.invalidate_vcs_cache() | ||||
return update_cache | ||||
r4214 | def hg_rebuild_fn_cache(self): | |||
update_cache = self._remote.hg_rebuild_fn_cache() | ||||
self._remote.invalidate_vcs_cache() | ||||
return update_cache | ||||
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 | ||||
ancestors = self._remote.revs_from_revspec( | ||||
"ancestor(id(%s), id(%s))", commit_id1, commit_id2, | ||||
other_path=repo2.path) | ||||
r4346 | ||||
ancestor_id = repo2[ancestors[0]].raw_id if ancestors else None | ||||
log.debug('Found common ancestor with sha: %s', ancestor_id) | ||||
return ancestor_id | ||||
r1 | ||||
def compare(self, commit_id1, commit_id2, repo2, merge, pre_load=None): | ||||
if commit_id1 == commit_id2: | ||||
commits = [] | ||||
else: | ||||
if merge: | ||||
indexes = self._remote.revs_from_revspec( | ||||
"ancestors(id(%s)) - ancestors(id(%s)) - id(%s)", | ||||
commit_id2, commit_id1, commit_id1, other_path=repo2.path) | ||||
else: | ||||
indexes = self._remote.revs_from_revspec( | ||||
"id(%s)..id(%s) - id(%s)", commit_id1, commit_id2, | ||||
commit_id1, other_path=repo2.path) | ||||
commits = [repo2.get_commit(commit_idx=idx, pre_load=pre_load) | ||||
for idx in indexes] | ||||
return commits | ||||
@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 mercurial 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 local url | ||||
if os.path.isdir(url) or url.startswith('file:'): | ||||
return True | ||||
# Request the _remote to verify the url | ||||
return connection.Hg.check_url(url, config.serialize()) | ||||
@staticmethod | ||||
def is_valid_repository(path): | ||||
return os.path.isdir(os.path.join(path, '.hg')) | ||||
r3078 | def _init_repo(self, create, src_url=None, do_workspace_checkout=False): | |||
r1 | """ | |||
Function will check for mercurial repository in given path. If there | ||||
is no repository in that path it will raise an exception unless | ||||
`create` parameter is set to True - in that case repository would | ||||
be created. | ||||
If `src_url` is given, would try to clone repository from the | ||||
r5581 | location at given clone_point. Additionally, it'll make update to | |||
r3078 | working copy accordingly to `do_workspace_checkout` flag. | |||
r1 | """ | |||
if create and os.path.exists(self.path): | ||||
raise RepositoryError( | ||||
r5074 | f"Cannot create repository at {self.path}, location already exist") | |||
r1 | ||||
r5581 | if create: | |||
if src_url: | ||||
url = str(self._get_url(src_url)) | ||||
MercurialRepository.check_url(url, self.config) | ||||
r1 | ||||
r5581 | self._remote.clone(url, self.path, do_workspace_checkout) | |||
r1 | ||||
r5581 | # Don't try to create if we've already cloned repo | |||
create = False | ||||
self._remote.localrepository(create) | ||||
else: | ||||
os.makedirs(self.path, mode=0o755) | ||||
create = True | ||||
self._remote.localrepository(create) | ||||
r1 | ||||
r5581 | else: | |||
if not self._remote.assert_correct_path(): | ||||
raise RepositoryError( | ||||
f'Path "{self.path}" does not contain a Mercurial repository') | ||||
r1 | ||||
@LazyProperty | ||||
def in_memory_commit(self): | ||||
return MercurialInMemoryCommit(self) | ||||
@LazyProperty | ||||
def description(self): | ||||
description = self._remote.get_config_value( | ||||
'web', 'description', untrusted=True) | ||||
r5074 | return safe_str(description or self.DEFAULT_DESCRIPTION) | |||
r1 | ||||
@LazyProperty | ||||
def contact(self): | ||||
contact = ( | ||||
self._remote.get_config_value("web", "contact") or | ||||
self._remote.get_config_value("ui", "username")) | ||||
r5074 | return safe_str(contact or self.DEFAULT_CONTACT) | |||
r1 | ||||
@LazyProperty | ||||
def last_change(self): | ||||
""" | ||||
Returns last change made on this repository as | ||||
r1349 | `datetime.datetime` object. | |||
r1 | """ | |||
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): | ||||
# fallback to filesystem | ||||
cl_path = os.path.join(self.path, '.hg', "00changelog.i") | ||||
st_path = os.path.join(self.path, '.hg', "store") | ||||
if os.path.exists(cl_path): | ||||
return os.stat(cl_path).st_mtime | ||||
else: | ||||
return os.stat(st_path).st_mtime | ||||
r1 | ||||
def _get_url(self, url): | ||||
""" | ||||
Returns normalized url. If schema is not given, would fall | ||||
to filesystem | ||||
(``file:///``) schema. | ||||
""" | ||||
if url != 'default' and '://' not in url: | ||||
r4914 | url = "file:" + urllib.request.pathname2url(url) | |||
r1 | return url | |||
def get_hook_location(self): | ||||
""" | ||||
returns absolute path to location where hooks are stored | ||||
""" | ||||
return os.path.join(self.path, '.hg', '.hgrc') | ||||
r4299 | def get_commit(self, commit_id=None, commit_idx=None, pre_load=None, | |||
r5647 | translate_tag=None, maybe_unreachable=False, reference_obj=None) -> MercurialCommit: | |||
r1 | """ | |||
Returns ``MercurialCommit`` object representing repository's | ||||
commit at the given `commit_id` or `commit_idx`. | ||||
""" | ||||
if self.is_empty(): | ||||
raise EmptyRepositoryError("There are no commits yet") | ||||
if commit_id is not None: | ||||
self._validate_commit_id(commit_id) | ||||
try: | ||||
r3740 | # we have cached idx, use it without contacting the remote | |||
r1 | idx = self._commit_ids[commit_id] | |||
return MercurialCommit(self, commit_id, idx, pre_load=pre_load) | ||||
except KeyError: | ||||
pass | ||||
r3740 | ||||
r1 | elif commit_idx is not None: | |||
self._validate_commit_idx(commit_idx) | ||||
try: | ||||
r3740 | _commit_id = self.commit_ids[commit_idx] | |||
r1 | if commit_idx < 0: | |||
r3740 | commit_idx = self.commit_ids.index(_commit_id) | |||
return MercurialCommit(self, _commit_id, commit_idx, pre_load=pre_load) | ||||
r1 | except IndexError: | |||
commit_id = commit_idx | ||||
else: | ||||
commit_id = "tip" | ||||
r5074 | # case here is no cached version, do an actual lookup instead | |||
r1428 | try: | |||
raw_id, idx = self._remote.lookup(commit_id, both=True) | ||||
except CommitDoesNotExistError: | ||||
r3772 | msg = "Commit {} does not exist for `{}`".format( | |||
r3743 | *map(safe_str, [commit_id, self.name])) | |||
r1428 | raise CommitDoesNotExistError(msg) | |||
r1 | ||||
return MercurialCommit(self, raw_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=None): | |||
r1 | """ | |||
Returns generator of ``MercurialCommit`` objects from start to end | ||||
(both are inclusive) | ||||
: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 commit for given ``start`` or | ||||
``end`` could not be found. | ||||
""" | ||||
# actually we should check now if it's not an empty repo | ||||
if self.is_empty(): | ||||
raise EmptyRepositoryError("There are no commits yet") | ||||
self._validate_branch_name(branch_name) | ||||
r3742 | branch_ancestors = False | |||
r1 | if start_id is not None: | |||
self._validate_commit_id(start_id) | ||||
c_start = self.get_commit(commit_id=start_id) | ||||
start_pos = self._commit_ids[c_start.raw_id] | ||||
else: | ||||
start_pos = None | ||||
if end_id is not None: | ||||
self._validate_commit_id(end_id) | ||||
c_end = self.get_commit(commit_id=end_id) | ||||
end_pos = max(0, self._commit_ids[c_end.raw_id]) | ||||
else: | ||||
end_pos = 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 | ||||
commit_filter = [] | ||||
r2144 | ||||
r1 | if branch_name and not branch_ancestors: | |||
r5096 | commit_filter.append(f'branch("{branch_name}")') | |||
r1 | elif branch_name and branch_ancestors: | |||
r5096 | commit_filter.append(f'ancestors(branch("{branch_name}"))') | |||
r2144 | ||||
r1 | if start_date and not end_date: | |||
r5096 | commit_filter.append(f'date(">{start_date}")') | |||
r1 | if end_date and not start_date: | |||
r5096 | commit_filter.append(f'date("<{end_date}")') | |||
r1 | if start_date and end_date: | |||
commit_filter.append( | ||||
r5096 | f'date(">{start_date}") and date("<{end_date}")') | |||
r1 | ||||
r2144 | if not show_hidden: | |||
commit_filter.append('not obsolete()') | ||||
commit_filter.append('not hidden()') | ||||
r1 | # TODO: johbo: Figure out a simpler way for this solution | |||
collection_generator = CollectionGenerator | ||||
if commit_filter: | ||||
r2144 | commit_filter = ' and '.join(map(safe_str, commit_filter)) | |||
revisions = self._remote.rev_range([commit_filter]) | ||||
r1 | collection_generator = MercurialIndexBasedCollectionGenerator | |||
else: | ||||
revisions = self.commit_ids | ||||
if start_pos or end_pos: | ||||
revisions = revisions[start_pos:end_pos] | ||||
return collection_generator(self, revisions, pre_load=pre_load) | ||||
def pull(self, url, commit_ids=None): | ||||
""" | ||||
r3078 | Pull changes from external location. | |||
r1 | ||||
:param commit_ids: Optional. Can be set to a list of commit ids | ||||
which shall be pulled from the other repository. | ||||
""" | ||||
url = self._get_url(url) | ||||
self._remote.pull(url, commit_ids=commit_ids) | ||||
Martin Bornhold
|
r407 | self._remote.invalidate_vcs_cache() | ||
r1 | ||||
r5256 | def fetch(self, url, commit_ids=None, **kwargs): | |||
r3078 | """ | |||
Backward compatibility with GIT fetch==pull | ||||
""" | ||||
return self.pull(url, commit_ids=commit_ids) | ||||
r5258 | def push(self, url, **kwargs): | |||
r2492 | url = self._get_url(url) | |||
self._remote.sync_push(url) | ||||
r1 | def _local_clone(self, clone_path): | |||
""" | ||||
Create a local clone of the current repo. | ||||
""" | ||||
r5647 | self._remote.clone(self.path, clone_path, update_after_clone=True, hooks=False) | |||
r1 | ||||
def _update(self, revision, clean=False): | ||||
""" | ||||
r2058 | Update the working copy to the specified revision. | |||
r1 | """ | |||
r2056 | log.debug('Doing checkout to commit: `%s` for %s', revision, self) | |||
r1 | self._remote.update(revision, clean=clean) | |||
def _identify(self): | ||||
""" | ||||
Return the current state of the working directory. | ||||
""" | ||||
return self._remote.identify().strip().rstrip('+') | ||||
def _heads(self, branch=None): | ||||
""" | ||||
Return the commit ids of the repository heads. | ||||
""" | ||||
return self._remote.heads(branch=branch).strip().split(' ') | ||||
def _ancestor(self, revision1, revision2): | ||||
""" | ||||
Return the common ancestor of the two revisions. | ||||
""" | ||||
r1414 | return self._remote.ancestor(revision1, revision2) | |||
r1 | ||||
def _local_push( | ||||
self, revision, repository_path, push_branches=False, | ||||
enable_hooks=False): | ||||
""" | ||||
Push the given revision to the specified repository. | ||||
:param push_branches: allow to create branches in the target repo. | ||||
""" | ||||
self._remote.push( | ||||
[revision], repository_path, hooks=enable_hooks, | ||||
push_branches=push_branches) | ||||
def _local_merge(self, target_ref, merge_message, user_name, user_email, | ||||
r4436 | source_ref, use_rebase=False, close_commit_id=None, dry_run=False): | |||
r1 | """ | |||
Merge the given source_revision into the checked out revision. | ||||
Returns the commit id of the merge and a boolean indicating if the | ||||
commit needs to be pushed. | ||||
""" | ||||
r5188 | ||||
r4436 | source_ref_commit_id = source_ref.commit_id | |||
target_ref_commit_id = target_ref.commit_id | ||||
r1 | ||||
r4436 | # update our workdir to target ref, for proper merge | |||
self._update(target_ref_commit_id, clean=True) | ||||
ancestor = self._ancestor(target_ref_commit_id, source_ref_commit_id) | ||||
r1 | is_the_same_branch = self._is_the_same_branch(target_ref, source_ref) | |||
r4436 | if close_commit_id: | |||
# NOTE(marcink): if we get the close commit, this is our new source | ||||
# which will include the close commit itself. | ||||
source_ref_commit_id = close_commit_id | ||||
r1 | ||||
r4436 | if ancestor == source_ref_commit_id: | |||
# Nothing to do, the changes were already integrated | ||||
return target_ref_commit_id, False | ||||
elif ancestor == target_ref_commit_id and is_the_same_branch: | ||||
r1 | # In this case we should force a commit message | |||
r4436 | return source_ref_commit_id, True | |||
r1 | ||||
r4080 | unresolved = None | |||
Martin Bornhold
|
r362 | if use_rebase: | ||
r1 | try: | |||
r5096 | bookmark_name = f'rcbook{source_ref_commit_id}{target_ref_commit_id}' | |||
r1 | self.bookmark(bookmark_name, revision=source_ref.commit_id) | |||
self._remote.rebase( | ||||
r4436 | source=source_ref_commit_id, dest=target_ref_commit_id) | |||
Martin Bornhold
|
r407 | self._remote.invalidate_vcs_cache() | ||
r3339 | self._update(bookmark_name, clean=True) | |||
r1 | return self._identify(), True | |||
r4080 | except RepositoryError as e: | |||
r156 | # The rebase-abort may raise another exception which 'hides' | |||
# the original one, therefore we log it here. | ||||
log.exception('Error while rebasing shadow repo during merge.') | ||||
r4119 | if 'unresolved conflicts' in safe_str(e): | |||
r4080 | unresolved = self._remote.get_unresolved_files() | |||
log.debug('unresolved files: %s', unresolved) | ||||
r156 | ||||
r1 | # Cleanup any rebase leftovers | |||
Martin Bornhold
|
r407 | self._remote.invalidate_vcs_cache() | ||
r1 | self._remote.rebase(abort=True) | |||
Martin Bornhold
|
r407 | self._remote.invalidate_vcs_cache() | ||
r1 | self._remote.update(clean=True) | |||
r4080 | if unresolved: | |||
raise UnresolvedFilesInRepo(unresolved) | ||||
else: | ||||
raise | ||||
r1 | else: | |||
try: | ||||
r4436 | self._remote.merge(source_ref_commit_id) | |||
Martin Bornhold
|
r407 | self._remote.invalidate_vcs_cache() | ||
r1 | self._remote.commit( | |||
message=safe_str(merge_message), | ||||
r5096 | username=safe_str(f'{user_name} <{user_email}>')) | |||
Martin Bornhold
|
r407 | self._remote.invalidate_vcs_cache() | ||
r1 | return self._identify(), True | |||
r4080 | except RepositoryError as e: | |||
# The merge-abort may raise another exception which 'hides' | ||||
# the original one, therefore we log it here. | ||||
log.exception('Error while merging shadow repo during merge.') | ||||
r4119 | if 'unresolved merge conflicts' in safe_str(e): | |||
r4080 | unresolved = self._remote.get_unresolved_files() | |||
log.debug('unresolved files: %s', unresolved) | ||||
r1 | # Cleanup any merge leftovers | |||
self._remote.update(clean=True) | ||||
r4080 | if unresolved: | |||
raise UnresolvedFilesInRepo(unresolved) | ||||
else: | ||||
raise | ||||
r1 | ||||
Mathieu Cantin
|
r2055 | def _local_close(self, target_ref, user_name, user_email, | ||
source_ref, close_message=''): | ||||
""" | ||||
Close the branch of the given source_revision | ||||
Returns the commit id of the close and a boolean indicating if the | ||||
commit needs to be pushed. | ||||
""" | ||||
r2056 | self._update(source_ref.commit_id) | |||
r5334 | message = (close_message or vcs_settings.HG_CLOSE_BRANCH_MESSAGE_TMPL).format( | |||
user_name=user_name, | ||||
user_email=user_email, | ||||
target_ref_name=target_ref.name, | ||||
source_ref_name=source_ref.name | ||||
) | ||||
Mathieu Cantin
|
r2055 | try: | ||
self._remote.commit( | ||||
message=safe_str(message), | ||||
r5096 | username=safe_str(f'{user_name} <{user_email}>'), | |||
Mathieu Cantin
|
r2055 | close_branch=True) | ||
self._remote.invalidate_vcs_cache() | ||||
return self._identify(), True | ||||
except RepositoryError: | ||||
# Cleanup any commit leftovers | ||||
self._remote.update(clean=True) | ||||
raise | ||||
r1 | def _is_the_same_branch(self, target_ref, source_ref): | |||
return ( | ||||
self._get_branch_name(target_ref) == | ||||
self._get_branch_name(source_ref)) | ||||
def _get_branch_name(self, ref): | ||||
if ref.type == 'branch': | ||||
return ref.name | ||||
return self._remote.ctx_branch(ref.commit_id) | ||||
r2810 | def _maybe_prepare_merge_workspace( | |||
self, repo_id, workspace_id, unused_target_ref, unused_source_ref): | ||||
shadow_repository_path = self._get_shadow_repository_path( | ||||
r3931 | self.path, repo_id, workspace_id) | |||
r1 | if not os.path.exists(shadow_repository_path): | |||
self._local_clone(shadow_repository_path) | ||||
r142 | log.debug( | |||
'Prepared shadow repository in %s', shadow_repository_path) | ||||
r1 | ||||
return shadow_repository_path | ||||
r2810 | 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): | ||
r2058 | ||||
log.debug('Executing merge_repo with %s strategy, dry_run mode:%s', | ||||
'rebase' if use_rebase else 'merge', dry_run) | ||||
r5188 | ||||
r1 | if target_ref.commit_id not in self._heads(): | |||
return MergeResponse( | ||||
r3339 | False, False, None, MergeFailureReason.TARGET_IS_NOT_HEAD, | |||
metadata={'target_ref': target_ref}) | ||||
r1 | ||||
Martin Bornhold
|
r1080 | try: | ||
r3339 | if target_ref.type == 'branch' and len(self._heads(target_ref.name)) != 1: | |||
r4628 | heads_all = self._heads(target_ref.name) | |||
max_heads = 10 | ||||
if len(heads_all) > max_heads: | ||||
heads = '\n,'.join( | ||||
heads_all[:max_heads] + | ||||
r5096 | [f'and {len(heads_all)-max_heads} more.']) | |||
r4628 | else: | |||
heads = '\n,'.join(heads_all) | ||||
r3627 | metadata = { | |||
'target_ref': target_ref, | ||||
'source_ref': source_ref, | ||||
'heads': heads | ||||
} | ||||
Martin Bornhold
|
r1080 | return MergeResponse( | ||
False, False, None, | ||||
r3339 | MergeFailureReason.HG_TARGET_HAS_MULTIPLE_HEADS, | |||
r3627 | metadata=metadata) | |||
r2058 | except CommitDoesNotExistError: | |||
Martin Bornhold
|
r1080 | log.exception('Failure when looking up branch heads on hg target') | ||
r1 | return MergeResponse( | |||
r3339 | False, False, None, MergeFailureReason.MISSING_TARGET_REF, | |||
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) | |||
r1 | ||||
r142 | log.debug('Pulling in target reference %s', target_ref) | |||
r1 | self._validate_pull_reference(target_ref) | |||
shadow_repo._local_pull(self.path, target_ref) | ||||
r3339 | ||||
r1 | try: | |||
r142 | log.debug('Pulling in source reference %s', source_ref) | |||
r1 | source_repo._validate_pull_reference(source_ref) | |||
shadow_repo._local_pull(source_repo.path, source_ref) | ||||
Martin Bornhold
|
r1050 | except CommitDoesNotExistError: | ||
r63 | log.exception('Failure when doing local pull on hg 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 | ||
r2056 | merge_commit_id = None | |||
close_commit_id = None | ||||
r1 | merge_failure_reason = MergeFailureReason.NONE | |||
r3339 | metadata = {} | |||
r1 | ||||
r2056 | # enforce that close branch should be used only in case we source from | |||
# an actual Branch | ||||
close_branch = close_branch and source_ref.type == 'branch' | ||||
# don't allow to close branch if source and target are the same | ||||
close_branch = close_branch and source_ref.name != target_ref.name | ||||
needs_push_on_close = False | ||||
if close_branch and not use_rebase and not dry_run: | ||||
Mathieu Cantin
|
r2055 | try: | ||
r2056 | close_commit_id, needs_push_on_close = shadow_repo._local_close( | |||
Mathieu Cantin
|
r2055 | target_ref, merger_name, merger_email, source_ref) | ||
merge_possible = True | ||||
except RepositoryError: | ||||
r3339 | log.exception('Failure when doing close branch on ' | |||
'shadow repo: %s', shadow_repo) | ||||
Mathieu Cantin
|
r2055 | merge_possible = False | ||
merge_failure_reason = MergeFailureReason.MERGE_FAILED | ||||
else: | ||||
r1 | merge_possible = True | |||
Martin Bornhold
|
r1040 | |||
r2623 | needs_push = False | |||
Mathieu Cantin
|
r2055 | if merge_possible: | ||
r4436 | ||||
Mathieu Cantin
|
r2055 | try: | ||
merge_commit_id, needs_push = shadow_repo._local_merge( | ||||
target_ref, merge_message, merger_name, merger_email, | ||||
r4436 | source_ref, use_rebase=use_rebase, | |||
close_commit_id=close_commit_id, dry_run=dry_run) | ||||
Mathieu Cantin
|
r2055 | merge_possible = True | ||
r2056 | # read the state of the close action, if it | |||
# maybe required a push | ||||
needs_push = needs_push or needs_push_on_close | ||||
# Set a bookmark pointing to the merge commit. This bookmark | ||||
# may be used to easily identify the last successful merge | ||||
# commit in the shadow repository. | ||||
Mathieu Cantin
|
r2055 | shadow_repo.bookmark('pr-merge', revision=merge_commit_id) | ||
merge_ref = Reference('book', 'pr-merge', merge_commit_id) | ||||
except SubrepoMergeError: | ||||
log.exception( | ||||
'Subrepo merge error during local merge on hg shadow repo.') | ||||
merge_possible = False | ||||
merge_failure_reason = MergeFailureReason.SUBREPO_MERGE_FAILED | ||||
r2058 | needs_push = False | |||
r4080 | except RepositoryError as e: | |||
Mathieu Cantin
|
r2055 | log.exception('Failure when doing local merge on hg shadow repo') | ||
r5188 | metadata['unresolved_files'] = 'no unresolved files found' | |||
r4080 | if isinstance(e, UnresolvedFilesInRepo): | |||
r4628 | all_conflicts = list(e.args[0]) | |||
max_conflicts = 20 | ||||
if len(all_conflicts) > max_conflicts: | ||||
conflicts = all_conflicts[:max_conflicts] \ | ||||
r5096 | + [f'and {len(all_conflicts)-max_conflicts} more.'] | |||
r4628 | else: | |||
conflicts = all_conflicts | ||||
metadata['unresolved_files'] = \ | ||||
'\n* conflict: ' + \ | ||||
('\n * conflict: '.join(conflicts)) | ||||
r4080 | ||||
Mathieu Cantin
|
r2055 | merge_possible = False | ||
merge_failure_reason = MergeFailureReason.MERGE_FAILED | ||||
r2058 | needs_push = False | |||
r1 | ||||
if merge_possible and not dry_run: | ||||
if needs_push: | ||||
# In case the target is a bookmark, update it, so after pushing | ||||
# the bookmarks is also updated in the target. | ||||
if target_ref.type == 'book': | ||||
shadow_repo.bookmark( | ||||
target_ref.name, revision=merge_commit_id) | ||||
try: | ||||
r3848 | shadow_repo_with_hooks = self.get_shadow_instance( | |||
r1 | shadow_repository_path, | |||
enable_hooks=True) | ||||
r2058 | # This is the actual merge action, we push from shadow | |||
# into origin. | ||||
r1 | # Note: the push_branches option will push any new branch | |||
# defined in the source repository to the target. This may | ||||
# be dangerous as branches are permanent in Mercurial. | ||||
# This feature was requested in issue #441. | ||||
shadow_repo_with_hooks._local_push( | ||||
merge_commit_id, self.path, push_branches=True, | ||||
enable_hooks=True) | ||||
r2056 | ||||
# maybe we also need to push the close_commit_id | ||||
if close_commit_id: | ||||
shadow_repo_with_hooks._local_push( | ||||
close_commit_id, self.path, push_branches=True, | ||||
enable_hooks=True) | ||||
r1 | merge_succeeded = True | |||
r146 | except RepositoryError: | |||
r63 | log.exception( | |||
r156 | 'Failure when doing local push from the shadow ' | |||
r3339 | 'repository to the target repository at %s.', self.path) | |||
r1 | merge_succeeded = False | |||
merge_failure_reason = MergeFailureReason.PUSH_FAILED | ||||
r3339 | metadata['target'] = 'hg shadow repo' | |||
metadata['merge_commit'] = merge_commit_id | ||||
r1 | else: | |||
merge_succeeded = True | ||||
else: | ||||
merge_succeeded = False | ||||
return MergeResponse( | ||||
r3339 | merge_possible, merge_succeeded, merge_ref, merge_failure_reason, | |||
metadata=metadata) | ||||
r1 | ||||
r3848 | def get_shadow_instance(self, shadow_repository_path, enable_hooks=False, cache=False): | |||
r1 | config = self.config.copy() | |||
if not enable_hooks: | ||||
config.clear_section('hooks') | ||||
r3848 | return MercurialRepository(shadow_repository_path, config, with_wire={"cache": cache}) | |||
r1 | ||||
def _validate_pull_reference(self, reference): | ||||
if not (reference.name in self.bookmarks or | ||||
reference.name in self.branches or | ||||
self.get_commit(reference.commit_id)): | ||||
raise CommitDoesNotExistError( | ||||
'Unknown branch, bookmark or commit id') | ||||
def _local_pull(self, repository_path, reference): | ||||
""" | ||||
Fetch a branch, bookmark or commit from a local repository. | ||||
""" | ||||
repository_path = os.path.abspath(repository_path) | ||||
if repository_path == self.path: | ||||
raise ValueError('Cannot pull from the same repository') | ||||
reference_type_to_option_name = { | ||||
'book': 'bookmark', | ||||
'branch': 'branch', | ||||
} | ||||
option_name = reference_type_to_option_name.get( | ||||
reference.type, 'revision') | ||||
if option_name == 'revision': | ||||
ref = reference.commit_id | ||||
else: | ||||
ref = reference.name | ||||
options = {option_name: [ref]} | ||||
self._remote.pull_cmd(repository_path, hooks=False, **options) | ||||
Martin Bornhold
|
r407 | self._remote.invalidate_vcs_cache() | ||
r1 | ||||
def bookmark(self, bookmark, revision=None): | ||||
r4961 | if isinstance(bookmark, str): | |||
r1 | bookmark = safe_str(bookmark) | |||
self._remote.bookmark(bookmark, revision=revision) | ||||
Martin Bornhold
|
r407 | self._remote.invalidate_vcs_cache() | ||
r1 | ||||
r2619 | def get_path_permissions(self, username): | |||
r2623 | hgacl_file = os.path.join(self.path, '.hg/hgacl') | |||
def read_patterns(suffix): | ||||
svalue = None | ||||
r3646 | for section, option in [ | |||
('narrowacl', username + suffix), | ||||
('narrowacl', 'default' + suffix), | ||||
('narrowhgacl', username + suffix), | ||||
('narrowhgacl', 'default' + suffix) | ||||
]: | ||||
r2623 | try: | |||
r3646 | svalue = hgacl.get(section, option) | |||
break # stop at the first value we find | ||||
r2623 | except configparser.NoOptionError: | |||
pass | ||||
if not svalue: | ||||
return None | ||||
result = ['/'] | ||||
for pattern in svalue.split(): | ||||
result.append(pattern) | ||||
if '*' not in pattern and '?' not in pattern: | ||||
result.append(pattern + '/*') | ||||
return result | ||||
r2619 | if os.path.exists(hgacl_file): | |||
r2622 | try: | |||
r2623 | hgacl = configparser.RawConfigParser() | |||
r2622 | hgacl.read(hgacl_file) | |||
r2623 | ||||
r2622 | includes = read_patterns('.includes') | |||
excludes = read_patterns('.excludes') | ||||
r2623 | return BasePathPermissionChecker.create_from_patterns( | |||
includes, excludes) | ||||
r2622 | except BaseException as e: | |||
r2623 | msg = 'Cannot read ACL settings from {} on {}: {}'.format( | |||
hgacl_file, self.name, e) | ||||
raise exceptions.RepositoryRequirementError(msg) | ||||
r2619 | else: | |||
return None | ||||
r1 | ||||
r2623 | ||||
r1 | class MercurialIndexBasedCollectionGenerator(CollectionGenerator): | |||
def _commit_factory(self, commit_id): | ||||
r5074 | if isinstance(commit_id, int): | |||
return self.repo.get_commit( | ||||
commit_idx=commit_id, pre_load=self.pre_load) | ||||
else: | ||||
return self.repo.get_commit( | ||||
commit_id=commit_id, pre_load=self.pre_load) | ||||