##// END OF EJS Templates
feat(mercurial): added close branch commit message template
super-admin -
r5334:c713b60d default
parent child Browse files
Show More
@@ -1,1024 +1,1030 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 HG repository module
21 21 """
22 22 import os
23 23 import logging
24 24 import binascii
25 25 import configparser
26 26 import urllib.request
27 27 import urllib.parse
28 28 import urllib.error
29 29
30 30 from zope.cachedescriptors.property import Lazy as LazyProperty
31 31
32 32 from collections import OrderedDict
33 33 from rhodecode.lib.datelib import (
34 34 date_to_timestamp_plus_offset, utcdate_fromtimestamp, makedate)
35 35 from rhodecode.lib.str_utils import safe_str
36 36 from rhodecode.lib.utils2 import CachedProperty
37 37 from rhodecode.lib.vcs import connection, exceptions
38 from rhodecode.lib.vcs.conf import settings as vcs_settings
38 39 from rhodecode.lib.vcs.backends.base import (
39 40 BaseRepository, CollectionGenerator, Config, MergeResponse,
40 41 MergeFailureReason, Reference, BasePathPermissionChecker)
41 42 from rhodecode.lib.vcs.backends.hg.commit import MercurialCommit
42 43 from rhodecode.lib.vcs.backends.hg.diff import MercurialDiff
43 44 from rhodecode.lib.vcs.backends.hg.inmemory import MercurialInMemoryCommit
44 45 from rhodecode.lib.vcs.exceptions import (
45 46 EmptyRepositoryError, RepositoryError, TagAlreadyExistError,
46 47 TagDoesNotExistError, CommitDoesNotExistError, SubrepoMergeError, UnresolvedFilesInRepo)
47 48
48 49 hexlify = binascii.hexlify
49 50 nullid = "\0" * 20
50 51
51 52 log = logging.getLogger(__name__)
52 53
53 54
54 55 class MercurialRepository(BaseRepository):
55 56 """
56 57 Mercurial repository backend
57 58 """
58 59 DEFAULT_BRANCH_NAME = 'default'
59 60
60 61 def __init__(self, repo_path, config=None, create=False, src_url=None,
61 62 do_workspace_checkout=False, with_wire=None, bare=False):
62 63 """
63 64 Raises RepositoryError if repository could not be find at the given
64 65 ``repo_path``.
65 66
66 67 :param repo_path: local path of the repository
67 68 :param config: config object containing the repo configuration
68 69 :param create=False: if set to True, would try to create repository if
69 70 it does not exist rather than raising exception
70 71 :param src_url=None: would try to clone repository from given location
71 72 :param do_workspace_checkout=False: sets update of working copy after
72 73 making a clone
73 74 :param bare: not used, compatible with other VCS
74 75 """
75 76
76 77 self.path = safe_str(os.path.abspath(repo_path))
77 78 # mercurial since 4.4.X requires certain configuration to be present
78 79 # because sometimes we init the repos with config we need to meet
79 80 # special requirements
80 81 self.config = config if config else self.get_default_config(
81 82 default=[('extensions', 'largefiles', '')])
82 83
83 84 # NOTE(marcink): since python3 hgsubversion is deprecated.
84 85 # From old installations we might still have this set enabled
85 86 # we explicitly remove this now here to make sure it wont propagate further
86 87 if config and config.get('extensions', 'hgsubversion') is not None:
87 88 config.drop_option('extensions', 'hgsubversion')
88 89
89 90 self.with_wire = with_wire or {"cache": False} # default should not use cache
90 91
91 92 self._init_repo(create, src_url, do_workspace_checkout)
92 93
93 94 # caches
94 95 self._commit_ids = {}
95 96
96 97 @LazyProperty
97 98 def _remote(self):
98 99 repo_id = self.path
99 100 return connection.Hg(self.path, repo_id, self.config, with_wire=self.with_wire)
100 101
101 102 @CachedProperty
102 103 def commit_ids(self):
103 104 """
104 105 Returns list of commit ids, in ascending order. Being lazy
105 106 attribute allows external tools to inject shas from cache.
106 107 """
107 108 commit_ids = self._get_all_commit_ids()
108 109 self._rebuild_cache(commit_ids)
109 110 return commit_ids
110 111
111 112 def _rebuild_cache(self, commit_ids):
112 113 self._commit_ids = {commit_id: index
113 114 for index, commit_id in enumerate(commit_ids)}
114 115
115 116 @CachedProperty
116 117 def branches(self):
117 118 return self._get_branches()
118 119
119 120 @CachedProperty
120 121 def branches_closed(self):
121 122 return self._get_branches(active=False, closed=True)
122 123
123 124 @CachedProperty
124 125 def branches_all(self):
125 126 all_branches = {}
126 127 all_branches.update(self.branches)
127 128 all_branches.update(self.branches_closed)
128 129 return all_branches
129 130
130 131 def _get_branches(self, active=True, closed=False):
131 132 """
132 133 Gets branches for this repository
133 134 Returns only not closed active branches by default
134 135
135 136 :param active: return also active branches
136 137 :param closed: return also closed branches
137 138
138 139 """
139 140 if self.is_empty():
140 141 return {}
141 142
142 143 def get_name(ctx):
143 144 return ctx[0]
144 145
145 146 _branches = [(n, h,) for n, h in
146 147 self._remote.branches(active, closed).items()]
147 148
148 149 return OrderedDict(sorted(_branches, key=get_name, reverse=False))
149 150
150 151 @CachedProperty
151 152 def tags(self):
152 153 """
153 154 Gets tags for this repository
154 155 """
155 156 return self._get_tags()
156 157
157 158 def _get_tags(self):
158 159 if self.is_empty():
159 160 return {}
160 161
161 162 def get_name(ctx):
162 163 return ctx[0]
163 164
164 165 _tags = [(n, h,) for n, h in
165 166 self._remote.tags().items()]
166 167
167 168 return OrderedDict(sorted(_tags, key=get_name, reverse=True))
168 169
169 170 def tag(self, name, user, commit_id=None, message=None, date=None, **kwargs):
170 171 """
171 172 Creates and returns a tag for the given ``commit_id``.
172 173
173 174 :param name: name for new tag
174 175 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
175 176 :param commit_id: commit id for which new tag would be created
176 177 :param message: message of the tag's commit
177 178 :param date: date of tag's commit
178 179
179 180 :raises TagAlreadyExistError: if tag with same name already exists
180 181 """
181 182 if name in self.tags:
182 183 raise TagAlreadyExistError("Tag %s already exists" % name)
183 184
184 185 commit = self.get_commit(commit_id=commit_id)
185 186 local = kwargs.setdefault('local', False)
186 187
187 188 if message is None:
188 189 message = f"Added tag {name} for commit {commit.short_id}"
189 190
190 191 date, tz = date_to_timestamp_plus_offset(date)
191 192
192 193 self._remote.tag(name, commit.raw_id, message, local, user, date, tz)
193 194 self._remote.invalidate_vcs_cache()
194 195
195 196 # Reinitialize tags
196 197 self._invalidate_prop_cache('tags')
197 198 tag_id = self.tags[name]
198 199
199 200 return self.get_commit(commit_id=tag_id)
200 201
201 202 def remove_tag(self, name, user, message=None, date=None):
202 203 """
203 204 Removes tag with the given `name`.
204 205
205 206 :param name: name of the tag to be removed
206 207 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
207 208 :param message: message of the tag's removal commit
208 209 :param date: date of tag's removal commit
209 210
210 211 :raises TagDoesNotExistError: if tag with given name does not exists
211 212 """
212 213 if name not in self.tags:
213 214 raise TagDoesNotExistError("Tag %s does not exist" % name)
214 215
215 216 if message is None:
216 217 message = "Removed tag %s" % name
217 218 local = False
218 219
219 220 date, tz = date_to_timestamp_plus_offset(date)
220 221
221 222 self._remote.tag(name, nullid, message, local, user, date, tz)
222 223 self._remote.invalidate_vcs_cache()
223 224 self._invalidate_prop_cache('tags')
224 225
225 226 @LazyProperty
226 227 def bookmarks(self):
227 228 """
228 229 Gets bookmarks for this repository
229 230 """
230 231 return self._get_bookmarks()
231 232
232 233 def _get_bookmarks(self):
233 234 if self.is_empty():
234 235 return {}
235 236
236 237 def get_name(ctx):
237 238 return ctx[0]
238 239
239 240 _bookmarks = [
240 241 (n, h) for n, h in
241 242 self._remote.bookmarks().items()]
242 243
243 244 return OrderedDict(sorted(_bookmarks, key=get_name))
244 245
245 246 def _get_all_commit_ids(self):
246 247 return self._remote.get_all_commit_ids('visible')
247 248
248 249 def get_diff(
249 250 self, commit1, commit2, path='', ignore_whitespace=False,
250 251 context=3, path1=None):
251 252 """
252 253 Returns (git like) *diff*, as plain text. Shows changes introduced by
253 254 `commit2` since `commit1`.
254 255
255 256 :param commit1: Entry point from which diff is shown. Can be
256 257 ``self.EMPTY_COMMIT`` - in this case, patch showing all
257 258 the changes since empty state of the repository until `commit2`
258 259 :param commit2: Until which commit changes should be shown.
259 260 :param ignore_whitespace: If set to ``True``, would not show whitespace
260 261 changes. Defaults to ``False``.
261 262 :param context: How many lines before/after changed lines should be
262 263 shown. Defaults to ``3``.
263 264 """
264 265 self._validate_diff_commits(commit1, commit2)
265 266 if path1 is not None and path1 != path:
266 267 raise ValueError("Diff of two different paths not supported.")
267 268
268 269 if path:
269 270 file_filter = [self.path, path]
270 271 else:
271 272 file_filter = None
272 273
273 274 diff = self._remote.diff(
274 275 commit1.raw_id, commit2.raw_id, file_filter=file_filter,
275 276 opt_git=True, opt_ignorews=ignore_whitespace,
276 277 context=context)
277 278 return MercurialDiff(diff)
278 279
279 280 def strip(self, commit_id, branch=None):
280 281 self._remote.strip(commit_id, update=False, backup=False)
281 282
282 283 self._remote.invalidate_vcs_cache()
283 284 # clear cache
284 285 self._invalidate_prop_cache('commit_ids')
285 286
286 287 return len(self.commit_ids)
287 288
288 289 def verify(self):
289 290 verify = self._remote.verify()
290 291
291 292 self._remote.invalidate_vcs_cache()
292 293 return verify
293 294
294 295 def hg_update_cache(self):
295 296 update_cache = self._remote.hg_update_cache()
296 297
297 298 self._remote.invalidate_vcs_cache()
298 299 return update_cache
299 300
300 301 def hg_rebuild_fn_cache(self):
301 302 update_cache = self._remote.hg_rebuild_fn_cache()
302 303
303 304 self._remote.invalidate_vcs_cache()
304 305 return update_cache
305 306
306 307 def get_common_ancestor(self, commit_id1, commit_id2, repo2):
307 308 log.debug('Calculating common ancestor between %sc1:%s and %sc2:%s',
308 309 self, commit_id1, repo2, commit_id2)
309 310
310 311 if commit_id1 == commit_id2:
311 312 return commit_id1
312 313
313 314 ancestors = self._remote.revs_from_revspec(
314 315 "ancestor(id(%s), id(%s))", commit_id1, commit_id2,
315 316 other_path=repo2.path)
316 317
317 318 ancestor_id = repo2[ancestors[0]].raw_id if ancestors else None
318 319
319 320 log.debug('Found common ancestor with sha: %s', ancestor_id)
320 321 return ancestor_id
321 322
322 323 def compare(self, commit_id1, commit_id2, repo2, merge, pre_load=None):
323 324 if commit_id1 == commit_id2:
324 325 commits = []
325 326 else:
326 327 if merge:
327 328 indexes = self._remote.revs_from_revspec(
328 329 "ancestors(id(%s)) - ancestors(id(%s)) - id(%s)",
329 330 commit_id2, commit_id1, commit_id1, other_path=repo2.path)
330 331 else:
331 332 indexes = self._remote.revs_from_revspec(
332 333 "id(%s)..id(%s) - id(%s)", commit_id1, commit_id2,
333 334 commit_id1, other_path=repo2.path)
334 335
335 336 commits = [repo2.get_commit(commit_idx=idx, pre_load=pre_load)
336 337 for idx in indexes]
337 338
338 339 return commits
339 340
340 341 @staticmethod
341 342 def check_url(url, config):
342 343 """
343 344 Function will check given url and try to verify if it's a valid
344 345 link. Sometimes it may happened that mercurial will issue basic
345 346 auth request that can cause whole API to hang when used from python
346 347 or other external calls.
347 348
348 349 On failures it'll raise urllib2.HTTPError, exception is also thrown
349 350 when the return code is non 200
350 351 """
351 352 # check first if it's not an local url
352 353 if os.path.isdir(url) or url.startswith('file:'):
353 354 return True
354 355
355 356 # Request the _remote to verify the url
356 357 return connection.Hg.check_url(url, config.serialize())
357 358
358 359 @staticmethod
359 360 def is_valid_repository(path):
360 361 return os.path.isdir(os.path.join(path, '.hg'))
361 362
362 363 def _init_repo(self, create, src_url=None, do_workspace_checkout=False):
363 364 """
364 365 Function will check for mercurial repository in given path. If there
365 366 is no repository in that path it will raise an exception unless
366 367 `create` parameter is set to True - in that case repository would
367 368 be created.
368 369
369 370 If `src_url` is given, would try to clone repository from the
370 371 location at given clone_point. Additionally it'll make update to
371 372 working copy accordingly to `do_workspace_checkout` flag.
372 373 """
373 374 if create and os.path.exists(self.path):
374 375 raise RepositoryError(
375 376 f"Cannot create repository at {self.path}, location already exist")
376 377
377 378 if src_url:
378 379 url = str(self._get_url(src_url))
379 380 MercurialRepository.check_url(url, self.config)
380 381
381 382 self._remote.clone(url, self.path, do_workspace_checkout)
382 383
383 384 # Don't try to create if we've already cloned repo
384 385 create = False
385 386
386 387 if create:
387 388 os.makedirs(self.path, mode=0o755)
388 389
389 390 self._remote.localrepository(create)
390 391
391 392 @LazyProperty
392 393 def in_memory_commit(self):
393 394 return MercurialInMemoryCommit(self)
394 395
395 396 @LazyProperty
396 397 def description(self):
397 398 description = self._remote.get_config_value(
398 399 'web', 'description', untrusted=True)
399 400 return safe_str(description or self.DEFAULT_DESCRIPTION)
400 401
401 402 @LazyProperty
402 403 def contact(self):
403 404 contact = (
404 405 self._remote.get_config_value("web", "contact") or
405 406 self._remote.get_config_value("ui", "username"))
406 407 return safe_str(contact or self.DEFAULT_CONTACT)
407 408
408 409 @LazyProperty
409 410 def last_change(self):
410 411 """
411 412 Returns last change made on this repository as
412 413 `datetime.datetime` object.
413 414 """
414 415 try:
415 416 return self.get_commit().date
416 417 except RepositoryError:
417 418 tzoffset = makedate()[1]
418 419 return utcdate_fromtimestamp(self._get_fs_mtime(), tzoffset)
419 420
420 421 def _get_fs_mtime(self):
421 422 # fallback to filesystem
422 423 cl_path = os.path.join(self.path, '.hg', "00changelog.i")
423 424 st_path = os.path.join(self.path, '.hg', "store")
424 425 if os.path.exists(cl_path):
425 426 return os.stat(cl_path).st_mtime
426 427 else:
427 428 return os.stat(st_path).st_mtime
428 429
429 430 def _get_url(self, url):
430 431 """
431 432 Returns normalized url. If schema is not given, would fall
432 433 to filesystem
433 434 (``file:///``) schema.
434 435 """
435 436 if url != 'default' and '://' not in url:
436 437 url = "file:" + urllib.request.pathname2url(url)
437 438 return url
438 439
439 440 def get_hook_location(self):
440 441 """
441 442 returns absolute path to location where hooks are stored
442 443 """
443 444 return os.path.join(self.path, '.hg', '.hgrc')
444 445
445 446 def get_commit(self, commit_id=None, commit_idx=None, pre_load=None,
446 447 translate_tag=None, maybe_unreachable=False, reference_obj=None):
447 448 """
448 449 Returns ``MercurialCommit`` object representing repository's
449 450 commit at the given `commit_id` or `commit_idx`.
450 451 """
451 452 if self.is_empty():
452 453 raise EmptyRepositoryError("There are no commits yet")
453 454
454 455 if commit_id is not None:
455 456 self._validate_commit_id(commit_id)
456 457 try:
457 458 # we have cached idx, use it without contacting the remote
458 459 idx = self._commit_ids[commit_id]
459 460 return MercurialCommit(self, commit_id, idx, pre_load=pre_load)
460 461 except KeyError:
461 462 pass
462 463
463 464 elif commit_idx is not None:
464 465 self._validate_commit_idx(commit_idx)
465 466 try:
466 467 _commit_id = self.commit_ids[commit_idx]
467 468 if commit_idx < 0:
468 469 commit_idx = self.commit_ids.index(_commit_id)
469 470
470 471 return MercurialCommit(self, _commit_id, commit_idx, pre_load=pre_load)
471 472 except IndexError:
472 473 commit_id = commit_idx
473 474 else:
474 475 commit_id = "tip"
475 476
476 477 # case here is no cached version, do an actual lookup instead
477 478 try:
478 479 raw_id, idx = self._remote.lookup(commit_id, both=True)
479 480 except CommitDoesNotExistError:
480 481 msg = "Commit {} does not exist for `{}`".format(
481 482 *map(safe_str, [commit_id, self.name]))
482 483 raise CommitDoesNotExistError(msg)
483 484
484 485 return MercurialCommit(self, raw_id, idx, pre_load=pre_load)
485 486
486 487 def get_commits(
487 488 self, start_id=None, end_id=None, start_date=None, end_date=None,
488 489 branch_name=None, show_hidden=False, pre_load=None, translate_tags=None):
489 490 """
490 491 Returns generator of ``MercurialCommit`` objects from start to end
491 492 (both are inclusive)
492 493
493 494 :param start_id: None, str(commit_id)
494 495 :param end_id: None, str(commit_id)
495 496 :param start_date: if specified, commits with commit date less than
496 497 ``start_date`` would be filtered out from returned set
497 498 :param end_date: if specified, commits with commit date greater than
498 499 ``end_date`` would be filtered out from returned set
499 500 :param branch_name: if specified, commits not reachable from given
500 501 branch would be filtered out from returned set
501 502 :param show_hidden: Show hidden commits such as obsolete or hidden from
502 503 Mercurial evolve
503 504 :raise BranchDoesNotExistError: If given ``branch_name`` does not
504 505 exist.
505 506 :raise CommitDoesNotExistError: If commit for given ``start`` or
506 507 ``end`` could not be found.
507 508 """
508 509 # actually we should check now if it's not an empty repo
509 510 if self.is_empty():
510 511 raise EmptyRepositoryError("There are no commits yet")
511 512 self._validate_branch_name(branch_name)
512 513
513 514 branch_ancestors = False
514 515 if start_id is not None:
515 516 self._validate_commit_id(start_id)
516 517 c_start = self.get_commit(commit_id=start_id)
517 518 start_pos = self._commit_ids[c_start.raw_id]
518 519 else:
519 520 start_pos = None
520 521
521 522 if end_id is not None:
522 523 self._validate_commit_id(end_id)
523 524 c_end = self.get_commit(commit_id=end_id)
524 525 end_pos = max(0, self._commit_ids[c_end.raw_id])
525 526 else:
526 527 end_pos = None
527 528
528 529 if None not in [start_id, end_id] and start_pos > end_pos:
529 530 raise RepositoryError(
530 531 "Start commit '%s' cannot be after end commit '%s'" %
531 532 (start_id, end_id))
532 533
533 534 if end_pos is not None:
534 535 end_pos += 1
535 536
536 537 commit_filter = []
537 538
538 539 if branch_name and not branch_ancestors:
539 540 commit_filter.append(f'branch("{branch_name}")')
540 541 elif branch_name and branch_ancestors:
541 542 commit_filter.append(f'ancestors(branch("{branch_name}"))')
542 543
543 544 if start_date and not end_date:
544 545 commit_filter.append(f'date(">{start_date}")')
545 546 if end_date and not start_date:
546 547 commit_filter.append(f'date("<{end_date}")')
547 548 if start_date and end_date:
548 549 commit_filter.append(
549 550 f'date(">{start_date}") and date("<{end_date}")')
550 551
551 552 if not show_hidden:
552 553 commit_filter.append('not obsolete()')
553 554 commit_filter.append('not hidden()')
554 555
555 556 # TODO: johbo: Figure out a simpler way for this solution
556 557 collection_generator = CollectionGenerator
557 558 if commit_filter:
558 559 commit_filter = ' and '.join(map(safe_str, commit_filter))
559 560 revisions = self._remote.rev_range([commit_filter])
560 561 collection_generator = MercurialIndexBasedCollectionGenerator
561 562 else:
562 563 revisions = self.commit_ids
563 564
564 565 if start_pos or end_pos:
565 566 revisions = revisions[start_pos:end_pos]
566 567
567 568 return collection_generator(self, revisions, pre_load=pre_load)
568 569
569 570 def pull(self, url, commit_ids=None):
570 571 """
571 572 Pull changes from external location.
572 573
573 574 :param commit_ids: Optional. Can be set to a list of commit ids
574 575 which shall be pulled from the other repository.
575 576 """
576 577 url = self._get_url(url)
577 578 self._remote.pull(url, commit_ids=commit_ids)
578 579 self._remote.invalidate_vcs_cache()
579 580
580 581 def fetch(self, url, commit_ids=None, **kwargs):
581 582 """
582 583 Backward compatibility with GIT fetch==pull
583 584 """
584 585 return self.pull(url, commit_ids=commit_ids)
585 586
586 587 def push(self, url, **kwargs):
587 588 url = self._get_url(url)
588 589 self._remote.sync_push(url)
589 590
590 591 def _local_clone(self, clone_path):
591 592 """
592 593 Create a local clone of the current repo.
593 594 """
594 595 self._remote.clone(self.path, clone_path, update_after_clone=True,
595 596 hooks=False)
596 597
597 598 def _update(self, revision, clean=False):
598 599 """
599 600 Update the working copy to the specified revision.
600 601 """
601 602 log.debug('Doing checkout to commit: `%s` for %s', revision, self)
602 603 self._remote.update(revision, clean=clean)
603 604
604 605 def _identify(self):
605 606 """
606 607 Return the current state of the working directory.
607 608 """
608 609 return self._remote.identify().strip().rstrip('+')
609 610
610 611 def _heads(self, branch=None):
611 612 """
612 613 Return the commit ids of the repository heads.
613 614 """
614 615 return self._remote.heads(branch=branch).strip().split(' ')
615 616
616 617 def _ancestor(self, revision1, revision2):
617 618 """
618 619 Return the common ancestor of the two revisions.
619 620 """
620 621 return self._remote.ancestor(revision1, revision2)
621 622
622 623 def _local_push(
623 624 self, revision, repository_path, push_branches=False,
624 625 enable_hooks=False):
625 626 """
626 627 Push the given revision to the specified repository.
627 628
628 629 :param push_branches: allow to create branches in the target repo.
629 630 """
630 631 self._remote.push(
631 632 [revision], repository_path, hooks=enable_hooks,
632 633 push_branches=push_branches)
633 634
634 635 def _local_merge(self, target_ref, merge_message, user_name, user_email,
635 636 source_ref, use_rebase=False, close_commit_id=None, dry_run=False):
636 637 """
637 638 Merge the given source_revision into the checked out revision.
638 639
639 640 Returns the commit id of the merge and a boolean indicating if the
640 641 commit needs to be pushed.
641 642 """
642 643
643 644 source_ref_commit_id = source_ref.commit_id
644 645 target_ref_commit_id = target_ref.commit_id
645 646
646 647 # update our workdir to target ref, for proper merge
647 648 self._update(target_ref_commit_id, clean=True)
648 649
649 650 ancestor = self._ancestor(target_ref_commit_id, source_ref_commit_id)
650 651 is_the_same_branch = self._is_the_same_branch(target_ref, source_ref)
651 652
652 653 if close_commit_id:
653 654 # NOTE(marcink): if we get the close commit, this is our new source
654 655 # which will include the close commit itself.
655 656 source_ref_commit_id = close_commit_id
656 657
657 658 if ancestor == source_ref_commit_id:
658 659 # Nothing to do, the changes were already integrated
659 660 return target_ref_commit_id, False
660 661
661 662 elif ancestor == target_ref_commit_id and is_the_same_branch:
662 663 # In this case we should force a commit message
663 664 return source_ref_commit_id, True
664 665
665 666 unresolved = None
666 667 if use_rebase:
667 668 try:
668 669 bookmark_name = f'rcbook{source_ref_commit_id}{target_ref_commit_id}'
669 670 self.bookmark(bookmark_name, revision=source_ref.commit_id)
670 671 self._remote.rebase(
671 672 source=source_ref_commit_id, dest=target_ref_commit_id)
672 673 self._remote.invalidate_vcs_cache()
673 674 self._update(bookmark_name, clean=True)
674 675 return self._identify(), True
675 676 except RepositoryError as e:
676 677 # The rebase-abort may raise another exception which 'hides'
677 678 # the original one, therefore we log it here.
678 679 log.exception('Error while rebasing shadow repo during merge.')
679 680 if 'unresolved conflicts' in safe_str(e):
680 681 unresolved = self._remote.get_unresolved_files()
681 682 log.debug('unresolved files: %s', unresolved)
682 683
683 684 # Cleanup any rebase leftovers
684 685 self._remote.invalidate_vcs_cache()
685 686 self._remote.rebase(abort=True)
686 687 self._remote.invalidate_vcs_cache()
687 688 self._remote.update(clean=True)
688 689 if unresolved:
689 690 raise UnresolvedFilesInRepo(unresolved)
690 691 else:
691 692 raise
692 693 else:
693 694 try:
694 695 self._remote.merge(source_ref_commit_id)
695 696 self._remote.invalidate_vcs_cache()
696 697 self._remote.commit(
697 698 message=safe_str(merge_message),
698 699 username=safe_str(f'{user_name} <{user_email}>'))
699 700 self._remote.invalidate_vcs_cache()
700 701 return self._identify(), True
701 702 except RepositoryError as e:
702 703 # The merge-abort may raise another exception which 'hides'
703 704 # the original one, therefore we log it here.
704 705 log.exception('Error while merging shadow repo during merge.')
705 706 if 'unresolved merge conflicts' in safe_str(e):
706 707 unresolved = self._remote.get_unresolved_files()
707 708 log.debug('unresolved files: %s', unresolved)
708 709
709 710 # Cleanup any merge leftovers
710 711 self._remote.update(clean=True)
711 712 if unresolved:
712 713 raise UnresolvedFilesInRepo(unresolved)
713 714 else:
714 715 raise
715 716
716 717 def _local_close(self, target_ref, user_name, user_email,
717 718 source_ref, close_message=''):
718 719 """
719 720 Close the branch of the given source_revision
720 721
721 722 Returns the commit id of the close and a boolean indicating if the
722 723 commit needs to be pushed.
723 724 """
724 725 self._update(source_ref.commit_id)
725 message = close_message or f"Closing branch: `{source_ref.name}`"
726 message = (close_message or vcs_settings.HG_CLOSE_BRANCH_MESSAGE_TMPL).format(
727 user_name=user_name,
728 user_email=user_email,
729 target_ref_name=target_ref.name,
730 source_ref_name=source_ref.name
731 )
726 732 try:
727 733 self._remote.commit(
728 734 message=safe_str(message),
729 735 username=safe_str(f'{user_name} <{user_email}>'),
730 736 close_branch=True)
731 737 self._remote.invalidate_vcs_cache()
732 738 return self._identify(), True
733 739 except RepositoryError:
734 740 # Cleanup any commit leftovers
735 741 self._remote.update(clean=True)
736 742 raise
737 743
738 744 def _is_the_same_branch(self, target_ref, source_ref):
739 745 return (
740 746 self._get_branch_name(target_ref) ==
741 747 self._get_branch_name(source_ref))
742 748
743 749 def _get_branch_name(self, ref):
744 750 if ref.type == 'branch':
745 751 return ref.name
746 752 return self._remote.ctx_branch(ref.commit_id)
747 753
748 754 def _maybe_prepare_merge_workspace(
749 755 self, repo_id, workspace_id, unused_target_ref, unused_source_ref):
750 756 shadow_repository_path = self._get_shadow_repository_path(
751 757 self.path, repo_id, workspace_id)
752 758 if not os.path.exists(shadow_repository_path):
753 759 self._local_clone(shadow_repository_path)
754 760 log.debug(
755 761 'Prepared shadow repository in %s', shadow_repository_path)
756 762
757 763 return shadow_repository_path
758 764
759 765 def _merge_repo(self, repo_id, workspace_id, target_ref,
760 766 source_repo, source_ref, merge_message,
761 767 merger_name, merger_email, dry_run=False,
762 768 use_rebase=False, close_branch=False):
763 769
764 770 log.debug('Executing merge_repo with %s strategy, dry_run mode:%s',
765 771 'rebase' if use_rebase else 'merge', dry_run)
766 772
767 773 if target_ref.commit_id not in self._heads():
768 774 return MergeResponse(
769 775 False, False, None, MergeFailureReason.TARGET_IS_NOT_HEAD,
770 776 metadata={'target_ref': target_ref})
771 777
772 778 try:
773 779 if target_ref.type == 'branch' and len(self._heads(target_ref.name)) != 1:
774 780 heads_all = self._heads(target_ref.name)
775 781 max_heads = 10
776 782 if len(heads_all) > max_heads:
777 783 heads = '\n,'.join(
778 784 heads_all[:max_heads] +
779 785 [f'and {len(heads_all)-max_heads} more.'])
780 786 else:
781 787 heads = '\n,'.join(heads_all)
782 788 metadata = {
783 789 'target_ref': target_ref,
784 790 'source_ref': source_ref,
785 791 'heads': heads
786 792 }
787 793 return MergeResponse(
788 794 False, False, None,
789 795 MergeFailureReason.HG_TARGET_HAS_MULTIPLE_HEADS,
790 796 metadata=metadata)
791 797 except CommitDoesNotExistError:
792 798 log.exception('Failure when looking up branch heads on hg target')
793 799 return MergeResponse(
794 800 False, False, None, MergeFailureReason.MISSING_TARGET_REF,
795 801 metadata={'target_ref': target_ref})
796 802
797 803 shadow_repository_path = self._maybe_prepare_merge_workspace(
798 804 repo_id, workspace_id, target_ref, source_ref)
799 805 shadow_repo = self.get_shadow_instance(shadow_repository_path)
800 806
801 807 log.debug('Pulling in target reference %s', target_ref)
802 808 self._validate_pull_reference(target_ref)
803 809 shadow_repo._local_pull(self.path, target_ref)
804 810
805 811 try:
806 812 log.debug('Pulling in source reference %s', source_ref)
807 813 source_repo._validate_pull_reference(source_ref)
808 814 shadow_repo._local_pull(source_repo.path, source_ref)
809 815 except CommitDoesNotExistError:
810 816 log.exception('Failure when doing local pull on hg shadow repo')
811 817 return MergeResponse(
812 818 False, False, None, MergeFailureReason.MISSING_SOURCE_REF,
813 819 metadata={'source_ref': source_ref})
814 820
815 821 merge_ref = None
816 822 merge_commit_id = None
817 823 close_commit_id = None
818 824 merge_failure_reason = MergeFailureReason.NONE
819 825 metadata = {}
820 826
821 827 # enforce that close branch should be used only in case we source from
822 828 # an actual Branch
823 829 close_branch = close_branch and source_ref.type == 'branch'
824 830
825 831 # don't allow to close branch if source and target are the same
826 832 close_branch = close_branch and source_ref.name != target_ref.name
827 833
828 834 needs_push_on_close = False
829 835 if close_branch and not use_rebase and not dry_run:
830 836 try:
831 837 close_commit_id, needs_push_on_close = shadow_repo._local_close(
832 838 target_ref, merger_name, merger_email, source_ref)
833 839 merge_possible = True
834 840 except RepositoryError:
835 841 log.exception('Failure when doing close branch on '
836 842 'shadow repo: %s', shadow_repo)
837 843 merge_possible = False
838 844 merge_failure_reason = MergeFailureReason.MERGE_FAILED
839 845 else:
840 846 merge_possible = True
841 847
842 848 needs_push = False
843 849 if merge_possible:
844 850
845 851 try:
846 852 merge_commit_id, needs_push = shadow_repo._local_merge(
847 853 target_ref, merge_message, merger_name, merger_email,
848 854 source_ref, use_rebase=use_rebase,
849 855 close_commit_id=close_commit_id, dry_run=dry_run)
850 856 merge_possible = True
851 857
852 858 # read the state of the close action, if it
853 859 # maybe required a push
854 860 needs_push = needs_push or needs_push_on_close
855 861
856 862 # Set a bookmark pointing to the merge commit. This bookmark
857 863 # may be used to easily identify the last successful merge
858 864 # commit in the shadow repository.
859 865 shadow_repo.bookmark('pr-merge', revision=merge_commit_id)
860 866 merge_ref = Reference('book', 'pr-merge', merge_commit_id)
861 867 except SubrepoMergeError:
862 868 log.exception(
863 869 'Subrepo merge error during local merge on hg shadow repo.')
864 870 merge_possible = False
865 871 merge_failure_reason = MergeFailureReason.SUBREPO_MERGE_FAILED
866 872 needs_push = False
867 873 except RepositoryError as e:
868 874 log.exception('Failure when doing local merge on hg shadow repo')
869 875 metadata['unresolved_files'] = 'no unresolved files found'
870 876
871 877 if isinstance(e, UnresolvedFilesInRepo):
872 878 all_conflicts = list(e.args[0])
873 879 max_conflicts = 20
874 880 if len(all_conflicts) > max_conflicts:
875 881 conflicts = all_conflicts[:max_conflicts] \
876 882 + [f'and {len(all_conflicts)-max_conflicts} more.']
877 883 else:
878 884 conflicts = all_conflicts
879 885 metadata['unresolved_files'] = \
880 886 '\n* conflict: ' + \
881 887 ('\n * conflict: '.join(conflicts))
882 888
883 889 merge_possible = False
884 890 merge_failure_reason = MergeFailureReason.MERGE_FAILED
885 891 needs_push = False
886 892
887 893 if merge_possible and not dry_run:
888 894 if needs_push:
889 895 # In case the target is a bookmark, update it, so after pushing
890 896 # the bookmarks is also updated in the target.
891 897 if target_ref.type == 'book':
892 898 shadow_repo.bookmark(
893 899 target_ref.name, revision=merge_commit_id)
894 900 try:
895 901 shadow_repo_with_hooks = self.get_shadow_instance(
896 902 shadow_repository_path,
897 903 enable_hooks=True)
898 904 # This is the actual merge action, we push from shadow
899 905 # into origin.
900 906 # Note: the push_branches option will push any new branch
901 907 # defined in the source repository to the target. This may
902 908 # be dangerous as branches are permanent in Mercurial.
903 909 # This feature was requested in issue #441.
904 910 shadow_repo_with_hooks._local_push(
905 911 merge_commit_id, self.path, push_branches=True,
906 912 enable_hooks=True)
907 913
908 914 # maybe we also need to push the close_commit_id
909 915 if close_commit_id:
910 916 shadow_repo_with_hooks._local_push(
911 917 close_commit_id, self.path, push_branches=True,
912 918 enable_hooks=True)
913 919 merge_succeeded = True
914 920 except RepositoryError:
915 921 log.exception(
916 922 'Failure when doing local push from the shadow '
917 923 'repository to the target repository at %s.', self.path)
918 924 merge_succeeded = False
919 925 merge_failure_reason = MergeFailureReason.PUSH_FAILED
920 926 metadata['target'] = 'hg shadow repo'
921 927 metadata['merge_commit'] = merge_commit_id
922 928 else:
923 929 merge_succeeded = True
924 930 else:
925 931 merge_succeeded = False
926 932
927 933 return MergeResponse(
928 934 merge_possible, merge_succeeded, merge_ref, merge_failure_reason,
929 935 metadata=metadata)
930 936
931 937 def get_shadow_instance(self, shadow_repository_path, enable_hooks=False, cache=False):
932 938 config = self.config.copy()
933 939 if not enable_hooks:
934 940 config.clear_section('hooks')
935 941 return MercurialRepository(shadow_repository_path, config, with_wire={"cache": cache})
936 942
937 943 def _validate_pull_reference(self, reference):
938 944 if not (reference.name in self.bookmarks or
939 945 reference.name in self.branches or
940 946 self.get_commit(reference.commit_id)):
941 947 raise CommitDoesNotExistError(
942 948 'Unknown branch, bookmark or commit id')
943 949
944 950 def _local_pull(self, repository_path, reference):
945 951 """
946 952 Fetch a branch, bookmark or commit from a local repository.
947 953 """
948 954 repository_path = os.path.abspath(repository_path)
949 955 if repository_path == self.path:
950 956 raise ValueError('Cannot pull from the same repository')
951 957
952 958 reference_type_to_option_name = {
953 959 'book': 'bookmark',
954 960 'branch': 'branch',
955 961 }
956 962 option_name = reference_type_to_option_name.get(
957 963 reference.type, 'revision')
958 964
959 965 if option_name == 'revision':
960 966 ref = reference.commit_id
961 967 else:
962 968 ref = reference.name
963 969
964 970 options = {option_name: [ref]}
965 971 self._remote.pull_cmd(repository_path, hooks=False, **options)
966 972 self._remote.invalidate_vcs_cache()
967 973
968 974 def bookmark(self, bookmark, revision=None):
969 975 if isinstance(bookmark, str):
970 976 bookmark = safe_str(bookmark)
971 977 self._remote.bookmark(bookmark, revision=revision)
972 978 self._remote.invalidate_vcs_cache()
973 979
974 980 def get_path_permissions(self, username):
975 981 hgacl_file = os.path.join(self.path, '.hg/hgacl')
976 982
977 983 def read_patterns(suffix):
978 984 svalue = None
979 985 for section, option in [
980 986 ('narrowacl', username + suffix),
981 987 ('narrowacl', 'default' + suffix),
982 988 ('narrowhgacl', username + suffix),
983 989 ('narrowhgacl', 'default' + suffix)
984 990 ]:
985 991 try:
986 992 svalue = hgacl.get(section, option)
987 993 break # stop at the first value we find
988 994 except configparser.NoOptionError:
989 995 pass
990 996 if not svalue:
991 997 return None
992 998 result = ['/']
993 999 for pattern in svalue.split():
994 1000 result.append(pattern)
995 1001 if '*' not in pattern and '?' not in pattern:
996 1002 result.append(pattern + '/*')
997 1003 return result
998 1004
999 1005 if os.path.exists(hgacl_file):
1000 1006 try:
1001 1007 hgacl = configparser.RawConfigParser()
1002 1008 hgacl.read(hgacl_file)
1003 1009
1004 1010 includes = read_patterns('.includes')
1005 1011 excludes = read_patterns('.excludes')
1006 1012 return BasePathPermissionChecker.create_from_patterns(
1007 1013 includes, excludes)
1008 1014 except BaseException as e:
1009 1015 msg = 'Cannot read ACL settings from {} on {}: {}'.format(
1010 1016 hgacl_file, self.name, e)
1011 1017 raise exceptions.RepositoryRequirementError(msg)
1012 1018 else:
1013 1019 return None
1014 1020
1015 1021
1016 1022 class MercurialIndexBasedCollectionGenerator(CollectionGenerator):
1017 1023
1018 1024 def _commit_factory(self, commit_id):
1019 1025 if isinstance(commit_id, int):
1020 1026 return self.repo.get_commit(
1021 1027 commit_idx=commit_id, pre_load=self.pre_load)
1022 1028 else:
1023 1029 return self.repo.get_commit(
1024 1030 commit_id=commit_id, pre_load=self.pre_load)
@@ -1,73 +1,76 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 Internal settings for vcs-lib
21 21 """
22 22
23 23 # list of default encoding used in safe_str methods
24 24 DEFAULT_ENCODINGS = ['utf8']
25 25
26 26
27 27 # Compatibility version when creating SVN repositories. None means newest.
28 28 # Other available options are: pre-1.4-compatible, pre-1.5-compatible,
29 29 # pre-1.6-compatible, pre-1.8-compatible
30 30 SVN_COMPATIBLE_VERSION = None
31 31
32 32 ALIASES = ['hg', 'git', 'svn']
33 33
34 34 BACKENDS = {
35 35 'hg': 'rhodecode.lib.vcs.backends.hg.MercurialRepository',
36 36 'git': 'rhodecode.lib.vcs.backends.git.GitRepository',
37 37 'svn': 'rhodecode.lib.vcs.backends.svn.SubversionRepository',
38 38 }
39 39
40 40
41 41 ARCHIVE_SPECS = [
42 42 ('tbz2', 'application/x-bzip2', '.tbz2'),
43 43 ('tbz2', 'application/x-bzip2', '.tar.bz2'),
44 44
45 45 ('tgz', 'application/x-gzip', '.tgz'),
46 46 ('tgz', 'application/x-gzip', '.tar.gz'),
47 47
48 48 ('zip', 'application/zip', '.zip'),
49 49 ]
50 50
51 51 HOOKS_PROTOCOL = None
52 52 HOOKS_HOST = '127.0.0.1'
53 53
54 54
55 55 MERGE_MESSAGE_TMPL = (
56 56 'Merge pull request !{pr_id} from {source_repo} {source_ref_name}\n\n '
57 57 '{pr_title}')
58 58 MERGE_DRY_RUN_MESSAGE = 'dry_run_merge_message_from_rhodecode'
59 59 MERGE_DRY_RUN_USER = 'Dry-Run User'
60 60 MERGE_DRY_RUN_EMAIL = 'dry-run-merge@rhodecode.com'
61 HG_CLOSE_BRANCH_MESSAGE_TMPL = (
62 'Closing branch: `{source_ref_name}`'
63 )
61 64
62 65
63 66 def available_aliases():
64 67 """
65 68 Mercurial is required for the system to work, so in case vcs.backends does
66 69 not include it, we make sure it will be available internally
67 70 TODO: anderson: refactor vcs.backends so it won't be necessary, VCS server
68 71 should be responsible to dictate available backends.
69 72 """
70 73 aliases = ALIASES[:]
71 74 if 'hg' not in aliases:
72 75 aliases += ['hg']
73 76 return aliases
General Comments 0
You need to be logged in to leave comments. Login now