##// END OF EJS Templates
feat(git/svn): remove filesystem modifications from git/svn calls. When sharding comes in place we can't do this....
super-admin -
r5216:07778025 default
parent child Browse files
Show More
@@ -1,1054 +1,1050 b''
1 1 # Copyright (C) 2014-2023 RhodeCode GmbH
2 2 #
3 3 # This program is free software: you can redistribute it and/or modify
4 4 # it under the terms of the GNU Affero General Public License, version 3
5 5 # (only), as published by the Free Software Foundation.
6 6 #
7 7 # This program is distributed in the hope that it will be useful,
8 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 10 # GNU General Public License for more details.
11 11 #
12 12 # You should have received a copy of the GNU Affero General Public License
13 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 #
15 15 # This program is dual-licensed. If you wish to learn more about the
16 16 # RhodeCode Enterprise Edition, including its added features, Support services,
17 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
18 18
19 19 """
20 20 GIT repository module
21 21 """
22 22
23 23 import logging
24 24 import os
25 25 import re
26 26
27 27 from zope.cachedescriptors.property import Lazy as LazyProperty
28 28
29 29 from collections import OrderedDict
30 30 from rhodecode.lib.datelib import (
31 31 utcdate_fromtimestamp, makedate, date_astimestamp)
32 32 from rhodecode.lib.hash_utils import safe_str
33 33 from rhodecode.lib.utils2 import CachedProperty
34 34 from rhodecode.lib.vcs import connection, path as vcspath
35 35 from rhodecode.lib.vcs.backends.base import (
36 36 BaseRepository, CollectionGenerator, Config, MergeResponse,
37 37 MergeFailureReason, Reference)
38 38 from rhodecode.lib.vcs.backends.git.commit import GitCommit
39 39 from rhodecode.lib.vcs.backends.git.diff import GitDiff
40 40 from rhodecode.lib.vcs.backends.git.inmemory import GitInMemoryCommit
41 41 from rhodecode.lib.vcs.exceptions import (
42 42 CommitDoesNotExistError, EmptyRepositoryError,
43 43 RepositoryError, TagAlreadyExistError, TagDoesNotExistError, VCSError, UnresolvedFilesInRepo)
44 44
45 45
46 46 SHA_PATTERN = re.compile(r'^([0-9a-fA-F]{12}|[0-9a-fA-F]{40})$')
47 47
48 48 log = logging.getLogger(__name__)
49 49
50 50
51 51 class GitRepository(BaseRepository):
52 52 """
53 53 Git repository backend.
54 54 """
55 55 DEFAULT_BRANCH_NAME = os.environ.get('GIT_DEFAULT_BRANCH_NAME') or 'master'
56 56 DEFAULT_REF = f'branch:{DEFAULT_BRANCH_NAME}'
57 57
58 58 contact = BaseRepository.DEFAULT_CONTACT
59 59
60 60 def __init__(self, repo_path, config=None, create=False, src_url=None,
61 61 do_workspace_checkout=False, with_wire=None, bare=False):
62 62
63 63 self.path = safe_str(os.path.abspath(repo_path))
64 64 self.config = config if config else self.get_default_config()
65 65 self.with_wire = with_wire or {"cache": False} # default should not use cache
66 66
67 67 self._init_repo(create, src_url, do_workspace_checkout, bare)
68 68
69 69 # caches
70 70 self._commit_ids = {}
71 71
72 72 @LazyProperty
73 73 def _remote(self):
74 74 repo_id = self.path
75 75 return connection.Git(self.path, repo_id, self.config, with_wire=self.with_wire)
76 76
77 77 @LazyProperty
78 78 def bare(self):
79 79 return self._remote.bare()
80 80
81 81 @LazyProperty
82 82 def head(self):
83 83 return self._remote.head()
84 84
85 85 @CachedProperty
86 86 def commit_ids(self):
87 87 """
88 88 Returns list of commit ids, in ascending order. Being lazy
89 89 attribute allows external tools to inject commit ids from cache.
90 90 """
91 91 commit_ids = self._get_all_commit_ids()
92 92 self._rebuild_cache(commit_ids)
93 93 return commit_ids
94 94
95 95 def _rebuild_cache(self, commit_ids):
96 96 self._commit_ids = {commit_id: index
97 97 for index, commit_id in enumerate(commit_ids)}
98 98
99 99 def run_git_command(self, cmd, **opts):
100 100 """
101 101 Runs given ``cmd`` as git command and returns tuple
102 102 (stdout, stderr).
103 103
104 104 :param cmd: git command to be executed
105 105 :param opts: env options to pass into Subprocess command
106 106 """
107 107 if not isinstance(cmd, list):
108 108 raise ValueError(f'cmd must be a list, got {type(cmd)} instead')
109 109
110 110 skip_stderr_log = opts.pop('skip_stderr_log', False)
111 111 out, err = self._remote.run_git_command(cmd, **opts)
112 112 if err and not skip_stderr_log:
113 113 log.debug('Stderr output of git command "%s":\n%s', cmd, err)
114 114 return out, err
115 115
116 116 @staticmethod
117 117 def check_url(url, config):
118 118 """
119 119 Function will check given url and try to verify if it's a valid
120 120 link. Sometimes it may happened that git will issue basic
121 121 auth request that can cause whole API to hang when used from python
122 122 or other external calls.
123 123
124 124 On failures it'll raise urllib2.HTTPError, exception is also thrown
125 125 when the return code is non 200
126 126 """
127 127 # check first if it's not an url
128 128 if os.path.isdir(url) or url.startswith('file:'):
129 129 return True
130 130
131 131 if '+' in url.split('://', 1)[0]:
132 132 url = url.split('+', 1)[1]
133 133
134 134 # Request the _remote to verify the url
135 135 return connection.Git.check_url(url, config.serialize())
136 136
137 137 @staticmethod
138 138 def is_valid_repository(path):
139 139 if os.path.isdir(os.path.join(path, '.git')):
140 140 return True
141 141 # check case of bare repository
142 142 try:
143 143 GitRepository(path)
144 144 return True
145 145 except VCSError:
146 146 pass
147 147 return False
148 148
149 149 def _init_repo(self, create, src_url=None, do_workspace_checkout=False,
150 150 bare=False):
151 151 if create and os.path.exists(self.path):
152 152 raise RepositoryError(
153 "Cannot create repository at %s, location already exist"
154 % self.path)
153 f"Cannot create repository at {self.path}, location already exist")
155 154
156 155 if bare and do_workspace_checkout:
157 156 raise RepositoryError("Cannot update a bare repository")
158 157 try:
159 158
160 159 if src_url:
161 160 # check URL before any actions
162 161 GitRepository.check_url(src_url, self.config)
163 162
164 163 if create:
165 os.makedirs(self.path, mode=0o755)
166
167 164 if bare:
168 165 self._remote.init_bare()
169 166 else:
170 167 self._remote.init()
171 168
172 169 if src_url and bare:
173 170 # bare repository only allows a fetch and checkout is not allowed
174 171 self.fetch(src_url, commit_ids=None)
175 172 elif src_url:
176 173 self.pull(src_url, commit_ids=None,
177 174 update_after=do_workspace_checkout)
178 175
179 176 else:
180 177 if not self._remote.assert_correct_path():
181 178 raise RepositoryError(
182 'Path "%s" does not contain a Git repository' %
183 (self.path,))
179 f'Path "{self.path}" does not contain a Git repository')
184 180
185 181 # TODO: johbo: check if we have to translate the OSError here
186 182 except OSError as err:
187 183 raise RepositoryError(err)
188 184
189 185 def _get_all_commit_ids(self):
190 186 return self._remote.get_all_commit_ids()
191 187
192 188 def _get_commit_ids(self, filters=None):
193 189 # we must check if this repo is not empty, since later command
194 190 # fails if it is. And it's cheaper to ask than throw the subprocess
195 191 # errors
196 192
197 193 head = self._remote.head(show_exc=False)
198 194
199 195 if not head:
200 196 return []
201 197
202 198 rev_filter = ['--branches', '--tags']
203 199 extra_filter = []
204 200
205 201 if filters:
206 202 if filters.get('since'):
207 203 extra_filter.append('--since=%s' % (filters['since']))
208 204 if filters.get('until'):
209 205 extra_filter.append('--until=%s' % (filters['until']))
210 206 if filters.get('branch_name'):
211 207 rev_filter = []
212 208 extra_filter.append(filters['branch_name'])
213 209 rev_filter.extend(extra_filter)
214 210
215 211 # if filters.get('start') or filters.get('end'):
216 212 # # skip is offset, max-count is limit
217 213 # if filters.get('start'):
218 214 # extra_filter += ' --skip=%s' % filters['start']
219 215 # if filters.get('end'):
220 216 # extra_filter += ' --max-count=%s' % (filters['end'] - (filters['start'] or 0))
221 217
222 218 cmd = ['rev-list', '--reverse', '--date-order'] + rev_filter
223 219 try:
224 220 output, __ = self.run_git_command(cmd)
225 221 except RepositoryError:
226 222 # Can be raised for empty repositories
227 223 return []
228 224 return output.splitlines()
229 225
230 226 def _lookup_commit(self, commit_id_or_idx, translate_tag=True, maybe_unreachable=False, reference_obj=None):
231 227
232 228 def is_null(value):
233 229 return len(value) == commit_id_or_idx.count('0')
234 230
235 231 if commit_id_or_idx in (None, '', 'tip', 'HEAD', 'head', -1):
236 232 return self.commit_ids[-1]
237 233
238 234 commit_missing_err = "Commit {} does not exist for `{}`".format(
239 235 *map(safe_str, [commit_id_or_idx, self.name]))
240 236
241 237 is_bstr = isinstance(commit_id_or_idx, str)
242 238 is_branch = reference_obj and reference_obj.branch
243 239
244 240 lookup_ok = False
245 241 if is_bstr:
246 242 # Need to call remote to translate id for tagging scenarios,
247 243 # or branch that are numeric
248 244 try:
249 245 remote_data = self._remote.get_object(commit_id_or_idx,
250 246 maybe_unreachable=maybe_unreachable)
251 247 commit_id_or_idx = remote_data["commit_id"]
252 248 lookup_ok = True
253 249 except (CommitDoesNotExistError,):
254 250 lookup_ok = False
255 251
256 252 if lookup_ok is False:
257 253 is_numeric_idx = \
258 254 (is_bstr and commit_id_or_idx.isdigit() and len(commit_id_or_idx) < 12) \
259 255 or isinstance(commit_id_or_idx, int)
260 256 if not is_branch and (is_numeric_idx or is_null(commit_id_or_idx)):
261 257 try:
262 258 commit_id_or_idx = self.commit_ids[int(commit_id_or_idx)]
263 259 lookup_ok = True
264 260 except Exception:
265 261 raise CommitDoesNotExistError(commit_missing_err)
266 262
267 263 # we failed regular lookup, and by integer number lookup
268 264 if lookup_ok is False:
269 265 raise CommitDoesNotExistError(commit_missing_err)
270 266
271 267 # Ensure we return full id
272 268 if not SHA_PATTERN.match(str(commit_id_or_idx)):
273 269 raise CommitDoesNotExistError(
274 270 "Given commit id %s not recognized" % commit_id_or_idx)
275 271 return commit_id_or_idx
276 272
277 273 def get_hook_location(self):
278 274 """
279 275 returns absolute path to location where hooks are stored
280 276 """
281 277 loc = os.path.join(self.path, 'hooks')
282 278 if not self.bare:
283 279 loc = os.path.join(self.path, '.git', 'hooks')
284 280 return loc
285 281
286 282 @LazyProperty
287 283 def last_change(self):
288 284 """
289 285 Returns last change made on this repository as
290 286 `datetime.datetime` object.
291 287 """
292 288 try:
293 289 return self.get_commit().date
294 290 except RepositoryError:
295 291 tzoffset = makedate()[1]
296 292 return utcdate_fromtimestamp(self._get_fs_mtime(), tzoffset)
297 293
298 294 def _get_fs_mtime(self):
299 295 idx_loc = '' if self.bare else '.git'
300 296 # fallback to filesystem
301 297 in_path = os.path.join(self.path, idx_loc, "index")
302 298 he_path = os.path.join(self.path, idx_loc, "HEAD")
303 299 if os.path.exists(in_path):
304 300 return os.stat(in_path).st_mtime
305 301 else:
306 302 return os.stat(he_path).st_mtime
307 303
308 304 @LazyProperty
309 305 def description(self):
310 306 description = self._remote.get_description()
311 307 return safe_str(description or self.DEFAULT_DESCRIPTION)
312 308
313 309 def _get_refs_entries(self, prefix='', reverse=False, strip_prefix=True):
314 310 if self.is_empty():
315 311 return OrderedDict()
316 312
317 313 result = []
318 314 for ref, sha in self._refs.items():
319 315 if ref.startswith(prefix):
320 316 ref_name = ref
321 317 if strip_prefix:
322 318 ref_name = ref[len(prefix):]
323 319 result.append((safe_str(ref_name), sha))
324 320
325 321 def get_name(entry):
326 322 return entry[0]
327 323
328 324 return OrderedDict(sorted(result, key=get_name, reverse=reverse))
329 325
330 326 def _get_branches(self):
331 327 return self._get_refs_entries(prefix='refs/heads/', strip_prefix=True)
332 328
333 329 @CachedProperty
334 330 def branches(self):
335 331 return self._get_branches()
336 332
337 333 @CachedProperty
338 334 def branches_closed(self):
339 335 return {}
340 336
341 337 @CachedProperty
342 338 def bookmarks(self):
343 339 return {}
344 340
345 341 @CachedProperty
346 342 def branches_all(self):
347 343 all_branches = {}
348 344 all_branches.update(self.branches)
349 345 all_branches.update(self.branches_closed)
350 346 return all_branches
351 347
352 348 @CachedProperty
353 349 def tags(self):
354 350 return self._get_tags()
355 351
356 352 def _get_tags(self):
357 353 return self._get_refs_entries(prefix='refs/tags/', strip_prefix=True, reverse=True)
358 354
359 355 def tag(self, name, user, commit_id=None, message=None, date=None,
360 356 **kwargs):
361 357 # TODO: fix this method to apply annotated tags correct with message
362 358 """
363 359 Creates and returns a tag for the given ``commit_id``.
364 360
365 361 :param name: name for new tag
366 362 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
367 363 :param commit_id: commit id for which new tag would be created
368 364 :param message: message of the tag's commit
369 365 :param date: date of tag's commit
370 366
371 367 :raises TagAlreadyExistError: if tag with same name already exists
372 368 """
373 369 if name in self.tags:
374 370 raise TagAlreadyExistError("Tag %s already exists" % name)
375 371 commit = self.get_commit(commit_id=commit_id)
376 372 message = message or f"Added tag {name} for commit {commit.raw_id}"
377 373
378 374 self._remote.set_refs('refs/tags/%s' % name, commit.raw_id)
379 375
380 376 self._invalidate_prop_cache('tags')
381 377 self._invalidate_prop_cache('_refs')
382 378
383 379 return commit
384 380
385 381 def remove_tag(self, name, user, message=None, date=None):
386 382 """
387 383 Removes tag with the given ``name``.
388 384
389 385 :param name: name of the tag to be removed
390 386 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
391 387 :param message: message of the tag's removal commit
392 388 :param date: date of tag's removal commit
393 389
394 390 :raises TagDoesNotExistError: if tag with given name does not exists
395 391 """
396 392 if name not in self.tags:
397 393 raise TagDoesNotExistError("Tag %s does not exist" % name)
398 394
399 395 self._remote.tag_remove(name)
400 396 self._invalidate_prop_cache('tags')
401 397 self._invalidate_prop_cache('_refs')
402 398
403 399 def _get_refs(self):
404 400 return self._remote.get_refs()
405 401
406 402 @CachedProperty
407 403 def _refs(self):
408 404 return self._get_refs()
409 405
410 406 @property
411 407 def _ref_tree(self):
412 408 node = tree = {}
413 409 for ref, sha in self._refs.items():
414 410 path = ref.split('/')
415 411 for bit in path[:-1]:
416 412 node = node.setdefault(bit, {})
417 413 node[path[-1]] = sha
418 414 node = tree
419 415 return tree
420 416
421 417 def get_remote_ref(self, ref_name):
422 418 ref_key = f'refs/remotes/origin/{safe_str(ref_name)}'
423 419 try:
424 420 return self._refs[ref_key]
425 421 except Exception:
426 422 return
427 423
428 424 def get_commit(self, commit_id=None, commit_idx=None, pre_load=None,
429 425 translate_tag=True, maybe_unreachable=False, reference_obj=None):
430 426 """
431 427 Returns `GitCommit` object representing commit from git repository
432 428 at the given `commit_id` or head (most recent commit) if None given.
433 429 """
434 430
435 431 if self.is_empty():
436 432 raise EmptyRepositoryError("There are no commits yet")
437 433
438 434 if commit_id is not None:
439 435 self._validate_commit_id(commit_id)
440 436 try:
441 437 # we have cached idx, use it without contacting the remote
442 438 idx = self._commit_ids[commit_id]
443 439 return GitCommit(self, commit_id, idx, pre_load=pre_load)
444 440 except KeyError:
445 441 pass
446 442
447 443 elif commit_idx is not None:
448 444 self._validate_commit_idx(commit_idx)
449 445 try:
450 446 _commit_id = self.commit_ids[commit_idx]
451 447 if commit_idx < 0:
452 448 commit_idx = self.commit_ids.index(_commit_id)
453 449 return GitCommit(self, _commit_id, commit_idx, pre_load=pre_load)
454 450 except IndexError:
455 451 commit_id = commit_idx
456 452 else:
457 453 commit_id = "tip"
458 454
459 455 if translate_tag:
460 456 commit_id = self._lookup_commit(
461 457 commit_id, maybe_unreachable=maybe_unreachable,
462 458 reference_obj=reference_obj)
463 459
464 460 try:
465 461 idx = self._commit_ids[commit_id]
466 462 except KeyError:
467 463 idx = -1
468 464
469 465 return GitCommit(self, commit_id, idx, pre_load=pre_load)
470 466
471 467 def get_commits(
472 468 self, start_id=None, end_id=None, start_date=None, end_date=None,
473 469 branch_name=None, show_hidden=False, pre_load=None, translate_tags=True):
474 470 """
475 471 Returns generator of `GitCommit` objects from start to end (both
476 472 are inclusive), in ascending date order.
477 473
478 474 :param start_id: None, str(commit_id)
479 475 :param end_id: None, str(commit_id)
480 476 :param start_date: if specified, commits with commit date less than
481 477 ``start_date`` would be filtered out from returned set
482 478 :param end_date: if specified, commits with commit date greater than
483 479 ``end_date`` would be filtered out from returned set
484 480 :param branch_name: if specified, commits not reachable from given
485 481 branch would be filtered out from returned set
486 482 :param show_hidden: Show hidden commits such as obsolete or hidden from
487 483 Mercurial evolve
488 484 :raise BranchDoesNotExistError: If given `branch_name` does not
489 485 exist.
490 486 :raise CommitDoesNotExistError: If commits for given `start` or
491 487 `end` could not be found.
492 488
493 489 """
494 490 if self.is_empty():
495 491 raise EmptyRepositoryError("There are no commits yet")
496 492
497 493 self._validate_branch_name(branch_name)
498 494
499 495 if start_id is not None:
500 496 self._validate_commit_id(start_id)
501 497 if end_id is not None:
502 498 self._validate_commit_id(end_id)
503 499
504 500 start_raw_id = self._lookup_commit(start_id)
505 501 start_pos = self._commit_ids[start_raw_id] if start_id else None
506 502 end_raw_id = self._lookup_commit(end_id)
507 503 end_pos = max(0, self._commit_ids[end_raw_id]) if end_id else None
508 504
509 505 if None not in [start_id, end_id] and start_pos > end_pos:
510 506 raise RepositoryError(
511 507 "Start commit '%s' cannot be after end commit '%s'" %
512 508 (start_id, end_id))
513 509
514 510 if end_pos is not None:
515 511 end_pos += 1
516 512
517 513 filter_ = []
518 514 if branch_name:
519 515 filter_.append({'branch_name': branch_name})
520 516 if start_date and not end_date:
521 517 filter_.append({'since': start_date})
522 518 if end_date and not start_date:
523 519 filter_.append({'until': end_date})
524 520 if start_date and end_date:
525 521 filter_.append({'since': start_date})
526 522 filter_.append({'until': end_date})
527 523
528 524 # if start_pos or end_pos:
529 525 # filter_.append({'start': start_pos})
530 526 # filter_.append({'end': end_pos})
531 527
532 528 if filter_:
533 529 revfilters = {
534 530 'branch_name': branch_name,
535 531 'since': start_date.strftime('%m/%d/%y %H:%M:%S') if start_date else None,
536 532 'until': end_date.strftime('%m/%d/%y %H:%M:%S') if end_date else None,
537 533 'start': start_pos,
538 534 'end': end_pos,
539 535 }
540 536 commit_ids = self._get_commit_ids(filters=revfilters)
541 537
542 538 else:
543 539 commit_ids = self.commit_ids
544 540
545 541 if start_pos or end_pos:
546 542 commit_ids = commit_ids[start_pos: end_pos]
547 543
548 544 return CollectionGenerator(self, commit_ids, pre_load=pre_load,
549 545 translate_tag=translate_tags)
550 546
551 547 def get_diff(
552 548 self, commit1, commit2, path='', ignore_whitespace=False,
553 549 context=3, path1=None):
554 550 """
555 551 Returns (git like) *diff*, as plain text. Shows changes introduced by
556 552 ``commit2`` since ``commit1``.
557 553
558 554 :param commit1: Entry point from which diff is shown. Can be
559 555 ``self.EMPTY_COMMIT`` - in this case, patch showing all
560 556 the changes since empty state of the repository until ``commit2``
561 557 :param commit2: Until which commits changes should be shown.
562 558 :param path:
563 559 :param ignore_whitespace: If set to ``True``, would not show whitespace
564 560 changes. Defaults to ``False``.
565 561 :param context: How many lines before/after changed lines should be
566 562 shown. Defaults to ``3``.
567 563 :param path1:
568 564 """
569 565 self._validate_diff_commits(commit1, commit2)
570 566 if path1 is not None and path1 != path:
571 567 raise ValueError("Diff of two different paths not supported.")
572 568
573 569 if path:
574 570 file_filter = path
575 571 else:
576 572 file_filter = None
577 573
578 574 diff = self._remote.diff(
579 575 commit1.raw_id, commit2.raw_id, file_filter=file_filter,
580 576 opt_ignorews=ignore_whitespace,
581 577 context=context)
582 578
583 579 return GitDiff(diff)
584 580
585 581 def strip(self, commit_id, branch_name):
586 582 commit = self.get_commit(commit_id=commit_id)
587 583 if commit.merge:
588 584 raise Exception('Cannot reset to merge commit')
589 585
590 586 # parent is going to be the new head now
591 587 commit = commit.parents[0]
592 588 self._remote.set_refs('refs/heads/%s' % branch_name, commit.raw_id)
593 589
594 590 # clear cached properties
595 591 self._invalidate_prop_cache('commit_ids')
596 592 self._invalidate_prop_cache('_refs')
597 593 self._invalidate_prop_cache('branches')
598 594
599 595 return len(self.commit_ids)
600 596
601 597 def get_common_ancestor(self, commit_id1, commit_id2, repo2):
602 598 log.debug('Calculating common ancestor between %sc1:%s and %sc2:%s',
603 599 self, commit_id1, repo2, commit_id2)
604 600
605 601 if commit_id1 == commit_id2:
606 602 return commit_id1
607 603
608 604 if self != repo2:
609 605 commits = self._remote.get_missing_revs(
610 606 commit_id1, commit_id2, repo2.path)
611 607 if commits:
612 608 commit = repo2.get_commit(commits[-1])
613 609 if commit.parents:
614 610 ancestor_id = commit.parents[0].raw_id
615 611 else:
616 612 ancestor_id = None
617 613 else:
618 614 # no commits from other repo, ancestor_id is the commit_id2
619 615 ancestor_id = commit_id2
620 616 else:
621 617 output, __ = self.run_git_command(
622 618 ['merge-base', commit_id1, commit_id2])
623 619 ancestor_id = self.COMMIT_ID_PAT.findall(output)[0]
624 620
625 621 log.debug('Found common ancestor with sha: %s', ancestor_id)
626 622
627 623 return ancestor_id
628 624
629 625 def compare(self, commit_id1, commit_id2, repo2, merge, pre_load=None):
630 626 repo1 = self
631 627 ancestor_id = None
632 628
633 629 if commit_id1 == commit_id2:
634 630 commits = []
635 631 elif repo1 != repo2:
636 632 missing_ids = self._remote.get_missing_revs(commit_id1, commit_id2,
637 633 repo2.path)
638 634 commits = [
639 635 repo2.get_commit(commit_id=commit_id, pre_load=pre_load)
640 636 for commit_id in reversed(missing_ids)]
641 637 else:
642 638 output, __ = repo1.run_git_command(
643 639 ['log', '--reverse', '--pretty=format: %H', '-s',
644 640 f'{commit_id1}..{commit_id2}'])
645 641 commits = [
646 642 repo1.get_commit(commit_id=commit_id, pre_load=pre_load)
647 643 for commit_id in self.COMMIT_ID_PAT.findall(output)]
648 644
649 645 return commits
650 646
651 647 @LazyProperty
652 648 def in_memory_commit(self):
653 649 """
654 650 Returns ``GitInMemoryCommit`` object for this repository.
655 651 """
656 652 return GitInMemoryCommit(self)
657 653
658 654 def pull(self, url, commit_ids=None, update_after=False):
659 655 """
660 656 Pull changes from external location. Pull is different in GIT
661 657 that fetch since it's doing a checkout
662 658
663 659 :param commit_ids: Optional. Can be set to a list of commit ids
664 660 which shall be pulled from the other repository.
665 661 """
666 662 refs = None
667 663 if commit_ids is not None:
668 664 remote_refs = self._remote.get_remote_refs(url)
669 665 refs = [ref for ref in remote_refs if remote_refs[ref] in commit_ids]
670 666 self._remote.pull(url, refs=refs, update_after=update_after)
671 667 self._remote.invalidate_vcs_cache()
672 668
673 669 def fetch(self, url, commit_ids=None):
674 670 """
675 671 Fetch all git objects from external location.
676 672 """
677 673 self._remote.sync_fetch(url, refs=commit_ids)
678 674 self._remote.invalidate_vcs_cache()
679 675
680 676 def push(self, url):
681 677 refs = None
682 678 self._remote.sync_push(url, refs=refs)
683 679
684 680 def set_refs(self, ref_name, commit_id):
685 681 self._remote.set_refs(ref_name, commit_id)
686 682 self._invalidate_prop_cache('_refs')
687 683
688 684 def remove_ref(self, ref_name):
689 685 self._remote.remove_ref(ref_name)
690 686 self._invalidate_prop_cache('_refs')
691 687
692 688 def run_gc(self, prune=True):
693 689 cmd = ['gc', '--aggressive']
694 690 if prune:
695 691 cmd += ['--prune=now']
696 692 _stdout, stderr = self.run_git_command(cmd, fail_on_stderr=False)
697 693 return stderr
698 694
699 695 def _update_server_info(self):
700 696 """
701 697 runs gits update-server-info command in this repo instance
702 698 """
703 699 self._remote.update_server_info()
704 700
705 701 def _current_branch(self):
706 702 """
707 703 Return the name of the current branch.
708 704
709 705 It only works for non bare repositories (i.e. repositories with a
710 706 working copy)
711 707 """
712 708 if self.bare:
713 709 raise RepositoryError('Bare git repos do not have active branches')
714 710
715 711 if self.is_empty():
716 712 return None
717 713
718 714 stdout, _ = self.run_git_command(['rev-parse', '--abbrev-ref', 'HEAD'])
719 715 return stdout.strip()
720 716
721 717 def _checkout(self, branch_name, create=False, force=False):
722 718 """
723 719 Checkout a branch in the working directory.
724 720
725 721 It tries to create the branch if create is True, failing if the branch
726 722 already exists.
727 723
728 724 It only works for non bare repositories (i.e. repositories with a
729 725 working copy)
730 726 """
731 727 if self.bare:
732 728 raise RepositoryError('Cannot checkout branches in a bare git repo')
733 729
734 730 cmd = ['checkout']
735 731 if force:
736 732 cmd.append('-f')
737 733 if create:
738 734 cmd.append('-b')
739 735 cmd.append(branch_name)
740 736 self.run_git_command(cmd, fail_on_stderr=False)
741 737
742 738 def _create_branch(self, branch_name, commit_id):
743 739 """
744 740 creates a branch in a GIT repo
745 741 """
746 742 self._remote.create_branch(branch_name, commit_id)
747 743
748 744 def _identify(self):
749 745 """
750 746 Return the current state of the working directory.
751 747 """
752 748 if self.bare:
753 749 raise RepositoryError('Bare git repos do not have active branches')
754 750
755 751 if self.is_empty():
756 752 return None
757 753
758 754 stdout, _ = self.run_git_command(['rev-parse', 'HEAD'])
759 755 return stdout.strip()
760 756
761 757 def _local_clone(self, clone_path, branch_name, source_branch=None):
762 758 """
763 759 Create a local clone of the current repo.
764 760 """
765 761 # N.B.(skreft): the --branch option is required as otherwise the shallow
766 762 # clone will only fetch the active branch.
767 763 cmd = ['clone', '--branch', branch_name,
768 764 self.path, os.path.abspath(clone_path)]
769 765
770 766 self.run_git_command(cmd, fail_on_stderr=False)
771 767
772 768 # if we get the different source branch, make sure we also fetch it for
773 769 # merge conditions
774 770 if source_branch and source_branch != branch_name:
775 771 # check if the ref exists.
776 772 shadow_repo = GitRepository(os.path.abspath(clone_path))
777 773 if shadow_repo.get_remote_ref(source_branch):
778 774 cmd = ['fetch', self.path, source_branch]
779 775 self.run_git_command(cmd, fail_on_stderr=False)
780 776
781 777 def _local_fetch(self, repository_path, branch_name, use_origin=False):
782 778 """
783 779 Fetch a branch from a local repository.
784 780 """
785 781 repository_path = os.path.abspath(repository_path)
786 782 if repository_path == self.path:
787 783 raise ValueError('Cannot fetch from the same repository')
788 784
789 785 if use_origin:
790 786 branch_name = '+{branch}:refs/heads/{branch}'.format(
791 787 branch=branch_name)
792 788
793 789 cmd = ['fetch', '--no-tags', '--update-head-ok',
794 790 repository_path, branch_name]
795 791 self.run_git_command(cmd, fail_on_stderr=False)
796 792
797 793 def _local_reset(self, branch_name):
798 794 branch_name = f'{branch_name}'
799 795 cmd = ['reset', '--hard', branch_name, '--']
800 796 self.run_git_command(cmd, fail_on_stderr=False)
801 797
802 798 def _last_fetch_heads(self):
803 799 """
804 800 Return the last fetched heads that need merging.
805 801
806 802 The algorithm is defined at
807 803 https://github.com/git/git/blob/v2.1.3/git-pull.sh#L283
808 804 """
809 805 if not self.bare:
810 806 fetch_heads_path = os.path.join(self.path, '.git', 'FETCH_HEAD')
811 807 else:
812 808 fetch_heads_path = os.path.join(self.path, 'FETCH_HEAD')
813 809
814 810 heads = []
815 811 with open(fetch_heads_path) as f:
816 812 for line in f:
817 813 if ' not-for-merge ' in line:
818 814 continue
819 815 line = re.sub('\t.*', '', line, flags=re.DOTALL)
820 816 heads.append(line)
821 817
822 818 return heads
823 819
824 820 def get_shadow_instance(self, shadow_repository_path, enable_hooks=False, cache=False):
825 821 return GitRepository(shadow_repository_path, with_wire={"cache": cache})
826 822
827 823 def _local_pull(self, repository_path, branch_name, ff_only=True):
828 824 """
829 825 Pull a branch from a local repository.
830 826 """
831 827 if self.bare:
832 828 raise RepositoryError('Cannot pull into a bare git repository')
833 829 # N.B.(skreft): The --ff-only option is to make sure this is a
834 830 # fast-forward (i.e., we are only pulling new changes and there are no
835 831 # conflicts with our current branch)
836 832 # Additionally, that option needs to go before --no-tags, otherwise git
837 833 # pull complains about it being an unknown flag.
838 834 cmd = ['pull']
839 835 if ff_only:
840 836 cmd.append('--ff-only')
841 837 cmd.extend(['--no-tags', repository_path, branch_name])
842 838 self.run_git_command(cmd, fail_on_stderr=False)
843 839
844 840 def _local_merge(self, merge_message, user_name, user_email, heads):
845 841 """
846 842 Merge the given head into the checked out branch.
847 843
848 844 It will force a merge commit.
849 845
850 846 Currently it raises an error if the repo is empty, as it is not possible
851 847 to create a merge commit in an empty repo.
852 848
853 849 :param merge_message: The message to use for the merge commit.
854 850 :param heads: the heads to merge.
855 851 """
856 852 if self.bare:
857 853 raise RepositoryError('Cannot merge into a bare git repository')
858 854
859 855 if not heads:
860 856 return
861 857
862 858 if self.is_empty():
863 859 # TODO(skreft): do something more robust in this case.
864 860 raise RepositoryError('Do not know how to merge into empty repositories yet')
865 861 unresolved = None
866 862
867 863 # N.B.(skreft): the --no-ff option is used to enforce the creation of a
868 864 # commit message. We also specify the user who is doing the merge.
869 865 cmd = ['-c', f'user.name="{user_name}"',
870 866 '-c', f'user.email={user_email}',
871 867 'merge', '--no-ff', '-m', safe_str(merge_message)]
872 868
873 869 merge_cmd = cmd + heads
874 870
875 871 try:
876 872 self.run_git_command(merge_cmd, fail_on_stderr=False)
877 873 except RepositoryError:
878 874 files = self.run_git_command(['diff', '--name-only', '--diff-filter', 'U'],
879 875 fail_on_stderr=False)[0].splitlines()
880 876 # NOTE(marcink): we add U notation for consistent with HG backend output
881 877 unresolved = [f'U {f}' for f in files]
882 878
883 879 # Cleanup any merge leftovers
884 880 self._remote.invalidate_vcs_cache()
885 881 self.run_git_command(['merge', '--abort'], fail_on_stderr=False)
886 882
887 883 if unresolved:
888 884 raise UnresolvedFilesInRepo(unresolved)
889 885 else:
890 886 raise
891 887
892 888 def _local_push(
893 889 self, source_branch, repository_path, target_branch,
894 890 enable_hooks=False, rc_scm_data=None):
895 891 """
896 892 Push the source_branch to the given repository and target_branch.
897 893
898 894 Currently it if the target_branch is not master and the target repo is
899 895 empty, the push will work, but then GitRepository won't be able to find
900 896 the pushed branch or the commits. As the HEAD will be corrupted (i.e.,
901 897 pointing to master, which does not exist).
902 898
903 899 It does not run the hooks in the target repo.
904 900 """
905 901 # TODO(skreft): deal with the case in which the target repo is empty,
906 902 # and the target_branch is not master.
907 903 target_repo = GitRepository(repository_path)
908 904 if (not target_repo.bare and
909 905 target_repo._current_branch() == target_branch):
910 906 # Git prevents pushing to the checked out branch, so simulate it by
911 907 # pulling into the target repository.
912 908 target_repo._local_pull(self.path, source_branch)
913 909 else:
914 910 cmd = ['push', os.path.abspath(repository_path),
915 911 f'{source_branch}:{target_branch}']
916 912 gitenv = {}
917 913 if rc_scm_data:
918 914 gitenv.update({'RC_SCM_DATA': rc_scm_data})
919 915
920 916 if not enable_hooks:
921 917 gitenv['RC_SKIP_HOOKS'] = '1'
922 918 self.run_git_command(cmd, fail_on_stderr=False, extra_env=gitenv)
923 919
924 920 def _get_new_pr_branch(self, source_branch, target_branch):
925 921 prefix = f'pr_{source_branch}-{target_branch}_'
926 922 pr_branches = []
927 923 for branch in self.branches:
928 924 if branch.startswith(prefix):
929 925 pr_branches.append(int(branch[len(prefix):]))
930 926
931 927 if not pr_branches:
932 928 branch_id = 0
933 929 else:
934 930 branch_id = max(pr_branches) + 1
935 931
936 932 return '%s%d' % (prefix, branch_id)
937 933
938 934 def _maybe_prepare_merge_workspace(
939 935 self, repo_id, workspace_id, target_ref, source_ref):
940 936 shadow_repository_path = self._get_shadow_repository_path(
941 937 self.path, repo_id, workspace_id)
942 938 if not os.path.exists(shadow_repository_path):
943 939 self._local_clone(
944 940 shadow_repository_path, target_ref.name, source_ref.name)
945 941 log.debug('Prepared %s shadow repository in %s',
946 942 self.alias, shadow_repository_path)
947 943
948 944 return shadow_repository_path
949 945
950 946 def _merge_repo(self, repo_id, workspace_id, target_ref,
951 947 source_repo, source_ref, merge_message,
952 948 merger_name, merger_email, dry_run=False,
953 949 use_rebase=False, close_branch=False):
954 950
955 951 log.debug('Executing merge_repo with %s strategy, dry_run mode:%s',
956 952 'rebase' if use_rebase else 'merge', dry_run)
957 953
958 954 if target_ref.commit_id != self.branches[target_ref.name]:
959 955 log.warning('Target ref %s commit mismatch %s vs %s', target_ref,
960 956 target_ref.commit_id, self.branches[target_ref.name])
961 957 return MergeResponse(
962 958 False, False, None, MergeFailureReason.TARGET_IS_NOT_HEAD,
963 959 metadata={'target_ref': target_ref})
964 960
965 961 shadow_repository_path = self._maybe_prepare_merge_workspace(
966 962 repo_id, workspace_id, target_ref, source_ref)
967 963 shadow_repo = self.get_shadow_instance(shadow_repository_path)
968 964
969 965 # checkout source, if it's different. Otherwise we could not
970 966 # fetch proper commits for merge testing
971 967 if source_ref.name != target_ref.name:
972 968 if shadow_repo.get_remote_ref(source_ref.name):
973 969 shadow_repo._checkout(source_ref.name, force=True)
974 970
975 971 # checkout target, and fetch changes
976 972 shadow_repo._checkout(target_ref.name, force=True)
977 973
978 974 # fetch/reset pull the target, in case it is changed
979 975 # this handles even force changes
980 976 shadow_repo._local_fetch(self.path, target_ref.name, use_origin=True)
981 977 shadow_repo._local_reset(target_ref.name)
982 978
983 979 # Need to reload repo to invalidate the cache, or otherwise we cannot
984 980 # retrieve the last target commit.
985 981 shadow_repo = self.get_shadow_instance(shadow_repository_path)
986 982 if target_ref.commit_id != shadow_repo.branches[target_ref.name]:
987 983 log.warning('Shadow Target ref %s commit mismatch %s vs %s',
988 984 target_ref, target_ref.commit_id,
989 985 shadow_repo.branches[target_ref.name])
990 986 return MergeResponse(
991 987 False, False, None, MergeFailureReason.TARGET_IS_NOT_HEAD,
992 988 metadata={'target_ref': target_ref})
993 989
994 990 # calculate new branch
995 991 pr_branch = shadow_repo._get_new_pr_branch(
996 992 source_ref.name, target_ref.name)
997 993 log.debug('using pull-request merge branch: `%s`', pr_branch)
998 994 # checkout to temp branch, and fetch changes
999 995 shadow_repo._checkout(pr_branch, create=True)
1000 996 try:
1001 997 shadow_repo._local_fetch(source_repo.path, source_ref.name)
1002 998 except RepositoryError:
1003 999 log.exception('Failure when doing local fetch on '
1004 1000 'shadow repo: %s', shadow_repo)
1005 1001 return MergeResponse(
1006 1002 False, False, None, MergeFailureReason.MISSING_SOURCE_REF,
1007 1003 metadata={'source_ref': source_ref})
1008 1004
1009 1005 merge_ref = None
1010 1006 merge_failure_reason = MergeFailureReason.NONE
1011 1007 metadata = {}
1012 1008 try:
1013 1009 shadow_repo._local_merge(merge_message, merger_name, merger_email,
1014 1010 [source_ref.commit_id])
1015 1011 merge_possible = True
1016 1012
1017 1013 # Need to invalidate the cache, or otherwise we
1018 1014 # cannot retrieve the merge commit.
1019 1015 shadow_repo = shadow_repo.get_shadow_instance(shadow_repository_path)
1020 1016 merge_commit_id = shadow_repo.branches[pr_branch]
1021 1017
1022 1018 # Set a reference pointing to the merge commit. This reference may
1023 1019 # be used to easily identify the last successful merge commit in
1024 1020 # the shadow repository.
1025 1021 shadow_repo.set_refs('refs/heads/pr-merge', merge_commit_id)
1026 1022 merge_ref = Reference('branch', 'pr-merge', merge_commit_id)
1027 1023 except RepositoryError as e:
1028 1024 log.exception('Failure when doing local merge on git shadow repo')
1029 1025 if isinstance(e, UnresolvedFilesInRepo):
1030 1026 metadata['unresolved_files'] = '\n* conflict: ' + ('\n * conflict: '.join(e.args[0]))
1031 1027
1032 1028 merge_possible = False
1033 1029 merge_failure_reason = MergeFailureReason.MERGE_FAILED
1034 1030
1035 1031 if merge_possible and not dry_run:
1036 1032 try:
1037 1033 shadow_repo._local_push(
1038 1034 pr_branch, self.path, target_ref.name, enable_hooks=True,
1039 1035 rc_scm_data=self.config.get('rhodecode', 'RC_SCM_DATA'))
1040 1036 merge_succeeded = True
1041 1037 except RepositoryError:
1042 1038 log.exception(
1043 1039 'Failure when doing local push from the shadow '
1044 1040 'repository to the target repository at %s.', self.path)
1045 1041 merge_succeeded = False
1046 1042 merge_failure_reason = MergeFailureReason.PUSH_FAILED
1047 1043 metadata['target'] = 'git shadow repo'
1048 1044 metadata['merge_commit'] = pr_branch
1049 1045 else:
1050 1046 merge_succeeded = False
1051 1047
1052 1048 return MergeResponse(
1053 1049 merge_possible, merge_succeeded, merge_ref, merge_failure_reason,
1054 1050 metadata=metadata)
@@ -1,367 +1,361 b''
1 1 # Copyright (C) 2014-2023 RhodeCode GmbH
2 2 #
3 3 # This program is free software: you can redistribute it and/or modify
4 4 # it under the terms of the GNU Affero General Public License, version 3
5 5 # (only), as published by the Free Software Foundation.
6 6 #
7 7 # This program is distributed in the hope that it will be useful,
8 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 10 # GNU General Public License for more details.
11 11 #
12 12 # You should have received a copy of the GNU Affero General Public License
13 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 #
15 15 # This program is dual-licensed. If you wish to learn more about the
16 16 # RhodeCode Enterprise Edition, including its added features, Support services,
17 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
18 18
19 19 """
20 20 SVN repository module
21 21 """
22 22
23 23 import logging
24 24 import os
25 25 import urllib.request
26 26 import urllib.parse
27 27 import urllib.error
28 28
29 29 from zope.cachedescriptors.property import Lazy as LazyProperty
30 30
31 31 from collections import OrderedDict
32 32 from rhodecode.lib.datelib import date_astimestamp
33 33 from rhodecode.lib.str_utils import safe_str
34 34 from rhodecode.lib.utils2 import CachedProperty
35 35 from rhodecode.lib.vcs import connection, path as vcspath
36 36 from rhodecode.lib.vcs.backends import base
37 37 from rhodecode.lib.vcs.backends.svn.commit import (
38 38 SubversionCommit, _date_from_svn_properties)
39 39 from rhodecode.lib.vcs.backends.svn.diff import SubversionDiff
40 40 from rhodecode.lib.vcs.backends.svn.inmemory import SubversionInMemoryCommit
41 41 from rhodecode.lib.vcs.conf import settings
42 42 from rhodecode.lib.vcs.exceptions import (
43 43 CommitDoesNotExistError, EmptyRepositoryError, RepositoryError,
44 44 VCSError, NodeDoesNotExistError)
45 45
46 46
47 47 log = logging.getLogger(__name__)
48 48
49 49
50 50 class SubversionRepository(base.BaseRepository):
51 51 """
52 52 Subversion backend implementation
53 53
54 54 .. important::
55 55
56 56 It is very important to distinguish the commit index and the commit id
57 57 which is assigned by Subversion. The first one is always handled as an
58 58 `int` by this implementation. The commit id assigned by Subversion on
59 59 the other side will always be a `str`.
60 60
61 61 There is a specific trap since the first commit will have the index
62 62 ``0`` but the svn id will be ``"1"``.
63 63
64 64 """
65 65
66 66 # Note: Subversion does not really have a default branch name.
67 67 DEFAULT_BRANCH_NAME = None
68 68
69 69 contact = base.BaseRepository.DEFAULT_CONTACT
70 70 description = base.BaseRepository.DEFAULT_DESCRIPTION
71 71
72 72 def __init__(self, repo_path, config=None, create=False, src_url=None, with_wire=None,
73 73 bare=False, **kwargs):
74 74 self.path = safe_str(os.path.abspath(repo_path))
75 75 self.config = config if config else self.get_default_config()
76 76 self.with_wire = with_wire or {"cache": False} # default should not use cache
77 77
78 78 self._init_repo(create, src_url)
79 79
80 80 # caches
81 81 self._commit_ids = {}
82 82
83 83 @LazyProperty
84 84 def _remote(self):
85 85 repo_id = self.path
86 86 return connection.Svn(self.path, repo_id, self.config, with_wire=self.with_wire)
87 87
88 88 def _init_repo(self, create, src_url):
89 89 if create and os.path.exists(self.path):
90 90 raise RepositoryError(
91 f"Cannot create repository at {self.path}, location already exist"
92 )
91 f"Cannot create repository at {self.path}, location already exist")
93 92
94 93 if create:
95 94 self._remote.create_repository(settings.SVN_COMPATIBLE_VERSION)
96 95 if src_url:
97 96 src_url = _sanitize_url(src_url)
98 97 self._remote.import_remote_repository(src_url)
99 98 else:
100 self._check_path()
99 if not self._remote.is_path_valid_repository(self.path):
100 raise VCSError(
101 f'Path "{self.path}" does not contain a Subversion repository')
101 102
102 103 @CachedProperty
103 104 def commit_ids(self):
104 105 head = self._remote.lookup(None)
105 106 return [str(r) for r in range(1, head + 1)]
106 107
107 108 def _rebuild_cache(self, commit_ids):
108 109 pass
109 110
110 111 def run_svn_command(self, cmd, **opts):
111 112 """
112 113 Runs given ``cmd`` as svn command and returns tuple
113 114 (stdout, stderr).
114 115
115 116 :param cmd: full svn command to be executed
116 117 :param opts: env options to pass into Subprocess command
117 118 """
118 119 if not isinstance(cmd, list):
119 120 raise ValueError(f'cmd must be a list, got {type(cmd)} instead')
120 121
121 122 skip_stderr_log = opts.pop('skip_stderr_log', False)
122 123 out, err = self._remote.run_svn_command(cmd, **opts)
123 124 if err and not skip_stderr_log:
124 125 log.debug('Stderr output of svn command "%s":\n%s', cmd, err)
125 126 return out, err
126 127
127 128 @LazyProperty
128 129 def branches(self):
129 130 return self._tags_or_branches('vcs_svn_branch')
130 131
131 132 @LazyProperty
132 133 def branches_closed(self):
133 134 return {}
134 135
135 136 @LazyProperty
136 137 def bookmarks(self):
137 138 return {}
138 139
139 140 @LazyProperty
140 141 def branches_all(self):
141 142 # TODO: johbo: Implement proper branch support
142 143 all_branches = {}
143 144 all_branches.update(self.branches)
144 145 all_branches.update(self.branches_closed)
145 146 return all_branches
146 147
147 148 @LazyProperty
148 149 def tags(self):
149 150 return self._tags_or_branches('vcs_svn_tag')
150 151
151 152 def _tags_or_branches(self, config_section):
152 153 found_items = {}
153 154
154 155 if self.is_empty():
155 156 return {}
156 157
157 158 for pattern in self._patterns_from_section(config_section):
158 159 pattern = vcspath.sanitize(pattern)
159 160 tip = self.get_commit()
160 161 try:
161 162 if pattern.endswith('*'):
162 163 basedir = tip.get_node(vcspath.dirname(pattern))
163 164 directories = basedir.dirs
164 165 else:
165 166 directories = (tip.get_node(pattern), )
166 167 except NodeDoesNotExistError:
167 168 continue
168 169 found_items.update((safe_str(n.path), self.commit_ids[-1]) for n in directories)
169 170
170 171 def get_name(item):
171 172 return item[0]
172 173
173 174 return OrderedDict(sorted(found_items.items(), key=get_name))
174 175
175 176 def _patterns_from_section(self, section):
176 177 return (pattern for key, pattern in self.config.items(section))
177 178
178 179 def get_common_ancestor(self, commit_id1, commit_id2, repo2):
179 180 if self != repo2:
180 181 raise ValueError(
181 182 "Subversion does not support getting common ancestor of"
182 183 " different repositories.")
183 184
184 185 if int(commit_id1) < int(commit_id2):
185 186 return commit_id1
186 187 return commit_id2
187 188
188 189 def verify(self):
189 190 verify = self._remote.verify()
190 191
191 192 self._remote.invalidate_vcs_cache()
192 193 return verify
193 194
194 195 def compare(self, commit_id1, commit_id2, repo2, merge, pre_load=None):
195 196 # TODO: johbo: Implement better comparison, this is a very naive
196 197 # version which does not allow to compare branches, tags or folders
197 198 # at all.
198 199 if repo2 != self:
199 200 raise ValueError(
200 201 "Subversion does not support comparison of of different "
201 202 "repositories.")
202 203
203 204 if commit_id1 == commit_id2:
204 205 return []
205 206
206 207 commit_idx1 = self._get_commit_idx(commit_id1)
207 208 commit_idx2 = self._get_commit_idx(commit_id2)
208 209
209 210 commits = [
210 211 self.get_commit(commit_idx=idx)
211 212 for idx in range(commit_idx1 + 1, commit_idx2 + 1)]
212 213
213 214 return commits
214 215
215 216 def _get_commit_idx(self, commit_id):
216 217 try:
217 218 svn_rev = int(commit_id)
218 219 except:
219 220 # TODO: johbo: this might be only one case, HEAD, check this
220 221 svn_rev = self._remote.lookup(commit_id)
221 222 commit_idx = svn_rev - 1
222 223 if commit_idx >= len(self.commit_ids):
223 224 raise CommitDoesNotExistError(
224 225 f"Commit at index {commit_idx} does not exist.")
225 226 return commit_idx
226 227
227 228 @staticmethod
228 229 def check_url(url, config):
229 230 """
230 231 Check if `url` is a valid source to import a Subversion repository.
231 232 """
232 233 # convert to URL if it's a local directory
233 234 if os.path.isdir(url):
234 235 url = 'file://' + urllib.request.pathname2url(url)
235 236 return connection.Svn.check_url(url, config.serialize())
236 237
237 238 @staticmethod
238 239 def is_valid_repository(path):
239 240 try:
240 241 SubversionRepository(path)
241 242 return True
242 243 except VCSError:
243 244 pass
244 245 return False
245 246
246 def _check_path(self):
247 if not os.path.exists(self.path):
248 raise VCSError(f'Path "{self.path}" does not exist!')
249 if not self._remote.is_path_valid_repository(self.path):
250 raise VCSError(
251 'Path "%s" does not contain a Subversion repository' %
252 (self.path, ))
253 247
254 248 @LazyProperty
255 249 def last_change(self):
256 250 """
257 251 Returns last change made on this repository as
258 252 `datetime.datetime` object.
259 253 """
260 254 # Subversion always has a first commit which has id "0" and contains
261 255 # what we are looking for.
262 256 last_id = len(self.commit_ids)
263 257 properties = self._remote.revision_properties(last_id)
264 258 return _date_from_svn_properties(properties)
265 259
266 260 @LazyProperty
267 261 def in_memory_commit(self):
268 262 return SubversionInMemoryCommit(self)
269 263
270 264 def get_hook_location(self):
271 265 """
272 266 returns absolute path to location where hooks are stored
273 267 """
274 268 return os.path.join(self.path, 'hooks')
275 269
276 270 def get_commit(self, commit_id=None, commit_idx=None, pre_load=None,
277 271 translate_tag=None, maybe_unreachable=False, reference_obj=None):
278 272 if self.is_empty():
279 273 raise EmptyRepositoryError("There are no commits yet")
280 274 if commit_id is not None:
281 275 self._validate_commit_id(commit_id)
282 276 elif commit_idx is not None:
283 277 self._validate_commit_idx(commit_idx)
284 278 try:
285 279 commit_id = self.commit_ids[commit_idx]
286 280 except IndexError:
287 281 raise CommitDoesNotExistError(f'No commit with idx: {commit_idx}')
288 282
289 283 commit_id = self._sanitize_commit_id(commit_id)
290 284 commit = SubversionCommit(repository=self, commit_id=commit_id)
291 285 return commit
292 286
293 287 def get_commits(
294 288 self, start_id=None, end_id=None, start_date=None, end_date=None,
295 289 branch_name=None, show_hidden=False, pre_load=None, translate_tags=None):
296 290 if self.is_empty():
297 291 raise EmptyRepositoryError("There are no commit_ids yet")
298 292 self._validate_branch_name(branch_name)
299 293
300 294 if start_id is not None:
301 295 self._validate_commit_id(start_id)
302 296 if end_id is not None:
303 297 self._validate_commit_id(end_id)
304 298
305 299 start_raw_id = self._sanitize_commit_id(start_id)
306 300 start_pos = self.commit_ids.index(start_raw_id) if start_id else None
307 301 end_raw_id = self._sanitize_commit_id(end_id)
308 302 end_pos = max(0, self.commit_ids.index(end_raw_id)) if end_id else None
309 303
310 304 if None not in [start_id, end_id] and start_pos > end_pos:
311 305 raise RepositoryError(
312 306 "Start commit '%s' cannot be after end commit '%s'" %
313 307 (start_id, end_id))
314 308 if end_pos is not None:
315 309 end_pos += 1
316 310
317 311 # Date based filtering
318 312 if start_date or end_date:
319 313 start_raw_id, end_raw_id = self._remote.lookup_interval(
320 314 date_astimestamp(start_date) if start_date else None,
321 315 date_astimestamp(end_date) if end_date else None)
322 316 start_pos = start_raw_id - 1
323 317 end_pos = end_raw_id
324 318
325 319 commit_ids = self.commit_ids
326 320
327 321 # TODO: johbo: Reconsider impact of DEFAULT_BRANCH_NAME here
328 322 if branch_name not in [None, self.DEFAULT_BRANCH_NAME]:
329 323 svn_rev = int(self.commit_ids[-1])
330 324 commit_ids = self._remote.node_history(
331 325 path=branch_name, revision=svn_rev, limit=None)
332 326 commit_ids = [str(i) for i in reversed(commit_ids)]
333 327
334 328 if start_pos or end_pos:
335 329 commit_ids = commit_ids[start_pos:end_pos]
336 330 return base.CollectionGenerator(self, commit_ids, pre_load=pre_load)
337 331
338 332 def _sanitize_commit_id(self, commit_id):
339 333 if commit_id and commit_id.isdigit():
340 334 if int(commit_id) <= len(self.commit_ids):
341 335 return commit_id
342 336 else:
343 337 raise CommitDoesNotExistError(
344 338 f"Commit {commit_id} does not exist.")
345 339 if commit_id not in [
346 340 None, 'HEAD', 'tip', self.DEFAULT_BRANCH_NAME]:
347 341 raise CommitDoesNotExistError(
348 342 f"Commit id {commit_id} not understood.")
349 343 svn_rev = self._remote.lookup('HEAD')
350 344 return str(svn_rev)
351 345
352 346 def get_diff(
353 347 self, commit1, commit2, path=None, ignore_whitespace=False,
354 348 context=3, path1=None):
355 349 self._validate_diff_commits(commit1, commit2)
356 350 svn_rev1 = int(commit1.raw_id)
357 351 svn_rev2 = int(commit2.raw_id)
358 352 diff = self._remote.diff(
359 353 svn_rev1, svn_rev2, path1=path1, path2=path,
360 354 ignore_whitespace=ignore_whitespace, context=context)
361 355 return SubversionDiff(diff)
362 356
363 357
364 358 def _sanitize_url(url):
365 359 if '://' not in url:
366 360 url = 'file://' + urllib.request.pathname2url(url)
367 361 return url
General Comments 0
You need to be logged in to leave comments. Login now