##// END OF EJS Templates
git: fix lookups....
milka -
r4656:a9d6cd92 default
parent child Browse files
Show More
@@ -1,1043 +1,1051 b''
1 # -*- coding: utf-8 -*-
1 # -*- coding: utf-8 -*-
2
2
3 # Copyright (C) 2014-2020 RhodeCode GmbH
3 # Copyright (C) 2014-2020 RhodeCode GmbH
4 #
4 #
5 # This program is free software: you can redistribute it and/or modify
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License, version 3
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
7 # (only), as published by the Free Software Foundation.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU Affero General Public License
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
16 #
17 # This program is dual-licensed. If you wish to learn more about the
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
20
21 """
21 """
22 GIT repository module
22 GIT repository module
23 """
23 """
24
24
25 import logging
25 import logging
26 import os
26 import os
27 import re
27 import re
28
28
29 from zope.cachedescriptors.property import Lazy as LazyProperty
29 from zope.cachedescriptors.property import Lazy as LazyProperty
30
30
31 from rhodecode.lib.compat import OrderedDict
31 from rhodecode.lib.compat import OrderedDict
32 from rhodecode.lib.datelib import (
32 from rhodecode.lib.datelib import (
33 utcdate_fromtimestamp, makedate, date_astimestamp)
33 utcdate_fromtimestamp, makedate, date_astimestamp)
34 from rhodecode.lib.utils import safe_unicode, safe_str
34 from rhodecode.lib.utils import safe_unicode, safe_str
35 from rhodecode.lib.utils2 import CachedProperty
35 from rhodecode.lib.utils2 import CachedProperty
36 from rhodecode.lib.vcs import connection, path as vcspath
36 from rhodecode.lib.vcs import connection, path as vcspath
37 from rhodecode.lib.vcs.backends.base import (
37 from rhodecode.lib.vcs.backends.base import (
38 BaseRepository, CollectionGenerator, Config, MergeResponse,
38 BaseRepository, CollectionGenerator, Config, MergeResponse,
39 MergeFailureReason, Reference)
39 MergeFailureReason, Reference)
40 from rhodecode.lib.vcs.backends.git.commit import GitCommit
40 from rhodecode.lib.vcs.backends.git.commit import GitCommit
41 from rhodecode.lib.vcs.backends.git.diff import GitDiff
41 from rhodecode.lib.vcs.backends.git.diff import GitDiff
42 from rhodecode.lib.vcs.backends.git.inmemory import GitInMemoryCommit
42 from rhodecode.lib.vcs.backends.git.inmemory import GitInMemoryCommit
43 from rhodecode.lib.vcs.exceptions import (
43 from rhodecode.lib.vcs.exceptions import (
44 CommitDoesNotExistError, EmptyRepositoryError,
44 CommitDoesNotExistError, EmptyRepositoryError,
45 RepositoryError, TagAlreadyExistError, TagDoesNotExistError, VCSError, UnresolvedFilesInRepo)
45 RepositoryError, TagAlreadyExistError, TagDoesNotExistError, VCSError, UnresolvedFilesInRepo)
46
46
47
47
48 SHA_PATTERN = re.compile(r'^[[0-9a-fA-F]{12}|[0-9a-fA-F]{40}]$')
48 SHA_PATTERN = re.compile(r'^[[0-9a-fA-F]{12}|[0-9a-fA-F]{40}]$')
49
49
50 log = logging.getLogger(__name__)
50 log = logging.getLogger(__name__)
51
51
52
52
53 class GitRepository(BaseRepository):
53 class GitRepository(BaseRepository):
54 """
54 """
55 Git repository backend.
55 Git repository backend.
56 """
56 """
57 DEFAULT_BRANCH_NAME = 'master'
57 DEFAULT_BRANCH_NAME = 'master'
58
58
59 contact = BaseRepository.DEFAULT_CONTACT
59 contact = BaseRepository.DEFAULT_CONTACT
60
60
61 def __init__(self, repo_path, config=None, create=False, src_url=None,
61 def __init__(self, repo_path, config=None, create=False, src_url=None,
62 do_workspace_checkout=False, with_wire=None, bare=False):
62 do_workspace_checkout=False, with_wire=None, bare=False):
63
63
64 self.path = safe_str(os.path.abspath(repo_path))
64 self.path = safe_str(os.path.abspath(repo_path))
65 self.config = config if config else self.get_default_config()
65 self.config = config if config else self.get_default_config()
66 self.with_wire = with_wire or {"cache": False} # default should not use cache
66 self.with_wire = with_wire or {"cache": False} # default should not use cache
67
67
68 self._init_repo(create, src_url, do_workspace_checkout, bare)
68 self._init_repo(create, src_url, do_workspace_checkout, bare)
69
69
70 # caches
70 # caches
71 self._commit_ids = {}
71 self._commit_ids = {}
72
72
73 @LazyProperty
73 @LazyProperty
74 def _remote(self):
74 def _remote(self):
75 repo_id = self.path
75 repo_id = self.path
76 return connection.Git(self.path, repo_id, self.config, with_wire=self.with_wire)
76 return connection.Git(self.path, repo_id, self.config, with_wire=self.with_wire)
77
77
78 @LazyProperty
78 @LazyProperty
79 def bare(self):
79 def bare(self):
80 return self._remote.bare()
80 return self._remote.bare()
81
81
82 @LazyProperty
82 @LazyProperty
83 def head(self):
83 def head(self):
84 return self._remote.head()
84 return self._remote.head()
85
85
86 @CachedProperty
86 @CachedProperty
87 def commit_ids(self):
87 def commit_ids(self):
88 """
88 """
89 Returns list of commit ids, in ascending order. Being lazy
89 Returns list of commit ids, in ascending order. Being lazy
90 attribute allows external tools to inject commit ids from cache.
90 attribute allows external tools to inject commit ids from cache.
91 """
91 """
92 commit_ids = self._get_all_commit_ids()
92 commit_ids = self._get_all_commit_ids()
93 self._rebuild_cache(commit_ids)
93 self._rebuild_cache(commit_ids)
94 return commit_ids
94 return commit_ids
95
95
96 def _rebuild_cache(self, commit_ids):
96 def _rebuild_cache(self, commit_ids):
97 self._commit_ids = dict((commit_id, index)
97 self._commit_ids = dict((commit_id, index)
98 for index, commit_id in enumerate(commit_ids))
98 for index, commit_id in enumerate(commit_ids))
99
99
100 def run_git_command(self, cmd, **opts):
100 def run_git_command(self, cmd, **opts):
101 """
101 """
102 Runs given ``cmd`` as git command and returns tuple
102 Runs given ``cmd`` as git command and returns tuple
103 (stdout, stderr).
103 (stdout, stderr).
104
104
105 :param cmd: git command to be executed
105 :param cmd: git command to be executed
106 :param opts: env options to pass into Subprocess command
106 :param opts: env options to pass into Subprocess command
107 """
107 """
108 if not isinstance(cmd, list):
108 if not isinstance(cmd, list):
109 raise ValueError('cmd must be a list, got %s instead' % type(cmd))
109 raise ValueError('cmd must be a list, got %s instead' % type(cmd))
110
110
111 skip_stderr_log = opts.pop('skip_stderr_log', False)
111 skip_stderr_log = opts.pop('skip_stderr_log', False)
112 out, err = self._remote.run_git_command(cmd, **opts)
112 out, err = self._remote.run_git_command(cmd, **opts)
113 if err and not skip_stderr_log:
113 if err and not skip_stderr_log:
114 log.debug('Stderr output of git command "%s":\n%s', cmd, err)
114 log.debug('Stderr output of git command "%s":\n%s', cmd, err)
115 return out, err
115 return out, err
116
116
117 @staticmethod
117 @staticmethod
118 def check_url(url, config):
118 def check_url(url, config):
119 """
119 """
120 Function will check given url and try to verify if it's a valid
120 Function will check given url and try to verify if it's a valid
121 link. Sometimes it may happened that git will issue basic
121 link. Sometimes it may happened that git will issue basic
122 auth request that can cause whole API to hang when used from python
122 auth request that can cause whole API to hang when used from python
123 or other external calls.
123 or other external calls.
124
124
125 On failures it'll raise urllib2.HTTPError, exception is also thrown
125 On failures it'll raise urllib2.HTTPError, exception is also thrown
126 when the return code is non 200
126 when the return code is non 200
127 """
127 """
128 # check first if it's not an url
128 # check first if it's not an url
129 if os.path.isdir(url) or url.startswith('file:'):
129 if os.path.isdir(url) or url.startswith('file:'):
130 return True
130 return True
131
131
132 if '+' in url.split('://', 1)[0]:
132 if '+' in url.split('://', 1)[0]:
133 url = url.split('+', 1)[1]
133 url = url.split('+', 1)[1]
134
134
135 # Request the _remote to verify the url
135 # Request the _remote to verify the url
136 return connection.Git.check_url(url, config.serialize())
136 return connection.Git.check_url(url, config.serialize())
137
137
138 @staticmethod
138 @staticmethod
139 def is_valid_repository(path):
139 def is_valid_repository(path):
140 if os.path.isdir(os.path.join(path, '.git')):
140 if os.path.isdir(os.path.join(path, '.git')):
141 return True
141 return True
142 # check case of bare repository
142 # check case of bare repository
143 try:
143 try:
144 GitRepository(path)
144 GitRepository(path)
145 return True
145 return True
146 except VCSError:
146 except VCSError:
147 pass
147 pass
148 return False
148 return False
149
149
150 def _init_repo(self, create, src_url=None, do_workspace_checkout=False,
150 def _init_repo(self, create, src_url=None, do_workspace_checkout=False,
151 bare=False):
151 bare=False):
152 if create and os.path.exists(self.path):
152 if create and os.path.exists(self.path):
153 raise RepositoryError(
153 raise RepositoryError(
154 "Cannot create repository at %s, location already exist"
154 "Cannot create repository at %s, location already exist"
155 % self.path)
155 % self.path)
156
156
157 if bare and do_workspace_checkout:
157 if bare and do_workspace_checkout:
158 raise RepositoryError("Cannot update a bare repository")
158 raise RepositoryError("Cannot update a bare repository")
159 try:
159 try:
160
160
161 if src_url:
161 if src_url:
162 # check URL before any actions
162 # check URL before any actions
163 GitRepository.check_url(src_url, self.config)
163 GitRepository.check_url(src_url, self.config)
164
164
165 if create:
165 if create:
166 os.makedirs(self.path, mode=0o755)
166 os.makedirs(self.path, mode=0o755)
167
167
168 if bare:
168 if bare:
169 self._remote.init_bare()
169 self._remote.init_bare()
170 else:
170 else:
171 self._remote.init()
171 self._remote.init()
172
172
173 if src_url and bare:
173 if src_url and bare:
174 # bare repository only allows a fetch and checkout is not allowed
174 # bare repository only allows a fetch and checkout is not allowed
175 self.fetch(src_url, commit_ids=None)
175 self.fetch(src_url, commit_ids=None)
176 elif src_url:
176 elif src_url:
177 self.pull(src_url, commit_ids=None,
177 self.pull(src_url, commit_ids=None,
178 update_after=do_workspace_checkout)
178 update_after=do_workspace_checkout)
179
179
180 else:
180 else:
181 if not self._remote.assert_correct_path():
181 if not self._remote.assert_correct_path():
182 raise RepositoryError(
182 raise RepositoryError(
183 'Path "%s" does not contain a Git repository' %
183 'Path "%s" does not contain a Git repository' %
184 (self.path,))
184 (self.path,))
185
185
186 # TODO: johbo: check if we have to translate the OSError here
186 # TODO: johbo: check if we have to translate the OSError here
187 except OSError as err:
187 except OSError as err:
188 raise RepositoryError(err)
188 raise RepositoryError(err)
189
189
190 def _get_all_commit_ids(self):
190 def _get_all_commit_ids(self):
191 return self._remote.get_all_commit_ids()
191 return self._remote.get_all_commit_ids()
192
192
193 def _get_commit_ids(self, filters=None):
193 def _get_commit_ids(self, filters=None):
194 # we must check if this repo is not empty, since later command
194 # we must check if this repo is not empty, since later command
195 # fails if it is. And it's cheaper to ask than throw the subprocess
195 # fails if it is. And it's cheaper to ask than throw the subprocess
196 # errors
196 # errors
197
197
198 head = self._remote.head(show_exc=False)
198 head = self._remote.head(show_exc=False)
199
199
200 if not head:
200 if not head:
201 return []
201 return []
202
202
203 rev_filter = ['--branches', '--tags']
203 rev_filter = ['--branches', '--tags']
204 extra_filter = []
204 extra_filter = []
205
205
206 if filters:
206 if filters:
207 if filters.get('since'):
207 if filters.get('since'):
208 extra_filter.append('--since=%s' % (filters['since']))
208 extra_filter.append('--since=%s' % (filters['since']))
209 if filters.get('until'):
209 if filters.get('until'):
210 extra_filter.append('--until=%s' % (filters['until']))
210 extra_filter.append('--until=%s' % (filters['until']))
211 if filters.get('branch_name'):
211 if filters.get('branch_name'):
212 rev_filter = []
212 rev_filter = []
213 extra_filter.append(filters['branch_name'])
213 extra_filter.append(filters['branch_name'])
214 rev_filter.extend(extra_filter)
214 rev_filter.extend(extra_filter)
215
215
216 # if filters.get('start') or filters.get('end'):
216 # if filters.get('start') or filters.get('end'):
217 # # skip is offset, max-count is limit
217 # # skip is offset, max-count is limit
218 # if filters.get('start'):
218 # if filters.get('start'):
219 # extra_filter += ' --skip=%s' % filters['start']
219 # extra_filter += ' --skip=%s' % filters['start']
220 # if filters.get('end'):
220 # if filters.get('end'):
221 # extra_filter += ' --max-count=%s' % (filters['end'] - (filters['start'] or 0))
221 # extra_filter += ' --max-count=%s' % (filters['end'] - (filters['start'] or 0))
222
222
223 cmd = ['rev-list', '--reverse', '--date-order'] + rev_filter
223 cmd = ['rev-list', '--reverse', '--date-order'] + rev_filter
224 try:
224 try:
225 output, __ = self.run_git_command(cmd)
225 output, __ = self.run_git_command(cmd)
226 except RepositoryError:
226 except RepositoryError:
227 # Can be raised for empty repositories
227 # Can be raised for empty repositories
228 return []
228 return []
229 return output.splitlines()
229 return output.splitlines()
230
230
231 def _lookup_commit(self, commit_id_or_idx, translate_tag=True, maybe_unreachable=False, reference_obj=None):
231 def _lookup_commit(self, commit_id_or_idx, translate_tag=True, maybe_unreachable=False, reference_obj=None):
232
232
233 def is_null(value):
233 def is_null(value):
234 return len(value) == commit_id_or_idx.count('0')
234 return len(value) == commit_id_or_idx.count('0')
235
235
236 if commit_id_or_idx in (None, '', 'tip', 'HEAD', 'head', -1):
236 if commit_id_or_idx in (None, '', 'tip', 'HEAD', 'head', -1):
237 return self.commit_ids[-1]
237 return self.commit_ids[-1]
238
238
239 commit_missing_err = "Commit {} does not exist for `{}`".format(
239 commit_missing_err = "Commit {} does not exist for `{}`".format(
240 *map(safe_str, [commit_id_or_idx, self.name]))
240 *map(safe_str, [commit_id_or_idx, self.name]))
241
241
242 is_bstr = isinstance(commit_id_or_idx, (str, unicode))
242 is_bstr = isinstance(commit_id_or_idx, (str, unicode))
243 is_branch = reference_obj and reference_obj.branch
243 is_branch = reference_obj and reference_obj.branch
244 is_numeric_idx = \
245 (is_bstr and commit_id_or_idx.isdigit() and len(commit_id_or_idx) < 12) \
246 or isinstance(commit_id_or_idx, int)
247
244
248 if not is_branch and (is_numeric_idx or is_null(commit_id_or_idx)):
245 lookup_ok = False
249 try:
246 if is_bstr:
250 commit_id_or_idx = self.commit_ids[int(commit_id_or_idx)]
251 except Exception:
252 raise CommitDoesNotExistError(commit_missing_err)
253
254 elif is_bstr:
255 # Need to call remote to translate id for tagging scenarios,
247 # Need to call remote to translate id for tagging scenarios,
256 # or branch that are numeric
248 # or branch that are numeric
257 try:
249 try:
258 remote_data = self._remote.get_object(commit_id_or_idx,
250 remote_data = self._remote.get_object(commit_id_or_idx,
259 maybe_unreachable=maybe_unreachable)
251 maybe_unreachable=maybe_unreachable)
260 commit_id_or_idx = remote_data["commit_id"]
252 commit_id_or_idx = remote_data["commit_id"]
253 lookup_ok = True
261 except (CommitDoesNotExistError,):
254 except (CommitDoesNotExistError,):
262 raise CommitDoesNotExistError(commit_missing_err)
255 lookup_ok = False
256
257 if lookup_ok is False:
258 is_numeric_idx = \
259 (is_bstr and commit_id_or_idx.isdigit() and len(commit_id_or_idx) < 12) \
260 or isinstance(commit_id_or_idx, int)
261 if not is_branch and (is_numeric_idx or is_null(commit_id_or_idx)):
262 try:
263 commit_id_or_idx = self.commit_ids[int(commit_id_or_idx)]
264 lookup_ok = True
265 except Exception:
266 raise CommitDoesNotExistError(commit_missing_err)
267
268 # we failed regular lookup, and by integer number lookup
269 if lookup_ok is False:
270 raise CommitDoesNotExistError(commit_missing_err)
263
271
264 # Ensure we return full id
272 # Ensure we return full id
265 if not SHA_PATTERN.match(str(commit_id_or_idx)):
273 if not SHA_PATTERN.match(str(commit_id_or_idx)):
266 raise CommitDoesNotExistError(
274 raise CommitDoesNotExistError(
267 "Given commit id %s not recognized" % commit_id_or_idx)
275 "Given commit id %s not recognized" % commit_id_or_idx)
268 return commit_id_or_idx
276 return commit_id_or_idx
269
277
270 def get_hook_location(self):
278 def get_hook_location(self):
271 """
279 """
272 returns absolute path to location where hooks are stored
280 returns absolute path to location where hooks are stored
273 """
281 """
274 loc = os.path.join(self.path, 'hooks')
282 loc = os.path.join(self.path, 'hooks')
275 if not self.bare:
283 if not self.bare:
276 loc = os.path.join(self.path, '.git', 'hooks')
284 loc = os.path.join(self.path, '.git', 'hooks')
277 return loc
285 return loc
278
286
279 @LazyProperty
287 @LazyProperty
280 def last_change(self):
288 def last_change(self):
281 """
289 """
282 Returns last change made on this repository as
290 Returns last change made on this repository as
283 `datetime.datetime` object.
291 `datetime.datetime` object.
284 """
292 """
285 try:
293 try:
286 return self.get_commit().date
294 return self.get_commit().date
287 except RepositoryError:
295 except RepositoryError:
288 tzoffset = makedate()[1]
296 tzoffset = makedate()[1]
289 return utcdate_fromtimestamp(self._get_fs_mtime(), tzoffset)
297 return utcdate_fromtimestamp(self._get_fs_mtime(), tzoffset)
290
298
291 def _get_fs_mtime(self):
299 def _get_fs_mtime(self):
292 idx_loc = '' if self.bare else '.git'
300 idx_loc = '' if self.bare else '.git'
293 # fallback to filesystem
301 # fallback to filesystem
294 in_path = os.path.join(self.path, idx_loc, "index")
302 in_path = os.path.join(self.path, idx_loc, "index")
295 he_path = os.path.join(self.path, idx_loc, "HEAD")
303 he_path = os.path.join(self.path, idx_loc, "HEAD")
296 if os.path.exists(in_path):
304 if os.path.exists(in_path):
297 return os.stat(in_path).st_mtime
305 return os.stat(in_path).st_mtime
298 else:
306 else:
299 return os.stat(he_path).st_mtime
307 return os.stat(he_path).st_mtime
300
308
301 @LazyProperty
309 @LazyProperty
302 def description(self):
310 def description(self):
303 description = self._remote.get_description()
311 description = self._remote.get_description()
304 return safe_unicode(description or self.DEFAULT_DESCRIPTION)
312 return safe_unicode(description or self.DEFAULT_DESCRIPTION)
305
313
306 def _get_refs_entries(self, prefix='', reverse=False, strip_prefix=True):
314 def _get_refs_entries(self, prefix='', reverse=False, strip_prefix=True):
307 if self.is_empty():
315 if self.is_empty():
308 return OrderedDict()
316 return OrderedDict()
309
317
310 result = []
318 result = []
311 for ref, sha in self._refs.iteritems():
319 for ref, sha in self._refs.iteritems():
312 if ref.startswith(prefix):
320 if ref.startswith(prefix):
313 ref_name = ref
321 ref_name = ref
314 if strip_prefix:
322 if strip_prefix:
315 ref_name = ref[len(prefix):]
323 ref_name = ref[len(prefix):]
316 result.append((safe_unicode(ref_name), sha))
324 result.append((safe_unicode(ref_name), sha))
317
325
318 def get_name(entry):
326 def get_name(entry):
319 return entry[0]
327 return entry[0]
320
328
321 return OrderedDict(sorted(result, key=get_name, reverse=reverse))
329 return OrderedDict(sorted(result, key=get_name, reverse=reverse))
322
330
323 def _get_branches(self):
331 def _get_branches(self):
324 return self._get_refs_entries(prefix='refs/heads/', strip_prefix=True)
332 return self._get_refs_entries(prefix='refs/heads/', strip_prefix=True)
325
333
326 @CachedProperty
334 @CachedProperty
327 def branches(self):
335 def branches(self):
328 return self._get_branches()
336 return self._get_branches()
329
337
330 @CachedProperty
338 @CachedProperty
331 def branches_closed(self):
339 def branches_closed(self):
332 return {}
340 return {}
333
341
334 @CachedProperty
342 @CachedProperty
335 def bookmarks(self):
343 def bookmarks(self):
336 return {}
344 return {}
337
345
338 @CachedProperty
346 @CachedProperty
339 def branches_all(self):
347 def branches_all(self):
340 all_branches = {}
348 all_branches = {}
341 all_branches.update(self.branches)
349 all_branches.update(self.branches)
342 all_branches.update(self.branches_closed)
350 all_branches.update(self.branches_closed)
343 return all_branches
351 return all_branches
344
352
345 @CachedProperty
353 @CachedProperty
346 def tags(self):
354 def tags(self):
347 return self._get_tags()
355 return self._get_tags()
348
356
349 def _get_tags(self):
357 def _get_tags(self):
350 return self._get_refs_entries(prefix='refs/tags/', strip_prefix=True, reverse=True)
358 return self._get_refs_entries(prefix='refs/tags/', strip_prefix=True, reverse=True)
351
359
352 def tag(self, name, user, commit_id=None, message=None, date=None,
360 def tag(self, name, user, commit_id=None, message=None, date=None,
353 **kwargs):
361 **kwargs):
354 # TODO: fix this method to apply annotated tags correct with message
362 # TODO: fix this method to apply annotated tags correct with message
355 """
363 """
356 Creates and returns a tag for the given ``commit_id``.
364 Creates and returns a tag for the given ``commit_id``.
357
365
358 :param name: name for new tag
366 :param name: name for new tag
359 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
367 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
360 :param commit_id: commit id for which new tag would be created
368 :param commit_id: commit id for which new tag would be created
361 :param message: message of the tag's commit
369 :param message: message of the tag's commit
362 :param date: date of tag's commit
370 :param date: date of tag's commit
363
371
364 :raises TagAlreadyExistError: if tag with same name already exists
372 :raises TagAlreadyExistError: if tag with same name already exists
365 """
373 """
366 if name in self.tags:
374 if name in self.tags:
367 raise TagAlreadyExistError("Tag %s already exists" % name)
375 raise TagAlreadyExistError("Tag %s already exists" % name)
368 commit = self.get_commit(commit_id=commit_id)
376 commit = self.get_commit(commit_id=commit_id)
369 message = message or "Added tag %s for commit %s" % (name, commit.raw_id)
377 message = message or "Added tag %s for commit %s" % (name, commit.raw_id)
370
378
371 self._remote.set_refs('refs/tags/%s' % name, commit.raw_id)
379 self._remote.set_refs('refs/tags/%s' % name, commit.raw_id)
372
380
373 self._invalidate_prop_cache('tags')
381 self._invalidate_prop_cache('tags')
374 self._invalidate_prop_cache('_refs')
382 self._invalidate_prop_cache('_refs')
375
383
376 return commit
384 return commit
377
385
378 def remove_tag(self, name, user, message=None, date=None):
386 def remove_tag(self, name, user, message=None, date=None):
379 """
387 """
380 Removes tag with the given ``name``.
388 Removes tag with the given ``name``.
381
389
382 :param name: name of the tag to be removed
390 :param name: name of the tag to be removed
383 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
391 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
384 :param message: message of the tag's removal commit
392 :param message: message of the tag's removal commit
385 :param date: date of tag's removal commit
393 :param date: date of tag's removal commit
386
394
387 :raises TagDoesNotExistError: if tag with given name does not exists
395 :raises TagDoesNotExistError: if tag with given name does not exists
388 """
396 """
389 if name not in self.tags:
397 if name not in self.tags:
390 raise TagDoesNotExistError("Tag %s does not exist" % name)
398 raise TagDoesNotExistError("Tag %s does not exist" % name)
391
399
392 self._remote.tag_remove(name)
400 self._remote.tag_remove(name)
393 self._invalidate_prop_cache('tags')
401 self._invalidate_prop_cache('tags')
394 self._invalidate_prop_cache('_refs')
402 self._invalidate_prop_cache('_refs')
395
403
396 def _get_refs(self):
404 def _get_refs(self):
397 return self._remote.get_refs()
405 return self._remote.get_refs()
398
406
399 @CachedProperty
407 @CachedProperty
400 def _refs(self):
408 def _refs(self):
401 return self._get_refs()
409 return self._get_refs()
402
410
403 @property
411 @property
404 def _ref_tree(self):
412 def _ref_tree(self):
405 node = tree = {}
413 node = tree = {}
406 for ref, sha in self._refs.iteritems():
414 for ref, sha in self._refs.iteritems():
407 path = ref.split('/')
415 path = ref.split('/')
408 for bit in path[:-1]:
416 for bit in path[:-1]:
409 node = node.setdefault(bit, {})
417 node = node.setdefault(bit, {})
410 node[path[-1]] = sha
418 node[path[-1]] = sha
411 node = tree
419 node = tree
412 return tree
420 return tree
413
421
414 def get_remote_ref(self, ref_name):
422 def get_remote_ref(self, ref_name):
415 ref_key = 'refs/remotes/origin/{}'.format(safe_str(ref_name))
423 ref_key = 'refs/remotes/origin/{}'.format(safe_str(ref_name))
416 try:
424 try:
417 return self._refs[ref_key]
425 return self._refs[ref_key]
418 except Exception:
426 except Exception:
419 return
427 return
420
428
421 def get_commit(self, commit_id=None, commit_idx=None, pre_load=None,
429 def get_commit(self, commit_id=None, commit_idx=None, pre_load=None,
422 translate_tag=True, maybe_unreachable=False, reference_obj=None):
430 translate_tag=True, maybe_unreachable=False, reference_obj=None):
423 """
431 """
424 Returns `GitCommit` object representing commit from git repository
432 Returns `GitCommit` object representing commit from git repository
425 at the given `commit_id` or head (most recent commit) if None given.
433 at the given `commit_id` or head (most recent commit) if None given.
426 """
434 """
427
435
428 if self.is_empty():
436 if self.is_empty():
429 raise EmptyRepositoryError("There are no commits yet")
437 raise EmptyRepositoryError("There are no commits yet")
430
438
431 if commit_id is not None:
439 if commit_id is not None:
432 self._validate_commit_id(commit_id)
440 self._validate_commit_id(commit_id)
433 try:
441 try:
434 # we have cached idx, use it without contacting the remote
442 # we have cached idx, use it without contacting the remote
435 idx = self._commit_ids[commit_id]
443 idx = self._commit_ids[commit_id]
436 return GitCommit(self, commit_id, idx, pre_load=pre_load)
444 return GitCommit(self, commit_id, idx, pre_load=pre_load)
437 except KeyError:
445 except KeyError:
438 pass
446 pass
439
447
440 elif commit_idx is not None:
448 elif commit_idx is not None:
441 self._validate_commit_idx(commit_idx)
449 self._validate_commit_idx(commit_idx)
442 try:
450 try:
443 _commit_id = self.commit_ids[commit_idx]
451 _commit_id = self.commit_ids[commit_idx]
444 if commit_idx < 0:
452 if commit_idx < 0:
445 commit_idx = self.commit_ids.index(_commit_id)
453 commit_idx = self.commit_ids.index(_commit_id)
446 return GitCommit(self, _commit_id, commit_idx, pre_load=pre_load)
454 return GitCommit(self, _commit_id, commit_idx, pre_load=pre_load)
447 except IndexError:
455 except IndexError:
448 commit_id = commit_idx
456 commit_id = commit_idx
449 else:
457 else:
450 commit_id = "tip"
458 commit_id = "tip"
451
459
452 if translate_tag:
460 if translate_tag:
453 commit_id = self._lookup_commit(
461 commit_id = self._lookup_commit(
454 commit_id, maybe_unreachable=maybe_unreachable,
462 commit_id, maybe_unreachable=maybe_unreachable,
455 reference_obj=reference_obj)
463 reference_obj=reference_obj)
456
464
457 try:
465 try:
458 idx = self._commit_ids[commit_id]
466 idx = self._commit_ids[commit_id]
459 except KeyError:
467 except KeyError:
460 idx = -1
468 idx = -1
461
469
462 return GitCommit(self, commit_id, idx, pre_load=pre_load)
470 return GitCommit(self, commit_id, idx, pre_load=pre_load)
463
471
464 def get_commits(
472 def get_commits(
465 self, start_id=None, end_id=None, start_date=None, end_date=None,
473 self, start_id=None, end_id=None, start_date=None, end_date=None,
466 branch_name=None, show_hidden=False, pre_load=None, translate_tags=True):
474 branch_name=None, show_hidden=False, pre_load=None, translate_tags=True):
467 """
475 """
468 Returns generator of `GitCommit` objects from start to end (both
476 Returns generator of `GitCommit` objects from start to end (both
469 are inclusive), in ascending date order.
477 are inclusive), in ascending date order.
470
478
471 :param start_id: None, str(commit_id)
479 :param start_id: None, str(commit_id)
472 :param end_id: None, str(commit_id)
480 :param end_id: None, str(commit_id)
473 :param start_date: if specified, commits with commit date less than
481 :param start_date: if specified, commits with commit date less than
474 ``start_date`` would be filtered out from returned set
482 ``start_date`` would be filtered out from returned set
475 :param end_date: if specified, commits with commit date greater than
483 :param end_date: if specified, commits with commit date greater than
476 ``end_date`` would be filtered out from returned set
484 ``end_date`` would be filtered out from returned set
477 :param branch_name: if specified, commits not reachable from given
485 :param branch_name: if specified, commits not reachable from given
478 branch would be filtered out from returned set
486 branch would be filtered out from returned set
479 :param show_hidden: Show hidden commits such as obsolete or hidden from
487 :param show_hidden: Show hidden commits such as obsolete or hidden from
480 Mercurial evolve
488 Mercurial evolve
481 :raise BranchDoesNotExistError: If given `branch_name` does not
489 :raise BranchDoesNotExistError: If given `branch_name` does not
482 exist.
490 exist.
483 :raise CommitDoesNotExistError: If commits for given `start` or
491 :raise CommitDoesNotExistError: If commits for given `start` or
484 `end` could not be found.
492 `end` could not be found.
485
493
486 """
494 """
487 if self.is_empty():
495 if self.is_empty():
488 raise EmptyRepositoryError("There are no commits yet")
496 raise EmptyRepositoryError("There are no commits yet")
489
497
490 self._validate_branch_name(branch_name)
498 self._validate_branch_name(branch_name)
491
499
492 if start_id is not None:
500 if start_id is not None:
493 self._validate_commit_id(start_id)
501 self._validate_commit_id(start_id)
494 if end_id is not None:
502 if end_id is not None:
495 self._validate_commit_id(end_id)
503 self._validate_commit_id(end_id)
496
504
497 start_raw_id = self._lookup_commit(start_id)
505 start_raw_id = self._lookup_commit(start_id)
498 start_pos = self._commit_ids[start_raw_id] if start_id else None
506 start_pos = self._commit_ids[start_raw_id] if start_id else None
499 end_raw_id = self._lookup_commit(end_id)
507 end_raw_id = self._lookup_commit(end_id)
500 end_pos = max(0, self._commit_ids[end_raw_id]) if end_id else None
508 end_pos = max(0, self._commit_ids[end_raw_id]) if end_id else None
501
509
502 if None not in [start_id, end_id] and start_pos > end_pos:
510 if None not in [start_id, end_id] and start_pos > end_pos:
503 raise RepositoryError(
511 raise RepositoryError(
504 "Start commit '%s' cannot be after end commit '%s'" %
512 "Start commit '%s' cannot be after end commit '%s'" %
505 (start_id, end_id))
513 (start_id, end_id))
506
514
507 if end_pos is not None:
515 if end_pos is not None:
508 end_pos += 1
516 end_pos += 1
509
517
510 filter_ = []
518 filter_ = []
511 if branch_name:
519 if branch_name:
512 filter_.append({'branch_name': branch_name})
520 filter_.append({'branch_name': branch_name})
513 if start_date and not end_date:
521 if start_date and not end_date:
514 filter_.append({'since': start_date})
522 filter_.append({'since': start_date})
515 if end_date and not start_date:
523 if end_date and not start_date:
516 filter_.append({'until': end_date})
524 filter_.append({'until': end_date})
517 if start_date and end_date:
525 if start_date and end_date:
518 filter_.append({'since': start_date})
526 filter_.append({'since': start_date})
519 filter_.append({'until': end_date})
527 filter_.append({'until': end_date})
520
528
521 # if start_pos or end_pos:
529 # if start_pos or end_pos:
522 # filter_.append({'start': start_pos})
530 # filter_.append({'start': start_pos})
523 # filter_.append({'end': end_pos})
531 # filter_.append({'end': end_pos})
524
532
525 if filter_:
533 if filter_:
526 revfilters = {
534 revfilters = {
527 'branch_name': branch_name,
535 'branch_name': branch_name,
528 'since': start_date.strftime('%m/%d/%y %H:%M:%S') if start_date else None,
536 'since': start_date.strftime('%m/%d/%y %H:%M:%S') if start_date else None,
529 'until': end_date.strftime('%m/%d/%y %H:%M:%S') if end_date else None,
537 'until': end_date.strftime('%m/%d/%y %H:%M:%S') if end_date else None,
530 'start': start_pos,
538 'start': start_pos,
531 'end': end_pos,
539 'end': end_pos,
532 }
540 }
533 commit_ids = self._get_commit_ids(filters=revfilters)
541 commit_ids = self._get_commit_ids(filters=revfilters)
534
542
535 else:
543 else:
536 commit_ids = self.commit_ids
544 commit_ids = self.commit_ids
537
545
538 if start_pos or end_pos:
546 if start_pos or end_pos:
539 commit_ids = commit_ids[start_pos: end_pos]
547 commit_ids = commit_ids[start_pos: end_pos]
540
548
541 return CollectionGenerator(self, commit_ids, pre_load=pre_load,
549 return CollectionGenerator(self, commit_ids, pre_load=pre_load,
542 translate_tag=translate_tags)
550 translate_tag=translate_tags)
543
551
544 def get_diff(
552 def get_diff(
545 self, commit1, commit2, path='', ignore_whitespace=False,
553 self, commit1, commit2, path='', ignore_whitespace=False,
546 context=3, path1=None):
554 context=3, path1=None):
547 """
555 """
548 Returns (git like) *diff*, as plain text. Shows changes introduced by
556 Returns (git like) *diff*, as plain text. Shows changes introduced by
549 ``commit2`` since ``commit1``.
557 ``commit2`` since ``commit1``.
550
558
551 :param commit1: Entry point from which diff is shown. Can be
559 :param commit1: Entry point from which diff is shown. Can be
552 ``self.EMPTY_COMMIT`` - in this case, patch showing all
560 ``self.EMPTY_COMMIT`` - in this case, patch showing all
553 the changes since empty state of the repository until ``commit2``
561 the changes since empty state of the repository until ``commit2``
554 :param commit2: Until which commits changes should be shown.
562 :param commit2: Until which commits changes should be shown.
555 :param ignore_whitespace: If set to ``True``, would not show whitespace
563 :param ignore_whitespace: If set to ``True``, would not show whitespace
556 changes. Defaults to ``False``.
564 changes. Defaults to ``False``.
557 :param context: How many lines before/after changed lines should be
565 :param context: How many lines before/after changed lines should be
558 shown. Defaults to ``3``.
566 shown. Defaults to ``3``.
559 """
567 """
560 self._validate_diff_commits(commit1, commit2)
568 self._validate_diff_commits(commit1, commit2)
561 if path1 is not None and path1 != path:
569 if path1 is not None and path1 != path:
562 raise ValueError("Diff of two different paths not supported.")
570 raise ValueError("Diff of two different paths not supported.")
563
571
564 if path:
572 if path:
565 file_filter = path
573 file_filter = path
566 else:
574 else:
567 file_filter = None
575 file_filter = None
568
576
569 diff = self._remote.diff(
577 diff = self._remote.diff(
570 commit1.raw_id, commit2.raw_id, file_filter=file_filter,
578 commit1.raw_id, commit2.raw_id, file_filter=file_filter,
571 opt_ignorews=ignore_whitespace,
579 opt_ignorews=ignore_whitespace,
572 context=context)
580 context=context)
573 return GitDiff(diff)
581 return GitDiff(diff)
574
582
575 def strip(self, commit_id, branch_name):
583 def strip(self, commit_id, branch_name):
576 commit = self.get_commit(commit_id=commit_id)
584 commit = self.get_commit(commit_id=commit_id)
577 if commit.merge:
585 if commit.merge:
578 raise Exception('Cannot reset to merge commit')
586 raise Exception('Cannot reset to merge commit')
579
587
580 # parent is going to be the new head now
588 # parent is going to be the new head now
581 commit = commit.parents[0]
589 commit = commit.parents[0]
582 self._remote.set_refs('refs/heads/%s' % branch_name, commit.raw_id)
590 self._remote.set_refs('refs/heads/%s' % branch_name, commit.raw_id)
583
591
584 # clear cached properties
592 # clear cached properties
585 self._invalidate_prop_cache('commit_ids')
593 self._invalidate_prop_cache('commit_ids')
586 self._invalidate_prop_cache('_refs')
594 self._invalidate_prop_cache('_refs')
587 self._invalidate_prop_cache('branches')
595 self._invalidate_prop_cache('branches')
588
596
589 return len(self.commit_ids)
597 return len(self.commit_ids)
590
598
591 def get_common_ancestor(self, commit_id1, commit_id2, repo2):
599 def get_common_ancestor(self, commit_id1, commit_id2, repo2):
592 log.debug('Calculating common ancestor between %sc1:%s and %sc2:%s',
600 log.debug('Calculating common ancestor between %sc1:%s and %sc2:%s',
593 self, commit_id1, repo2, commit_id2)
601 self, commit_id1, repo2, commit_id2)
594
602
595 if commit_id1 == commit_id2:
603 if commit_id1 == commit_id2:
596 return commit_id1
604 return commit_id1
597
605
598 if self != repo2:
606 if self != repo2:
599 commits = self._remote.get_missing_revs(
607 commits = self._remote.get_missing_revs(
600 commit_id1, commit_id2, repo2.path)
608 commit_id1, commit_id2, repo2.path)
601 if commits:
609 if commits:
602 commit = repo2.get_commit(commits[-1])
610 commit = repo2.get_commit(commits[-1])
603 if commit.parents:
611 if commit.parents:
604 ancestor_id = commit.parents[0].raw_id
612 ancestor_id = commit.parents[0].raw_id
605 else:
613 else:
606 ancestor_id = None
614 ancestor_id = None
607 else:
615 else:
608 # no commits from other repo, ancestor_id is the commit_id2
616 # no commits from other repo, ancestor_id is the commit_id2
609 ancestor_id = commit_id2
617 ancestor_id = commit_id2
610 else:
618 else:
611 output, __ = self.run_git_command(
619 output, __ = self.run_git_command(
612 ['merge-base', commit_id1, commit_id2])
620 ['merge-base', commit_id1, commit_id2])
613 ancestor_id = re.findall(r'[0-9a-fA-F]{40}', output)[0]
621 ancestor_id = re.findall(r'[0-9a-fA-F]{40}', output)[0]
614
622
615 log.debug('Found common ancestor with sha: %s', ancestor_id)
623 log.debug('Found common ancestor with sha: %s', ancestor_id)
616
624
617 return ancestor_id
625 return ancestor_id
618
626
619 def compare(self, commit_id1, commit_id2, repo2, merge, pre_load=None):
627 def compare(self, commit_id1, commit_id2, repo2, merge, pre_load=None):
620 repo1 = self
628 repo1 = self
621 ancestor_id = None
629 ancestor_id = None
622
630
623 if commit_id1 == commit_id2:
631 if commit_id1 == commit_id2:
624 commits = []
632 commits = []
625 elif repo1 != repo2:
633 elif repo1 != repo2:
626 missing_ids = self._remote.get_missing_revs(commit_id1, commit_id2,
634 missing_ids = self._remote.get_missing_revs(commit_id1, commit_id2,
627 repo2.path)
635 repo2.path)
628 commits = [
636 commits = [
629 repo2.get_commit(commit_id=commit_id, pre_load=pre_load)
637 repo2.get_commit(commit_id=commit_id, pre_load=pre_load)
630 for commit_id in reversed(missing_ids)]
638 for commit_id in reversed(missing_ids)]
631 else:
639 else:
632 output, __ = repo1.run_git_command(
640 output, __ = repo1.run_git_command(
633 ['log', '--reverse', '--pretty=format: %H', '-s',
641 ['log', '--reverse', '--pretty=format: %H', '-s',
634 '%s..%s' % (commit_id1, commit_id2)])
642 '%s..%s' % (commit_id1, commit_id2)])
635 commits = [
643 commits = [
636 repo1.get_commit(commit_id=commit_id, pre_load=pre_load)
644 repo1.get_commit(commit_id=commit_id, pre_load=pre_load)
637 for commit_id in re.findall(r'[0-9a-fA-F]{40}', output)]
645 for commit_id in re.findall(r'[0-9a-fA-F]{40}', output)]
638
646
639 return commits
647 return commits
640
648
641 @LazyProperty
649 @LazyProperty
642 def in_memory_commit(self):
650 def in_memory_commit(self):
643 """
651 """
644 Returns ``GitInMemoryCommit`` object for this repository.
652 Returns ``GitInMemoryCommit`` object for this repository.
645 """
653 """
646 return GitInMemoryCommit(self)
654 return GitInMemoryCommit(self)
647
655
648 def pull(self, url, commit_ids=None, update_after=False):
656 def pull(self, url, commit_ids=None, update_after=False):
649 """
657 """
650 Pull changes from external location. Pull is different in GIT
658 Pull changes from external location. Pull is different in GIT
651 that fetch since it's doing a checkout
659 that fetch since it's doing a checkout
652
660
653 :param commit_ids: Optional. Can be set to a list of commit ids
661 :param commit_ids: Optional. Can be set to a list of commit ids
654 which shall be pulled from the other repository.
662 which shall be pulled from the other repository.
655 """
663 """
656 refs = None
664 refs = None
657 if commit_ids is not None:
665 if commit_ids is not None:
658 remote_refs = self._remote.get_remote_refs(url)
666 remote_refs = self._remote.get_remote_refs(url)
659 refs = [ref for ref in remote_refs if remote_refs[ref] in commit_ids]
667 refs = [ref for ref in remote_refs if remote_refs[ref] in commit_ids]
660 self._remote.pull(url, refs=refs, update_after=update_after)
668 self._remote.pull(url, refs=refs, update_after=update_after)
661 self._remote.invalidate_vcs_cache()
669 self._remote.invalidate_vcs_cache()
662
670
663 def fetch(self, url, commit_ids=None):
671 def fetch(self, url, commit_ids=None):
664 """
672 """
665 Fetch all git objects from external location.
673 Fetch all git objects from external location.
666 """
674 """
667 self._remote.sync_fetch(url, refs=commit_ids)
675 self._remote.sync_fetch(url, refs=commit_ids)
668 self._remote.invalidate_vcs_cache()
676 self._remote.invalidate_vcs_cache()
669
677
670 def push(self, url):
678 def push(self, url):
671 refs = None
679 refs = None
672 self._remote.sync_push(url, refs=refs)
680 self._remote.sync_push(url, refs=refs)
673
681
674 def set_refs(self, ref_name, commit_id):
682 def set_refs(self, ref_name, commit_id):
675 self._remote.set_refs(ref_name, commit_id)
683 self._remote.set_refs(ref_name, commit_id)
676 self._invalidate_prop_cache('_refs')
684 self._invalidate_prop_cache('_refs')
677
685
678 def remove_ref(self, ref_name):
686 def remove_ref(self, ref_name):
679 self._remote.remove_ref(ref_name)
687 self._remote.remove_ref(ref_name)
680 self._invalidate_prop_cache('_refs')
688 self._invalidate_prop_cache('_refs')
681
689
682 def run_gc(self, prune=True):
690 def run_gc(self, prune=True):
683 cmd = ['gc', '--aggressive']
691 cmd = ['gc', '--aggressive']
684 if prune:
692 if prune:
685 cmd += ['--prune=now']
693 cmd += ['--prune=now']
686 _stdout, stderr = self.run_git_command(cmd, fail_on_stderr=False)
694 _stdout, stderr = self.run_git_command(cmd, fail_on_stderr=False)
687 return stderr
695 return stderr
688
696
689 def _update_server_info(self):
697 def _update_server_info(self):
690 """
698 """
691 runs gits update-server-info command in this repo instance
699 runs gits update-server-info command in this repo instance
692 """
700 """
693 self._remote.update_server_info()
701 self._remote.update_server_info()
694
702
695 def _current_branch(self):
703 def _current_branch(self):
696 """
704 """
697 Return the name of the current branch.
705 Return the name of the current branch.
698
706
699 It only works for non bare repositories (i.e. repositories with a
707 It only works for non bare repositories (i.e. repositories with a
700 working copy)
708 working copy)
701 """
709 """
702 if self.bare:
710 if self.bare:
703 raise RepositoryError('Bare git repos do not have active branches')
711 raise RepositoryError('Bare git repos do not have active branches')
704
712
705 if self.is_empty():
713 if self.is_empty():
706 return None
714 return None
707
715
708 stdout, _ = self.run_git_command(['rev-parse', '--abbrev-ref', 'HEAD'])
716 stdout, _ = self.run_git_command(['rev-parse', '--abbrev-ref', 'HEAD'])
709 return stdout.strip()
717 return stdout.strip()
710
718
711 def _checkout(self, branch_name, create=False, force=False):
719 def _checkout(self, branch_name, create=False, force=False):
712 """
720 """
713 Checkout a branch in the working directory.
721 Checkout a branch in the working directory.
714
722
715 It tries to create the branch if create is True, failing if the branch
723 It tries to create the branch if create is True, failing if the branch
716 already exists.
724 already exists.
717
725
718 It only works for non bare repositories (i.e. repositories with a
726 It only works for non bare repositories (i.e. repositories with a
719 working copy)
727 working copy)
720 """
728 """
721 if self.bare:
729 if self.bare:
722 raise RepositoryError('Cannot checkout branches in a bare git repo')
730 raise RepositoryError('Cannot checkout branches in a bare git repo')
723
731
724 cmd = ['checkout']
732 cmd = ['checkout']
725 if force:
733 if force:
726 cmd.append('-f')
734 cmd.append('-f')
727 if create:
735 if create:
728 cmd.append('-b')
736 cmd.append('-b')
729 cmd.append(branch_name)
737 cmd.append(branch_name)
730 self.run_git_command(cmd, fail_on_stderr=False)
738 self.run_git_command(cmd, fail_on_stderr=False)
731
739
732 def _create_branch(self, branch_name, commit_id):
740 def _create_branch(self, branch_name, commit_id):
733 """
741 """
734 creates a branch in a GIT repo
742 creates a branch in a GIT repo
735 """
743 """
736 self._remote.create_branch(branch_name, commit_id)
744 self._remote.create_branch(branch_name, commit_id)
737
745
738 def _identify(self):
746 def _identify(self):
739 """
747 """
740 Return the current state of the working directory.
748 Return the current state of the working directory.
741 """
749 """
742 if self.bare:
750 if self.bare:
743 raise RepositoryError('Bare git repos do not have active branches')
751 raise RepositoryError('Bare git repos do not have active branches')
744
752
745 if self.is_empty():
753 if self.is_empty():
746 return None
754 return None
747
755
748 stdout, _ = self.run_git_command(['rev-parse', 'HEAD'])
756 stdout, _ = self.run_git_command(['rev-parse', 'HEAD'])
749 return stdout.strip()
757 return stdout.strip()
750
758
751 def _local_clone(self, clone_path, branch_name, source_branch=None):
759 def _local_clone(self, clone_path, branch_name, source_branch=None):
752 """
760 """
753 Create a local clone of the current repo.
761 Create a local clone of the current repo.
754 """
762 """
755 # N.B.(skreft): the --branch option is required as otherwise the shallow
763 # N.B.(skreft): the --branch option is required as otherwise the shallow
756 # clone will only fetch the active branch.
764 # clone will only fetch the active branch.
757 cmd = ['clone', '--branch', branch_name,
765 cmd = ['clone', '--branch', branch_name,
758 self.path, os.path.abspath(clone_path)]
766 self.path, os.path.abspath(clone_path)]
759
767
760 self.run_git_command(cmd, fail_on_stderr=False)
768 self.run_git_command(cmd, fail_on_stderr=False)
761
769
762 # if we get the different source branch, make sure we also fetch it for
770 # if we get the different source branch, make sure we also fetch it for
763 # merge conditions
771 # merge conditions
764 if source_branch and source_branch != branch_name:
772 if source_branch and source_branch != branch_name:
765 # check if the ref exists.
773 # check if the ref exists.
766 shadow_repo = GitRepository(os.path.abspath(clone_path))
774 shadow_repo = GitRepository(os.path.abspath(clone_path))
767 if shadow_repo.get_remote_ref(source_branch):
775 if shadow_repo.get_remote_ref(source_branch):
768 cmd = ['fetch', self.path, source_branch]
776 cmd = ['fetch', self.path, source_branch]
769 self.run_git_command(cmd, fail_on_stderr=False)
777 self.run_git_command(cmd, fail_on_stderr=False)
770
778
771 def _local_fetch(self, repository_path, branch_name, use_origin=False):
779 def _local_fetch(self, repository_path, branch_name, use_origin=False):
772 """
780 """
773 Fetch a branch from a local repository.
781 Fetch a branch from a local repository.
774 """
782 """
775 repository_path = os.path.abspath(repository_path)
783 repository_path = os.path.abspath(repository_path)
776 if repository_path == self.path:
784 if repository_path == self.path:
777 raise ValueError('Cannot fetch from the same repository')
785 raise ValueError('Cannot fetch from the same repository')
778
786
779 if use_origin:
787 if use_origin:
780 branch_name = '+{branch}:refs/heads/{branch}'.format(
788 branch_name = '+{branch}:refs/heads/{branch}'.format(
781 branch=branch_name)
789 branch=branch_name)
782
790
783 cmd = ['fetch', '--no-tags', '--update-head-ok',
791 cmd = ['fetch', '--no-tags', '--update-head-ok',
784 repository_path, branch_name]
792 repository_path, branch_name]
785 self.run_git_command(cmd, fail_on_stderr=False)
793 self.run_git_command(cmd, fail_on_stderr=False)
786
794
787 def _local_reset(self, branch_name):
795 def _local_reset(self, branch_name):
788 branch_name = '{}'.format(branch_name)
796 branch_name = '{}'.format(branch_name)
789 cmd = ['reset', '--hard', branch_name, '--']
797 cmd = ['reset', '--hard', branch_name, '--']
790 self.run_git_command(cmd, fail_on_stderr=False)
798 self.run_git_command(cmd, fail_on_stderr=False)
791
799
792 def _last_fetch_heads(self):
800 def _last_fetch_heads(self):
793 """
801 """
794 Return the last fetched heads that need merging.
802 Return the last fetched heads that need merging.
795
803
796 The algorithm is defined at
804 The algorithm is defined at
797 https://github.com/git/git/blob/v2.1.3/git-pull.sh#L283
805 https://github.com/git/git/blob/v2.1.3/git-pull.sh#L283
798 """
806 """
799 if not self.bare:
807 if not self.bare:
800 fetch_heads_path = os.path.join(self.path, '.git', 'FETCH_HEAD')
808 fetch_heads_path = os.path.join(self.path, '.git', 'FETCH_HEAD')
801 else:
809 else:
802 fetch_heads_path = os.path.join(self.path, 'FETCH_HEAD')
810 fetch_heads_path = os.path.join(self.path, 'FETCH_HEAD')
803
811
804 heads = []
812 heads = []
805 with open(fetch_heads_path) as f:
813 with open(fetch_heads_path) as f:
806 for line in f:
814 for line in f:
807 if ' not-for-merge ' in line:
815 if ' not-for-merge ' in line:
808 continue
816 continue
809 line = re.sub('\t.*', '', line, flags=re.DOTALL)
817 line = re.sub('\t.*', '', line, flags=re.DOTALL)
810 heads.append(line)
818 heads.append(line)
811
819
812 return heads
820 return heads
813
821
814 def get_shadow_instance(self, shadow_repository_path, enable_hooks=False, cache=False):
822 def get_shadow_instance(self, shadow_repository_path, enable_hooks=False, cache=False):
815 return GitRepository(shadow_repository_path, with_wire={"cache": cache})
823 return GitRepository(shadow_repository_path, with_wire={"cache": cache})
816
824
817 def _local_pull(self, repository_path, branch_name, ff_only=True):
825 def _local_pull(self, repository_path, branch_name, ff_only=True):
818 """
826 """
819 Pull a branch from a local repository.
827 Pull a branch from a local repository.
820 """
828 """
821 if self.bare:
829 if self.bare:
822 raise RepositoryError('Cannot pull into a bare git repository')
830 raise RepositoryError('Cannot pull into a bare git repository')
823 # N.B.(skreft): The --ff-only option is to make sure this is a
831 # N.B.(skreft): The --ff-only option is to make sure this is a
824 # fast-forward (i.e., we are only pulling new changes and there are no
832 # fast-forward (i.e., we are only pulling new changes and there are no
825 # conflicts with our current branch)
833 # conflicts with our current branch)
826 # Additionally, that option needs to go before --no-tags, otherwise git
834 # Additionally, that option needs to go before --no-tags, otherwise git
827 # pull complains about it being an unknown flag.
835 # pull complains about it being an unknown flag.
828 cmd = ['pull']
836 cmd = ['pull']
829 if ff_only:
837 if ff_only:
830 cmd.append('--ff-only')
838 cmd.append('--ff-only')
831 cmd.extend(['--no-tags', repository_path, branch_name])
839 cmd.extend(['--no-tags', repository_path, branch_name])
832 self.run_git_command(cmd, fail_on_stderr=False)
840 self.run_git_command(cmd, fail_on_stderr=False)
833
841
834 def _local_merge(self, merge_message, user_name, user_email, heads):
842 def _local_merge(self, merge_message, user_name, user_email, heads):
835 """
843 """
836 Merge the given head into the checked out branch.
844 Merge the given head into the checked out branch.
837
845
838 It will force a merge commit.
846 It will force a merge commit.
839
847
840 Currently it raises an error if the repo is empty, as it is not possible
848 Currently it raises an error if the repo is empty, as it is not possible
841 to create a merge commit in an empty repo.
849 to create a merge commit in an empty repo.
842
850
843 :param merge_message: The message to use for the merge commit.
851 :param merge_message: The message to use for the merge commit.
844 :param heads: the heads to merge.
852 :param heads: the heads to merge.
845 """
853 """
846 if self.bare:
854 if self.bare:
847 raise RepositoryError('Cannot merge into a bare git repository')
855 raise RepositoryError('Cannot merge into a bare git repository')
848
856
849 if not heads:
857 if not heads:
850 return
858 return
851
859
852 if self.is_empty():
860 if self.is_empty():
853 # TODO(skreft): do something more robust in this case.
861 # TODO(skreft): do something more robust in this case.
854 raise RepositoryError('Do not know how to merge into empty repositories yet')
862 raise RepositoryError('Do not know how to merge into empty repositories yet')
855 unresolved = None
863 unresolved = None
856
864
857 # N.B.(skreft): the --no-ff option is used to enforce the creation of a
865 # N.B.(skreft): the --no-ff option is used to enforce the creation of a
858 # commit message. We also specify the user who is doing the merge.
866 # commit message. We also specify the user who is doing the merge.
859 cmd = ['-c', 'user.name="%s"' % safe_str(user_name),
867 cmd = ['-c', 'user.name="%s"' % safe_str(user_name),
860 '-c', 'user.email=%s' % safe_str(user_email),
868 '-c', 'user.email=%s' % safe_str(user_email),
861 'merge', '--no-ff', '-m', safe_str(merge_message)]
869 'merge', '--no-ff', '-m', safe_str(merge_message)]
862
870
863 merge_cmd = cmd + heads
871 merge_cmd = cmd + heads
864
872
865 try:
873 try:
866 self.run_git_command(merge_cmd, fail_on_stderr=False)
874 self.run_git_command(merge_cmd, fail_on_stderr=False)
867 except RepositoryError:
875 except RepositoryError:
868 files = self.run_git_command(['diff', '--name-only', '--diff-filter', 'U'],
876 files = self.run_git_command(['diff', '--name-only', '--diff-filter', 'U'],
869 fail_on_stderr=False)[0].splitlines()
877 fail_on_stderr=False)[0].splitlines()
870 # NOTE(marcink): we add U notation for consistent with HG backend output
878 # NOTE(marcink): we add U notation for consistent with HG backend output
871 unresolved = ['U {}'.format(f) for f in files]
879 unresolved = ['U {}'.format(f) for f in files]
872
880
873 # Cleanup any merge leftovers
881 # Cleanup any merge leftovers
874 self._remote.invalidate_vcs_cache()
882 self._remote.invalidate_vcs_cache()
875 self.run_git_command(['merge', '--abort'], fail_on_stderr=False)
883 self.run_git_command(['merge', '--abort'], fail_on_stderr=False)
876
884
877 if unresolved:
885 if unresolved:
878 raise UnresolvedFilesInRepo(unresolved)
886 raise UnresolvedFilesInRepo(unresolved)
879 else:
887 else:
880 raise
888 raise
881
889
882 def _local_push(
890 def _local_push(
883 self, source_branch, repository_path, target_branch,
891 self, source_branch, repository_path, target_branch,
884 enable_hooks=False, rc_scm_data=None):
892 enable_hooks=False, rc_scm_data=None):
885 """
893 """
886 Push the source_branch to the given repository and target_branch.
894 Push the source_branch to the given repository and target_branch.
887
895
888 Currently it if the target_branch is not master and the target repo is
896 Currently it if the target_branch is not master and the target repo is
889 empty, the push will work, but then GitRepository won't be able to find
897 empty, the push will work, but then GitRepository won't be able to find
890 the pushed branch or the commits. As the HEAD will be corrupted (i.e.,
898 the pushed branch or the commits. As the HEAD will be corrupted (i.e.,
891 pointing to master, which does not exist).
899 pointing to master, which does not exist).
892
900
893 It does not run the hooks in the target repo.
901 It does not run the hooks in the target repo.
894 """
902 """
895 # TODO(skreft): deal with the case in which the target repo is empty,
903 # TODO(skreft): deal with the case in which the target repo is empty,
896 # and the target_branch is not master.
904 # and the target_branch is not master.
897 target_repo = GitRepository(repository_path)
905 target_repo = GitRepository(repository_path)
898 if (not target_repo.bare and
906 if (not target_repo.bare and
899 target_repo._current_branch() == target_branch):
907 target_repo._current_branch() == target_branch):
900 # Git prevents pushing to the checked out branch, so simulate it by
908 # Git prevents pushing to the checked out branch, so simulate it by
901 # pulling into the target repository.
909 # pulling into the target repository.
902 target_repo._local_pull(self.path, source_branch)
910 target_repo._local_pull(self.path, source_branch)
903 else:
911 else:
904 cmd = ['push', os.path.abspath(repository_path),
912 cmd = ['push', os.path.abspath(repository_path),
905 '%s:%s' % (source_branch, target_branch)]
913 '%s:%s' % (source_branch, target_branch)]
906 gitenv = {}
914 gitenv = {}
907 if rc_scm_data:
915 if rc_scm_data:
908 gitenv.update({'RC_SCM_DATA': rc_scm_data})
916 gitenv.update({'RC_SCM_DATA': rc_scm_data})
909
917
910 if not enable_hooks:
918 if not enable_hooks:
911 gitenv['RC_SKIP_HOOKS'] = '1'
919 gitenv['RC_SKIP_HOOKS'] = '1'
912 self.run_git_command(cmd, fail_on_stderr=False, extra_env=gitenv)
920 self.run_git_command(cmd, fail_on_stderr=False, extra_env=gitenv)
913
921
914 def _get_new_pr_branch(self, source_branch, target_branch):
922 def _get_new_pr_branch(self, source_branch, target_branch):
915 prefix = 'pr_%s-%s_' % (source_branch, target_branch)
923 prefix = 'pr_%s-%s_' % (source_branch, target_branch)
916 pr_branches = []
924 pr_branches = []
917 for branch in self.branches:
925 for branch in self.branches:
918 if branch.startswith(prefix):
926 if branch.startswith(prefix):
919 pr_branches.append(int(branch[len(prefix):]))
927 pr_branches.append(int(branch[len(prefix):]))
920
928
921 if not pr_branches:
929 if not pr_branches:
922 branch_id = 0
930 branch_id = 0
923 else:
931 else:
924 branch_id = max(pr_branches) + 1
932 branch_id = max(pr_branches) + 1
925
933
926 return '%s%d' % (prefix, branch_id)
934 return '%s%d' % (prefix, branch_id)
927
935
928 def _maybe_prepare_merge_workspace(
936 def _maybe_prepare_merge_workspace(
929 self, repo_id, workspace_id, target_ref, source_ref):
937 self, repo_id, workspace_id, target_ref, source_ref):
930 shadow_repository_path = self._get_shadow_repository_path(
938 shadow_repository_path = self._get_shadow_repository_path(
931 self.path, repo_id, workspace_id)
939 self.path, repo_id, workspace_id)
932 if not os.path.exists(shadow_repository_path):
940 if not os.path.exists(shadow_repository_path):
933 self._local_clone(
941 self._local_clone(
934 shadow_repository_path, target_ref.name, source_ref.name)
942 shadow_repository_path, target_ref.name, source_ref.name)
935 log.debug('Prepared %s shadow repository in %s',
943 log.debug('Prepared %s shadow repository in %s',
936 self.alias, shadow_repository_path)
944 self.alias, shadow_repository_path)
937
945
938 return shadow_repository_path
946 return shadow_repository_path
939
947
940 def _merge_repo(self, repo_id, workspace_id, target_ref,
948 def _merge_repo(self, repo_id, workspace_id, target_ref,
941 source_repo, source_ref, merge_message,
949 source_repo, source_ref, merge_message,
942 merger_name, merger_email, dry_run=False,
950 merger_name, merger_email, dry_run=False,
943 use_rebase=False, close_branch=False):
951 use_rebase=False, close_branch=False):
944
952
945 log.debug('Executing merge_repo with %s strategy, dry_run mode:%s',
953 log.debug('Executing merge_repo with %s strategy, dry_run mode:%s',
946 'rebase' if use_rebase else 'merge', dry_run)
954 'rebase' if use_rebase else 'merge', dry_run)
947 if target_ref.commit_id != self.branches[target_ref.name]:
955 if target_ref.commit_id != self.branches[target_ref.name]:
948 log.warning('Target ref %s commit mismatch %s vs %s', target_ref,
956 log.warning('Target ref %s commit mismatch %s vs %s', target_ref,
949 target_ref.commit_id, self.branches[target_ref.name])
957 target_ref.commit_id, self.branches[target_ref.name])
950 return MergeResponse(
958 return MergeResponse(
951 False, False, None, MergeFailureReason.TARGET_IS_NOT_HEAD,
959 False, False, None, MergeFailureReason.TARGET_IS_NOT_HEAD,
952 metadata={'target_ref': target_ref})
960 metadata={'target_ref': target_ref})
953
961
954 shadow_repository_path = self._maybe_prepare_merge_workspace(
962 shadow_repository_path = self._maybe_prepare_merge_workspace(
955 repo_id, workspace_id, target_ref, source_ref)
963 repo_id, workspace_id, target_ref, source_ref)
956 shadow_repo = self.get_shadow_instance(shadow_repository_path)
964 shadow_repo = self.get_shadow_instance(shadow_repository_path)
957
965
958 # checkout source, if it's different. Otherwise we could not
966 # checkout source, if it's different. Otherwise we could not
959 # fetch proper commits for merge testing
967 # fetch proper commits for merge testing
960 if source_ref.name != target_ref.name:
968 if source_ref.name != target_ref.name:
961 if shadow_repo.get_remote_ref(source_ref.name):
969 if shadow_repo.get_remote_ref(source_ref.name):
962 shadow_repo._checkout(source_ref.name, force=True)
970 shadow_repo._checkout(source_ref.name, force=True)
963
971
964 # checkout target, and fetch changes
972 # checkout target, and fetch changes
965 shadow_repo._checkout(target_ref.name, force=True)
973 shadow_repo._checkout(target_ref.name, force=True)
966
974
967 # fetch/reset pull the target, in case it is changed
975 # fetch/reset pull the target, in case it is changed
968 # this handles even force changes
976 # this handles even force changes
969 shadow_repo._local_fetch(self.path, target_ref.name, use_origin=True)
977 shadow_repo._local_fetch(self.path, target_ref.name, use_origin=True)
970 shadow_repo._local_reset(target_ref.name)
978 shadow_repo._local_reset(target_ref.name)
971
979
972 # Need to reload repo to invalidate the cache, or otherwise we cannot
980 # Need to reload repo to invalidate the cache, or otherwise we cannot
973 # retrieve the last target commit.
981 # retrieve the last target commit.
974 shadow_repo = self.get_shadow_instance(shadow_repository_path)
982 shadow_repo = self.get_shadow_instance(shadow_repository_path)
975 if target_ref.commit_id != shadow_repo.branches[target_ref.name]:
983 if target_ref.commit_id != shadow_repo.branches[target_ref.name]:
976 log.warning('Shadow Target ref %s commit mismatch %s vs %s',
984 log.warning('Shadow Target ref %s commit mismatch %s vs %s',
977 target_ref, target_ref.commit_id,
985 target_ref, target_ref.commit_id,
978 shadow_repo.branches[target_ref.name])
986 shadow_repo.branches[target_ref.name])
979 return MergeResponse(
987 return MergeResponse(
980 False, False, None, MergeFailureReason.TARGET_IS_NOT_HEAD,
988 False, False, None, MergeFailureReason.TARGET_IS_NOT_HEAD,
981 metadata={'target_ref': target_ref})
989 metadata={'target_ref': target_ref})
982
990
983 # calculate new branch
991 # calculate new branch
984 pr_branch = shadow_repo._get_new_pr_branch(
992 pr_branch = shadow_repo._get_new_pr_branch(
985 source_ref.name, target_ref.name)
993 source_ref.name, target_ref.name)
986 log.debug('using pull-request merge branch: `%s`', pr_branch)
994 log.debug('using pull-request merge branch: `%s`', pr_branch)
987 # checkout to temp branch, and fetch changes
995 # checkout to temp branch, and fetch changes
988 shadow_repo._checkout(pr_branch, create=True)
996 shadow_repo._checkout(pr_branch, create=True)
989 try:
997 try:
990 shadow_repo._local_fetch(source_repo.path, source_ref.name)
998 shadow_repo._local_fetch(source_repo.path, source_ref.name)
991 except RepositoryError:
999 except RepositoryError:
992 log.exception('Failure when doing local fetch on '
1000 log.exception('Failure when doing local fetch on '
993 'shadow repo: %s', shadow_repo)
1001 'shadow repo: %s', shadow_repo)
994 return MergeResponse(
1002 return MergeResponse(
995 False, False, None, MergeFailureReason.MISSING_SOURCE_REF,
1003 False, False, None, MergeFailureReason.MISSING_SOURCE_REF,
996 metadata={'source_ref': source_ref})
1004 metadata={'source_ref': source_ref})
997
1005
998 merge_ref = None
1006 merge_ref = None
999 merge_failure_reason = MergeFailureReason.NONE
1007 merge_failure_reason = MergeFailureReason.NONE
1000 metadata = {}
1008 metadata = {}
1001 try:
1009 try:
1002 shadow_repo._local_merge(merge_message, merger_name, merger_email,
1010 shadow_repo._local_merge(merge_message, merger_name, merger_email,
1003 [source_ref.commit_id])
1011 [source_ref.commit_id])
1004 merge_possible = True
1012 merge_possible = True
1005
1013
1006 # Need to invalidate the cache, or otherwise we
1014 # Need to invalidate the cache, or otherwise we
1007 # cannot retrieve the merge commit.
1015 # cannot retrieve the merge commit.
1008 shadow_repo = shadow_repo.get_shadow_instance(shadow_repository_path)
1016 shadow_repo = shadow_repo.get_shadow_instance(shadow_repository_path)
1009 merge_commit_id = shadow_repo.branches[pr_branch]
1017 merge_commit_id = shadow_repo.branches[pr_branch]
1010
1018
1011 # Set a reference pointing to the merge commit. This reference may
1019 # Set a reference pointing to the merge commit. This reference may
1012 # be used to easily identify the last successful merge commit in
1020 # be used to easily identify the last successful merge commit in
1013 # the shadow repository.
1021 # the shadow repository.
1014 shadow_repo.set_refs('refs/heads/pr-merge', merge_commit_id)
1022 shadow_repo.set_refs('refs/heads/pr-merge', merge_commit_id)
1015 merge_ref = Reference('branch', 'pr-merge', merge_commit_id)
1023 merge_ref = Reference('branch', 'pr-merge', merge_commit_id)
1016 except RepositoryError as e:
1024 except RepositoryError as e:
1017 log.exception('Failure when doing local merge on git shadow repo')
1025 log.exception('Failure when doing local merge on git shadow repo')
1018 if isinstance(e, UnresolvedFilesInRepo):
1026 if isinstance(e, UnresolvedFilesInRepo):
1019 metadata['unresolved_files'] = '\n* conflict: ' + ('\n * conflict: '.join(e.args[0]))
1027 metadata['unresolved_files'] = '\n* conflict: ' + ('\n * conflict: '.join(e.args[0]))
1020
1028
1021 merge_possible = False
1029 merge_possible = False
1022 merge_failure_reason = MergeFailureReason.MERGE_FAILED
1030 merge_failure_reason = MergeFailureReason.MERGE_FAILED
1023
1031
1024 if merge_possible and not dry_run:
1032 if merge_possible and not dry_run:
1025 try:
1033 try:
1026 shadow_repo._local_push(
1034 shadow_repo._local_push(
1027 pr_branch, self.path, target_ref.name, enable_hooks=True,
1035 pr_branch, self.path, target_ref.name, enable_hooks=True,
1028 rc_scm_data=self.config.get('rhodecode', 'RC_SCM_DATA'))
1036 rc_scm_data=self.config.get('rhodecode', 'RC_SCM_DATA'))
1029 merge_succeeded = True
1037 merge_succeeded = True
1030 except RepositoryError:
1038 except RepositoryError:
1031 log.exception(
1039 log.exception(
1032 'Failure when doing local push from the shadow '
1040 'Failure when doing local push from the shadow '
1033 'repository to the target repository at %s.', self.path)
1041 'repository to the target repository at %s.', self.path)
1034 merge_succeeded = False
1042 merge_succeeded = False
1035 merge_failure_reason = MergeFailureReason.PUSH_FAILED
1043 merge_failure_reason = MergeFailureReason.PUSH_FAILED
1036 metadata['target'] = 'git shadow repo'
1044 metadata['target'] = 'git shadow repo'
1037 metadata['merge_commit'] = pr_branch
1045 metadata['merge_commit'] = pr_branch
1038 else:
1046 else:
1039 merge_succeeded = False
1047 merge_succeeded = False
1040
1048
1041 return MergeResponse(
1049 return MergeResponse(
1042 merge_possible, merge_succeeded, merge_ref, merge_failure_reason,
1050 merge_possible, merge_succeeded, merge_ref, merge_failure_reason,
1043 metadata=metadata)
1051 metadata=metadata)
General Comments 0
You need to be logged in to leave comments. Login now