##// END OF EJS Templates
vcs: Return merge reference in merge response....
Martin Bornhold -
r1051:668a5e47 default
parent child Browse files
Show More
@@ -1,1508 +1,1508 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2014-2016 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 Base module for all VCS systems
23 23 """
24 24
25 25 import collections
26 26 import datetime
27 27 import itertools
28 28 import logging
29 29 import os
30 30 import time
31 31 import warnings
32 32
33 33 from zope.cachedescriptors.property import Lazy as LazyProperty
34 34
35 35 from rhodecode.lib.utils2 import safe_str, safe_unicode
36 36 from rhodecode.lib.vcs import connection
37 37 from rhodecode.lib.vcs.utils import author_name, author_email
38 38 from rhodecode.lib.vcs.conf import settings
39 39 from rhodecode.lib.vcs.exceptions import (
40 40 CommitError, EmptyRepositoryError, NodeAlreadyAddedError,
41 41 NodeAlreadyChangedError, NodeAlreadyExistsError, NodeAlreadyRemovedError,
42 42 NodeDoesNotExistError, NodeNotChangedError, VCSError,
43 43 ImproperArchiveTypeError, BranchDoesNotExistError, CommitDoesNotExistError,
44 44 RepositoryError)
45 45
46 46
47 47 log = logging.getLogger(__name__)
48 48
49 49
50 50 FILEMODE_DEFAULT = 0100644
51 51 FILEMODE_EXECUTABLE = 0100755
52 52
53 53 Reference = collections.namedtuple('Reference', ('type', 'name', 'commit_id'))
54 54 MergeResponse = collections.namedtuple(
55 55 'MergeResponse',
56 ('possible', 'executed', 'merge_commit_id', 'failure_reason'))
56 ('possible', 'executed', 'merge_ref', 'failure_reason'))
57 57
58 58
59 59 class MergeFailureReason(object):
60 60 """
61 61 Enumeration with all the reasons why the server side merge could fail.
62 62
63 63 DO NOT change the number of the reasons, as they may be stored in the
64 64 database.
65 65
66 66 Changing the name of a reason is acceptable and encouraged to deprecate old
67 67 reasons.
68 68 """
69 69
70 70 # Everything went well.
71 71 NONE = 0
72 72
73 73 # An unexpected exception was raised. Check the logs for more details.
74 74 UNKNOWN = 1
75 75
76 76 # The merge was not successful, there are conflicts.
77 77 MERGE_FAILED = 2
78 78
79 79 # The merge succeeded but we could not push it to the target repository.
80 80 PUSH_FAILED = 3
81 81
82 82 # The specified target is not a head in the target repository.
83 83 TARGET_IS_NOT_HEAD = 4
84 84
85 85 # The source repository contains more branches than the target. Pushing
86 86 # the merge will create additional branches in the target.
87 87 HG_SOURCE_HAS_MORE_BRANCHES = 5
88 88
89 89 # The target reference has multiple heads. That does not allow to correctly
90 90 # identify the target location. This could only happen for mercurial
91 91 # branches.
92 92 HG_TARGET_HAS_MULTIPLE_HEADS = 6
93 93
94 94 # The target repository is locked
95 95 TARGET_IS_LOCKED = 7
96 96
97 97 # A involved commit could not be found.
98 98 MISSING_COMMIT = 8
99 99
100 100
101 101 class BaseRepository(object):
102 102 """
103 103 Base Repository for final backends
104 104
105 105 .. attribute:: DEFAULT_BRANCH_NAME
106 106
107 107 name of default branch (i.e. "trunk" for svn, "master" for git etc.
108 108
109 109 .. attribute:: commit_ids
110 110
111 111 list of all available commit ids, in ascending order
112 112
113 113 .. attribute:: path
114 114
115 115 absolute path to the repository
116 116
117 117 .. attribute:: bookmarks
118 118
119 119 Mapping from name to :term:`Commit ID` of the bookmark. Empty in case
120 120 there are no bookmarks or the backend implementation does not support
121 121 bookmarks.
122 122
123 123 .. attribute:: tags
124 124
125 125 Mapping from name to :term:`Commit ID` of the tag.
126 126
127 127 """
128 128
129 129 DEFAULT_BRANCH_NAME = None
130 130 DEFAULT_CONTACT = u"Unknown"
131 131 DEFAULT_DESCRIPTION = u"unknown"
132 132 EMPTY_COMMIT_ID = '0' * 40
133 133
134 134 path = None
135 135
136 136 def __init__(self, repo_path, config=None, create=False, **kwargs):
137 137 """
138 138 Initializes repository. Raises RepositoryError if repository could
139 139 not be find at the given ``repo_path`` or directory at ``repo_path``
140 140 exists and ``create`` is set to True.
141 141
142 142 :param repo_path: local path of the repository
143 143 :param config: repository configuration
144 144 :param create=False: if set to True, would try to create repository.
145 145 :param src_url=None: if set, should be proper url from which repository
146 146 would be cloned; requires ``create`` parameter to be set to True -
147 147 raises RepositoryError if src_url is set and create evaluates to
148 148 False
149 149 """
150 150 raise NotImplementedError
151 151
152 152 def __repr__(self):
153 153 return '<%s at %s>' % (self.__class__.__name__, self.path)
154 154
155 155 def __len__(self):
156 156 return self.count()
157 157
158 158 def __eq__(self, other):
159 159 same_instance = isinstance(other, self.__class__)
160 160 return same_instance and other.path == self.path
161 161
162 162 def __ne__(self, other):
163 163 return not self.__eq__(other)
164 164
165 165 @LazyProperty
166 166 def EMPTY_COMMIT(self):
167 167 return EmptyCommit(self.EMPTY_COMMIT_ID)
168 168
169 169 @LazyProperty
170 170 def alias(self):
171 171 for k, v in settings.BACKENDS.items():
172 172 if v.split('.')[-1] == str(self.__class__.__name__):
173 173 return k
174 174
175 175 @LazyProperty
176 176 def name(self):
177 177 return safe_unicode(os.path.basename(self.path))
178 178
179 179 @LazyProperty
180 180 def description(self):
181 181 raise NotImplementedError
182 182
183 183 def refs(self):
184 184 """
185 185 returns a `dict` with branches, bookmarks, tags, and closed_branches
186 186 for this repository
187 187 """
188 188 raise NotImplementedError
189 189
190 190 @LazyProperty
191 191 def branches(self):
192 192 """
193 193 A `dict` which maps branch names to commit ids.
194 194 """
195 195 raise NotImplementedError
196 196
197 197 @LazyProperty
198 198 def size(self):
199 199 """
200 200 Returns combined size in bytes for all repository files
201 201 """
202 202 tip = self.get_commit()
203 203 return tip.size
204 204
205 205 def size_at_commit(self, commit_id):
206 206 commit = self.get_commit(commit_id)
207 207 return commit.size
208 208
209 209 def is_empty(self):
210 210 return not bool(self.commit_ids)
211 211
212 212 @staticmethod
213 213 def check_url(url, config):
214 214 """
215 215 Function will check given url and try to verify if it's a valid
216 216 link.
217 217 """
218 218 raise NotImplementedError
219 219
220 220 @staticmethod
221 221 def is_valid_repository(path):
222 222 """
223 223 Check if given `path` contains a valid repository of this backend
224 224 """
225 225 raise NotImplementedError
226 226
227 227 # ==========================================================================
228 228 # COMMITS
229 229 # ==========================================================================
230 230
231 231 def get_commit(self, commit_id=None, commit_idx=None, pre_load=None):
232 232 """
233 233 Returns instance of `BaseCommit` class. If `commit_id` and `commit_idx`
234 234 are both None, most recent commit is returned.
235 235
236 236 :param pre_load: Optional. List of commit attributes to load.
237 237
238 238 :raises ``EmptyRepositoryError``: if there are no commits
239 239 """
240 240 raise NotImplementedError
241 241
242 242 def __iter__(self):
243 243 for commit_id in self.commit_ids:
244 244 yield self.get_commit(commit_id=commit_id)
245 245
246 246 def get_commits(
247 247 self, start_id=None, end_id=None, start_date=None, end_date=None,
248 248 branch_name=None, pre_load=None):
249 249 """
250 250 Returns iterator of `BaseCommit` objects from start to end
251 251 not inclusive. This should behave just like a list, ie. end is not
252 252 inclusive.
253 253
254 254 :param start_id: None or str, must be a valid commit id
255 255 :param end_id: None or str, must be a valid commit id
256 256 :param start_date:
257 257 :param end_date:
258 258 :param branch_name:
259 259 :param pre_load:
260 260 """
261 261 raise NotImplementedError
262 262
263 263 def __getitem__(self, key):
264 264 """
265 265 Allows index based access to the commit objects of this repository.
266 266 """
267 267 pre_load = ["author", "branch", "date", "message", "parents"]
268 268 if isinstance(key, slice):
269 269 return self._get_range(key, pre_load)
270 270 return self.get_commit(commit_idx=key, pre_load=pre_load)
271 271
272 272 def _get_range(self, slice_obj, pre_load):
273 273 for commit_id in self.commit_ids.__getitem__(slice_obj):
274 274 yield self.get_commit(commit_id=commit_id, pre_load=pre_load)
275 275
276 276 def count(self):
277 277 return len(self.commit_ids)
278 278
279 279 def tag(self, name, user, commit_id=None, message=None, date=None, **opts):
280 280 """
281 281 Creates and returns a tag for the given ``commit_id``.
282 282
283 283 :param name: name for new tag
284 284 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
285 285 :param commit_id: commit id for which new tag would be created
286 286 :param message: message of the tag's commit
287 287 :param date: date of tag's commit
288 288
289 289 :raises TagAlreadyExistError: if tag with same name already exists
290 290 """
291 291 raise NotImplementedError
292 292
293 293 def remove_tag(self, name, user, message=None, date=None):
294 294 """
295 295 Removes tag with the given ``name``.
296 296
297 297 :param name: name of the tag to be removed
298 298 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
299 299 :param message: message of the tag's removal commit
300 300 :param date: date of tag's removal commit
301 301
302 302 :raises TagDoesNotExistError: if tag with given name does not exists
303 303 """
304 304 raise NotImplementedError
305 305
306 306 def get_diff(
307 307 self, commit1, commit2, path=None, ignore_whitespace=False,
308 308 context=3, path1=None):
309 309 """
310 310 Returns (git like) *diff*, as plain text. Shows changes introduced by
311 311 `commit2` since `commit1`.
312 312
313 313 :param commit1: Entry point from which diff is shown. Can be
314 314 ``self.EMPTY_COMMIT`` - in this case, patch showing all
315 315 the changes since empty state of the repository until `commit2`
316 316 :param commit2: Until which commit changes should be shown.
317 317 :param path: Can be set to a path of a file to create a diff of that
318 318 file. If `path1` is also set, this value is only associated to
319 319 `commit2`.
320 320 :param ignore_whitespace: If set to ``True``, would not show whitespace
321 321 changes. Defaults to ``False``.
322 322 :param context: How many lines before/after changed lines should be
323 323 shown. Defaults to ``3``.
324 324 :param path1: Can be set to a path to associate with `commit1`. This
325 325 parameter works only for backends which support diff generation for
326 326 different paths. Other backends will raise a `ValueError` if `path1`
327 327 is set and has a different value than `path`.
328 328 """
329 329 raise NotImplementedError
330 330
331 331 def strip(self, commit_id, branch=None):
332 332 """
333 333 Strip given commit_id from the repository
334 334 """
335 335 raise NotImplementedError
336 336
337 337 def get_common_ancestor(self, commit_id1, commit_id2, repo2):
338 338 """
339 339 Return a latest common ancestor commit if one exists for this repo
340 340 `commit_id1` vs `commit_id2` from `repo2`.
341 341
342 342 :param commit_id1: Commit it from this repository to use as a
343 343 target for the comparison.
344 344 :param commit_id2: Source commit id to use for comparison.
345 345 :param repo2: Source repository to use for comparison.
346 346 """
347 347 raise NotImplementedError
348 348
349 349 def compare(self, commit_id1, commit_id2, repo2, merge, pre_load=None):
350 350 """
351 351 Compare this repository's revision `commit_id1` with `commit_id2`.
352 352
353 353 Returns a tuple(commits, ancestor) that would be merged from
354 354 `commit_id2`. Doing a normal compare (``merge=False``), ``None``
355 355 will be returned as ancestor.
356 356
357 357 :param commit_id1: Commit it from this repository to use as a
358 358 target for the comparison.
359 359 :param commit_id2: Source commit id to use for comparison.
360 360 :param repo2: Source repository to use for comparison.
361 361 :param merge: If set to ``True`` will do a merge compare which also
362 362 returns the common ancestor.
363 363 :param pre_load: Optional. List of commit attributes to load.
364 364 """
365 365 raise NotImplementedError
366 366
367 367 def merge(self, target_ref, source_repo, source_ref, workspace_id,
368 368 user_name='', user_email='', message='', dry_run=False,
369 369 use_rebase=False):
370 370 """
371 371 Merge the revisions specified in `source_ref` from `source_repo`
372 372 onto the `target_ref` of this repository.
373 373
374 374 `source_ref` and `target_ref` are named tupls with the following
375 375 fields `type`, `name` and `commit_id`.
376 376
377 377 Returns a MergeResponse named tuple with the following fields
378 378 'possible', 'executed', 'source_commit', 'target_commit',
379 379 'merge_commit'.
380 380
381 381 :param target_ref: `target_ref` points to the commit on top of which
382 382 the `source_ref` should be merged.
383 383 :param source_repo: The repository that contains the commits to be
384 384 merged.
385 385 :param source_ref: `source_ref` points to the topmost commit from
386 386 the `source_repo` which should be merged.
387 387 :param workspace_id: `workspace_id` unique identifier.
388 388 :param user_name: Merge commit `user_name`.
389 389 :param user_email: Merge commit `user_email`.
390 390 :param message: Merge commit `message`.
391 391 :param dry_run: If `True` the merge will not take place.
392 392 :param use_rebase: If `True` commits from the source will be rebased
393 393 on top of the target instead of being merged.
394 394 """
395 395 if dry_run:
396 396 message = message or 'dry_run_merge_message'
397 397 user_email = user_email or 'dry-run-merge@rhodecode.com'
398 398 user_name = user_name or 'Dry-Run User'
399 399 else:
400 400 if not user_name:
401 401 raise ValueError('user_name cannot be empty')
402 402 if not user_email:
403 403 raise ValueError('user_email cannot be empty')
404 404 if not message:
405 405 raise ValueError('message cannot be empty')
406 406
407 407 shadow_repository_path = self._maybe_prepare_merge_workspace(
408 408 workspace_id, target_ref)
409 409
410 410 try:
411 411 return self._merge_repo(
412 412 shadow_repository_path, target_ref, source_repo,
413 413 source_ref, message, user_name, user_email, dry_run=dry_run,
414 414 use_rebase=use_rebase)
415 415 except RepositoryError:
416 416 log.exception(
417 417 'Unexpected failure when running merge, dry-run=%s',
418 418 dry_run)
419 419 return MergeResponse(
420 420 False, False, None, MergeFailureReason.UNKNOWN)
421 421
422 422 def _merge_repo(self, shadow_repository_path, target_ref,
423 423 source_repo, source_ref, merge_message,
424 424 merger_name, merger_email, dry_run=False, use_rebase=False):
425 425 """Internal implementation of merge."""
426 426 raise NotImplementedError
427 427
428 428 def _maybe_prepare_merge_workspace(self, workspace_id, target_ref):
429 429 """
430 430 Create the merge workspace.
431 431
432 432 :param workspace_id: `workspace_id` unique identifier.
433 433 """
434 434 raise NotImplementedError
435 435
436 436 def cleanup_merge_workspace(self, workspace_id):
437 437 """
438 438 Remove merge workspace.
439 439
440 440 This function MUST not fail in case there is no workspace associated to
441 441 the given `workspace_id`.
442 442
443 443 :param workspace_id: `workspace_id` unique identifier.
444 444 """
445 445 raise NotImplementedError
446 446
447 447 # ========== #
448 448 # COMMIT API #
449 449 # ========== #
450 450
451 451 @LazyProperty
452 452 def in_memory_commit(self):
453 453 """
454 454 Returns :class:`InMemoryCommit` object for this repository.
455 455 """
456 456 raise NotImplementedError
457 457
458 458 # ======================== #
459 459 # UTILITIES FOR SUBCLASSES #
460 460 # ======================== #
461 461
462 462 def _validate_diff_commits(self, commit1, commit2):
463 463 """
464 464 Validates that the given commits are related to this repository.
465 465
466 466 Intended as a utility for sub classes to have a consistent validation
467 467 of input parameters in methods like :meth:`get_diff`.
468 468 """
469 469 self._validate_commit(commit1)
470 470 self._validate_commit(commit2)
471 471 if (isinstance(commit1, EmptyCommit) and
472 472 isinstance(commit2, EmptyCommit)):
473 473 raise ValueError("Cannot compare two empty commits")
474 474
475 475 def _validate_commit(self, commit):
476 476 if not isinstance(commit, BaseCommit):
477 477 raise TypeError(
478 478 "%s is not of type BaseCommit" % repr(commit))
479 479 if commit.repository != self and not isinstance(commit, EmptyCommit):
480 480 raise ValueError(
481 481 "Commit %s must be a valid commit from this repository %s, "
482 482 "related to this repository instead %s." %
483 483 (commit, self, commit.repository))
484 484
485 485 def _validate_commit_id(self, commit_id):
486 486 if not isinstance(commit_id, basestring):
487 487 raise TypeError("commit_id must be a string value")
488 488
489 489 def _validate_commit_idx(self, commit_idx):
490 490 if not isinstance(commit_idx, (int, long)):
491 491 raise TypeError("commit_idx must be a numeric value")
492 492
493 493 def _validate_branch_name(self, branch_name):
494 494 if branch_name and branch_name not in self.branches_all:
495 495 msg = ("Branch %s not found in %s" % (branch_name, self))
496 496 raise BranchDoesNotExistError(msg)
497 497
498 498 #
499 499 # Supporting deprecated API parts
500 500 # TODO: johbo: consider to move this into a mixin
501 501 #
502 502
503 503 @property
504 504 def EMPTY_CHANGESET(self):
505 505 warnings.warn(
506 506 "Use EMPTY_COMMIT or EMPTY_COMMIT_ID instead", DeprecationWarning)
507 507 return self.EMPTY_COMMIT_ID
508 508
509 509 @property
510 510 def revisions(self):
511 511 warnings.warn("Use commits attribute instead", DeprecationWarning)
512 512 return self.commit_ids
513 513
514 514 @revisions.setter
515 515 def revisions(self, value):
516 516 warnings.warn("Use commits attribute instead", DeprecationWarning)
517 517 self.commit_ids = value
518 518
519 519 def get_changeset(self, revision=None, pre_load=None):
520 520 warnings.warn("Use get_commit instead", DeprecationWarning)
521 521 commit_id = None
522 522 commit_idx = None
523 523 if isinstance(revision, basestring):
524 524 commit_id = revision
525 525 else:
526 526 commit_idx = revision
527 527 return self.get_commit(
528 528 commit_id=commit_id, commit_idx=commit_idx, pre_load=pre_load)
529 529
530 530 def get_changesets(
531 531 self, start=None, end=None, start_date=None, end_date=None,
532 532 branch_name=None, pre_load=None):
533 533 warnings.warn("Use get_commits instead", DeprecationWarning)
534 534 start_id = self._revision_to_commit(start)
535 535 end_id = self._revision_to_commit(end)
536 536 return self.get_commits(
537 537 start_id=start_id, end_id=end_id, start_date=start_date,
538 538 end_date=end_date, branch_name=branch_name, pre_load=pre_load)
539 539
540 540 def _revision_to_commit(self, revision):
541 541 """
542 542 Translates a revision to a commit_id
543 543
544 544 Helps to support the old changeset based API which allows to use
545 545 commit ids and commit indices interchangeable.
546 546 """
547 547 if revision is None:
548 548 return revision
549 549
550 550 if isinstance(revision, basestring):
551 551 commit_id = revision
552 552 else:
553 553 commit_id = self.commit_ids[revision]
554 554 return commit_id
555 555
556 556 @property
557 557 def in_memory_changeset(self):
558 558 warnings.warn("Use in_memory_commit instead", DeprecationWarning)
559 559 return self.in_memory_commit
560 560
561 561
562 562 class BaseCommit(object):
563 563 """
564 564 Each backend should implement it's commit representation.
565 565
566 566 **Attributes**
567 567
568 568 ``repository``
569 569 repository object within which commit exists
570 570
571 571 ``id``
572 572 The commit id, may be ``raw_id`` or i.e. for mercurial's tip
573 573 just ``tip``.
574 574
575 575 ``raw_id``
576 576 raw commit representation (i.e. full 40 length sha for git
577 577 backend)
578 578
579 579 ``short_id``
580 580 shortened (if apply) version of ``raw_id``; it would be simple
581 581 shortcut for ``raw_id[:12]`` for git/mercurial backends or same
582 582 as ``raw_id`` for subversion
583 583
584 584 ``idx``
585 585 commit index
586 586
587 587 ``files``
588 588 list of ``FileNode`` (``Node`` with NodeKind.FILE) objects
589 589
590 590 ``dirs``
591 591 list of ``DirNode`` (``Node`` with NodeKind.DIR) objects
592 592
593 593 ``nodes``
594 594 combined list of ``Node`` objects
595 595
596 596 ``author``
597 597 author of the commit, as unicode
598 598
599 599 ``message``
600 600 message of the commit, as unicode
601 601
602 602 ``parents``
603 603 list of parent commits
604 604
605 605 """
606 606
607 607 branch = None
608 608 """
609 609 Depending on the backend this should be set to the branch name of the
610 610 commit. Backends not supporting branches on commits should leave this
611 611 value as ``None``.
612 612 """
613 613
614 614 _ARCHIVE_PREFIX_TEMPLATE = b'{repo_name}-{short_id}'
615 615 """
616 616 This template is used to generate a default prefix for repository archives
617 617 if no prefix has been specified.
618 618 """
619 619
620 620 def __str__(self):
621 621 return '<%s at %s:%s>' % (
622 622 self.__class__.__name__, self.idx, self.short_id)
623 623
624 624 def __repr__(self):
625 625 return self.__str__()
626 626
627 627 def __unicode__(self):
628 628 return u'%s:%s' % (self.idx, self.short_id)
629 629
630 630 def __eq__(self, other):
631 631 same_instance = isinstance(other, self.__class__)
632 632 return same_instance and self.raw_id == other.raw_id
633 633
634 634 def __json__(self):
635 635 parents = []
636 636 try:
637 637 for parent in self.parents:
638 638 parents.append({'raw_id': parent.raw_id})
639 639 except NotImplementedError:
640 640 # empty commit doesn't have parents implemented
641 641 pass
642 642
643 643 return {
644 644 'short_id': self.short_id,
645 645 'raw_id': self.raw_id,
646 646 'revision': self.idx,
647 647 'message': self.message,
648 648 'date': self.date,
649 649 'author': self.author,
650 650 'parents': parents,
651 651 'branch': self.branch
652 652 }
653 653
654 654 @LazyProperty
655 655 def last(self):
656 656 """
657 657 ``True`` if this is last commit in repository, ``False``
658 658 otherwise; trying to access this attribute while there is no
659 659 commits would raise `EmptyRepositoryError`
660 660 """
661 661 if self.repository is None:
662 662 raise CommitError("Cannot check if it's most recent commit")
663 663 return self.raw_id == self.repository.commit_ids[-1]
664 664
665 665 @LazyProperty
666 666 def parents(self):
667 667 """
668 668 Returns list of parent commits.
669 669 """
670 670 raise NotImplementedError
671 671
672 672 @property
673 673 def merge(self):
674 674 """
675 675 Returns boolean if commit is a merge.
676 676 """
677 677 return len(self.parents) > 1
678 678
679 679 @LazyProperty
680 680 def children(self):
681 681 """
682 682 Returns list of child commits.
683 683 """
684 684 raise NotImplementedError
685 685
686 686 @LazyProperty
687 687 def id(self):
688 688 """
689 689 Returns string identifying this commit.
690 690 """
691 691 raise NotImplementedError
692 692
693 693 @LazyProperty
694 694 def raw_id(self):
695 695 """
696 696 Returns raw string identifying this commit.
697 697 """
698 698 raise NotImplementedError
699 699
700 700 @LazyProperty
701 701 def short_id(self):
702 702 """
703 703 Returns shortened version of ``raw_id`` attribute, as string,
704 704 identifying this commit, useful for presentation to users.
705 705 """
706 706 raise NotImplementedError
707 707
708 708 @LazyProperty
709 709 def idx(self):
710 710 """
711 711 Returns integer identifying this commit.
712 712 """
713 713 raise NotImplementedError
714 714
715 715 @LazyProperty
716 716 def committer(self):
717 717 """
718 718 Returns committer for this commit
719 719 """
720 720 raise NotImplementedError
721 721
722 722 @LazyProperty
723 723 def committer_name(self):
724 724 """
725 725 Returns committer name for this commit
726 726 """
727 727
728 728 return author_name(self.committer)
729 729
730 730 @LazyProperty
731 731 def committer_email(self):
732 732 """
733 733 Returns committer email address for this commit
734 734 """
735 735
736 736 return author_email(self.committer)
737 737
738 738 @LazyProperty
739 739 def author(self):
740 740 """
741 741 Returns author for this commit
742 742 """
743 743
744 744 raise NotImplementedError
745 745
746 746 @LazyProperty
747 747 def author_name(self):
748 748 """
749 749 Returns author name for this commit
750 750 """
751 751
752 752 return author_name(self.author)
753 753
754 754 @LazyProperty
755 755 def author_email(self):
756 756 """
757 757 Returns author email address for this commit
758 758 """
759 759
760 760 return author_email(self.author)
761 761
762 762 def get_file_mode(self, path):
763 763 """
764 764 Returns stat mode of the file at `path`.
765 765 """
766 766 raise NotImplementedError
767 767
768 768 def is_link(self, path):
769 769 """
770 770 Returns ``True`` if given `path` is a symlink
771 771 """
772 772 raise NotImplementedError
773 773
774 774 def get_file_content(self, path):
775 775 """
776 776 Returns content of the file at the given `path`.
777 777 """
778 778 raise NotImplementedError
779 779
780 780 def get_file_size(self, path):
781 781 """
782 782 Returns size of the file at the given `path`.
783 783 """
784 784 raise NotImplementedError
785 785
786 786 def get_file_commit(self, path, pre_load=None):
787 787 """
788 788 Returns last commit of the file at the given `path`.
789 789
790 790 :param pre_load: Optional. List of commit attributes to load.
791 791 """
792 792 return self.get_file_history(path, limit=1, pre_load=pre_load)[0]
793 793
794 794 def get_file_history(self, path, limit=None, pre_load=None):
795 795 """
796 796 Returns history of file as reversed list of :class:`BaseCommit`
797 797 objects for which file at given `path` has been modified.
798 798
799 799 :param limit: Optional. Allows to limit the size of the returned
800 800 history. This is intended as a hint to the underlying backend, so
801 801 that it can apply optimizations depending on the limit.
802 802 :param pre_load: Optional. List of commit attributes to load.
803 803 """
804 804 raise NotImplementedError
805 805
806 806 def get_file_annotate(self, path, pre_load=None):
807 807 """
808 808 Returns a generator of four element tuples with
809 809 lineno, sha, commit lazy loader and line
810 810
811 811 :param pre_load: Optional. List of commit attributes to load.
812 812 """
813 813 raise NotImplementedError
814 814
815 815 def get_nodes(self, path):
816 816 """
817 817 Returns combined ``DirNode`` and ``FileNode`` objects list representing
818 818 state of commit at the given ``path``.
819 819
820 820 :raises ``CommitError``: if node at the given ``path`` is not
821 821 instance of ``DirNode``
822 822 """
823 823 raise NotImplementedError
824 824
825 825 def get_node(self, path):
826 826 """
827 827 Returns ``Node`` object from the given ``path``.
828 828
829 829 :raises ``NodeDoesNotExistError``: if there is no node at the given
830 830 ``path``
831 831 """
832 832 raise NotImplementedError
833 833
834 834 def get_largefile_node(self, path):
835 835 """
836 836 Returns the path to largefile from Mercurial storage.
837 837 """
838 838 raise NotImplementedError
839 839
840 840 def archive_repo(self, file_path, kind='tgz', subrepos=None,
841 841 prefix=None, write_metadata=False, mtime=None):
842 842 """
843 843 Creates an archive containing the contents of the repository.
844 844
845 845 :param file_path: path to the file which to create the archive.
846 846 :param kind: one of following: ``"tbz2"``, ``"tgz"``, ``"zip"``.
847 847 :param prefix: name of root directory in archive.
848 848 Default is repository name and commit's short_id joined with dash:
849 849 ``"{repo_name}-{short_id}"``.
850 850 :param write_metadata: write a metadata file into archive.
851 851 :param mtime: custom modification time for archive creation, defaults
852 852 to time.time() if not given.
853 853
854 854 :raise VCSError: If prefix has a problem.
855 855 """
856 856 allowed_kinds = settings.ARCHIVE_SPECS.keys()
857 857 if kind not in allowed_kinds:
858 858 raise ImproperArchiveTypeError(
859 859 'Archive kind (%s) not supported use one of %s' %
860 860 (kind, allowed_kinds))
861 861
862 862 prefix = self._validate_archive_prefix(prefix)
863 863
864 864 mtime = mtime or time.mktime(self.date.timetuple())
865 865
866 866 file_info = []
867 867 cur_rev = self.repository.get_commit(commit_id=self.raw_id)
868 868 for _r, _d, files in cur_rev.walk('/'):
869 869 for f in files:
870 870 f_path = os.path.join(prefix, f.path)
871 871 file_info.append(
872 872 (f_path, f.mode, f.is_link(), f.raw_bytes))
873 873
874 874 if write_metadata:
875 875 metadata = [
876 876 ('repo_name', self.repository.name),
877 877 ('rev', self.raw_id),
878 878 ('create_time', mtime),
879 879 ('branch', self.branch),
880 880 ('tags', ','.join(self.tags)),
881 881 ]
882 882 meta = ["%s:%s" % (f_name, value) for f_name, value in metadata]
883 883 file_info.append(('.archival.txt', 0644, False, '\n'.join(meta)))
884 884
885 885 connection.Hg.archive_repo(file_path, mtime, file_info, kind)
886 886
887 887 def _validate_archive_prefix(self, prefix):
888 888 if prefix is None:
889 889 prefix = self._ARCHIVE_PREFIX_TEMPLATE.format(
890 890 repo_name=safe_str(self.repository.name),
891 891 short_id=self.short_id)
892 892 elif not isinstance(prefix, str):
893 893 raise ValueError("prefix not a bytes object: %s" % repr(prefix))
894 894 elif prefix.startswith('/'):
895 895 raise VCSError("Prefix cannot start with leading slash")
896 896 elif prefix.strip() == '':
897 897 raise VCSError("Prefix cannot be empty")
898 898 return prefix
899 899
900 900 @LazyProperty
901 901 def root(self):
902 902 """
903 903 Returns ``RootNode`` object for this commit.
904 904 """
905 905 return self.get_node('')
906 906
907 907 def next(self, branch=None):
908 908 """
909 909 Returns next commit from current, if branch is gives it will return
910 910 next commit belonging to this branch
911 911
912 912 :param branch: show commits within the given named branch
913 913 """
914 914 indexes = xrange(self.idx + 1, self.repository.count())
915 915 return self._find_next(indexes, branch)
916 916
917 917 def prev(self, branch=None):
918 918 """
919 919 Returns previous commit from current, if branch is gives it will
920 920 return previous commit belonging to this branch
921 921
922 922 :param branch: show commit within the given named branch
923 923 """
924 924 indexes = xrange(self.idx - 1, -1, -1)
925 925 return self._find_next(indexes, branch)
926 926
927 927 def _find_next(self, indexes, branch=None):
928 928 if branch and self.branch != branch:
929 929 raise VCSError('Branch option used on commit not belonging '
930 930 'to that branch')
931 931
932 932 for next_idx in indexes:
933 933 commit = self.repository.get_commit(commit_idx=next_idx)
934 934 if branch and branch != commit.branch:
935 935 continue
936 936 return commit
937 937 raise CommitDoesNotExistError
938 938
939 939 def diff(self, ignore_whitespace=True, context=3):
940 940 """
941 941 Returns a `Diff` object representing the change made by this commit.
942 942 """
943 943 parent = (
944 944 self.parents[0] if self.parents else self.repository.EMPTY_COMMIT)
945 945 diff = self.repository.get_diff(
946 946 parent, self,
947 947 ignore_whitespace=ignore_whitespace,
948 948 context=context)
949 949 return diff
950 950
951 951 @LazyProperty
952 952 def added(self):
953 953 """
954 954 Returns list of added ``FileNode`` objects.
955 955 """
956 956 raise NotImplementedError
957 957
958 958 @LazyProperty
959 959 def changed(self):
960 960 """
961 961 Returns list of modified ``FileNode`` objects.
962 962 """
963 963 raise NotImplementedError
964 964
965 965 @LazyProperty
966 966 def removed(self):
967 967 """
968 968 Returns list of removed ``FileNode`` objects.
969 969 """
970 970 raise NotImplementedError
971 971
972 972 @LazyProperty
973 973 def size(self):
974 974 """
975 975 Returns total number of bytes from contents of all filenodes.
976 976 """
977 977 return sum((node.size for node in self.get_filenodes_generator()))
978 978
979 979 def walk(self, topurl=''):
980 980 """
981 981 Similar to os.walk method. Insted of filesystem it walks through
982 982 commit starting at given ``topurl``. Returns generator of tuples
983 983 (topnode, dirnodes, filenodes).
984 984 """
985 985 topnode = self.get_node(topurl)
986 986 if not topnode.is_dir():
987 987 return
988 988 yield (topnode, topnode.dirs, topnode.files)
989 989 for dirnode in topnode.dirs:
990 990 for tup in self.walk(dirnode.path):
991 991 yield tup
992 992
993 993 def get_filenodes_generator(self):
994 994 """
995 995 Returns generator that yields *all* file nodes.
996 996 """
997 997 for topnode, dirs, files in self.walk():
998 998 for node in files:
999 999 yield node
1000 1000
1001 1001 #
1002 1002 # Utilities for sub classes to support consistent behavior
1003 1003 #
1004 1004
1005 1005 def no_node_at_path(self, path):
1006 1006 return NodeDoesNotExistError(
1007 1007 "There is no file nor directory at the given path: "
1008 1008 "'%s' at commit %s" % (path, self.short_id))
1009 1009
1010 1010 def _fix_path(self, path):
1011 1011 """
1012 1012 Paths are stored without trailing slash so we need to get rid off it if
1013 1013 needed.
1014 1014 """
1015 1015 return path.rstrip('/')
1016 1016
1017 1017 #
1018 1018 # Deprecated API based on changesets
1019 1019 #
1020 1020
1021 1021 @property
1022 1022 def revision(self):
1023 1023 warnings.warn("Use idx instead", DeprecationWarning)
1024 1024 return self.idx
1025 1025
1026 1026 @revision.setter
1027 1027 def revision(self, value):
1028 1028 warnings.warn("Use idx instead", DeprecationWarning)
1029 1029 self.idx = value
1030 1030
1031 1031 def get_file_changeset(self, path):
1032 1032 warnings.warn("Use get_file_commit instead", DeprecationWarning)
1033 1033 return self.get_file_commit(path)
1034 1034
1035 1035
1036 1036 class BaseChangesetClass(type):
1037 1037
1038 1038 def __instancecheck__(self, instance):
1039 1039 return isinstance(instance, BaseCommit)
1040 1040
1041 1041
1042 1042 class BaseChangeset(BaseCommit):
1043 1043
1044 1044 __metaclass__ = BaseChangesetClass
1045 1045
1046 1046 def __new__(cls, *args, **kwargs):
1047 1047 warnings.warn(
1048 1048 "Use BaseCommit instead of BaseChangeset", DeprecationWarning)
1049 1049 return super(BaseChangeset, cls).__new__(cls, *args, **kwargs)
1050 1050
1051 1051
1052 1052 class BaseInMemoryCommit(object):
1053 1053 """
1054 1054 Represents differences between repository's state (most recent head) and
1055 1055 changes made *in place*.
1056 1056
1057 1057 **Attributes**
1058 1058
1059 1059 ``repository``
1060 1060 repository object for this in-memory-commit
1061 1061
1062 1062 ``added``
1063 1063 list of ``FileNode`` objects marked as *added*
1064 1064
1065 1065 ``changed``
1066 1066 list of ``FileNode`` objects marked as *changed*
1067 1067
1068 1068 ``removed``
1069 1069 list of ``FileNode`` or ``RemovedFileNode`` objects marked to be
1070 1070 *removed*
1071 1071
1072 1072 ``parents``
1073 1073 list of :class:`BaseCommit` instances representing parents of
1074 1074 in-memory commit. Should always be 2-element sequence.
1075 1075
1076 1076 """
1077 1077
1078 1078 def __init__(self, repository):
1079 1079 self.repository = repository
1080 1080 self.added = []
1081 1081 self.changed = []
1082 1082 self.removed = []
1083 1083 self.parents = []
1084 1084
1085 1085 def add(self, *filenodes):
1086 1086 """
1087 1087 Marks given ``FileNode`` objects as *to be committed*.
1088 1088
1089 1089 :raises ``NodeAlreadyExistsError``: if node with same path exists at
1090 1090 latest commit
1091 1091 :raises ``NodeAlreadyAddedError``: if node with same path is already
1092 1092 marked as *added*
1093 1093 """
1094 1094 # Check if not already marked as *added* first
1095 1095 for node in filenodes:
1096 1096 if node.path in (n.path for n in self.added):
1097 1097 raise NodeAlreadyAddedError(
1098 1098 "Such FileNode %s is already marked for addition"
1099 1099 % node.path)
1100 1100 for node in filenodes:
1101 1101 self.added.append(node)
1102 1102
1103 1103 def change(self, *filenodes):
1104 1104 """
1105 1105 Marks given ``FileNode`` objects to be *changed* in next commit.
1106 1106
1107 1107 :raises ``EmptyRepositoryError``: if there are no commits yet
1108 1108 :raises ``NodeAlreadyExistsError``: if node with same path is already
1109 1109 marked to be *changed*
1110 1110 :raises ``NodeAlreadyRemovedError``: if node with same path is already
1111 1111 marked to be *removed*
1112 1112 :raises ``NodeDoesNotExistError``: if node doesn't exist in latest
1113 1113 commit
1114 1114 :raises ``NodeNotChangedError``: if node hasn't really be changed
1115 1115 """
1116 1116 for node in filenodes:
1117 1117 if node.path in (n.path for n in self.removed):
1118 1118 raise NodeAlreadyRemovedError(
1119 1119 "Node at %s is already marked as removed" % node.path)
1120 1120 try:
1121 1121 self.repository.get_commit()
1122 1122 except EmptyRepositoryError:
1123 1123 raise EmptyRepositoryError(
1124 1124 "Nothing to change - try to *add* new nodes rather than "
1125 1125 "changing them")
1126 1126 for node in filenodes:
1127 1127 if node.path in (n.path for n in self.changed):
1128 1128 raise NodeAlreadyChangedError(
1129 1129 "Node at '%s' is already marked as changed" % node.path)
1130 1130 self.changed.append(node)
1131 1131
1132 1132 def remove(self, *filenodes):
1133 1133 """
1134 1134 Marks given ``FileNode`` (or ``RemovedFileNode``) objects to be
1135 1135 *removed* in next commit.
1136 1136
1137 1137 :raises ``NodeAlreadyRemovedError``: if node has been already marked to
1138 1138 be *removed*
1139 1139 :raises ``NodeAlreadyChangedError``: if node has been already marked to
1140 1140 be *changed*
1141 1141 """
1142 1142 for node in filenodes:
1143 1143 if node.path in (n.path for n in self.removed):
1144 1144 raise NodeAlreadyRemovedError(
1145 1145 "Node is already marked to for removal at %s" % node.path)
1146 1146 if node.path in (n.path for n in self.changed):
1147 1147 raise NodeAlreadyChangedError(
1148 1148 "Node is already marked to be changed at %s" % node.path)
1149 1149 # We only mark node as *removed* - real removal is done by
1150 1150 # commit method
1151 1151 self.removed.append(node)
1152 1152
1153 1153 def reset(self):
1154 1154 """
1155 1155 Resets this instance to initial state (cleans ``added``, ``changed``
1156 1156 and ``removed`` lists).
1157 1157 """
1158 1158 self.added = []
1159 1159 self.changed = []
1160 1160 self.removed = []
1161 1161 self.parents = []
1162 1162
1163 1163 def get_ipaths(self):
1164 1164 """
1165 1165 Returns generator of paths from nodes marked as added, changed or
1166 1166 removed.
1167 1167 """
1168 1168 for node in itertools.chain(self.added, self.changed, self.removed):
1169 1169 yield node.path
1170 1170
1171 1171 def get_paths(self):
1172 1172 """
1173 1173 Returns list of paths from nodes marked as added, changed or removed.
1174 1174 """
1175 1175 return list(self.get_ipaths())
1176 1176
1177 1177 def check_integrity(self, parents=None):
1178 1178 """
1179 1179 Checks in-memory commit's integrity. Also, sets parents if not
1180 1180 already set.
1181 1181
1182 1182 :raises CommitError: if any error occurs (i.e.
1183 1183 ``NodeDoesNotExistError``).
1184 1184 """
1185 1185 if not self.parents:
1186 1186 parents = parents or []
1187 1187 if len(parents) == 0:
1188 1188 try:
1189 1189 parents = [self.repository.get_commit(), None]
1190 1190 except EmptyRepositoryError:
1191 1191 parents = [None, None]
1192 1192 elif len(parents) == 1:
1193 1193 parents += [None]
1194 1194 self.parents = parents
1195 1195
1196 1196 # Local parents, only if not None
1197 1197 parents = [p for p in self.parents if p]
1198 1198
1199 1199 # Check nodes marked as added
1200 1200 for p in parents:
1201 1201 for node in self.added:
1202 1202 try:
1203 1203 p.get_node(node.path)
1204 1204 except NodeDoesNotExistError:
1205 1205 pass
1206 1206 else:
1207 1207 raise NodeAlreadyExistsError(
1208 1208 "Node `%s` already exists at %s" % (node.path, p))
1209 1209
1210 1210 # Check nodes marked as changed
1211 1211 missing = set(self.changed)
1212 1212 not_changed = set(self.changed)
1213 1213 if self.changed and not parents:
1214 1214 raise NodeDoesNotExistError(str(self.changed[0].path))
1215 1215 for p in parents:
1216 1216 for node in self.changed:
1217 1217 try:
1218 1218 old = p.get_node(node.path)
1219 1219 missing.remove(node)
1220 1220 # if content actually changed, remove node from not_changed
1221 1221 if old.content != node.content:
1222 1222 not_changed.remove(node)
1223 1223 except NodeDoesNotExistError:
1224 1224 pass
1225 1225 if self.changed and missing:
1226 1226 raise NodeDoesNotExistError(
1227 1227 "Node `%s` marked as modified but missing in parents: %s"
1228 1228 % (node.path, parents))
1229 1229
1230 1230 if self.changed and not_changed:
1231 1231 raise NodeNotChangedError(
1232 1232 "Node `%s` wasn't actually changed (parents: %s)"
1233 1233 % (not_changed.pop().path, parents))
1234 1234
1235 1235 # Check nodes marked as removed
1236 1236 if self.removed and not parents:
1237 1237 raise NodeDoesNotExistError(
1238 1238 "Cannot remove node at %s as there "
1239 1239 "were no parents specified" % self.removed[0].path)
1240 1240 really_removed = set()
1241 1241 for p in parents:
1242 1242 for node in self.removed:
1243 1243 try:
1244 1244 p.get_node(node.path)
1245 1245 really_removed.add(node)
1246 1246 except CommitError:
1247 1247 pass
1248 1248 not_removed = set(self.removed) - really_removed
1249 1249 if not_removed:
1250 1250 # TODO: johbo: This code branch does not seem to be covered
1251 1251 raise NodeDoesNotExistError(
1252 1252 "Cannot remove node at %s from "
1253 1253 "following parents: %s" % (not_removed, parents))
1254 1254
1255 1255 def commit(
1256 1256 self, message, author, parents=None, branch=None, date=None,
1257 1257 **kwargs):
1258 1258 """
1259 1259 Performs in-memory commit (doesn't check workdir in any way) and
1260 1260 returns newly created :class:`BaseCommit`. Updates repository's
1261 1261 attribute `commits`.
1262 1262
1263 1263 .. note::
1264 1264
1265 1265 While overriding this method each backend's should call
1266 1266 ``self.check_integrity(parents)`` in the first place.
1267 1267
1268 1268 :param message: message of the commit
1269 1269 :param author: full username, i.e. "Joe Doe <joe.doe@example.com>"
1270 1270 :param parents: single parent or sequence of parents from which commit
1271 1271 would be derived
1272 1272 :param date: ``datetime.datetime`` instance. Defaults to
1273 1273 ``datetime.datetime.now()``.
1274 1274 :param branch: branch name, as string. If none given, default backend's
1275 1275 branch would be used.
1276 1276
1277 1277 :raises ``CommitError``: if any error occurs while committing
1278 1278 """
1279 1279 raise NotImplementedError
1280 1280
1281 1281
1282 1282 class BaseInMemoryChangesetClass(type):
1283 1283
1284 1284 def __instancecheck__(self, instance):
1285 1285 return isinstance(instance, BaseInMemoryCommit)
1286 1286
1287 1287
1288 1288 class BaseInMemoryChangeset(BaseInMemoryCommit):
1289 1289
1290 1290 __metaclass__ = BaseInMemoryChangesetClass
1291 1291
1292 1292 def __new__(cls, *args, **kwargs):
1293 1293 warnings.warn(
1294 1294 "Use BaseCommit instead of BaseInMemoryCommit", DeprecationWarning)
1295 1295 return super(BaseInMemoryChangeset, cls).__new__(cls, *args, **kwargs)
1296 1296
1297 1297
1298 1298 class EmptyCommit(BaseCommit):
1299 1299 """
1300 1300 An dummy empty commit. It's possible to pass hash when creating
1301 1301 an EmptyCommit
1302 1302 """
1303 1303
1304 1304 def __init__(
1305 1305 self, commit_id='0' * 40, repo=None, alias=None, idx=-1,
1306 1306 message='', author='', date=None):
1307 1307 self._empty_commit_id = commit_id
1308 1308 # TODO: johbo: Solve idx parameter, default value does not make
1309 1309 # too much sense
1310 1310 self.idx = idx
1311 1311 self.message = message
1312 1312 self.author = author
1313 1313 self.date = date or datetime.datetime.fromtimestamp(0)
1314 1314 self.repository = repo
1315 1315 self.alias = alias
1316 1316
1317 1317 @LazyProperty
1318 1318 def raw_id(self):
1319 1319 """
1320 1320 Returns raw string identifying this commit, useful for web
1321 1321 representation.
1322 1322 """
1323 1323
1324 1324 return self._empty_commit_id
1325 1325
1326 1326 @LazyProperty
1327 1327 def branch(self):
1328 1328 if self.alias:
1329 1329 from rhodecode.lib.vcs.backends import get_backend
1330 1330 return get_backend(self.alias).DEFAULT_BRANCH_NAME
1331 1331
1332 1332 @LazyProperty
1333 1333 def short_id(self):
1334 1334 return self.raw_id[:12]
1335 1335
1336 1336 @LazyProperty
1337 1337 def id(self):
1338 1338 return self.raw_id
1339 1339
1340 1340 def get_file_commit(self, path):
1341 1341 return self
1342 1342
1343 1343 def get_file_content(self, path):
1344 1344 return u''
1345 1345
1346 1346 def get_file_size(self, path):
1347 1347 return 0
1348 1348
1349 1349
1350 1350 class EmptyChangesetClass(type):
1351 1351
1352 1352 def __instancecheck__(self, instance):
1353 1353 return isinstance(instance, EmptyCommit)
1354 1354
1355 1355
1356 1356 class EmptyChangeset(EmptyCommit):
1357 1357
1358 1358 __metaclass__ = EmptyChangesetClass
1359 1359
1360 1360 def __new__(cls, *args, **kwargs):
1361 1361 warnings.warn(
1362 1362 "Use EmptyCommit instead of EmptyChangeset", DeprecationWarning)
1363 1363 return super(EmptyCommit, cls).__new__(cls, *args, **kwargs)
1364 1364
1365 1365 def __init__(self, cs='0' * 40, repo=None, requested_revision=None,
1366 1366 alias=None, revision=-1, message='', author='', date=None):
1367 1367 if requested_revision is not None:
1368 1368 warnings.warn(
1369 1369 "Parameter requested_revision not supported anymore",
1370 1370 DeprecationWarning)
1371 1371 super(EmptyChangeset, self).__init__(
1372 1372 commit_id=cs, repo=repo, alias=alias, idx=revision,
1373 1373 message=message, author=author, date=date)
1374 1374
1375 1375 @property
1376 1376 def revision(self):
1377 1377 warnings.warn("Use idx instead", DeprecationWarning)
1378 1378 return self.idx
1379 1379
1380 1380 @revision.setter
1381 1381 def revision(self, value):
1382 1382 warnings.warn("Use idx instead", DeprecationWarning)
1383 1383 self.idx = value
1384 1384
1385 1385
1386 1386 class CollectionGenerator(object):
1387 1387
1388 1388 def __init__(self, repo, commit_ids, collection_size=None, pre_load=None):
1389 1389 self.repo = repo
1390 1390 self.commit_ids = commit_ids
1391 1391 # TODO: (oliver) this isn't currently hooked up
1392 1392 self.collection_size = None
1393 1393 self.pre_load = pre_load
1394 1394
1395 1395 def __len__(self):
1396 1396 if self.collection_size is not None:
1397 1397 return self.collection_size
1398 1398 return self.commit_ids.__len__()
1399 1399
1400 1400 def __iter__(self):
1401 1401 for commit_id in self.commit_ids:
1402 1402 # TODO: johbo: Mercurial passes in commit indices or commit ids
1403 1403 yield self._commit_factory(commit_id)
1404 1404
1405 1405 def _commit_factory(self, commit_id):
1406 1406 """
1407 1407 Allows backends to override the way commits are generated.
1408 1408 """
1409 1409 return self.repo.get_commit(commit_id=commit_id,
1410 1410 pre_load=self.pre_load)
1411 1411
1412 1412 def __getslice__(self, i, j):
1413 1413 """
1414 1414 Returns an iterator of sliced repository
1415 1415 """
1416 1416 commit_ids = self.commit_ids[i:j]
1417 1417 return self.__class__(
1418 1418 self.repo, commit_ids, pre_load=self.pre_load)
1419 1419
1420 1420 def __repr__(self):
1421 1421 return '<CollectionGenerator[len:%s]>' % (self.__len__())
1422 1422
1423 1423
1424 1424 class Config(object):
1425 1425 """
1426 1426 Represents the configuration for a repository.
1427 1427
1428 1428 The API is inspired by :class:`ConfigParser.ConfigParser` from the
1429 1429 standard library. It implements only the needed subset.
1430 1430 """
1431 1431
1432 1432 def __init__(self):
1433 1433 self._values = {}
1434 1434
1435 1435 def copy(self):
1436 1436 clone = Config()
1437 1437 for section, values in self._values.items():
1438 1438 clone._values[section] = values.copy()
1439 1439 return clone
1440 1440
1441 1441 def __repr__(self):
1442 1442 return '<Config(%s sections) at %s>' % (
1443 1443 len(self._values), hex(id(self)))
1444 1444
1445 1445 def items(self, section):
1446 1446 return self._values.get(section, {}).iteritems()
1447 1447
1448 1448 def get(self, section, option):
1449 1449 return self._values.get(section, {}).get(option)
1450 1450
1451 1451 def set(self, section, option, value):
1452 1452 section_values = self._values.setdefault(section, {})
1453 1453 section_values[option] = value
1454 1454
1455 1455 def clear_section(self, section):
1456 1456 self._values[section] = {}
1457 1457
1458 1458 def serialize(self):
1459 1459 """
1460 1460 Creates a list of three tuples (section, key, value) representing
1461 1461 this config object.
1462 1462 """
1463 1463 items = []
1464 1464 for section in self._values:
1465 1465 for option, value in self._values[section].items():
1466 1466 items.append(
1467 1467 (safe_str(section), safe_str(option), safe_str(value)))
1468 1468 return items
1469 1469
1470 1470
1471 1471 class Diff(object):
1472 1472 """
1473 1473 Represents a diff result from a repository backend.
1474 1474
1475 1475 Subclasses have to provide a backend specific value for :attr:`_header_re`.
1476 1476 """
1477 1477
1478 1478 _header_re = None
1479 1479
1480 1480 def __init__(self, raw_diff):
1481 1481 self.raw = raw_diff
1482 1482
1483 1483 def chunks(self):
1484 1484 """
1485 1485 split the diff in chunks of separate --git a/file b/file chunks
1486 1486 to make diffs consistent we must prepend with \n, and make sure
1487 1487 we can detect last chunk as this was also has special rule
1488 1488 """
1489 1489 chunks = ('\n' + self.raw).split('\ndiff --git')[1:]
1490 1490 total_chunks = len(chunks)
1491 1491 return (DiffChunk(chunk, self, cur_chunk == total_chunks)
1492 1492 for cur_chunk, chunk in enumerate(chunks, start=1))
1493 1493
1494 1494
1495 1495 class DiffChunk(object):
1496 1496
1497 1497 def __init__(self, chunk, diff, last_chunk):
1498 1498 self._diff = diff
1499 1499
1500 1500 # since we split by \ndiff --git that part is lost from original diff
1501 1501 # we need to re-apply it at the end, EXCEPT ! if it's last chunk
1502 1502 if not last_chunk:
1503 1503 chunk += '\n'
1504 1504
1505 1505 match = self._diff._header_re.match(chunk)
1506 1506 self.header = match.groupdict()
1507 1507 self.diff = chunk[match.end():]
1508 1508 self.raw = chunk
@@ -1,927 +1,928 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2014-2016 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 29 import time
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 makedate, utcdate_fromtimestamp
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 MergeFailureReason)
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 log.debug('Stderr output of git command "%s":\n%s', cmd, err)
111 111 return out, err
112 112
113 113 @staticmethod
114 114 def check_url(url, config):
115 115 """
116 116 Function will check given url and try to verify if it's a valid
117 117 link. Sometimes it may happened that git will issue basic
118 118 auth request that can cause whole API to hang when used from python
119 119 or other external calls.
120 120
121 121 On failures it'll raise urllib2.HTTPError, exception is also thrown
122 122 when the return code is non 200
123 123 """
124 124 # check first if it's not an url
125 125 if os.path.isdir(url) or url.startswith('file:'):
126 126 return True
127 127
128 128 if '+' in url.split('://', 1)[0]:
129 129 url = url.split('+', 1)[1]
130 130
131 131 # Request the _remote to verify the url
132 132 return connection.Git.check_url(url, config.serialize())
133 133
134 134 @staticmethod
135 135 def is_valid_repository(path):
136 136 if os.path.isdir(os.path.join(path, '.git')):
137 137 return True
138 138 # check case of bare repository
139 139 try:
140 140 GitRepository(path)
141 141 return True
142 142 except VCSError:
143 143 pass
144 144 return False
145 145
146 146 def _init_repo(self, create, src_url=None, update_after_clone=False,
147 147 bare=False):
148 148 if create and os.path.exists(self.path):
149 149 raise RepositoryError(
150 150 "Cannot create repository at %s, location already exist"
151 151 % self.path)
152 152
153 153 try:
154 154 if create and src_url:
155 155 GitRepository.check_url(src_url, self.config)
156 156 self.clone(src_url, update_after_clone, bare)
157 157 elif create:
158 158 os.makedirs(self.path, mode=0755)
159 159
160 160 if bare:
161 161 self._remote.init_bare()
162 162 else:
163 163 self._remote.init()
164 164 else:
165 165 self._remote.assert_correct_path()
166 166 # TODO: johbo: check if we have to translate the OSError here
167 167 except OSError as err:
168 168 raise RepositoryError(err)
169 169
170 170 def _get_all_commit_ids(self, filters=None):
171 171 # we must check if this repo is not empty, since later command
172 172 # fails if it is. And it's cheaper to ask than throw the subprocess
173 173 # errors
174 174 try:
175 175 self._remote.head()
176 176 except KeyError:
177 177 return []
178 178
179 179 rev_filter = ['--branches', '--tags']
180 180 extra_filter = []
181 181
182 182 if filters:
183 183 if filters.get('since'):
184 184 extra_filter.append('--since=%s' % (filters['since']))
185 185 if filters.get('until'):
186 186 extra_filter.append('--until=%s' % (filters['until']))
187 187 if filters.get('branch_name'):
188 188 rev_filter = ['--tags']
189 189 extra_filter.append(filters['branch_name'])
190 190 rev_filter.extend(extra_filter)
191 191
192 192 # if filters.get('start') or filters.get('end'):
193 193 # # skip is offset, max-count is limit
194 194 # if filters.get('start'):
195 195 # extra_filter += ' --skip=%s' % filters['start']
196 196 # if filters.get('end'):
197 197 # extra_filter += ' --max-count=%s' % (filters['end'] - (filters['start'] or 0))
198 198
199 199 cmd = ['rev-list', '--reverse', '--date-order'] + rev_filter
200 200 try:
201 201 output, __ = self.run_git_command(cmd)
202 202 except RepositoryError:
203 203 # Can be raised for empty repositories
204 204 return []
205 205 return output.splitlines()
206 206
207 207 def _get_commit_id(self, commit_id_or_idx):
208 208 def is_null(value):
209 209 return len(value) == commit_id_or_idx.count('0')
210 210
211 211 if self.is_empty():
212 212 raise EmptyRepositoryError("There are no commits yet")
213 213
214 214 if commit_id_or_idx in (None, '', 'tip', 'HEAD', 'head', -1):
215 215 return self.commit_ids[-1]
216 216
217 217 is_bstr = isinstance(commit_id_or_idx, (str, unicode))
218 218 if ((is_bstr and commit_id_or_idx.isdigit() and len(commit_id_or_idx) < 12)
219 219 or isinstance(commit_id_or_idx, int) or is_null(commit_id_or_idx)):
220 220 try:
221 221 commit_id_or_idx = self.commit_ids[int(commit_id_or_idx)]
222 222 except Exception:
223 223 msg = "Commit %s does not exist for %s" % (
224 224 commit_id_or_idx, self)
225 225 raise CommitDoesNotExistError(msg)
226 226
227 227 elif is_bstr:
228 228 # check full path ref, eg. refs/heads/master
229 229 ref_id = self._refs.get(commit_id_or_idx)
230 230 if ref_id:
231 231 return ref_id
232 232
233 233 # check branch name
234 234 branch_ids = self.branches.values()
235 235 ref_id = self._refs.get('refs/heads/%s' % commit_id_or_idx)
236 236 if ref_id:
237 237 return ref_id
238 238
239 239 # check tag name
240 240 ref_id = self._refs.get('refs/tags/%s' % commit_id_or_idx)
241 241 if ref_id:
242 242 return ref_id
243 243
244 244 if (not SHA_PATTERN.match(commit_id_or_idx) or
245 245 commit_id_or_idx not in self.commit_ids):
246 246 msg = "Commit %s does not exist for %s" % (
247 247 commit_id_or_idx, self)
248 248 raise CommitDoesNotExistError(msg)
249 249
250 250 # Ensure we return full id
251 251 if not SHA_PATTERN.match(str(commit_id_or_idx)):
252 252 raise CommitDoesNotExistError(
253 253 "Given commit id %s not recognized" % commit_id_or_idx)
254 254 return commit_id_or_idx
255 255
256 256 def get_hook_location(self):
257 257 """
258 258 returns absolute path to location where hooks are stored
259 259 """
260 260 loc = os.path.join(self.path, 'hooks')
261 261 if not self.bare:
262 262 loc = os.path.join(self.path, '.git', 'hooks')
263 263 return loc
264 264
265 265 @LazyProperty
266 266 def last_change(self):
267 267 """
268 268 Returns last change made on this repository as
269 269 `datetime.datetime` object.
270 270 """
271 271 return utcdate_fromtimestamp(self._get_mtime(), makedate()[1])
272 272
273 273 def _get_mtime(self):
274 274 try:
275 275 return time.mktime(self.get_commit().date.timetuple())
276 276 except RepositoryError:
277 277 idx_loc = '' if self.bare else '.git'
278 278 # fallback to filesystem
279 279 in_path = os.path.join(self.path, idx_loc, "index")
280 280 he_path = os.path.join(self.path, idx_loc, "HEAD")
281 281 if os.path.exists(in_path):
282 282 return os.stat(in_path).st_mtime
283 283 else:
284 284 return os.stat(he_path).st_mtime
285 285
286 286 @LazyProperty
287 287 def description(self):
288 288 description = self._remote.get_description()
289 289 return safe_unicode(description or self.DEFAULT_DESCRIPTION)
290 290
291 291 def _get_refs_entries(self, prefix='', reverse=False, strip_prefix=True):
292 292 if self.is_empty():
293 293 return OrderedDict()
294 294
295 295 result = []
296 296 for ref, sha in self._refs.iteritems():
297 297 if ref.startswith(prefix):
298 298 ref_name = ref
299 299 if strip_prefix:
300 300 ref_name = ref[len(prefix):]
301 301 result.append((safe_unicode(ref_name), sha))
302 302
303 303 def get_name(entry):
304 304 return entry[0]
305 305
306 306 return OrderedDict(sorted(result, key=get_name, reverse=reverse))
307 307
308 308 def _get_branches(self):
309 309 return self._get_refs_entries(prefix='refs/heads/', strip_prefix=True)
310 310
311 311 @LazyProperty
312 312 def branches(self):
313 313 return self._get_branches()
314 314
315 315 @LazyProperty
316 316 def branches_closed(self):
317 317 return {}
318 318
319 319 @LazyProperty
320 320 def branches_all(self):
321 321 all_branches = {}
322 322 all_branches.update(self.branches)
323 323 all_branches.update(self.branches_closed)
324 324 return all_branches
325 325
326 326 @LazyProperty
327 327 def tags(self):
328 328 return self._get_tags()
329 329
330 330 def _get_tags(self):
331 331 return self._get_refs_entries(
332 332 prefix='refs/tags/', strip_prefix=True, reverse=True)
333 333
334 334 def tag(self, name, user, commit_id=None, message=None, date=None,
335 335 **kwargs):
336 336 # TODO: fix this method to apply annotated tags correct with message
337 337 """
338 338 Creates and returns a tag for the given ``commit_id``.
339 339
340 340 :param name: name for new tag
341 341 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
342 342 :param commit_id: commit id for which new tag would be created
343 343 :param message: message of the tag's commit
344 344 :param date: date of tag's commit
345 345
346 346 :raises TagAlreadyExistError: if tag with same name already exists
347 347 """
348 348 if name in self.tags:
349 349 raise TagAlreadyExistError("Tag %s already exists" % name)
350 350 commit = self.get_commit(commit_id=commit_id)
351 351 message = message or "Added tag %s for commit %s" % (
352 352 name, commit.raw_id)
353 353 self._remote.set_refs('refs/tags/%s' % name, commit._commit['id'])
354 354
355 355 self._refs = self._get_refs()
356 356 self.tags = self._get_tags()
357 357 return commit
358 358
359 359 def remove_tag(self, name, user, message=None, date=None):
360 360 """
361 361 Removes tag with the given ``name``.
362 362
363 363 :param name: name of the tag to be removed
364 364 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
365 365 :param message: message of the tag's removal commit
366 366 :param date: date of tag's removal commit
367 367
368 368 :raises TagDoesNotExistError: if tag with given name does not exists
369 369 """
370 370 if name not in self.tags:
371 371 raise TagDoesNotExistError("Tag %s does not exist" % name)
372 372 tagpath = vcspath.join(
373 373 self._remote.get_refs_path(), 'refs', 'tags', name)
374 374 try:
375 375 os.remove(tagpath)
376 376 self._refs = self._get_refs()
377 377 self.tags = self._get_tags()
378 378 except OSError as e:
379 379 raise RepositoryError(e.strerror)
380 380
381 381 def _get_refs(self):
382 382 return self._remote.get_refs()
383 383
384 384 @LazyProperty
385 385 def _refs(self):
386 386 return self._get_refs()
387 387
388 388 @property
389 389 def _ref_tree(self):
390 390 node = tree = {}
391 391 for ref, sha in self._refs.iteritems():
392 392 path = ref.split('/')
393 393 for bit in path[:-1]:
394 394 node = node.setdefault(bit, {})
395 395 node[path[-1]] = sha
396 396 node = tree
397 397 return tree
398 398
399 399 def get_commit(self, commit_id=None, commit_idx=None, pre_load=None):
400 400 """
401 401 Returns `GitCommit` object representing commit from git repository
402 402 at the given `commit_id` or head (most recent commit) if None given.
403 403 """
404 404 if commit_id is not None:
405 405 self._validate_commit_id(commit_id)
406 406 elif commit_idx is not None:
407 407 self._validate_commit_idx(commit_idx)
408 408 commit_id = commit_idx
409 409 commit_id = self._get_commit_id(commit_id)
410 410 try:
411 411 # Need to call remote to translate id for tagging scenario
412 412 commit_id = self._remote.get_object(commit_id)["commit_id"]
413 413 idx = self._commit_ids[commit_id]
414 414 except KeyError:
415 415 raise RepositoryError("Cannot get object with id %s" % commit_id)
416 416
417 417 return GitCommit(self, commit_id, idx, pre_load=pre_load)
418 418
419 419 def get_commits(
420 420 self, start_id=None, end_id=None, start_date=None, end_date=None,
421 421 branch_name=None, pre_load=None):
422 422 """
423 423 Returns generator of `GitCommit` objects from start to end (both
424 424 are inclusive), in ascending date order.
425 425
426 426 :param start_id: None, str(commit_id)
427 427 :param end_id: None, str(commit_id)
428 428 :param start_date: if specified, commits with commit date less than
429 429 ``start_date`` would be filtered out from returned set
430 430 :param end_date: if specified, commits with commit date greater than
431 431 ``end_date`` would be filtered out from returned set
432 432 :param branch_name: if specified, commits not reachable from given
433 433 branch would be filtered out from returned set
434 434
435 435 :raise BranchDoesNotExistError: If given `branch_name` does not
436 436 exist.
437 437 :raise CommitDoesNotExistError: If commits for given `start` or
438 438 `end` could not be found.
439 439
440 440 """
441 441 if self.is_empty():
442 442 raise EmptyRepositoryError("There are no commits yet")
443 443 self._validate_branch_name(branch_name)
444 444
445 445 if start_id is not None:
446 446 self._validate_commit_id(start_id)
447 447 if end_id is not None:
448 448 self._validate_commit_id(end_id)
449 449
450 450 start_raw_id = self._get_commit_id(start_id)
451 451 start_pos = self._commit_ids[start_raw_id] if start_id else None
452 452 end_raw_id = self._get_commit_id(end_id)
453 453 end_pos = max(0, self._commit_ids[end_raw_id]) if end_id else None
454 454
455 455 if None not in [start_id, end_id] and start_pos > end_pos:
456 456 raise RepositoryError(
457 457 "Start commit '%s' cannot be after end commit '%s'" %
458 458 (start_id, end_id))
459 459
460 460 if end_pos is not None:
461 461 end_pos += 1
462 462
463 463 filter_ = []
464 464 if branch_name:
465 465 filter_.append({'branch_name': branch_name})
466 466 if start_date and not end_date:
467 467 filter_.append({'since': start_date})
468 468 if end_date and not start_date:
469 469 filter_.append({'until': end_date})
470 470 if start_date and end_date:
471 471 filter_.append({'since': start_date})
472 472 filter_.append({'until': end_date})
473 473
474 474 # if start_pos or end_pos:
475 475 # filter_.append({'start': start_pos})
476 476 # filter_.append({'end': end_pos})
477 477
478 478 if filter_:
479 479 revfilters = {
480 480 'branch_name': branch_name,
481 481 'since': start_date.strftime('%m/%d/%y %H:%M:%S') if start_date else None,
482 482 'until': end_date.strftime('%m/%d/%y %H:%M:%S') if end_date else None,
483 483 'start': start_pos,
484 484 'end': end_pos,
485 485 }
486 486 commit_ids = self._get_all_commit_ids(filters=revfilters)
487 487
488 488 # pure python stuff, it's slow due to walker walking whole repo
489 489 # def get_revs(walker):
490 490 # for walker_entry in walker:
491 491 # yield walker_entry.commit.id
492 492 # revfilters = {}
493 493 # commit_ids = list(reversed(list(get_revs(self._repo.get_walker(**revfilters)))))
494 494 else:
495 495 commit_ids = self.commit_ids
496 496
497 497 if start_pos or end_pos:
498 498 commit_ids = commit_ids[start_pos: end_pos]
499 499
500 500 return CollectionGenerator(self, commit_ids, pre_load=pre_load)
501 501
502 502 def get_diff(
503 503 self, commit1, commit2, path='', ignore_whitespace=False,
504 504 context=3, path1=None):
505 505 """
506 506 Returns (git like) *diff*, as plain text. Shows changes introduced by
507 507 ``commit2`` since ``commit1``.
508 508
509 509 :param commit1: Entry point from which diff is shown. Can be
510 510 ``self.EMPTY_COMMIT`` - in this case, patch showing all
511 511 the changes since empty state of the repository until ``commit2``
512 512 :param commit2: Until which commits changes should be shown.
513 513 :param ignore_whitespace: If set to ``True``, would not show whitespace
514 514 changes. Defaults to ``False``.
515 515 :param context: How many lines before/after changed lines should be
516 516 shown. Defaults to ``3``.
517 517 """
518 518 self._validate_diff_commits(commit1, commit2)
519 519 if path1 is not None and path1 != path:
520 520 raise ValueError("Diff of two different paths not supported.")
521 521
522 522 flags = [
523 523 '-U%s' % context, '--full-index', '--binary', '-p',
524 524 '-M', '--abbrev=40']
525 525 if ignore_whitespace:
526 526 flags.append('-w')
527 527
528 528 if commit1 == self.EMPTY_COMMIT:
529 529 cmd = ['show'] + flags + [commit2.raw_id]
530 530 else:
531 531 cmd = ['diff'] + flags + [commit1.raw_id, commit2.raw_id]
532 532
533 533 if path:
534 534 cmd.extend(['--', path])
535 535
536 536 stdout, __ = self.run_git_command(cmd)
537 537 # If we used 'show' command, strip first few lines (until actual diff
538 538 # starts)
539 539 if commit1 == self.EMPTY_COMMIT:
540 540 lines = stdout.splitlines()
541 541 x = 0
542 542 for line in lines:
543 543 if line.startswith('diff'):
544 544 break
545 545 x += 1
546 546 # Append new line just like 'diff' command do
547 547 stdout = '\n'.join(lines[x:]) + '\n'
548 548 return GitDiff(stdout)
549 549
550 550 def strip(self, commit_id, branch_name):
551 551 commit = self.get_commit(commit_id=commit_id)
552 552 if commit.merge:
553 553 raise Exception('Cannot reset to merge commit')
554 554
555 555 # parent is going to be the new head now
556 556 commit = commit.parents[0]
557 557 self._remote.set_refs('refs/heads/%s' % branch_name, commit.raw_id)
558 558
559 559 self.commit_ids = self._get_all_commit_ids()
560 560 self._rebuild_cache(self.commit_ids)
561 561
562 562 def get_common_ancestor(self, commit_id1, commit_id2, repo2):
563 563 if commit_id1 == commit_id2:
564 564 return commit_id1
565 565
566 566 if self != repo2:
567 567 commits = self._remote.get_missing_revs(
568 568 commit_id1, commit_id2, repo2.path)
569 569 if commits:
570 570 commit = repo2.get_commit(commits[-1])
571 571 if commit.parents:
572 572 ancestor_id = commit.parents[0].raw_id
573 573 else:
574 574 ancestor_id = None
575 575 else:
576 576 # no commits from other repo, ancestor_id is the commit_id2
577 577 ancestor_id = commit_id2
578 578 else:
579 579 output, __ = self.run_git_command(
580 580 ['merge-base', commit_id1, commit_id2])
581 581 ancestor_id = re.findall(r'[0-9a-fA-F]{40}', output)[0]
582 582
583 583 return ancestor_id
584 584
585 585 def compare(self, commit_id1, commit_id2, repo2, merge, pre_load=None):
586 586 repo1 = self
587 587 ancestor_id = None
588 588
589 589 if commit_id1 == commit_id2:
590 590 commits = []
591 591 elif repo1 != repo2:
592 592 missing_ids = self._remote.get_missing_revs(commit_id1, commit_id2,
593 593 repo2.path)
594 594 commits = [
595 595 repo2.get_commit(commit_id=commit_id, pre_load=pre_load)
596 596 for commit_id in reversed(missing_ids)]
597 597 else:
598 598 output, __ = repo1.run_git_command(
599 599 ['log', '--reverse', '--pretty=format: %H', '-s',
600 600 '%s..%s' % (commit_id1, commit_id2)])
601 601 commits = [
602 602 repo1.get_commit(commit_id=commit_id, pre_load=pre_load)
603 603 for commit_id in re.findall(r'[0-9a-fA-F]{40}', output)]
604 604
605 605 return commits
606 606
607 607 @LazyProperty
608 608 def in_memory_commit(self):
609 609 """
610 610 Returns ``GitInMemoryCommit`` object for this repository.
611 611 """
612 612 return GitInMemoryCommit(self)
613 613
614 614 def clone(self, url, update_after_clone=True, bare=False):
615 615 """
616 616 Tries to clone commits from external location.
617 617
618 618 :param update_after_clone: If set to ``False``, git won't checkout
619 619 working directory
620 620 :param bare: If set to ``True``, repository would be cloned into
621 621 *bare* git repository (no working directory at all).
622 622 """
623 623 # init_bare and init expect empty dir created to proceed
624 624 if not os.path.exists(self.path):
625 625 os.mkdir(self.path)
626 626
627 627 if bare:
628 628 self._remote.init_bare()
629 629 else:
630 630 self._remote.init()
631 631
632 632 deferred = '^{}'
633 633 valid_refs = ('refs/heads', 'refs/tags', 'HEAD')
634 634
635 635 return self._remote.clone(
636 636 url, deferred, valid_refs, update_after_clone)
637 637
638 638 def pull(self, url, commit_ids=None):
639 639 """
640 640 Tries to pull changes from external location. We use fetch here since
641 641 pull in get does merges and we want to be compatible with hg backend so
642 642 pull == fetch in this case
643 643 """
644 644 self.fetch(url, commit_ids=commit_ids)
645 645
646 646 def fetch(self, url, commit_ids=None):
647 647 """
648 648 Tries to fetch changes from external location.
649 649 """
650 650 refs = None
651 651
652 652 if commit_ids is not None:
653 653 remote_refs = self._remote.get_remote_refs(url)
654 654 refs = [
655 655 ref for ref in remote_refs if remote_refs[ref] in commit_ids]
656 656 self._remote.fetch(url, refs=refs)
657 657
658 658 def set_refs(self, ref_name, commit_id):
659 659 self._remote.set_refs(ref_name, commit_id)
660 660
661 661 def remove_ref(self, ref_name):
662 662 self._remote.remove_ref(ref_name)
663 663
664 664 def _update_server_info(self):
665 665 """
666 666 runs gits update-server-info command in this repo instance
667 667 """
668 668 self._remote.update_server_info()
669 669
670 670 def _current_branch(self):
671 671 """
672 672 Return the name of the current branch.
673 673
674 674 It only works for non bare repositories (i.e. repositories with a
675 675 working copy)
676 676 """
677 677 if self.bare:
678 678 raise RepositoryError('Bare git repos do not have active branches')
679 679
680 680 if self.is_empty():
681 681 return None
682 682
683 683 stdout, _ = self.run_git_command(['rev-parse', '--abbrev-ref', 'HEAD'])
684 684 return stdout.strip()
685 685
686 686 def _checkout(self, branch_name, create=False):
687 687 """
688 688 Checkout a branch in the working directory.
689 689
690 690 It tries to create the branch if create is True, failing if the branch
691 691 already exists.
692 692
693 693 It only works for non bare repositories (i.e. repositories with a
694 694 working copy)
695 695 """
696 696 if self.bare:
697 697 raise RepositoryError('Cannot checkout branches in a bare git repo')
698 698
699 699 cmd = ['checkout']
700 700 if create:
701 701 cmd.append('-b')
702 702 cmd.append(branch_name)
703 703 self.run_git_command(cmd, fail_on_stderr=False)
704 704
705 705 def _local_clone(self, clone_path, branch_name):
706 706 """
707 707 Create a local clone of the current repo.
708 708 """
709 709 # N.B.(skreft): the --branch option is required as otherwise the shallow
710 710 # clone will only fetch the active branch.
711 711 cmd = ['clone', '--branch', branch_name, '--single-branch',
712 712 self.path, os.path.abspath(clone_path)]
713 713 self.run_git_command(cmd, fail_on_stderr=False)
714 714
715 715 def _local_fetch(self, repository_path, branch_name):
716 716 """
717 717 Fetch a branch from a local repository.
718 718 """
719 719 repository_path = os.path.abspath(repository_path)
720 720 if repository_path == self.path:
721 721 raise ValueError('Cannot fetch from the same repository')
722 722
723 723 cmd = ['fetch', '--no-tags', repository_path, branch_name]
724 724 self.run_git_command(cmd, fail_on_stderr=False)
725 725
726 726 def _last_fetch_heads(self):
727 727 """
728 728 Return the last fetched heads that need merging.
729 729
730 730 The algorithm is defined at
731 731 https://github.com/git/git/blob/v2.1.3/git-pull.sh#L283
732 732 """
733 733 if not self.bare:
734 734 fetch_heads_path = os.path.join(self.path, '.git', 'FETCH_HEAD')
735 735 else:
736 736 fetch_heads_path = os.path.join(self.path, 'FETCH_HEAD')
737 737
738 738 heads = []
739 739 with open(fetch_heads_path) as f:
740 740 for line in f:
741 741 if ' not-for-merge ' in line:
742 742 continue
743 743 line = re.sub('\t.*', '', line, flags=re.DOTALL)
744 744 heads.append(line)
745 745
746 746 return heads
747 747
748 748 def _local_pull(self, repository_path, branch_name):
749 749 """
750 750 Pull a branch from a local repository.
751 751 """
752 752 if self.bare:
753 753 raise RepositoryError('Cannot pull into a bare git repository')
754 754 # N.B.(skreft): The --ff-only option is to make sure this is a
755 755 # fast-forward (i.e., we are only pulling new changes and there are no
756 756 # conflicts with our current branch)
757 757 # Additionally, that option needs to go before --no-tags, otherwise git
758 758 # pull complains about it being an unknown flag.
759 759 cmd = ['pull', '--ff-only', '--no-tags', repository_path, branch_name]
760 760 self.run_git_command(cmd, fail_on_stderr=False)
761 761
762 762 def _local_merge(self, merge_message, user_name, user_email, heads):
763 763 """
764 764 Merge the given head into the checked out branch.
765 765
766 766 It will force a merge commit.
767 767
768 768 Currently it raises an error if the repo is empty, as it is not possible
769 769 to create a merge commit in an empty repo.
770 770
771 771 :param merge_message: The message to use for the merge commit.
772 772 :param heads: the heads to merge.
773 773 """
774 774 if self.bare:
775 775 raise RepositoryError('Cannot merge into a bare git repository')
776 776
777 777 if not heads:
778 778 return
779 779
780 780 if self.is_empty():
781 781 # TODO(skreft): do somehting more robust in this case.
782 782 raise RepositoryError(
783 783 'Do not know how to merge into empty repositories yet')
784 784
785 785 # N.B.(skreft): the --no-ff option is used to enforce the creation of a
786 786 # commit message. We also specify the user who is doing the merge.
787 787 cmd = ['-c', 'user.name=%s' % safe_str(user_name),
788 788 '-c', 'user.email=%s' % safe_str(user_email),
789 789 'merge', '--no-ff', '-m', safe_str(merge_message)]
790 790 cmd.extend(heads)
791 791 try:
792 792 self.run_git_command(cmd, fail_on_stderr=False)
793 793 except RepositoryError:
794 794 # Cleanup any merge leftovers
795 795 self.run_git_command(['merge', '--abort'], fail_on_stderr=False)
796 796 raise
797 797
798 798 def _local_push(
799 799 self, source_branch, repository_path, target_branch,
800 800 enable_hooks=False, rc_scm_data=None):
801 801 """
802 802 Push the source_branch to the given repository and target_branch.
803 803
804 804 Currently it if the target_branch is not master and the target repo is
805 805 empty, the push will work, but then GitRepository won't be able to find
806 806 the pushed branch or the commits. As the HEAD will be corrupted (i.e.,
807 807 pointing to master, which does not exist).
808 808
809 809 It does not run the hooks in the target repo.
810 810 """
811 811 # TODO(skreft): deal with the case in which the target repo is empty,
812 812 # and the target_branch is not master.
813 813 target_repo = GitRepository(repository_path)
814 814 if (not target_repo.bare and
815 815 target_repo._current_branch() == target_branch):
816 816 # Git prevents pushing to the checked out branch, so simulate it by
817 817 # pulling into the target repository.
818 818 target_repo._local_pull(self.path, source_branch)
819 819 else:
820 820 cmd = ['push', os.path.abspath(repository_path),
821 821 '%s:%s' % (source_branch, target_branch)]
822 822 gitenv = {}
823 823 if rc_scm_data:
824 824 gitenv.update({'RC_SCM_DATA': rc_scm_data})
825 825
826 826 if not enable_hooks:
827 827 gitenv['RC_SKIP_HOOKS'] = '1'
828 828 self.run_git_command(cmd, fail_on_stderr=False, extra_env=gitenv)
829 829
830 830 def _get_new_pr_branch(self, source_branch, target_branch):
831 831 prefix = 'pr_%s-%s_' % (source_branch, target_branch)
832 832 pr_branches = []
833 833 for branch in self.branches:
834 834 if branch.startswith(prefix):
835 835 pr_branches.append(int(branch[len(prefix):]))
836 836
837 837 if not pr_branches:
838 838 branch_id = 0
839 839 else:
840 840 branch_id = max(pr_branches) + 1
841 841
842 842 return '%s%d' % (prefix, branch_id)
843 843
844 844 def _merge_repo(self, shadow_repository_path, target_ref,
845 845 source_repo, source_ref, merge_message,
846 846 merger_name, merger_email, dry_run=False,
847 847 use_rebase=False):
848 848 if target_ref.commit_id != self.branches[target_ref.name]:
849 849 return MergeResponse(
850 850 False, False, None, MergeFailureReason.TARGET_IS_NOT_HEAD)
851 851
852 852 shadow_repo = GitRepository(shadow_repository_path)
853 853 shadow_repo._checkout(target_ref.name)
854 854 shadow_repo._local_pull(self.path, target_ref.name)
855 855 # Need to reload repo to invalidate the cache, or otherwise we cannot
856 856 # retrieve the last target commit.
857 857 shadow_repo = GitRepository(shadow_repository_path)
858 858 if target_ref.commit_id != shadow_repo.branches[target_ref.name]:
859 859 return MergeResponse(
860 860 False, False, None, MergeFailureReason.TARGET_IS_NOT_HEAD)
861 861
862 862 pr_branch = shadow_repo._get_new_pr_branch(
863 863 source_ref.name, target_ref.name)
864 864 shadow_repo._checkout(pr_branch, create=True)
865 865 try:
866 866 shadow_repo._local_fetch(source_repo.path, source_ref.name)
867 867 except RepositoryError:
868 868 log.exception('Failure when doing local fetch on git shadow repo')
869 869 return MergeResponse(
870 870 False, False, None, MergeFailureReason.MISSING_COMMIT)
871 871
872 merge_commit_id = None
872 merge_ref = None
873 873 merge_failure_reason = MergeFailureReason.NONE
874 874 try:
875 875 shadow_repo._local_merge(merge_message, merger_name, merger_email,
876 876 [source_ref.commit_id])
877 877 merge_possible = True
878 878
879 879 # Need to reload repo to invalidate the cache, or otherwise we
880 880 # cannot retrieve the merge commit.
881 881 shadow_repo = GitRepository(shadow_repository_path)
882 882 merge_commit_id = shadow_repo.branches[pr_branch]
883 883
884 884 # Set a reference pointing to the merge commit. This reference may
885 885 # be used to easily identify the last successful merge commit in
886 886 # the shadow repository.
887 887 shadow_repo.set_refs('refs/heads/pr-merge', merge_commit_id)
888 merge_ref = Reference('branch', 'pr-merge', merge_commit_id)
888 889 except RepositoryError:
889 890 log.exception('Failure when doing local merge on git shadow repo')
890 891 merge_possible = False
891 892 merge_failure_reason = MergeFailureReason.MERGE_FAILED
892 893
893 894 if merge_possible and not dry_run:
894 895 try:
895 896 shadow_repo._local_push(
896 897 pr_branch, self.path, target_ref.name, enable_hooks=True,
897 898 rc_scm_data=self.config.get('rhodecode', 'RC_SCM_DATA'))
898 899 merge_succeeded = True
899 900 except RepositoryError:
900 901 log.exception(
901 902 'Failure when doing local push on git shadow repo')
902 903 merge_succeeded = False
903 904 merge_failure_reason = MergeFailureReason.PUSH_FAILED
904 905 else:
905 906 merge_succeeded = False
906 907
907 908 return MergeResponse(
908 merge_possible, merge_succeeded, merge_commit_id,
909 merge_possible, merge_succeeded, merge_ref,
909 910 merge_failure_reason)
910 911
911 912 def _get_shadow_repository_path(self, workspace_id):
912 913 # The name of the shadow repository must start with '.', so it is
913 914 # skipped by 'rhodecode.lib.utils.get_filesystem_repos'.
914 915 return os.path.join(
915 916 os.path.dirname(self.path),
916 917 '.__shadow_%s_%s' % (os.path.basename(self.path), workspace_id))
917 918
918 919 def _maybe_prepare_merge_workspace(self, workspace_id, target_ref):
919 920 shadow_repository_path = self._get_shadow_repository_path(workspace_id)
920 921 if not os.path.exists(shadow_repository_path):
921 922 self._local_clone(shadow_repository_path, target_ref.name)
922 923
923 924 return shadow_repository_path
924 925
925 926 def cleanup_merge_workspace(self, workspace_id):
926 927 shadow_repository_path = self._get_shadow_repository_path(workspace_id)
927 928 shutil.rmtree(shadow_repository_path, ignore_errors=True)
@@ -1,798 +1,798 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2014-2016 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 MergeFailureReason)
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)
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 370 `datetime.datetime` object
371 371 """
372 372 return utcdate_fromtimestamp(self._get_mtime(), makedate()[1])
373 373
374 374 def _get_mtime(self):
375 375 try:
376 376 return date_astimestamp(self.get_commit().date)
377 377 except RepositoryError:
378 378 # fallback to filesystem
379 379 cl_path = os.path.join(self.path, '.hg', "00changelog.i")
380 380 st_path = os.path.join(self.path, '.hg', "store")
381 381 if os.path.exists(cl_path):
382 382 return os.stat(cl_path).st_mtime
383 383 else:
384 384 return os.stat(st_path).st_mtime
385 385
386 386 def _sanitize_commit_idx(self, idx):
387 387 # Note: Mercurial has ``int(-1)`` reserved as not existing id_or_idx
388 388 # number. A `long` is treated in the correct way though. So we convert
389 389 # `int` to `long` here to make sure it is handled correctly.
390 390 if isinstance(idx, int):
391 391 return long(idx)
392 392 return idx
393 393
394 394 def _get_url(self, url):
395 395 """
396 396 Returns normalized url. If schema is not given, would fall
397 397 to filesystem
398 398 (``file:///``) schema.
399 399 """
400 400 url = url.encode('utf8')
401 401 if url != 'default' and '://' not in url:
402 402 url = "file:" + urllib.pathname2url(url)
403 403 return url
404 404
405 405 def get_hook_location(self):
406 406 """
407 407 returns absolute path to location where hooks are stored
408 408 """
409 409 return os.path.join(self.path, '.hg', '.hgrc')
410 410
411 411 def get_commit(self, commit_id=None, commit_idx=None, pre_load=None):
412 412 """
413 413 Returns ``MercurialCommit`` object representing repository's
414 414 commit at the given `commit_id` or `commit_idx`.
415 415 """
416 416 if self.is_empty():
417 417 raise EmptyRepositoryError("There are no commits yet")
418 418
419 419 if commit_id is not None:
420 420 self._validate_commit_id(commit_id)
421 421 try:
422 422 idx = self._commit_ids[commit_id]
423 423 return MercurialCommit(self, commit_id, idx, pre_load=pre_load)
424 424 except KeyError:
425 425 pass
426 426 elif commit_idx is not None:
427 427 self._validate_commit_idx(commit_idx)
428 428 commit_idx = self._sanitize_commit_idx(commit_idx)
429 429 try:
430 430 id_ = self.commit_ids[commit_idx]
431 431 if commit_idx < 0:
432 432 commit_idx += len(self.commit_ids)
433 433 return MercurialCommit(
434 434 self, id_, commit_idx, pre_load=pre_load)
435 435 except IndexError:
436 436 commit_id = commit_idx
437 437 else:
438 438 commit_id = "tip"
439 439
440 440 # TODO Paris: Ugly hack to "serialize" long for msgpack
441 441 if isinstance(commit_id, long):
442 442 commit_id = float(commit_id)
443 443
444 444 if isinstance(commit_id, unicode):
445 445 commit_id = safe_str(commit_id)
446 446
447 447 raw_id, idx = self._remote.lookup(commit_id, both=True)
448 448
449 449 return MercurialCommit(self, raw_id, idx, pre_load=pre_load)
450 450
451 451 def get_commits(
452 452 self, start_id=None, end_id=None, start_date=None, end_date=None,
453 453 branch_name=None, pre_load=None):
454 454 """
455 455 Returns generator of ``MercurialCommit`` objects from start to end
456 456 (both are inclusive)
457 457
458 458 :param start_id: None, str(commit_id)
459 459 :param end_id: None, str(commit_id)
460 460 :param start_date: if specified, commits with commit date less than
461 461 ``start_date`` would be filtered out from returned set
462 462 :param end_date: if specified, commits with commit date greater than
463 463 ``end_date`` would be filtered out from returned set
464 464 :param branch_name: if specified, commits not reachable from given
465 465 branch would be filtered out from returned set
466 466
467 467 :raise BranchDoesNotExistError: If given ``branch_name`` does not
468 468 exist.
469 469 :raise CommitDoesNotExistError: If commit for given ``start`` or
470 470 ``end`` could not be found.
471 471 """
472 472 # actually we should check now if it's not an empty repo
473 473 branch_ancestors = False
474 474 if self.is_empty():
475 475 raise EmptyRepositoryError("There are no commits yet")
476 476 self._validate_branch_name(branch_name)
477 477
478 478 if start_id is not None:
479 479 self._validate_commit_id(start_id)
480 480 c_start = self.get_commit(commit_id=start_id)
481 481 start_pos = self._commit_ids[c_start.raw_id]
482 482 else:
483 483 start_pos = None
484 484
485 485 if end_id is not None:
486 486 self._validate_commit_id(end_id)
487 487 c_end = self.get_commit(commit_id=end_id)
488 488 end_pos = max(0, self._commit_ids[c_end.raw_id])
489 489 else:
490 490 end_pos = None
491 491
492 492 if None not in [start_id, end_id] and start_pos > end_pos:
493 493 raise RepositoryError(
494 494 "Start commit '%s' cannot be after end commit '%s'" %
495 495 (start_id, end_id))
496 496
497 497 if end_pos is not None:
498 498 end_pos += 1
499 499
500 500 commit_filter = []
501 501 if branch_name and not branch_ancestors:
502 502 commit_filter.append('branch("%s")' % branch_name)
503 503 elif branch_name and branch_ancestors:
504 504 commit_filter.append('ancestors(branch("%s"))' % branch_name)
505 505 if start_date and not end_date:
506 506 commit_filter.append('date(">%s")' % start_date)
507 507 if end_date and not start_date:
508 508 commit_filter.append('date("<%s")' % end_date)
509 509 if start_date and end_date:
510 510 commit_filter.append(
511 511 'date(">%s") and date("<%s")' % (start_date, end_date))
512 512
513 513 # TODO: johbo: Figure out a simpler way for this solution
514 514 collection_generator = CollectionGenerator
515 515 if commit_filter:
516 516 commit_filter = map(safe_str, commit_filter)
517 517 revisions = self._remote.rev_range(commit_filter)
518 518 collection_generator = MercurialIndexBasedCollectionGenerator
519 519 else:
520 520 revisions = self.commit_ids
521 521
522 522 if start_pos or end_pos:
523 523 revisions = revisions[start_pos:end_pos]
524 524
525 525 return collection_generator(self, revisions, pre_load=pre_load)
526 526
527 527 def pull(self, url, commit_ids=None):
528 528 """
529 529 Tries to pull changes from external location.
530 530
531 531 :param commit_ids: Optional. Can be set to a list of commit ids
532 532 which shall be pulled from the other repository.
533 533 """
534 534 url = self._get_url(url)
535 535 self._remote.pull(url, commit_ids=commit_ids)
536 536 self._remote.invalidate_vcs_cache()
537 537
538 538 def _local_clone(self, clone_path):
539 539 """
540 540 Create a local clone of the current repo.
541 541 """
542 542 self._remote.clone(self.path, clone_path, update_after_clone=True,
543 543 hooks=False)
544 544
545 545 def _update(self, revision, clean=False):
546 546 """
547 547 Update the working copty to the specified revision.
548 548 """
549 549 self._remote.update(revision, clean=clean)
550 550
551 551 def _identify(self):
552 552 """
553 553 Return the current state of the working directory.
554 554 """
555 555 return self._remote.identify().strip().rstrip('+')
556 556
557 557 def _heads(self, branch=None):
558 558 """
559 559 Return the commit ids of the repository heads.
560 560 """
561 561 return self._remote.heads(branch=branch).strip().split(' ')
562 562
563 563 def _ancestor(self, revision1, revision2):
564 564 """
565 565 Return the common ancestor of the two revisions.
566 566 """
567 567 return self._remote.ancestor(
568 568 revision1, revision2).strip().split(':')[-1]
569 569
570 570 def _local_push(
571 571 self, revision, repository_path, push_branches=False,
572 572 enable_hooks=False):
573 573 """
574 574 Push the given revision to the specified repository.
575 575
576 576 :param push_branches: allow to create branches in the target repo.
577 577 """
578 578 self._remote.push(
579 579 [revision], repository_path, hooks=enable_hooks,
580 580 push_branches=push_branches)
581 581
582 582 def _local_merge(self, target_ref, merge_message, user_name, user_email,
583 583 source_ref, use_rebase=False):
584 584 """
585 585 Merge the given source_revision into the checked out revision.
586 586
587 587 Returns the commit id of the merge and a boolean indicating if the
588 588 commit needs to be pushed.
589 589 """
590 590 self._update(target_ref.commit_id)
591 591
592 592 ancestor = self._ancestor(target_ref.commit_id, source_ref.commit_id)
593 593 is_the_same_branch = self._is_the_same_branch(target_ref, source_ref)
594 594
595 595 if ancestor == source_ref.commit_id:
596 596 # Nothing to do, the changes were already integrated
597 597 return target_ref.commit_id, False
598 598
599 599 elif ancestor == target_ref.commit_id and is_the_same_branch:
600 600 # In this case we should force a commit message
601 601 return source_ref.commit_id, True
602 602
603 603 if use_rebase:
604 604 try:
605 605 bookmark_name = 'rcbook%s%s' % (source_ref.commit_id,
606 606 target_ref.commit_id)
607 607 self.bookmark(bookmark_name, revision=source_ref.commit_id)
608 608 self._remote.rebase(
609 609 source=source_ref.commit_id, dest=target_ref.commit_id)
610 610 self._remote.invalidate_vcs_cache()
611 611 self._update(bookmark_name)
612 612 return self._identify(), True
613 613 except RepositoryError:
614 614 # The rebase-abort may raise another exception which 'hides'
615 615 # the original one, therefore we log it here.
616 616 log.exception('Error while rebasing shadow repo during merge.')
617 617
618 618 # Cleanup any rebase leftovers
619 619 self._remote.invalidate_vcs_cache()
620 620 self._remote.rebase(abort=True)
621 621 self._remote.invalidate_vcs_cache()
622 622 self._remote.update(clean=True)
623 623 raise
624 624 else:
625 625 try:
626 626 self._remote.merge(source_ref.commit_id)
627 627 self._remote.invalidate_vcs_cache()
628 628 self._remote.commit(
629 629 message=safe_str(merge_message),
630 630 username=safe_str('%s <%s>' % (user_name, user_email)))
631 631 self._remote.invalidate_vcs_cache()
632 632 return self._identify(), True
633 633 except RepositoryError:
634 634 # Cleanup any merge leftovers
635 635 self._remote.update(clean=True)
636 636 raise
637 637
638 638 def _is_the_same_branch(self, target_ref, source_ref):
639 639 return (
640 640 self._get_branch_name(target_ref) ==
641 641 self._get_branch_name(source_ref))
642 642
643 643 def _get_branch_name(self, ref):
644 644 if ref.type == 'branch':
645 645 return ref.name
646 646 return self._remote.ctx_branch(ref.commit_id)
647 647
648 648 def _get_shadow_repository_path(self, workspace_id):
649 649 # The name of the shadow repository must start with '.', so it is
650 650 # skipped by 'rhodecode.lib.utils.get_filesystem_repos'.
651 651 return os.path.join(
652 652 os.path.dirname(self.path),
653 653 '.__shadow_%s_%s' % (os.path.basename(self.path), workspace_id))
654 654
655 655 def _maybe_prepare_merge_workspace(self, workspace_id, unused_target_ref):
656 656 shadow_repository_path = self._get_shadow_repository_path(workspace_id)
657 657 if not os.path.exists(shadow_repository_path):
658 658 self._local_clone(shadow_repository_path)
659 659 log.debug(
660 660 'Prepared shadow repository in %s', shadow_repository_path)
661 661
662 662 return shadow_repository_path
663 663
664 664 def cleanup_merge_workspace(self, workspace_id):
665 665 shadow_repository_path = self._get_shadow_repository_path(workspace_id)
666 666 shutil.rmtree(shadow_repository_path, ignore_errors=True)
667 667
668 668 def _merge_repo(self, shadow_repository_path, target_ref,
669 669 source_repo, source_ref, merge_message,
670 670 merger_name, merger_email, dry_run=False,
671 671 use_rebase=False):
672 672 if target_ref.commit_id not in self._heads():
673 673 return MergeResponse(
674 674 False, False, None, MergeFailureReason.TARGET_IS_NOT_HEAD)
675 675
676 676 if (target_ref.type == 'branch' and
677 677 len(self._heads(target_ref.name)) != 1):
678 678 return MergeResponse(
679 679 False, False, None,
680 680 MergeFailureReason.HG_TARGET_HAS_MULTIPLE_HEADS)
681 681
682 682 shadow_repo = self._get_shadow_instance(shadow_repository_path)
683 683
684 684 log.debug('Pulling in target reference %s', target_ref)
685 685 self._validate_pull_reference(target_ref)
686 686 shadow_repo._local_pull(self.path, target_ref)
687 687 try:
688 688 log.debug('Pulling in source reference %s', source_ref)
689 689 source_repo._validate_pull_reference(source_ref)
690 690 shadow_repo._local_pull(source_repo.path, source_ref)
691 691 except CommitDoesNotExistError:
692 692 log.exception('Failure when doing local pull on hg shadow repo')
693 693 return MergeResponse(
694 694 False, False, None, MergeFailureReason.MISSING_COMMIT)
695 695
696 merge_commit_id = None
696 merge_ref = None
697 697 merge_failure_reason = MergeFailureReason.NONE
698 698
699 699 try:
700 700 merge_commit_id, needs_push = shadow_repo._local_merge(
701 701 target_ref, merge_message, merger_name, merger_email,
702 702 source_ref, use_rebase=use_rebase)
703 703 merge_possible = True
704 704
705 705 # Set a bookmark pointing to the merge commit. This bookmark may be
706 706 # used to easily identify the last successful merge commit in the
707 707 # shadow repository.
708 708 shadow_repo.bookmark('pr-merge', revision=merge_commit_id)
709 merge_ref = Reference('book', 'pr-merge', merge_commit_id)
709 710 except RepositoryError:
710 711 log.exception('Failure when doing local merge on hg shadow repo')
711 712 merge_possible = False
712 713 merge_failure_reason = MergeFailureReason.MERGE_FAILED
713 714
714 715 if merge_possible and not dry_run:
715 716 if needs_push:
716 717 # In case the target is a bookmark, update it, so after pushing
717 718 # the bookmarks is also updated in the target.
718 719 if target_ref.type == 'book':
719 720 shadow_repo.bookmark(
720 721 target_ref.name, revision=merge_commit_id)
721 722
722 723 try:
723 724 shadow_repo_with_hooks = self._get_shadow_instance(
724 725 shadow_repository_path,
725 726 enable_hooks=True)
726 727 # Note: the push_branches option will push any new branch
727 728 # defined in the source repository to the target. This may
728 729 # be dangerous as branches are permanent in Mercurial.
729 730 # This feature was requested in issue #441.
730 731 shadow_repo_with_hooks._local_push(
731 732 merge_commit_id, self.path, push_branches=True,
732 733 enable_hooks=True)
733 734 merge_succeeded = True
734 735 except RepositoryError:
735 736 log.exception(
736 737 'Failure when doing local push from the shadow '
737 738 'repository to the target repository.')
738 739 merge_succeeded = False
739 740 merge_failure_reason = MergeFailureReason.PUSH_FAILED
740 741 else:
741 742 merge_succeeded = True
742 743 else:
743 744 merge_succeeded = False
744 745
745 746 return MergeResponse(
746 merge_possible, merge_succeeded, merge_commit_id,
747 merge_failure_reason)
747 merge_possible, merge_succeeded, merge_ref, merge_failure_reason)
748 748
749 749 def _get_shadow_instance(
750 750 self, shadow_repository_path, enable_hooks=False):
751 751 config = self.config.copy()
752 752 if not enable_hooks:
753 753 config.clear_section('hooks')
754 754 return MercurialRepository(shadow_repository_path, config)
755 755
756 756 def _validate_pull_reference(self, reference):
757 757 if not (reference.name in self.bookmarks or
758 758 reference.name in self.branches or
759 759 self.get_commit(reference.commit_id)):
760 760 raise CommitDoesNotExistError(
761 761 'Unknown branch, bookmark or commit id')
762 762
763 763 def _local_pull(self, repository_path, reference):
764 764 """
765 765 Fetch a branch, bookmark or commit from a local repository.
766 766 """
767 767 repository_path = os.path.abspath(repository_path)
768 768 if repository_path == self.path:
769 769 raise ValueError('Cannot pull from the same repository')
770 770
771 771 reference_type_to_option_name = {
772 772 'book': 'bookmark',
773 773 'branch': 'branch',
774 774 }
775 775 option_name = reference_type_to_option_name.get(
776 776 reference.type, 'revision')
777 777
778 778 if option_name == 'revision':
779 779 ref = reference.commit_id
780 780 else:
781 781 ref = reference.name
782 782
783 783 options = {option_name: [ref]}
784 784 self._remote.pull_cmd(repository_path, hooks=False, **options)
785 785 self._remote.invalidate_vcs_cache()
786 786
787 787 def bookmark(self, bookmark, revision=None):
788 788 if isinstance(bookmark, unicode):
789 789 bookmark = safe_str(bookmark)
790 790 self._remote.bookmark(bookmark, revision=revision)
791 791 self._remote.invalidate_vcs_cache()
792 792
793 793
794 794 class MercurialIndexBasedCollectionGenerator(CollectionGenerator):
795 795
796 796 def _commit_factory(self, commit_id):
797 797 return self.repo.get_commit(
798 798 commit_idx=commit_id, pre_load=self.pre_load)
General Comments 0
You need to be logged in to leave comments. Login now