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