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