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