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