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