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