##// END OF EJS Templates
hg,git: change method of extracting last_change to use actualy date object...
marcink -
r1349:e7cd3f1f default
parent child Browse files
Show More
@@ -1,929 +1,930 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2014-2017 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 """
22 22 GIT repository module
23 23 """
24 24
25 25 import logging
26 26 import os
27 27 import re
28 28 import shutil
29 import time
30 29
31 30 from zope.cachedescriptors.property import Lazy as LazyProperty
32 31
33 32 from rhodecode.lib.compat import OrderedDict
34 from rhodecode.lib.datelib import makedate, utcdate_fromtimestamp
33 from rhodecode.lib.datelib import (
34 utcdate_fromtimestamp, makedate, date_astimestamp)
35 35 from rhodecode.lib.utils import safe_unicode, safe_str
36 36 from rhodecode.lib.vcs import connection, path as vcspath
37 37 from rhodecode.lib.vcs.backends.base import (
38 38 BaseRepository, CollectionGenerator, Config, MergeResponse,
39 39 MergeFailureReason, Reference)
40 40 from rhodecode.lib.vcs.backends.git.commit import GitCommit
41 41 from rhodecode.lib.vcs.backends.git.diff import GitDiff
42 42 from rhodecode.lib.vcs.backends.git.inmemory import GitInMemoryCommit
43 43 from rhodecode.lib.vcs.exceptions import (
44 44 CommitDoesNotExistError, EmptyRepositoryError,
45 45 RepositoryError, TagAlreadyExistError, TagDoesNotExistError, VCSError)
46 46
47 47
48 48 SHA_PATTERN = re.compile(r'^[[0-9a-fA-F]{12}|[0-9a-fA-F]{40}]$')
49 49
50 50 log = logging.getLogger(__name__)
51 51
52 52
53 53 class GitRepository(BaseRepository):
54 54 """
55 55 Git repository backend.
56 56 """
57 57 DEFAULT_BRANCH_NAME = 'master'
58 58
59 59 contact = BaseRepository.DEFAULT_CONTACT
60 60
61 61 def __init__(self, repo_path, config=None, create=False, src_url=None,
62 62 update_after_clone=False, with_wire=None, bare=False):
63 63
64 64 self.path = safe_str(os.path.abspath(repo_path))
65 65 self.config = config if config else Config()
66 66 self._remote = connection.Git(
67 67 self.path, self.config, with_wire=with_wire)
68 68
69 69 self._init_repo(create, src_url, update_after_clone, bare)
70 70
71 71 # caches
72 72 self._commit_ids = {}
73 73
74 74 self.bookmarks = {}
75 75
76 76 @LazyProperty
77 77 def bare(self):
78 78 return self._remote.bare()
79 79
80 80 @LazyProperty
81 81 def head(self):
82 82 return self._remote.head()
83 83
84 84 @LazyProperty
85 85 def commit_ids(self):
86 86 """
87 87 Returns list of commit ids, in ascending order. Being lazy
88 88 attribute allows external tools to inject commit ids from cache.
89 89 """
90 90 commit_ids = self._get_all_commit_ids()
91 91 self._rebuild_cache(commit_ids)
92 92 return commit_ids
93 93
94 94 def _rebuild_cache(self, commit_ids):
95 95 self._commit_ids = dict((commit_id, index)
96 96 for index, commit_id in enumerate(commit_ids))
97 97
98 98 def run_git_command(self, cmd, **opts):
99 99 """
100 100 Runs given ``cmd`` as git command and returns tuple
101 101 (stdout, stderr).
102 102
103 103 :param cmd: git command to be executed
104 104 :param opts: env options to pass into Subprocess command
105 105 """
106 106 if not isinstance(cmd, list):
107 107 raise ValueError('cmd must be a list, got %s instead' % type(cmd))
108 108
109 109 out, err = self._remote.run_git_command(cmd, **opts)
110 110 if err:
111 111 log.debug('Stderr output of git command "%s":\n%s', cmd, err)
112 112 return out, err
113 113
114 114 @staticmethod
115 115 def check_url(url, config):
116 116 """
117 117 Function will check given url and try to verify if it's a valid
118 118 link. Sometimes it may happened that git will issue basic
119 119 auth request that can cause whole API to hang when used from python
120 120 or other external calls.
121 121
122 122 On failures it'll raise urllib2.HTTPError, exception is also thrown
123 123 when the return code is non 200
124 124 """
125 125 # check first if it's not an url
126 126 if os.path.isdir(url) or url.startswith('file:'):
127 127 return True
128 128
129 129 if '+' in url.split('://', 1)[0]:
130 130 url = url.split('+', 1)[1]
131 131
132 132 # Request the _remote to verify the url
133 133 return connection.Git.check_url(url, config.serialize())
134 134
135 135 @staticmethod
136 136 def is_valid_repository(path):
137 137 if os.path.isdir(os.path.join(path, '.git')):
138 138 return True
139 139 # check case of bare repository
140 140 try:
141 141 GitRepository(path)
142 142 return True
143 143 except VCSError:
144 144 pass
145 145 return False
146 146
147 147 def _init_repo(self, create, src_url=None, update_after_clone=False,
148 148 bare=False):
149 149 if create and os.path.exists(self.path):
150 150 raise RepositoryError(
151 151 "Cannot create repository at %s, location already exist"
152 152 % self.path)
153 153
154 154 try:
155 155 if create and src_url:
156 156 GitRepository.check_url(src_url, self.config)
157 157 self.clone(src_url, update_after_clone, bare)
158 158 elif create:
159 159 os.makedirs(self.path, mode=0755)
160 160
161 161 if bare:
162 162 self._remote.init_bare()
163 163 else:
164 164 self._remote.init()
165 165 else:
166 166 self._remote.assert_correct_path()
167 167 # TODO: johbo: check if we have to translate the OSError here
168 168 except OSError as err:
169 169 raise RepositoryError(err)
170 170
171 171 def _get_all_commit_ids(self, filters=None):
172 172 # we must check if this repo is not empty, since later command
173 173 # fails if it is. And it's cheaper to ask than throw the subprocess
174 174 # errors
175 175 try:
176 176 self._remote.head()
177 177 except KeyError:
178 178 return []
179 179
180 180 rev_filter = ['--branches', '--tags']
181 181 extra_filter = []
182 182
183 183 if filters:
184 184 if filters.get('since'):
185 185 extra_filter.append('--since=%s' % (filters['since']))
186 186 if filters.get('until'):
187 187 extra_filter.append('--until=%s' % (filters['until']))
188 188 if filters.get('branch_name'):
189 189 rev_filter = ['--tags']
190 190 extra_filter.append(filters['branch_name'])
191 191 rev_filter.extend(extra_filter)
192 192
193 193 # if filters.get('start') or filters.get('end'):
194 194 # # skip is offset, max-count is limit
195 195 # if filters.get('start'):
196 196 # extra_filter += ' --skip=%s' % filters['start']
197 197 # if filters.get('end'):
198 198 # extra_filter += ' --max-count=%s' % (filters['end'] - (filters['start'] or 0))
199 199
200 200 cmd = ['rev-list', '--reverse', '--date-order'] + rev_filter
201 201 try:
202 202 output, __ = self.run_git_command(cmd)
203 203 except RepositoryError:
204 204 # Can be raised for empty repositories
205 205 return []
206 206 return output.splitlines()
207 207
208 208 def _get_commit_id(self, commit_id_or_idx):
209 209 def is_null(value):
210 210 return len(value) == commit_id_or_idx.count('0')
211 211
212 212 if self.is_empty():
213 213 raise EmptyRepositoryError("There are no commits yet")
214 214
215 215 if commit_id_or_idx in (None, '', 'tip', 'HEAD', 'head', -1):
216 216 return self.commit_ids[-1]
217 217
218 218 is_bstr = isinstance(commit_id_or_idx, (str, unicode))
219 219 if ((is_bstr and commit_id_or_idx.isdigit() and len(commit_id_or_idx) < 12)
220 220 or isinstance(commit_id_or_idx, int) or is_null(commit_id_or_idx)):
221 221 try:
222 222 commit_id_or_idx = self.commit_ids[int(commit_id_or_idx)]
223 223 except Exception:
224 224 msg = "Commit %s does not exist for %s" % (
225 225 commit_id_or_idx, self)
226 226 raise CommitDoesNotExistError(msg)
227 227
228 228 elif is_bstr:
229 229 # check full path ref, eg. refs/heads/master
230 230 ref_id = self._refs.get(commit_id_or_idx)
231 231 if ref_id:
232 232 return ref_id
233 233
234 234 # check branch name
235 235 branch_ids = self.branches.values()
236 236 ref_id = self._refs.get('refs/heads/%s' % commit_id_or_idx)
237 237 if ref_id:
238 238 return ref_id
239 239
240 240 # check tag name
241 241 ref_id = self._refs.get('refs/tags/%s' % commit_id_or_idx)
242 242 if ref_id:
243 243 return ref_id
244 244
245 245 if (not SHA_PATTERN.match(commit_id_or_idx) or
246 246 commit_id_or_idx not in self.commit_ids):
247 247 msg = "Commit %s does not exist for %s" % (
248 248 commit_id_or_idx, self)
249 249 raise CommitDoesNotExistError(msg)
250 250
251 251 # Ensure we return full id
252 252 if not SHA_PATTERN.match(str(commit_id_or_idx)):
253 253 raise CommitDoesNotExistError(
254 254 "Given commit id %s not recognized" % commit_id_or_idx)
255 255 return commit_id_or_idx
256 256
257 257 def get_hook_location(self):
258 258 """
259 259 returns absolute path to location where hooks are stored
260 260 """
261 261 loc = os.path.join(self.path, 'hooks')
262 262 if not self.bare:
263 263 loc = os.path.join(self.path, '.git', 'hooks')
264 264 return loc
265 265
266 266 @LazyProperty
267 267 def last_change(self):
268 268 """
269 269 Returns last change made on this repository as
270 270 `datetime.datetime` object.
271 271 """
272 return utcdate_fromtimestamp(self._get_mtime(), makedate()[1])
273
274 def _get_mtime(self):
275 272 try:
276 return time.mktime(self.get_commit().date.timetuple())
273 return self.get_commit().date
277 274 except RepositoryError:
278 idx_loc = '' if self.bare else '.git'
279 # fallback to filesystem
280 in_path = os.path.join(self.path, idx_loc, "index")
281 he_path = os.path.join(self.path, idx_loc, "HEAD")
282 if os.path.exists(in_path):
283 return os.stat(in_path).st_mtime
284 else:
285 return os.stat(he_path).st_mtime
275 tzoffset = makedate()[1]
276 return utcdate_fromtimestamp(self._get_fs_mtime(), tzoffset)
277
278 def _get_fs_mtime(self):
279 idx_loc = '' if self.bare else '.git'
280 # fallback to filesystem
281 in_path = os.path.join(self.path, idx_loc, "index")
282 he_path = os.path.join(self.path, idx_loc, "HEAD")
283 if os.path.exists(in_path):
284 return os.stat(in_path).st_mtime
285 else:
286 return os.stat(he_path).st_mtime
286 287
287 288 @LazyProperty
288 289 def description(self):
289 290 description = self._remote.get_description()
290 291 return safe_unicode(description or self.DEFAULT_DESCRIPTION)
291 292
292 293 def _get_refs_entries(self, prefix='', reverse=False, strip_prefix=True):
293 294 if self.is_empty():
294 295 return OrderedDict()
295 296
296 297 result = []
297 298 for ref, sha in self._refs.iteritems():
298 299 if ref.startswith(prefix):
299 300 ref_name = ref
300 301 if strip_prefix:
301 302 ref_name = ref[len(prefix):]
302 303 result.append((safe_unicode(ref_name), sha))
303 304
304 305 def get_name(entry):
305 306 return entry[0]
306 307
307 308 return OrderedDict(sorted(result, key=get_name, reverse=reverse))
308 309
309 310 def _get_branches(self):
310 311 return self._get_refs_entries(prefix='refs/heads/', strip_prefix=True)
311 312
312 313 @LazyProperty
313 314 def branches(self):
314 315 return self._get_branches()
315 316
316 317 @LazyProperty
317 318 def branches_closed(self):
318 319 return {}
319 320
320 321 @LazyProperty
321 322 def branches_all(self):
322 323 all_branches = {}
323 324 all_branches.update(self.branches)
324 325 all_branches.update(self.branches_closed)
325 326 return all_branches
326 327
327 328 @LazyProperty
328 329 def tags(self):
329 330 return self._get_tags()
330 331
331 332 def _get_tags(self):
332 333 return self._get_refs_entries(
333 334 prefix='refs/tags/', strip_prefix=True, reverse=True)
334 335
335 336 def tag(self, name, user, commit_id=None, message=None, date=None,
336 337 **kwargs):
337 338 # TODO: fix this method to apply annotated tags correct with message
338 339 """
339 340 Creates and returns a tag for the given ``commit_id``.
340 341
341 342 :param name: name for new tag
342 343 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
343 344 :param commit_id: commit id for which new tag would be created
344 345 :param message: message of the tag's commit
345 346 :param date: date of tag's commit
346 347
347 348 :raises TagAlreadyExistError: if tag with same name already exists
348 349 """
349 350 if name in self.tags:
350 351 raise TagAlreadyExistError("Tag %s already exists" % name)
351 352 commit = self.get_commit(commit_id=commit_id)
352 353 message = message or "Added tag %s for commit %s" % (
353 354 name, commit.raw_id)
354 355 self._remote.set_refs('refs/tags/%s' % name, commit._commit['id'])
355 356
356 357 self._refs = self._get_refs()
357 358 self.tags = self._get_tags()
358 359 return commit
359 360
360 361 def remove_tag(self, name, user, message=None, date=None):
361 362 """
362 363 Removes tag with the given ``name``.
363 364
364 365 :param name: name of the tag to be removed
365 366 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
366 367 :param message: message of the tag's removal commit
367 368 :param date: date of tag's removal commit
368 369
369 370 :raises TagDoesNotExistError: if tag with given name does not exists
370 371 """
371 372 if name not in self.tags:
372 373 raise TagDoesNotExistError("Tag %s does not exist" % name)
373 374 tagpath = vcspath.join(
374 375 self._remote.get_refs_path(), 'refs', 'tags', name)
375 376 try:
376 377 os.remove(tagpath)
377 378 self._refs = self._get_refs()
378 379 self.tags = self._get_tags()
379 380 except OSError as e:
380 381 raise RepositoryError(e.strerror)
381 382
382 383 def _get_refs(self):
383 384 return self._remote.get_refs()
384 385
385 386 @LazyProperty
386 387 def _refs(self):
387 388 return self._get_refs()
388 389
389 390 @property
390 391 def _ref_tree(self):
391 392 node = tree = {}
392 393 for ref, sha in self._refs.iteritems():
393 394 path = ref.split('/')
394 395 for bit in path[:-1]:
395 396 node = node.setdefault(bit, {})
396 397 node[path[-1]] = sha
397 398 node = tree
398 399 return tree
399 400
400 401 def get_commit(self, commit_id=None, commit_idx=None, pre_load=None):
401 402 """
402 403 Returns `GitCommit` object representing commit from git repository
403 404 at the given `commit_id` or head (most recent commit) if None given.
404 405 """
405 406 if commit_id is not None:
406 407 self._validate_commit_id(commit_id)
407 408 elif commit_idx is not None:
408 409 self._validate_commit_idx(commit_idx)
409 410 commit_id = commit_idx
410 411 commit_id = self._get_commit_id(commit_id)
411 412 try:
412 413 # Need to call remote to translate id for tagging scenario
413 414 commit_id = self._remote.get_object(commit_id)["commit_id"]
414 415 idx = self._commit_ids[commit_id]
415 416 except KeyError:
416 417 raise RepositoryError("Cannot get object with id %s" % commit_id)
417 418
418 419 return GitCommit(self, commit_id, idx, pre_load=pre_load)
419 420
420 421 def get_commits(
421 422 self, start_id=None, end_id=None, start_date=None, end_date=None,
422 423 branch_name=None, pre_load=None):
423 424 """
424 425 Returns generator of `GitCommit` objects from start to end (both
425 426 are inclusive), in ascending date order.
426 427
427 428 :param start_id: None, str(commit_id)
428 429 :param end_id: None, str(commit_id)
429 430 :param start_date: if specified, commits with commit date less than
430 431 ``start_date`` would be filtered out from returned set
431 432 :param end_date: if specified, commits with commit date greater than
432 433 ``end_date`` would be filtered out from returned set
433 434 :param branch_name: if specified, commits not reachable from given
434 435 branch would be filtered out from returned set
435 436
436 437 :raise BranchDoesNotExistError: If given `branch_name` does not
437 438 exist.
438 439 :raise CommitDoesNotExistError: If commits for given `start` or
439 440 `end` could not be found.
440 441
441 442 """
442 443 if self.is_empty():
443 444 raise EmptyRepositoryError("There are no commits yet")
444 445 self._validate_branch_name(branch_name)
445 446
446 447 if start_id is not None:
447 448 self._validate_commit_id(start_id)
448 449 if end_id is not None:
449 450 self._validate_commit_id(end_id)
450 451
451 452 start_raw_id = self._get_commit_id(start_id)
452 453 start_pos = self._commit_ids[start_raw_id] if start_id else None
453 454 end_raw_id = self._get_commit_id(end_id)
454 455 end_pos = max(0, self._commit_ids[end_raw_id]) if end_id else None
455 456
456 457 if None not in [start_id, end_id] and start_pos > end_pos:
457 458 raise RepositoryError(
458 459 "Start commit '%s' cannot be after end commit '%s'" %
459 460 (start_id, end_id))
460 461
461 462 if end_pos is not None:
462 463 end_pos += 1
463 464
464 465 filter_ = []
465 466 if branch_name:
466 467 filter_.append({'branch_name': branch_name})
467 468 if start_date and not end_date:
468 469 filter_.append({'since': start_date})
469 470 if end_date and not start_date:
470 471 filter_.append({'until': end_date})
471 472 if start_date and end_date:
472 473 filter_.append({'since': start_date})
473 474 filter_.append({'until': end_date})
474 475
475 476 # if start_pos or end_pos:
476 477 # filter_.append({'start': start_pos})
477 478 # filter_.append({'end': end_pos})
478 479
479 480 if filter_:
480 481 revfilters = {
481 482 'branch_name': branch_name,
482 483 'since': start_date.strftime('%m/%d/%y %H:%M:%S') if start_date else None,
483 484 'until': end_date.strftime('%m/%d/%y %H:%M:%S') if end_date else None,
484 485 'start': start_pos,
485 486 'end': end_pos,
486 487 }
487 488 commit_ids = self._get_all_commit_ids(filters=revfilters)
488 489
489 490 # pure python stuff, it's slow due to walker walking whole repo
490 491 # def get_revs(walker):
491 492 # for walker_entry in walker:
492 493 # yield walker_entry.commit.id
493 494 # revfilters = {}
494 495 # commit_ids = list(reversed(list(get_revs(self._repo.get_walker(**revfilters)))))
495 496 else:
496 497 commit_ids = self.commit_ids
497 498
498 499 if start_pos or end_pos:
499 500 commit_ids = commit_ids[start_pos: end_pos]
500 501
501 502 return CollectionGenerator(self, commit_ids, pre_load=pre_load)
502 503
503 504 def get_diff(
504 505 self, commit1, commit2, path='', ignore_whitespace=False,
505 506 context=3, path1=None):
506 507 """
507 508 Returns (git like) *diff*, as plain text. Shows changes introduced by
508 509 ``commit2`` since ``commit1``.
509 510
510 511 :param commit1: Entry point from which diff is shown. Can be
511 512 ``self.EMPTY_COMMIT`` - in this case, patch showing all
512 513 the changes since empty state of the repository until ``commit2``
513 514 :param commit2: Until which commits changes should be shown.
514 515 :param ignore_whitespace: If set to ``True``, would not show whitespace
515 516 changes. Defaults to ``False``.
516 517 :param context: How many lines before/after changed lines should be
517 518 shown. Defaults to ``3``.
518 519 """
519 520 self._validate_diff_commits(commit1, commit2)
520 521 if path1 is not None and path1 != path:
521 522 raise ValueError("Diff of two different paths not supported.")
522 523
523 524 flags = [
524 525 '-U%s' % context, '--full-index', '--binary', '-p',
525 526 '-M', '--abbrev=40']
526 527 if ignore_whitespace:
527 528 flags.append('-w')
528 529
529 530 if commit1 == self.EMPTY_COMMIT:
530 531 cmd = ['show'] + flags + [commit2.raw_id]
531 532 else:
532 533 cmd = ['diff'] + flags + [commit1.raw_id, commit2.raw_id]
533 534
534 535 if path:
535 536 cmd.extend(['--', path])
536 537
537 538 stdout, __ = self.run_git_command(cmd)
538 539 # If we used 'show' command, strip first few lines (until actual diff
539 540 # starts)
540 541 if commit1 == self.EMPTY_COMMIT:
541 542 lines = stdout.splitlines()
542 543 x = 0
543 544 for line in lines:
544 545 if line.startswith('diff'):
545 546 break
546 547 x += 1
547 548 # Append new line just like 'diff' command do
548 549 stdout = '\n'.join(lines[x:]) + '\n'
549 550 return GitDiff(stdout)
550 551
551 552 def strip(self, commit_id, branch_name):
552 553 commit = self.get_commit(commit_id=commit_id)
553 554 if commit.merge:
554 555 raise Exception('Cannot reset to merge commit')
555 556
556 557 # parent is going to be the new head now
557 558 commit = commit.parents[0]
558 559 self._remote.set_refs('refs/heads/%s' % branch_name, commit.raw_id)
559 560
560 561 self.commit_ids = self._get_all_commit_ids()
561 562 self._rebuild_cache(self.commit_ids)
562 563
563 564 def get_common_ancestor(self, commit_id1, commit_id2, repo2):
564 565 if commit_id1 == commit_id2:
565 566 return commit_id1
566 567
567 568 if self != repo2:
568 569 commits = self._remote.get_missing_revs(
569 570 commit_id1, commit_id2, repo2.path)
570 571 if commits:
571 572 commit = repo2.get_commit(commits[-1])
572 573 if commit.parents:
573 574 ancestor_id = commit.parents[0].raw_id
574 575 else:
575 576 ancestor_id = None
576 577 else:
577 578 # no commits from other repo, ancestor_id is the commit_id2
578 579 ancestor_id = commit_id2
579 580 else:
580 581 output, __ = self.run_git_command(
581 582 ['merge-base', commit_id1, commit_id2])
582 583 ancestor_id = re.findall(r'[0-9a-fA-F]{40}', output)[0]
583 584
584 585 return ancestor_id
585 586
586 587 def compare(self, commit_id1, commit_id2, repo2, merge, pre_load=None):
587 588 repo1 = self
588 589 ancestor_id = None
589 590
590 591 if commit_id1 == commit_id2:
591 592 commits = []
592 593 elif repo1 != repo2:
593 594 missing_ids = self._remote.get_missing_revs(commit_id1, commit_id2,
594 595 repo2.path)
595 596 commits = [
596 597 repo2.get_commit(commit_id=commit_id, pre_load=pre_load)
597 598 for commit_id in reversed(missing_ids)]
598 599 else:
599 600 output, __ = repo1.run_git_command(
600 601 ['log', '--reverse', '--pretty=format: %H', '-s',
601 602 '%s..%s' % (commit_id1, commit_id2)])
602 603 commits = [
603 604 repo1.get_commit(commit_id=commit_id, pre_load=pre_load)
604 605 for commit_id in re.findall(r'[0-9a-fA-F]{40}', output)]
605 606
606 607 return commits
607 608
608 609 @LazyProperty
609 610 def in_memory_commit(self):
610 611 """
611 612 Returns ``GitInMemoryCommit`` object for this repository.
612 613 """
613 614 return GitInMemoryCommit(self)
614 615
615 616 def clone(self, url, update_after_clone=True, bare=False):
616 617 """
617 618 Tries to clone commits from external location.
618 619
619 620 :param update_after_clone: If set to ``False``, git won't checkout
620 621 working directory
621 622 :param bare: If set to ``True``, repository would be cloned into
622 623 *bare* git repository (no working directory at all).
623 624 """
624 625 # init_bare and init expect empty dir created to proceed
625 626 if not os.path.exists(self.path):
626 627 os.mkdir(self.path)
627 628
628 629 if bare:
629 630 self._remote.init_bare()
630 631 else:
631 632 self._remote.init()
632 633
633 634 deferred = '^{}'
634 635 valid_refs = ('refs/heads', 'refs/tags', 'HEAD')
635 636
636 637 return self._remote.clone(
637 638 url, deferred, valid_refs, update_after_clone)
638 639
639 640 def pull(self, url, commit_ids=None):
640 641 """
641 642 Tries to pull changes from external location. We use fetch here since
642 643 pull in get does merges and we want to be compatible with hg backend so
643 644 pull == fetch in this case
644 645 """
645 646 self.fetch(url, commit_ids=commit_ids)
646 647
647 648 def fetch(self, url, commit_ids=None):
648 649 """
649 650 Tries to fetch changes from external location.
650 651 """
651 652 refs = None
652 653
653 654 if commit_ids is not None:
654 655 remote_refs = self._remote.get_remote_refs(url)
655 656 refs = [
656 657 ref for ref in remote_refs if remote_refs[ref] in commit_ids]
657 658 self._remote.fetch(url, refs=refs)
658 659
659 660 def set_refs(self, ref_name, commit_id):
660 661 self._remote.set_refs(ref_name, commit_id)
661 662
662 663 def remove_ref(self, ref_name):
663 664 self._remote.remove_ref(ref_name)
664 665
665 666 def _update_server_info(self):
666 667 """
667 668 runs gits update-server-info command in this repo instance
668 669 """
669 670 self._remote.update_server_info()
670 671
671 672 def _current_branch(self):
672 673 """
673 674 Return the name of the current branch.
674 675
675 676 It only works for non bare repositories (i.e. repositories with a
676 677 working copy)
677 678 """
678 679 if self.bare:
679 680 raise RepositoryError('Bare git repos do not have active branches')
680 681
681 682 if self.is_empty():
682 683 return None
683 684
684 685 stdout, _ = self.run_git_command(['rev-parse', '--abbrev-ref', 'HEAD'])
685 686 return stdout.strip()
686 687
687 688 def _checkout(self, branch_name, create=False):
688 689 """
689 690 Checkout a branch in the working directory.
690 691
691 692 It tries to create the branch if create is True, failing if the branch
692 693 already exists.
693 694
694 695 It only works for non bare repositories (i.e. repositories with a
695 696 working copy)
696 697 """
697 698 if self.bare:
698 699 raise RepositoryError('Cannot checkout branches in a bare git repo')
699 700
700 701 cmd = ['checkout']
701 702 if create:
702 703 cmd.append('-b')
703 704 cmd.append(branch_name)
704 705 self.run_git_command(cmd, fail_on_stderr=False)
705 706
706 707 def _local_clone(self, clone_path, branch_name):
707 708 """
708 709 Create a local clone of the current repo.
709 710 """
710 711 # N.B.(skreft): the --branch option is required as otherwise the shallow
711 712 # clone will only fetch the active branch.
712 713 cmd = ['clone', '--branch', branch_name, '--single-branch',
713 714 self.path, os.path.abspath(clone_path)]
714 715 self.run_git_command(cmd, fail_on_stderr=False)
715 716
716 717 def _local_fetch(self, repository_path, branch_name):
717 718 """
718 719 Fetch a branch from a local repository.
719 720 """
720 721 repository_path = os.path.abspath(repository_path)
721 722 if repository_path == self.path:
722 723 raise ValueError('Cannot fetch from the same repository')
723 724
724 725 cmd = ['fetch', '--no-tags', repository_path, branch_name]
725 726 self.run_git_command(cmd, fail_on_stderr=False)
726 727
727 728 def _last_fetch_heads(self):
728 729 """
729 730 Return the last fetched heads that need merging.
730 731
731 732 The algorithm is defined at
732 733 https://github.com/git/git/blob/v2.1.3/git-pull.sh#L283
733 734 """
734 735 if not self.bare:
735 736 fetch_heads_path = os.path.join(self.path, '.git', 'FETCH_HEAD')
736 737 else:
737 738 fetch_heads_path = os.path.join(self.path, 'FETCH_HEAD')
738 739
739 740 heads = []
740 741 with open(fetch_heads_path) as f:
741 742 for line in f:
742 743 if ' not-for-merge ' in line:
743 744 continue
744 745 line = re.sub('\t.*', '', line, flags=re.DOTALL)
745 746 heads.append(line)
746 747
747 748 return heads
748 749
749 750 def _local_pull(self, repository_path, branch_name):
750 751 """
751 752 Pull a branch from a local repository.
752 753 """
753 754 if self.bare:
754 755 raise RepositoryError('Cannot pull into a bare git repository')
755 756 # N.B.(skreft): The --ff-only option is to make sure this is a
756 757 # fast-forward (i.e., we are only pulling new changes and there are no
757 758 # conflicts with our current branch)
758 759 # Additionally, that option needs to go before --no-tags, otherwise git
759 760 # pull complains about it being an unknown flag.
760 761 cmd = ['pull', '--ff-only', '--no-tags', repository_path, branch_name]
761 762 self.run_git_command(cmd, fail_on_stderr=False)
762 763
763 764 def _local_merge(self, merge_message, user_name, user_email, heads):
764 765 """
765 766 Merge the given head into the checked out branch.
766 767
767 768 It will force a merge commit.
768 769
769 770 Currently it raises an error if the repo is empty, as it is not possible
770 771 to create a merge commit in an empty repo.
771 772
772 773 :param merge_message: The message to use for the merge commit.
773 774 :param heads: the heads to merge.
774 775 """
775 776 if self.bare:
776 777 raise RepositoryError('Cannot merge into a bare git repository')
777 778
778 779 if not heads:
779 780 return
780 781
781 782 if self.is_empty():
782 783 # TODO(skreft): do somehting more robust in this case.
783 784 raise RepositoryError(
784 785 'Do not know how to merge into empty repositories yet')
785 786
786 787 # N.B.(skreft): the --no-ff option is used to enforce the creation of a
787 788 # commit message. We also specify the user who is doing the merge.
788 789 cmd = ['-c', 'user.name=%s' % safe_str(user_name),
789 790 '-c', 'user.email=%s' % safe_str(user_email),
790 791 'merge', '--no-ff', '-m', safe_str(merge_message)]
791 792 cmd.extend(heads)
792 793 try:
793 794 self.run_git_command(cmd, fail_on_stderr=False)
794 795 except RepositoryError:
795 796 # Cleanup any merge leftovers
796 797 self.run_git_command(['merge', '--abort'], fail_on_stderr=False)
797 798 raise
798 799
799 800 def _local_push(
800 801 self, source_branch, repository_path, target_branch,
801 802 enable_hooks=False, rc_scm_data=None):
802 803 """
803 804 Push the source_branch to the given repository and target_branch.
804 805
805 806 Currently it if the target_branch is not master and the target repo is
806 807 empty, the push will work, but then GitRepository won't be able to find
807 808 the pushed branch or the commits. As the HEAD will be corrupted (i.e.,
808 809 pointing to master, which does not exist).
809 810
810 811 It does not run the hooks in the target repo.
811 812 """
812 813 # TODO(skreft): deal with the case in which the target repo is empty,
813 814 # and the target_branch is not master.
814 815 target_repo = GitRepository(repository_path)
815 816 if (not target_repo.bare and
816 817 target_repo._current_branch() == target_branch):
817 818 # Git prevents pushing to the checked out branch, so simulate it by
818 819 # pulling into the target repository.
819 820 target_repo._local_pull(self.path, source_branch)
820 821 else:
821 822 cmd = ['push', os.path.abspath(repository_path),
822 823 '%s:%s' % (source_branch, target_branch)]
823 824 gitenv = {}
824 825 if rc_scm_data:
825 826 gitenv.update({'RC_SCM_DATA': rc_scm_data})
826 827
827 828 if not enable_hooks:
828 829 gitenv['RC_SKIP_HOOKS'] = '1'
829 830 self.run_git_command(cmd, fail_on_stderr=False, extra_env=gitenv)
830 831
831 832 def _get_new_pr_branch(self, source_branch, target_branch):
832 833 prefix = 'pr_%s-%s_' % (source_branch, target_branch)
833 834 pr_branches = []
834 835 for branch in self.branches:
835 836 if branch.startswith(prefix):
836 837 pr_branches.append(int(branch[len(prefix):]))
837 838
838 839 if not pr_branches:
839 840 branch_id = 0
840 841 else:
841 842 branch_id = max(pr_branches) + 1
842 843
843 844 return '%s%d' % (prefix, branch_id)
844 845
845 846 def _merge_repo(self, shadow_repository_path, target_ref,
846 847 source_repo, source_ref, merge_message,
847 848 merger_name, merger_email, dry_run=False,
848 849 use_rebase=False):
849 850 if target_ref.commit_id != self.branches[target_ref.name]:
850 851 return MergeResponse(
851 852 False, False, None, MergeFailureReason.TARGET_IS_NOT_HEAD)
852 853
853 854 shadow_repo = GitRepository(shadow_repository_path)
854 855 shadow_repo._checkout(target_ref.name)
855 856 shadow_repo._local_pull(self.path, target_ref.name)
856 857 # Need to reload repo to invalidate the cache, or otherwise we cannot
857 858 # retrieve the last target commit.
858 859 shadow_repo = GitRepository(shadow_repository_path)
859 860 if target_ref.commit_id != shadow_repo.branches[target_ref.name]:
860 861 return MergeResponse(
861 862 False, False, None, MergeFailureReason.TARGET_IS_NOT_HEAD)
862 863
863 864 pr_branch = shadow_repo._get_new_pr_branch(
864 865 source_ref.name, target_ref.name)
865 866 shadow_repo._checkout(pr_branch, create=True)
866 867 try:
867 868 shadow_repo._local_fetch(source_repo.path, source_ref.name)
868 869 except RepositoryError:
869 870 log.exception('Failure when doing local fetch on git shadow repo')
870 871 return MergeResponse(
871 872 False, False, None, MergeFailureReason.MISSING_SOURCE_REF)
872 873
873 874 merge_ref = None
874 875 merge_failure_reason = MergeFailureReason.NONE
875 876 try:
876 877 shadow_repo._local_merge(merge_message, merger_name, merger_email,
877 878 [source_ref.commit_id])
878 879 merge_possible = True
879 880
880 881 # Need to reload repo to invalidate the cache, or otherwise we
881 882 # cannot retrieve the merge commit.
882 883 shadow_repo = GitRepository(shadow_repository_path)
883 884 merge_commit_id = shadow_repo.branches[pr_branch]
884 885
885 886 # Set a reference pointing to the merge commit. This reference may
886 887 # be used to easily identify the last successful merge commit in
887 888 # the shadow repository.
888 889 shadow_repo.set_refs('refs/heads/pr-merge', merge_commit_id)
889 890 merge_ref = Reference('branch', 'pr-merge', merge_commit_id)
890 891 except RepositoryError:
891 892 log.exception('Failure when doing local merge on git shadow repo')
892 893 merge_possible = False
893 894 merge_failure_reason = MergeFailureReason.MERGE_FAILED
894 895
895 896 if merge_possible and not dry_run:
896 897 try:
897 898 shadow_repo._local_push(
898 899 pr_branch, self.path, target_ref.name, enable_hooks=True,
899 900 rc_scm_data=self.config.get('rhodecode', 'RC_SCM_DATA'))
900 901 merge_succeeded = True
901 902 except RepositoryError:
902 903 log.exception(
903 904 'Failure when doing local push on git shadow repo')
904 905 merge_succeeded = False
905 906 merge_failure_reason = MergeFailureReason.PUSH_FAILED
906 907 else:
907 908 merge_succeeded = False
908 909
909 910 return MergeResponse(
910 911 merge_possible, merge_succeeded, merge_ref,
911 912 merge_failure_reason)
912 913
913 914 def _get_shadow_repository_path(self, workspace_id):
914 915 # The name of the shadow repository must start with '.', so it is
915 916 # skipped by 'rhodecode.lib.utils.get_filesystem_repos'.
916 917 return os.path.join(
917 918 os.path.dirname(self.path),
918 919 '.__shadow_%s_%s' % (os.path.basename(self.path), workspace_id))
919 920
920 921 def _maybe_prepare_merge_workspace(self, workspace_id, target_ref):
921 922 shadow_repository_path = self._get_shadow_repository_path(workspace_id)
922 923 if not os.path.exists(shadow_repository_path):
923 924 self._local_clone(shadow_repository_path, target_ref.name)
924 925
925 926 return shadow_repository_path
926 927
927 928 def cleanup_merge_workspace(self, workspace_id):
928 929 shadow_repository_path = self._get_shadow_repository_path(workspace_id)
929 930 shutil.rmtree(shadow_repository_path, ignore_errors=True)
@@ -1,808 +1,809 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2014-2017 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 """
22 22 HG repository module
23 23 """
24 24
25 25 import logging
26 26 import binascii
27 27 import os
28 28 import shutil
29 29 import urllib
30 30
31 31 from zope.cachedescriptors.property import Lazy as LazyProperty
32 32
33 33 from rhodecode.lib.compat import OrderedDict
34 34 from rhodecode.lib.datelib import (
35 35 date_to_timestamp_plus_offset, utcdate_fromtimestamp, makedate,
36 36 date_astimestamp)
37 37 from rhodecode.lib.utils import safe_unicode, safe_str
38 38 from rhodecode.lib.vcs import connection
39 39 from rhodecode.lib.vcs.backends.base import (
40 40 BaseRepository, CollectionGenerator, Config, MergeResponse,
41 41 MergeFailureReason, Reference)
42 42 from rhodecode.lib.vcs.backends.hg.commit import MercurialCommit
43 43 from rhodecode.lib.vcs.backends.hg.diff import MercurialDiff
44 44 from rhodecode.lib.vcs.backends.hg.inmemory import MercurialInMemoryCommit
45 45 from rhodecode.lib.vcs.exceptions import (
46 46 EmptyRepositoryError, RepositoryError, TagAlreadyExistError,
47 47 TagDoesNotExistError, CommitDoesNotExistError, SubrepoMergeError)
48 48
49 49 hexlify = binascii.hexlify
50 50 nullid = "\0" * 20
51 51
52 52 log = logging.getLogger(__name__)
53 53
54 54
55 55 class MercurialRepository(BaseRepository):
56 56 """
57 57 Mercurial repository backend
58 58 """
59 59 DEFAULT_BRANCH_NAME = 'default'
60 60
61 61 def __init__(self, repo_path, config=None, create=False, src_url=None,
62 62 update_after_clone=False, with_wire=None):
63 63 """
64 64 Raises RepositoryError if repository could not be find at the given
65 65 ``repo_path``.
66 66
67 67 :param repo_path: local path of the repository
68 68 :param config: config object containing the repo configuration
69 69 :param create=False: if set to True, would try to create repository if
70 70 it does not exist rather than raising exception
71 71 :param src_url=None: would try to clone repository from given location
72 72 :param update_after_clone=False: sets update of working copy after
73 73 making a clone
74 74 """
75 75 self.path = safe_str(os.path.abspath(repo_path))
76 76 self.config = config if config else Config()
77 77 self._remote = connection.Hg(
78 78 self.path, self.config, with_wire=with_wire)
79 79
80 80 self._init_repo(create, src_url, update_after_clone)
81 81
82 82 # caches
83 83 self._commit_ids = {}
84 84
85 85 @LazyProperty
86 86 def commit_ids(self):
87 87 """
88 88 Returns list of commit ids, in ascending order. Being lazy
89 89 attribute allows external tools to inject shas from cache.
90 90 """
91 91 commit_ids = self._get_all_commit_ids()
92 92 self._rebuild_cache(commit_ids)
93 93 return commit_ids
94 94
95 95 def _rebuild_cache(self, commit_ids):
96 96 self._commit_ids = dict((commit_id, index)
97 97 for index, commit_id in enumerate(commit_ids))
98 98
99 99 @LazyProperty
100 100 def branches(self):
101 101 return self._get_branches()
102 102
103 103 @LazyProperty
104 104 def branches_closed(self):
105 105 return self._get_branches(active=False, closed=True)
106 106
107 107 @LazyProperty
108 108 def branches_all(self):
109 109 all_branches = {}
110 110 all_branches.update(self.branches)
111 111 all_branches.update(self.branches_closed)
112 112 return all_branches
113 113
114 114 def _get_branches(self, active=True, closed=False):
115 115 """
116 116 Gets branches for this repository
117 117 Returns only not closed active branches by default
118 118
119 119 :param active: return also active branches
120 120 :param closed: return also closed branches
121 121
122 122 """
123 123 if self.is_empty():
124 124 return {}
125 125
126 126 def get_name(ctx):
127 127 return ctx[0]
128 128
129 129 _branches = [(safe_unicode(n), hexlify(h),) for n, h in
130 130 self._remote.branches(active, closed).items()]
131 131
132 132 return OrderedDict(sorted(_branches, key=get_name, reverse=False))
133 133
134 134 @LazyProperty
135 135 def tags(self):
136 136 """
137 137 Gets tags for this repository
138 138 """
139 139 return self._get_tags()
140 140
141 141 def _get_tags(self):
142 142 if self.is_empty():
143 143 return {}
144 144
145 145 def get_name(ctx):
146 146 return ctx[0]
147 147
148 148 _tags = [(safe_unicode(n), hexlify(h),) for n, h in
149 149 self._remote.tags().items()]
150 150
151 151 return OrderedDict(sorted(_tags, key=get_name, reverse=True))
152 152
153 153 def tag(self, name, user, commit_id=None, message=None, date=None,
154 154 **kwargs):
155 155 """
156 156 Creates and returns a tag for the given ``commit_id``.
157 157
158 158 :param name: name for new tag
159 159 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
160 160 :param commit_id: commit id for which new tag would be created
161 161 :param message: message of the tag's commit
162 162 :param date: date of tag's commit
163 163
164 164 :raises TagAlreadyExistError: if tag with same name already exists
165 165 """
166 166 if name in self.tags:
167 167 raise TagAlreadyExistError("Tag %s already exists" % name)
168 168 commit = self.get_commit(commit_id=commit_id)
169 169 local = kwargs.setdefault('local', False)
170 170
171 171 if message is None:
172 172 message = "Added tag %s for commit %s" % (name, commit.short_id)
173 173
174 174 date, tz = date_to_timestamp_plus_offset(date)
175 175
176 176 self._remote.tag(
177 177 name, commit.raw_id, message, local, user, date, tz)
178 178 self._remote.invalidate_vcs_cache()
179 179
180 180 # Reinitialize tags
181 181 self.tags = self._get_tags()
182 182 tag_id = self.tags[name]
183 183
184 184 return self.get_commit(commit_id=tag_id)
185 185
186 186 def remove_tag(self, name, user, message=None, date=None):
187 187 """
188 188 Removes tag with the given `name`.
189 189
190 190 :param name: name of the tag to be removed
191 191 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
192 192 :param message: message of the tag's removal commit
193 193 :param date: date of tag's removal commit
194 194
195 195 :raises TagDoesNotExistError: if tag with given name does not exists
196 196 """
197 197 if name not in self.tags:
198 198 raise TagDoesNotExistError("Tag %s does not exist" % name)
199 199 if message is None:
200 200 message = "Removed tag %s" % name
201 201 local = False
202 202
203 203 date, tz = date_to_timestamp_plus_offset(date)
204 204
205 205 self._remote.tag(name, nullid, message, local, user, date, tz)
206 206 self._remote.invalidate_vcs_cache()
207 207 self.tags = self._get_tags()
208 208
209 209 @LazyProperty
210 210 def bookmarks(self):
211 211 """
212 212 Gets bookmarks for this repository
213 213 """
214 214 return self._get_bookmarks()
215 215
216 216 def _get_bookmarks(self):
217 217 if self.is_empty():
218 218 return {}
219 219
220 220 def get_name(ctx):
221 221 return ctx[0]
222 222
223 223 _bookmarks = [
224 224 (safe_unicode(n), hexlify(h)) for n, h in
225 225 self._remote.bookmarks().items()]
226 226
227 227 return OrderedDict(sorted(_bookmarks, key=get_name))
228 228
229 229 def _get_all_commit_ids(self):
230 230 return self._remote.get_all_commit_ids('visible')
231 231
232 232 def get_diff(
233 233 self, commit1, commit2, path='', ignore_whitespace=False,
234 234 context=3, path1=None):
235 235 """
236 236 Returns (git like) *diff*, as plain text. Shows changes introduced by
237 237 `commit2` since `commit1`.
238 238
239 239 :param commit1: Entry point from which diff is shown. Can be
240 240 ``self.EMPTY_COMMIT`` - in this case, patch showing all
241 241 the changes since empty state of the repository until `commit2`
242 242 :param commit2: Until which commit changes should be shown.
243 243 :param ignore_whitespace: If set to ``True``, would not show whitespace
244 244 changes. Defaults to ``False``.
245 245 :param context: How many lines before/after changed lines should be
246 246 shown. Defaults to ``3``.
247 247 """
248 248 self._validate_diff_commits(commit1, commit2)
249 249 if path1 is not None and path1 != path:
250 250 raise ValueError("Diff of two different paths not supported.")
251 251
252 252 if path:
253 253 file_filter = [self.path, path]
254 254 else:
255 255 file_filter = None
256 256
257 257 diff = self._remote.diff(
258 258 commit1.raw_id, commit2.raw_id, file_filter=file_filter,
259 259 opt_git=True, opt_ignorews=ignore_whitespace,
260 260 context=context)
261 261 return MercurialDiff(diff)
262 262
263 263 def strip(self, commit_id, branch=None):
264 264 self._remote.strip(commit_id, update=False, backup="none")
265 265
266 266 self._remote.invalidate_vcs_cache()
267 267 self.commit_ids = self._get_all_commit_ids()
268 268 self._rebuild_cache(self.commit_ids)
269 269
270 270 def get_common_ancestor(self, commit_id1, commit_id2, repo2):
271 271 if commit_id1 == commit_id2:
272 272 return commit_id1
273 273
274 274 ancestors = self._remote.revs_from_revspec(
275 275 "ancestor(id(%s), id(%s))", commit_id1, commit_id2,
276 276 other_path=repo2.path)
277 277 return repo2[ancestors[0]].raw_id if ancestors else None
278 278
279 279 def compare(self, commit_id1, commit_id2, repo2, merge, pre_load=None):
280 280 if commit_id1 == commit_id2:
281 281 commits = []
282 282 else:
283 283 if merge:
284 284 indexes = self._remote.revs_from_revspec(
285 285 "ancestors(id(%s)) - ancestors(id(%s)) - id(%s)",
286 286 commit_id2, commit_id1, commit_id1, other_path=repo2.path)
287 287 else:
288 288 indexes = self._remote.revs_from_revspec(
289 289 "id(%s)..id(%s) - id(%s)", commit_id1, commit_id2,
290 290 commit_id1, other_path=repo2.path)
291 291
292 292 commits = [repo2.get_commit(commit_idx=idx, pre_load=pre_load)
293 293 for idx in indexes]
294 294
295 295 return commits
296 296
297 297 @staticmethod
298 298 def check_url(url, config):
299 299 """
300 300 Function will check given url and try to verify if it's a valid
301 301 link. Sometimes it may happened that mercurial will issue basic
302 302 auth request that can cause whole API to hang when used from python
303 303 or other external calls.
304 304
305 305 On failures it'll raise urllib2.HTTPError, exception is also thrown
306 306 when the return code is non 200
307 307 """
308 308 # check first if it's not an local url
309 309 if os.path.isdir(url) or url.startswith('file:'):
310 310 return True
311 311
312 312 # Request the _remote to verify the url
313 313 return connection.Hg.check_url(url, config.serialize())
314 314
315 315 @staticmethod
316 316 def is_valid_repository(path):
317 317 return os.path.isdir(os.path.join(path, '.hg'))
318 318
319 319 def _init_repo(self, create, src_url=None, update_after_clone=False):
320 320 """
321 321 Function will check for mercurial repository in given path. If there
322 322 is no repository in that path it will raise an exception unless
323 323 `create` parameter is set to True - in that case repository would
324 324 be created.
325 325
326 326 If `src_url` is given, would try to clone repository from the
327 327 location at given clone_point. Additionally it'll make update to
328 328 working copy accordingly to `update_after_clone` flag.
329 329 """
330 330 if create and os.path.exists(self.path):
331 331 raise RepositoryError(
332 332 "Cannot create repository at %s, location already exist"
333 333 % self.path)
334 334
335 335 if src_url:
336 336 url = str(self._get_url(src_url))
337 337 MercurialRepository.check_url(url, self.config)
338 338
339 339 self._remote.clone(url, self.path, update_after_clone)
340 340
341 341 # Don't try to create if we've already cloned repo
342 342 create = False
343 343
344 344 if create:
345 345 os.makedirs(self.path, mode=0755)
346 346
347 347 self._remote.localrepository(create)
348 348
349 349 @LazyProperty
350 350 def in_memory_commit(self):
351 351 return MercurialInMemoryCommit(self)
352 352
353 353 @LazyProperty
354 354 def description(self):
355 355 description = self._remote.get_config_value(
356 356 'web', 'description', untrusted=True)
357 357 return safe_unicode(description or self.DEFAULT_DESCRIPTION)
358 358
359 359 @LazyProperty
360 360 def contact(self):
361 361 contact = (
362 362 self._remote.get_config_value("web", "contact") or
363 363 self._remote.get_config_value("ui", "username"))
364 364 return safe_unicode(contact or self.DEFAULT_CONTACT)
365 365
366 366 @LazyProperty
367 367 def last_change(self):
368 368 """
369 369 Returns last change made on this repository as
370 `datetime.datetime` object
370 `datetime.datetime` object.
371 371 """
372 return utcdate_fromtimestamp(self._get_mtime(), makedate()[1])
373
374 def _get_mtime(self):
375 372 try:
376 return date_astimestamp(self.get_commit().date)
373 return self.get_commit().date
377 374 except RepositoryError:
378 # fallback to filesystem
379 cl_path = os.path.join(self.path, '.hg', "00changelog.i")
380 st_path = os.path.join(self.path, '.hg', "store")
381 if os.path.exists(cl_path):
382 return os.stat(cl_path).st_mtime
383 else:
384 return os.stat(st_path).st_mtime
375 tzoffset = makedate()[1]
376 return utcdate_fromtimestamp(self._get_fs_mtime(), tzoffset)
377
378 def _get_fs_mtime(self):
379 # fallback to filesystem
380 cl_path = os.path.join(self.path, '.hg', "00changelog.i")
381 st_path = os.path.join(self.path, '.hg', "store")
382 if os.path.exists(cl_path):
383 return os.stat(cl_path).st_mtime
384 else:
385 return os.stat(st_path).st_mtime
385 386
386 387 def _sanitize_commit_idx(self, idx):
387 388 # Note: Mercurial has ``int(-1)`` reserved as not existing id_or_idx
388 389 # number. A `long` is treated in the correct way though. So we convert
389 390 # `int` to `long` here to make sure it is handled correctly.
390 391 if isinstance(idx, int):
391 392 return long(idx)
392 393 return idx
393 394
394 395 def _get_url(self, url):
395 396 """
396 397 Returns normalized url. If schema is not given, would fall
397 398 to filesystem
398 399 (``file:///``) schema.
399 400 """
400 401 url = url.encode('utf8')
401 402 if url != 'default' and '://' not in url:
402 403 url = "file:" + urllib.pathname2url(url)
403 404 return url
404 405
405 406 def get_hook_location(self):
406 407 """
407 408 returns absolute path to location where hooks are stored
408 409 """
409 410 return os.path.join(self.path, '.hg', '.hgrc')
410 411
411 412 def get_commit(self, commit_id=None, commit_idx=None, pre_load=None):
412 413 """
413 414 Returns ``MercurialCommit`` object representing repository's
414 415 commit at the given `commit_id` or `commit_idx`.
415 416 """
416 417 if self.is_empty():
417 418 raise EmptyRepositoryError("There are no commits yet")
418 419
419 420 if commit_id is not None:
420 421 self._validate_commit_id(commit_id)
421 422 try:
422 423 idx = self._commit_ids[commit_id]
423 424 return MercurialCommit(self, commit_id, idx, pre_load=pre_load)
424 425 except KeyError:
425 426 pass
426 427 elif commit_idx is not None:
427 428 self._validate_commit_idx(commit_idx)
428 429 commit_idx = self._sanitize_commit_idx(commit_idx)
429 430 try:
430 431 id_ = self.commit_ids[commit_idx]
431 432 if commit_idx < 0:
432 433 commit_idx += len(self.commit_ids)
433 434 return MercurialCommit(
434 435 self, id_, commit_idx, pre_load=pre_load)
435 436 except IndexError:
436 437 commit_id = commit_idx
437 438 else:
438 439 commit_id = "tip"
439 440
440 441 # TODO Paris: Ugly hack to "serialize" long for msgpack
441 442 if isinstance(commit_id, long):
442 443 commit_id = float(commit_id)
443 444
444 445 if isinstance(commit_id, unicode):
445 446 commit_id = safe_str(commit_id)
446 447
447 448 raw_id, idx = self._remote.lookup(commit_id, both=True)
448 449
449 450 return MercurialCommit(self, raw_id, idx, pre_load=pre_load)
450 451
451 452 def get_commits(
452 453 self, start_id=None, end_id=None, start_date=None, end_date=None,
453 454 branch_name=None, pre_load=None):
454 455 """
455 456 Returns generator of ``MercurialCommit`` objects from start to end
456 457 (both are inclusive)
457 458
458 459 :param start_id: None, str(commit_id)
459 460 :param end_id: None, str(commit_id)
460 461 :param start_date: if specified, commits with commit date less than
461 462 ``start_date`` would be filtered out from returned set
462 463 :param end_date: if specified, commits with commit date greater than
463 464 ``end_date`` would be filtered out from returned set
464 465 :param branch_name: if specified, commits not reachable from given
465 466 branch would be filtered out from returned set
466 467
467 468 :raise BranchDoesNotExistError: If given ``branch_name`` does not
468 469 exist.
469 470 :raise CommitDoesNotExistError: If commit for given ``start`` or
470 471 ``end`` could not be found.
471 472 """
472 473 # actually we should check now if it's not an empty repo
473 474 branch_ancestors = False
474 475 if self.is_empty():
475 476 raise EmptyRepositoryError("There are no commits yet")
476 477 self._validate_branch_name(branch_name)
477 478
478 479 if start_id is not None:
479 480 self._validate_commit_id(start_id)
480 481 c_start = self.get_commit(commit_id=start_id)
481 482 start_pos = self._commit_ids[c_start.raw_id]
482 483 else:
483 484 start_pos = None
484 485
485 486 if end_id is not None:
486 487 self._validate_commit_id(end_id)
487 488 c_end = self.get_commit(commit_id=end_id)
488 489 end_pos = max(0, self._commit_ids[c_end.raw_id])
489 490 else:
490 491 end_pos = None
491 492
492 493 if None not in [start_id, end_id] and start_pos > end_pos:
493 494 raise RepositoryError(
494 495 "Start commit '%s' cannot be after end commit '%s'" %
495 496 (start_id, end_id))
496 497
497 498 if end_pos is not None:
498 499 end_pos += 1
499 500
500 501 commit_filter = []
501 502 if branch_name and not branch_ancestors:
502 503 commit_filter.append('branch("%s")' % branch_name)
503 504 elif branch_name and branch_ancestors:
504 505 commit_filter.append('ancestors(branch("%s"))' % branch_name)
505 506 if start_date and not end_date:
506 507 commit_filter.append('date(">%s")' % start_date)
507 508 if end_date and not start_date:
508 509 commit_filter.append('date("<%s")' % end_date)
509 510 if start_date and end_date:
510 511 commit_filter.append(
511 512 'date(">%s") and date("<%s")' % (start_date, end_date))
512 513
513 514 # TODO: johbo: Figure out a simpler way for this solution
514 515 collection_generator = CollectionGenerator
515 516 if commit_filter:
516 517 commit_filter = map(safe_str, commit_filter)
517 518 revisions = self._remote.rev_range(commit_filter)
518 519 collection_generator = MercurialIndexBasedCollectionGenerator
519 520 else:
520 521 revisions = self.commit_ids
521 522
522 523 if start_pos or end_pos:
523 524 revisions = revisions[start_pos:end_pos]
524 525
525 526 return collection_generator(self, revisions, pre_load=pre_load)
526 527
527 528 def pull(self, url, commit_ids=None):
528 529 """
529 530 Tries to pull changes from external location.
530 531
531 532 :param commit_ids: Optional. Can be set to a list of commit ids
532 533 which shall be pulled from the other repository.
533 534 """
534 535 url = self._get_url(url)
535 536 self._remote.pull(url, commit_ids=commit_ids)
536 537 self._remote.invalidate_vcs_cache()
537 538
538 539 def _local_clone(self, clone_path):
539 540 """
540 541 Create a local clone of the current repo.
541 542 """
542 543 self._remote.clone(self.path, clone_path, update_after_clone=True,
543 544 hooks=False)
544 545
545 546 def _update(self, revision, clean=False):
546 547 """
547 548 Update the working copty to the specified revision.
548 549 """
549 550 self._remote.update(revision, clean=clean)
550 551
551 552 def _identify(self):
552 553 """
553 554 Return the current state of the working directory.
554 555 """
555 556 return self._remote.identify().strip().rstrip('+')
556 557
557 558 def _heads(self, branch=None):
558 559 """
559 560 Return the commit ids of the repository heads.
560 561 """
561 562 return self._remote.heads(branch=branch).strip().split(' ')
562 563
563 564 def _ancestor(self, revision1, revision2):
564 565 """
565 566 Return the common ancestor of the two revisions.
566 567 """
567 568 return self._remote.ancestor(
568 569 revision1, revision2).strip().split(':')[-1]
569 570
570 571 def _local_push(
571 572 self, revision, repository_path, push_branches=False,
572 573 enable_hooks=False):
573 574 """
574 575 Push the given revision to the specified repository.
575 576
576 577 :param push_branches: allow to create branches in the target repo.
577 578 """
578 579 self._remote.push(
579 580 [revision], repository_path, hooks=enable_hooks,
580 581 push_branches=push_branches)
581 582
582 583 def _local_merge(self, target_ref, merge_message, user_name, user_email,
583 584 source_ref, use_rebase=False):
584 585 """
585 586 Merge the given source_revision into the checked out revision.
586 587
587 588 Returns the commit id of the merge and a boolean indicating if the
588 589 commit needs to be pushed.
589 590 """
590 591 self._update(target_ref.commit_id)
591 592
592 593 ancestor = self._ancestor(target_ref.commit_id, source_ref.commit_id)
593 594 is_the_same_branch = self._is_the_same_branch(target_ref, source_ref)
594 595
595 596 if ancestor == source_ref.commit_id:
596 597 # Nothing to do, the changes were already integrated
597 598 return target_ref.commit_id, False
598 599
599 600 elif ancestor == target_ref.commit_id and is_the_same_branch:
600 601 # In this case we should force a commit message
601 602 return source_ref.commit_id, True
602 603
603 604 if use_rebase:
604 605 try:
605 606 bookmark_name = 'rcbook%s%s' % (source_ref.commit_id,
606 607 target_ref.commit_id)
607 608 self.bookmark(bookmark_name, revision=source_ref.commit_id)
608 609 self._remote.rebase(
609 610 source=source_ref.commit_id, dest=target_ref.commit_id)
610 611 self._remote.invalidate_vcs_cache()
611 612 self._update(bookmark_name)
612 613 return self._identify(), True
613 614 except RepositoryError:
614 615 # The rebase-abort may raise another exception which 'hides'
615 616 # the original one, therefore we log it here.
616 617 log.exception('Error while rebasing shadow repo during merge.')
617 618
618 619 # Cleanup any rebase leftovers
619 620 self._remote.invalidate_vcs_cache()
620 621 self._remote.rebase(abort=True)
621 622 self._remote.invalidate_vcs_cache()
622 623 self._remote.update(clean=True)
623 624 raise
624 625 else:
625 626 try:
626 627 self._remote.merge(source_ref.commit_id)
627 628 self._remote.invalidate_vcs_cache()
628 629 self._remote.commit(
629 630 message=safe_str(merge_message),
630 631 username=safe_str('%s <%s>' % (user_name, user_email)))
631 632 self._remote.invalidate_vcs_cache()
632 633 return self._identify(), True
633 634 except RepositoryError:
634 635 # Cleanup any merge leftovers
635 636 self._remote.update(clean=True)
636 637 raise
637 638
638 639 def _is_the_same_branch(self, target_ref, source_ref):
639 640 return (
640 641 self._get_branch_name(target_ref) ==
641 642 self._get_branch_name(source_ref))
642 643
643 644 def _get_branch_name(self, ref):
644 645 if ref.type == 'branch':
645 646 return ref.name
646 647 return self._remote.ctx_branch(ref.commit_id)
647 648
648 649 def _get_shadow_repository_path(self, workspace_id):
649 650 # The name of the shadow repository must start with '.', so it is
650 651 # skipped by 'rhodecode.lib.utils.get_filesystem_repos'.
651 652 return os.path.join(
652 653 os.path.dirname(self.path),
653 654 '.__shadow_%s_%s' % (os.path.basename(self.path), workspace_id))
654 655
655 656 def _maybe_prepare_merge_workspace(self, workspace_id, unused_target_ref):
656 657 shadow_repository_path = self._get_shadow_repository_path(workspace_id)
657 658 if not os.path.exists(shadow_repository_path):
658 659 self._local_clone(shadow_repository_path)
659 660 log.debug(
660 661 'Prepared shadow repository in %s', shadow_repository_path)
661 662
662 663 return shadow_repository_path
663 664
664 665 def cleanup_merge_workspace(self, workspace_id):
665 666 shadow_repository_path = self._get_shadow_repository_path(workspace_id)
666 667 shutil.rmtree(shadow_repository_path, ignore_errors=True)
667 668
668 669 def _merge_repo(self, shadow_repository_path, target_ref,
669 670 source_repo, source_ref, merge_message,
670 671 merger_name, merger_email, dry_run=False,
671 672 use_rebase=False):
672 673 if target_ref.commit_id not in self._heads():
673 674 return MergeResponse(
674 675 False, False, None, MergeFailureReason.TARGET_IS_NOT_HEAD)
675 676
676 677 try:
677 678 if (target_ref.type == 'branch' and
678 679 len(self._heads(target_ref.name)) != 1):
679 680 return MergeResponse(
680 681 False, False, None,
681 682 MergeFailureReason.HG_TARGET_HAS_MULTIPLE_HEADS)
682 683 except CommitDoesNotExistError as e:
683 684 log.exception('Failure when looking up branch heads on hg target')
684 685 return MergeResponse(
685 686 False, False, None, MergeFailureReason.MISSING_TARGET_REF)
686 687
687 688 shadow_repo = self._get_shadow_instance(shadow_repository_path)
688 689
689 690 log.debug('Pulling in target reference %s', target_ref)
690 691 self._validate_pull_reference(target_ref)
691 692 shadow_repo._local_pull(self.path, target_ref)
692 693 try:
693 694 log.debug('Pulling in source reference %s', source_ref)
694 695 source_repo._validate_pull_reference(source_ref)
695 696 shadow_repo._local_pull(source_repo.path, source_ref)
696 697 except CommitDoesNotExistError:
697 698 log.exception('Failure when doing local pull on hg shadow repo')
698 699 return MergeResponse(
699 700 False, False, None, MergeFailureReason.MISSING_SOURCE_REF)
700 701
701 702 merge_ref = None
702 703 merge_failure_reason = MergeFailureReason.NONE
703 704
704 705 try:
705 706 merge_commit_id, needs_push = shadow_repo._local_merge(
706 707 target_ref, merge_message, merger_name, merger_email,
707 708 source_ref, use_rebase=use_rebase)
708 709 merge_possible = True
709 710
710 711 # Set a bookmark pointing to the merge commit. This bookmark may be
711 712 # used to easily identify the last successful merge commit in the
712 713 # shadow repository.
713 714 shadow_repo.bookmark('pr-merge', revision=merge_commit_id)
714 715 merge_ref = Reference('book', 'pr-merge', merge_commit_id)
715 716 except SubrepoMergeError:
716 717 log.exception(
717 718 'Subrepo merge error during local merge on hg shadow repo.')
718 719 merge_possible = False
719 720 merge_failure_reason = MergeFailureReason.SUBREPO_MERGE_FAILED
720 721 except RepositoryError:
721 722 log.exception('Failure when doing local merge on hg shadow repo')
722 723 merge_possible = False
723 724 merge_failure_reason = MergeFailureReason.MERGE_FAILED
724 725
725 726 if merge_possible and not dry_run:
726 727 if needs_push:
727 728 # In case the target is a bookmark, update it, so after pushing
728 729 # the bookmarks is also updated in the target.
729 730 if target_ref.type == 'book':
730 731 shadow_repo.bookmark(
731 732 target_ref.name, revision=merge_commit_id)
732 733
733 734 try:
734 735 shadow_repo_with_hooks = self._get_shadow_instance(
735 736 shadow_repository_path,
736 737 enable_hooks=True)
737 738 # Note: the push_branches option will push any new branch
738 739 # defined in the source repository to the target. This may
739 740 # be dangerous as branches are permanent in Mercurial.
740 741 # This feature was requested in issue #441.
741 742 shadow_repo_with_hooks._local_push(
742 743 merge_commit_id, self.path, push_branches=True,
743 744 enable_hooks=True)
744 745 merge_succeeded = True
745 746 except RepositoryError:
746 747 log.exception(
747 748 'Failure when doing local push from the shadow '
748 749 'repository to the target repository.')
749 750 merge_succeeded = False
750 751 merge_failure_reason = MergeFailureReason.PUSH_FAILED
751 752 else:
752 753 merge_succeeded = True
753 754 else:
754 755 merge_succeeded = False
755 756
756 757 return MergeResponse(
757 758 merge_possible, merge_succeeded, merge_ref, merge_failure_reason)
758 759
759 760 def _get_shadow_instance(
760 761 self, shadow_repository_path, enable_hooks=False):
761 762 config = self.config.copy()
762 763 if not enable_hooks:
763 764 config.clear_section('hooks')
764 765 return MercurialRepository(shadow_repository_path, config)
765 766
766 767 def _validate_pull_reference(self, reference):
767 768 if not (reference.name in self.bookmarks or
768 769 reference.name in self.branches or
769 770 self.get_commit(reference.commit_id)):
770 771 raise CommitDoesNotExistError(
771 772 'Unknown branch, bookmark or commit id')
772 773
773 774 def _local_pull(self, repository_path, reference):
774 775 """
775 776 Fetch a branch, bookmark or commit from a local repository.
776 777 """
777 778 repository_path = os.path.abspath(repository_path)
778 779 if repository_path == self.path:
779 780 raise ValueError('Cannot pull from the same repository')
780 781
781 782 reference_type_to_option_name = {
782 783 'book': 'bookmark',
783 784 'branch': 'branch',
784 785 }
785 786 option_name = reference_type_to_option_name.get(
786 787 reference.type, 'revision')
787 788
788 789 if option_name == 'revision':
789 790 ref = reference.commit_id
790 791 else:
791 792 ref = reference.name
792 793
793 794 options = {option_name: [ref]}
794 795 self._remote.pull_cmd(repository_path, hooks=False, **options)
795 796 self._remote.invalidate_vcs_cache()
796 797
797 798 def bookmark(self, bookmark, revision=None):
798 799 if isinstance(bookmark, unicode):
799 800 bookmark = safe_str(bookmark)
800 801 self._remote.bookmark(bookmark, revision=revision)
801 802 self._remote.invalidate_vcs_cache()
802 803
803 804
804 805 class MercurialIndexBasedCollectionGenerator(CollectionGenerator):
805 806
806 807 def _commit_factory(self, commit_id):
807 808 return self.repo.get_commit(
808 809 commit_idx=commit_id, pre_load=self.pre_load)
General Comments 0
You need to be logged in to leave comments. Login now