Package trac :: Package versioncontrol :: Module api

Source Code for Module trac.versioncontrol.api

   1  # -*- coding: utf-8 -*- 
   2  # 
   3  # Copyright (C) 2005-2020 Edgewall Software 
   4  # Copyright (C) 2005 Christopher Lenz <[email protected]> 
   5  # All rights reserved. 
   6  # 
   7  # This software is licensed as described in the file COPYING, which 
   8  # you should have received as part of this distribution. The terms 
   9  # are also available at https://trac.edgewall.org/wiki/TracLicense. 
  10  # 
  11  # This software consists of voluntary contributions made by many 
  12  # individuals. For the exact contribution history, see the revision 
  13  # history and logs, available at https://trac.edgewall.org/log/. 
  14  # 
  15  # Author: Christopher Lenz <[email protected]> 
  16   
  17  from __future__ import with_statement 
  18   
  19  import os.path 
  20   
  21  from trac.admin import AdminCommandError, IAdminCommandProvider, get_dir_list 
  22  from trac.config import ConfigSection, ListOption, Option 
  23  from trac.core import * 
  24  from trac.resource import IResourceManager, Resource, ResourceNotFound 
  25  from trac.util.concurrency import threading 
  26  from trac.util.datefmt import time_now 
  27  from trac.util.text import printout, to_unicode, exception_to_unicode 
  28  from trac.util.translation import _ 
  29  from trac.web.api import IRequestFilter 
  30  from trac.web.chrome import ITemplateProvider 
  31   
  32   
33 -def is_default(reponame):
34 """Check whether `reponame` is the default repository.""" 35 return not reponame or reponame in ('(default)', _('(default)'))
36 37
38 -class InvalidRepository(TracError):
39 """Exception raised when a repository is invalid."""
40 41
42 -class IRepositoryConnector(Interface):
43 """Provide support for a specific version control system.""" 44 45 error = None # place holder for storing relevant error message 46
48 """Return the types of version control systems that are supported. 49 50 Yields `(repotype, priority)` pairs, where `repotype` is used to 51 match against the configured `[trac] repository_type` value in TracIni. 52 53 If multiple provider match a given type, the `priority` is used to 54 choose between them (highest number is highest priority). 55 56 If the `priority` returned is negative, this indicates that the 57 connector for the given `repotype` indeed exists but can't be 58 used for some reason. The `error` property can then be used to 59 store an error message or exception relevant to the problem detected. 60 """
61
62 - def get_repository(repos_type, repos_dir, params):
63 """Return a Repository instance for the given repository type and dir. 64 """
65 66
67 -class IRepositoryProvider(Interface):
68 """Provide known named instances of Repository.""" 69
70 - def get_repositories():
71 """Generate repository information for known repositories. 72 73 Repository information is a key,value pair, where the value is 74 a dictionary which must contain at the very least either of 75 the following entries: 76 77 - `'dir'`: the repository directory which can be used by the 78 connector to create a `Repository` instance. This 79 defines a "real" repository. 80 81 - `'alias'`: the name of another repository. This defines an 82 alias to another (real) repository. 83 84 Optional entries: 85 86 - `'type'`: the type of the repository (if not given, the 87 default repository type will be used). 88 89 - `'description'`: a description of the repository (can 90 contain WikiFormatting). 91 92 - `'hidden'`: if set to `'true'`, the repository is hidden 93 from the repository index. 94 95 - `'url'`: the base URL for checking out the repository. 96 """
97 98
99 -class IRepositoryChangeListener(Interface):
100 """Listen for changes in repositories.""" 101
102 - def changeset_added(repos, changeset):
103 """Called after a changeset has been added to a repository."""
104
105 - def changeset_modified(repos, changeset, old_changeset):
106 """Called after a changeset has been modified in a repository. 107 108 The `old_changeset` argument contains the metadata of the changeset 109 prior to the modification. It is `None` if the old metadata cannot 110 be retrieved. 111 """
112 113
114 -class DbRepositoryProvider(Component):
115 """Component providing repositories registered in the DB.""" 116 117 implements(IRepositoryProvider, IAdminCommandProvider) 118 119 repository_attrs = ('alias', 'description', 'dir', 'hidden', 'name', 120 'type', 'url') 121 122 # IRepositoryProvider methods 123
124 - def get_repositories(self):
125 """Retrieve repositories specified in the repository DB table.""" 126 repos = {} 127 for id, name, value in self.env.db_query( 128 "SELECT id, name, value FROM repository WHERE name IN (%s)" 129 % ",".join("'%s'" % each for each in self.repository_attrs)): 130 if value is not None: 131 repos.setdefault(id, {})[name] = value 132 reponames = {} 133 for id, info in repos.iteritems(): 134 if 'name' in info and ('dir' in info or 'alias' in info): 135 info['id'] = id 136 reponames[info['name']] = info 137 return reponames.iteritems()
138 139 # IAdminCommandProvider methods 140
141 - def get_admin_commands(self):
142 yield ('repository add', '<repos> <dir> [type]', 143 "Add a source repository", 144 self._complete_add, self._do_add) 145 yield ('repository alias', '<name> <target>', 146 "Create an alias for a repository", 147 self._complete_alias, self._do_alias) 148 yield ('repository remove', '<repos>', 149 "Remove a source repository", 150 self._complete_repos, self._do_remove) 151 yield ('repository set', '<repos> <key> <value>', 152 """Set an attribute of a repository 153 154 The following keys are supported: %s 155 """ % ', '.join(self.repository_attrs), 156 self._complete_set, self._do_set)
157
158 - def get_reponames(self):
159 rm = RepositoryManager(self.env) 160 return [reponame or '(default)' for reponame 161 in rm.get_all_repositories()]
162
163 - def _complete_add(self, args):
164 if len(args) == 2: 165 return get_dir_list(args[-1], True) 166 elif len(args) == 3: 167 return RepositoryManager(self.env).get_supported_types()
168
169 - def _complete_alias(self, args):
170 if len(args) == 2: 171 return self.get_reponames()
172
173 - def _complete_repos(self, args):
174 if len(args) == 1: 175 return self.get_reponames()
176
177 - def _complete_set(self, args):
178 if len(args) == 1: 179 return self.get_reponames() 180 elif len(args) == 2: 181 return self.repository_attrs
182
183 - def _do_add(self, reponame, dir, type_=None):
184 self.add_repository(reponame, os.path.abspath(dir), type_)
185
186 - def _do_alias(self, reponame, target):
187 self.add_alias(reponame, target)
188
189 - def _do_remove(self, reponame):
190 self.remove_repository(reponame)
191
192 - def _do_set(self, reponame, key, value):
193 if key not in self.repository_attrs: 194 raise AdminCommandError(_('Invalid key "%(key)s"', key=key)) 195 if key == 'dir': 196 value = os.path.abspath(value) 197 self.modify_repository(reponame, {key: value}) 198 if not reponame: 199 reponame = '(default)' 200 if key == 'dir': 201 printout(_('You should now run "repository resync %(name)s".', 202 name=reponame)) 203 elif key == 'type': 204 printout(_('You may have to run "repository resync %(name)s".', 205 name=reponame))
206 207 # Public interface 208
209 - def add_repository(self, reponame, dir, type_=None):
210 """Add a repository.""" 211 if not os.path.isabs(dir): 212 raise TracError(_("The repository directory must be absolute")) 213 if is_default(reponame): 214 reponame = '' 215 rm = RepositoryManager(self.env) 216 if type_ and type_ not in rm.get_supported_types(): 217 raise TracError(_("The repository type '%(type)s' is not " 218 "supported", type=type_)) 219 with self.env.db_transaction as db: 220 id = rm.get_repository_id(reponame) 221 db.executemany( 222 "INSERT INTO repository (id, name, value) VALUES (%s, %s, %s)", 223 [(id, 'dir', dir), 224 (id, 'type', type_ or '')]) 225 rm.reload_repositories()
226
227 - def add_alias(self, reponame, target):
228 """Create an alias repository.""" 229 if is_default(reponame): 230 reponame = '' 231 if is_default(target): 232 target = '' 233 rm = RepositoryManager(self.env) 234 repositories = rm.get_all_repositories() 235 if target not in repositories: 236 raise TracError(_("Repository \"%(repo)s\" doesn't exist", 237 repo=target or '(default)')) 238 if 'alias' in repositories[target]: 239 raise TracError(_('Cannot create an alias to the alias "%(repo)s"', 240 repo=target or '(default)')) 241 with self.env.db_transaction as db: 242 id = rm.get_repository_id(reponame) 243 db.executemany( 244 "INSERT INTO repository (id, name, value) VALUES (%s, %s, %s)", 245 [(id, 'dir', None), 246 (id, 'alias', target)]) 247 rm.reload_repositories()
248
249 - def remove_repository(self, reponame):
250 """Remove a repository.""" 251 if is_default(reponame): 252 reponame = '' 253 rm = RepositoryManager(self.env) 254 repositories = rm.get_all_repositories() 255 if any(reponame == repos.get('alias') 256 for repos in repositories.itervalues()): 257 raise TracError(_('Cannot remove the repository "%(repos)s" used ' 258 'in aliases', repos=reponame or '(default)')) 259 with self.env.db_transaction as db: 260 id = rm.get_repository_id(reponame) 261 db("DELETE FROM repository WHERE id=%s", (id,)) 262 db("DELETE FROM revision WHERE repos=%s", (id,)) 263 db("DELETE FROM node_change WHERE repos=%s", (id,)) 264 rm.reload_repositories()
265
266 - def modify_repository(self, reponame, changes):
267 """Modify attributes of a repository.""" 268 if is_default(reponame): 269 reponame = '' 270 new_reponame = changes.get('name', reponame) 271 if is_default(new_reponame): 272 new_reponame = '' 273 rm = RepositoryManager(self.env) 274 if reponame != new_reponame: 275 repositories = rm.get_all_repositories() 276 if any(reponame == repos.get('alias') 277 for repos in repositories.itervalues()): 278 raise TracError(_('Cannot rename the repository "%(repos)s" ' 279 'used in aliases', 280 repos=reponame or '(default)')) 281 with self.env.db_transaction as db: 282 id = rm.get_repository_id(reponame) 283 if reponame != new_reponame: 284 if db("""SELECT id FROM repository WHERE name='name' AND 285 value=%s""", (new_reponame,)): 286 raise TracError(_('The repository "%(name)s" already ' 287 'exists.', 288 name=new_reponame or '(default)')) 289 for (k, v) in changes.iteritems(): 290 if k not in self.repository_attrs: 291 continue 292 if k in ('alias', 'name') and is_default(v): 293 v = '' 294 if k == 'dir' and not os.path.isabs(v): 295 raise TracError(_("The repository directory must be " 296 "absolute")) 297 db("UPDATE repository SET value=%s WHERE id=%s AND name=%s", 298 (v, id, k)) 299 if not db( 300 "SELECT value FROM repository WHERE id=%s AND name=%s", 301 (id, k)): 302 db("""INSERT INTO repository (id, name, value) 303 VALUES (%s, %s, %s) 304 """, (id, k, v)) 305 rm.reload_repositories()
306 307
308 -class RepositoryManager(Component):
309 """Version control system manager.""" 310 311 implements(IRequestFilter, IResourceManager, IRepositoryProvider, 312 ITemplateProvider) 313 314 connectors = ExtensionPoint(IRepositoryConnector) 315 providers = ExtensionPoint(IRepositoryProvider) 316 change_listeners = ExtensionPoint(IRepositoryChangeListener) 317 318 repositories_section = ConfigSection('repositories', 319 """One of the alternatives for registering new repositories is to 320 populate the `[repositories]` section of the `trac.ini`. 321 322 This is especially suited for setting up convenience aliases, 323 short-lived repositories, or during the initial phases of an 324 installation. 325 326 See [TracRepositoryAdmin#ReposTracIni TracRepositoryAdmin] for details 327 about the format adopted for this section and the rest of that page for 328 the other alternatives. 329 330 (''since 0.12'')""") 331 332 repository_type = Option('trac', 'repository_type', 'svn', 333 """Default repository connector type. (''since 0.10'') 334 335 This is also used as the default repository type for repositories 336 defined in the [TracIni#repositories-section repositories] section 337 or using the "Repositories" admin panel. (''since 0.12'') 338 """) 339 340 repository_dir = Option('trac', 'repository_dir', '', 341 """Path to the default repository. This can also be a relative path 342 (''since 0.11''). 343 344 This option is deprecated, and repositories should be defined in the 345 [TracIni#repositories-section repositories] section, or using the 346 "Repositories" admin panel. (''since 0.12'')""") 347 348 repository_sync_per_request = ListOption('trac', 349 'repository_sync_per_request', '(default)', 350 doc="""List of repositories that should be synchronized on every page 351 request. 352 353 Leave this option empty if you have set up post-commit hooks calling 354 `trac-admin $ENV changeset added` on all your repositories 355 (recommended). Otherwise, set it to a comma-separated list of 356 repository names. Note that this will negatively affect performance, 357 and will prevent changeset listeners from receiving events from the 358 repositories specified here. The default is to synchronize the default 359 repository, for backward compatibility. (''since 0.12'')""") 360
361 - def __init__(self):
362 self._cache = {} 363 self._lock = threading.Lock() 364 self._connectors = None 365 self._all_repositories = None
366 367 # IRequestFilter methods 368
369 - def pre_process_request(self, req, handler):
370 from trac.web.chrome import Chrome, add_warning 371 if handler is not Chrome(self.env): 372 for reponame in self.repository_sync_per_request: 373 start = time_now() 374 if is_default(reponame): 375 reponame = '' 376 try: 377 repo = self.get_repository(reponame) 378 if repo: 379 repo.sync() 380 else: 381 self.log.warning("Unable to find repository '%s' for " 382 "synchronization", 383 reponame or '(default)') 384 continue 385 except TracError, e: 386 add_warning(req, 387 _("Can't synchronize with repository \"%(name)s\" " 388 "(%(error)s). Look in the Trac log for more " 389 "information.", name=reponame or '(default)', 390 error=to_unicode(e))) 391 except Exception, e: 392 add_warning(req, 393 _("Failed to sync with repository \"%(name)s\": " 394 "%(error)s; repository information may be out of " 395 "date. Look in the Trac log for more information " 396 "including mitigation strategies.", 397 name=reponame or '(default)', error=to_unicode(e))) 398 self.log.error( 399 "Failed to sync with repository \"%s\"; You may be " 400 "able to reduce the impact of this issue by " 401 "configuring [trac] repository_sync_per_request; see " 402 "https://trac.edgewall.org/wiki/TracRepositoryAdmin" 403 "#ExplicitSync for more detail: %s", 404 reponame or '(default)', 405 exception_to_unicode(e, traceback=True)) 406 self.log.info("Synchronized '%s' repository in %0.2f seconds", 407 reponame or '(default)', time_now() - start) 408 return handler
409
410 - def post_process_request(self, req, template, data, content_type):
411 return (template, data, content_type)
412 413 # IResourceManager methods 414
415 - def get_resource_realms(self):
416 yield 'changeset' 417 yield 'source' 418 yield 'repository'
419
420 - def get_resource_description(self, resource, format=None, **kwargs):
421 if resource.realm == 'changeset': 422 parent = resource.parent 423 reponame = parent and parent.id 424 id = resource.id 425 if reponame: 426 return _("Changeset %(rev)s in %(repo)s", rev=id, repo=reponame) 427 else: 428 return _("Changeset %(rev)s", rev=id) 429 elif resource.realm == 'source': 430 parent = resource.parent 431 reponame = parent and parent.id 432 id = resource.id 433 version = '' 434 if format == 'summary': 435 repos = self.get_repository(reponame) 436 node = repos.get_node(resource.id, resource.version) 437 if node.isdir: 438 kind = _("directory") 439 elif node.isfile: 440 kind = _("file") 441 if resource.version: 442 version = _(" at version %(rev)s", rev=resource.version) 443 else: 444 kind = _("path") 445 if resource.version: 446 version = '@%s' % resource.version 447 in_repo = _(" in %(repo)s", repo=reponame) if reponame else '' 448 # TRANSLATOR: file /path/to/file.py at version 13 in reponame 449 return _('%(kind)s %(id)s%(at_version)s%(in_repo)s', 450 kind=kind, id=id, at_version=version, in_repo=in_repo) 451 elif resource.realm == 'repository': 452 if not resource.id: 453 return _("Default repository") 454 return _("Repository %(repo)s", repo=resource.id)
455
456 - def get_resource_url(self, resource, href, **kwargs):
457 if resource.realm == 'changeset': 458 parent = resource.parent 459 return href.changeset(resource.id, parent and parent.id or None) 460 elif resource.realm == 'source': 461 parent = resource.parent 462 return href.browser(parent and parent.id or None, resource.id, 463 rev=resource.version or None) 464 elif resource.realm == 'repository': 465 return href.browser(resource.id or None)
466
467 - def resource_exists(self, resource):
468 if resource.realm == 'repository': 469 reponame = resource.id 470 else: 471 reponame = resource.parent.id 472 repos = self.env.get_repository(reponame) 473 if not repos: 474 return False 475 if resource.realm == 'changeset': 476 try: 477 repos.get_changeset(resource.id) 478 return True 479 except NoSuchChangeset: 480 return False 481 elif resource.realm == 'source': 482 try: 483 repos.get_node(resource.id, resource.version) 484 return True 485 except NoSuchNode: 486 return False 487 elif resource.realm == 'repository': 488 return True
489 490 # IRepositoryProvider methods 491
492 - def get_repositories(self):
493 """Retrieve repositories specified in TracIni. 494 495 The `[repositories]` section can be used to specify a list 496 of repositories. 497 """ 498 repositories = self.repositories_section 499 reponames = {} 500 # eventually add pre-0.12 default repository 501 if self.repository_dir: 502 reponames[''] = {'dir': self.repository_dir} 503 # first pass to gather the <name>.dir entries 504 for option in repositories: 505 if option.endswith('.dir') and repositories.get(option): 506 reponames[option[:-4]] = {} 507 # second pass to gather aliases 508 for option in repositories: 509 alias = repositories.get(option) 510 if '.' not in option: # Support <alias> = <repo> syntax 511 option += '.alias' 512 if option.endswith('.alias') and alias in reponames: 513 reponames.setdefault(option[:-6], {})['alias'] = alias 514 # third pass to gather the <name>.<detail> entries 515 for option in repositories: 516 if '.' in option: 517 name, detail = option.rsplit('.', 1) 518 if name in reponames and detail != 'alias': 519 reponames[name][detail] = repositories.get(option) 520 521 for reponame, info in reponames.iteritems(): 522 yield (reponame, info)
523 524 # ITemplateProvider methods 525
526 - def get_htdocs_dirs(self):
527 return []
528
529 - def get_templates_dirs(self):
530 from pkg_resources import resource_filename 531 return [resource_filename('trac.versioncontrol', 'templates')]
532 533 # Public API methods 534
535 - def get_supported_types(self):
536 """Return the list of supported repository types.""" 537 types = set(type_ for connector in self.connectors 538 for (type_, prio) in connector.get_supported_types() or [] 539 if prio >= 0) 540 return list(types)
541
542 - def get_repositories_by_dir(self, directory):
543 """Retrieve the repositories based on the given directory. 544 545 :param directory: the key for identifying the repositories. 546 :return: list of `Repository` instances. 547 """ 548 directory = os.path.join(os.path.normcase(directory), '') 549 repositories = [] 550 for reponame, repoinfo in self.get_all_repositories().iteritems(): 551 dir = repoinfo.get('dir') 552 if dir: 553 dir = os.path.join(os.path.normcase(dir), '') 554 if dir.startswith(directory): 555 repos = self.get_repository(reponame) 556 if repos: 557 repositories.append(repos) 558 return repositories
559
560 - def get_repository_id(self, reponame):
561 """Return a unique id for the given repository name. 562 563 This will create and save a new id if none is found. 564 565 Note: this should probably be renamed as we're dealing 566 exclusively with *db* repository ids here. 567 """ 568 with self.env.db_transaction as db: 569 for id, in db( 570 "SELECT id FROM repository WHERE name='name' AND value=%s", 571 (reponame,)): 572 return id 573 574 id = db("SELECT COALESCE(MAX(id), 0) FROM repository")[0][0] + 1 575 db("INSERT INTO repository (id, name, value) VALUES (%s, %s, %s)", 576 (id, 'name', reponame)) 577 return id
578
579 - def get_repository(self, reponame):
580 """Retrieve the appropriate `Repository` for the given 581 repository name. 582 583 :param reponame: the key for specifying the repository. 584 If no name is given, take the default 585 repository. 586 :return: if no corresponding repository was defined, 587 simply return `None`. 588 589 :raises InvalidRepository: if the repository cannot be opened. 590 """ 591 reponame = reponame or '' 592 repoinfo = self.get_all_repositories().get(reponame, {}) 593 if 'alias' in repoinfo: 594 reponame = repoinfo['alias'] 595 repoinfo = self.get_all_repositories().get(reponame, {}) 596 rdir = repoinfo.get('dir') 597 if not rdir: 598 return None 599 rtype = repoinfo.get('type') or self.repository_type 600 601 # get a Repository for the reponame (use a thread-level cache) 602 with self.env.db_transaction: # prevent possible deadlock, see #4465 603 with self._lock: 604 tid = threading._get_ident() 605 if tid in self._cache: 606 repositories = self._cache[tid] 607 else: 608 repositories = self._cache[tid] = {} 609 repos = repositories.get(reponame) 610 if not repos: 611 if not os.path.isabs(rdir): 612 rdir = os.path.join(self.env.path, rdir) 613 connector = self._get_connector(rtype) 614 repos = connector.get_repository(rtype, rdir, 615 repoinfo.copy()) 616 repositories[reponame] = repos 617 return repos
618
619 - def get_repository_by_path(self, path):
620 """Retrieve a matching `Repository` for the given `path`. 621 622 :param path: the eventually scoped repository-scoped path 623 :return: a `(reponame, repos, path)` triple, where `path` is 624 the remaining part of `path` once the `reponame` has 625 been truncated, if needed. 626 """ 627 matches = [] 628 path = path.strip('/') + '/' if path else '/' 629 for reponame in self.get_all_repositories().keys(): 630 stripped_reponame = reponame.strip('/') + '/' 631 if path.startswith(stripped_reponame): 632 matches.append((len(stripped_reponame), reponame)) 633 if matches: 634 matches.sort() 635 length, reponame = matches[-1] 636 path = path[length:] 637 else: 638 reponame = '' 639 return (reponame, self.get_repository(reponame), 640 path.rstrip('/') or '/')
641
642 - def get_default_repository(self, context):
643 """Recover the appropriate repository from the current context. 644 645 Lookup the closest source or changeset resource in the context 646 hierarchy and return the name of its associated repository. 647 """ 648 while context: 649 if context.resource.realm in ('source', 'changeset') and \ 650 context.resource.parent: 651 return context.resource.parent.id 652 context = context.parent
653
654 - def get_all_repositories(self):
655 """Return a dictionary of repository information, indexed by name.""" 656 if not self._all_repositories: 657 all_repositories = {} 658 for provider in self.providers: 659 for reponame, info in provider.get_repositories() or []: 660 if reponame in all_repositories: 661 self.log.warn("Discarding duplicate repository '%s'", 662 reponame) 663 else: 664 info['name'] = reponame 665 if 'id' not in info: 666 info['id'] = self.get_repository_id(reponame) 667 all_repositories[reponame] = info 668 self._all_repositories = all_repositories 669 return self._all_repositories
670
671 - def get_real_repositories(self):
672 """Return a set of all real repositories (i.e. excluding aliases).""" 673 repositories = set() 674 for reponame in self.get_all_repositories(): 675 try: 676 repos = self.get_repository(reponame) 677 if repos is not None: 678 repositories.add(repos) 679 except TracError: 680 pass # Skip invalid repositories 681 return repositories
682
683 - def reload_repositories(self):
684 """Reload the repositories from the providers.""" 685 with self._lock: 686 # FIXME: trac-admin doesn't reload the environment 687 self._cache = {} 688 self._all_repositories = None 689 self.config.touch() # Force environment reload
690
691 - def notify(self, event, reponame, revs):
692 """Notify repositories and change listeners about repository events. 693 694 The supported events are the names of the methods defined in the 695 `IRepositoryChangeListener` interface. 696 """ 697 self.log.debug("Event %s on repository '%s' for changesets %r", 698 event, reponame or '(default)', revs) 699 700 # Notify a repository by name, and all repositories with the same 701 # base, or all repositories by base or by repository dir 702 repos = self.get_repository(reponame) 703 repositories = [] 704 if repos: 705 base = repos.get_base() 706 else: 707 dir = os.path.abspath(reponame) 708 repositories = self.get_repositories_by_dir(dir) 709 if repositories: 710 base = None 711 else: 712 base = reponame 713 if base: 714 repositories = [r for r in self.get_real_repositories() 715 if r.get_base() == base] 716 if not repositories: 717 self.log.warn("Found no repositories matching '%s' base.", 718 base or reponame) 719 return [_("Repository '%(repo)s' not found", 720 repo=reponame or _("(default)"))] 721 722 errors = [] 723 for repos in sorted(repositories, key=lambda r: r.reponame): 724 reponame = repos.reponame or '(default)' 725 if reponame in self.repository_sync_per_request: 726 self.log.warn("Repository '%s' should be removed from [trac] " 727 "repository_sync_per_request for explicit " 728 "synchronization", reponame) 729 repos.sync() 730 for rev in revs: 731 args = [] 732 if event == 'changeset_modified': 733 try: 734 old_changeset = repos.sync_changeset(rev) 735 except NoSuchChangeset, e: 736 errors.append(exception_to_unicode(e)) 737 self.log.warn( 738 "No changeset '%s' found in repository '%s'. " 739 "Skipping subscribers for event %s", 740 rev, reponame, event) 741 continue 742 else: 743 args.append(old_changeset) 744 try: 745 changeset = repos.get_changeset(rev) 746 except NoSuchChangeset: 747 try: 748 repos.sync_changeset(rev) 749 changeset = repos.get_changeset(rev) 750 except NoSuchChangeset, e: 751 errors.append(exception_to_unicode(e)) 752 self.log.warn( 753 "No changeset '%s' found in repository '%s'. " 754 "Skipping subscribers for event %s", 755 rev, reponame, event) 756 continue 757 self.log.debug("Event %s on repository '%s' for revision '%s'", 758 event, reponame, rev) 759 for listener in self.change_listeners: 760 getattr(listener, event)(repos, changeset, *args) 761 return errors
762
763 - def shutdown(self, tid=None):
764 """Free `Repository` instances bound to a given thread identifier""" 765 if tid: 766 assert tid == threading._get_ident() 767 with self._lock: 768 repositories = self._cache.pop(tid, {}) 769 for reponame, repos in repositories.iteritems(): 770 repos.close()
771 772 # private methods 773
774 - def _get_connector(self, rtype):
775 """Retrieve the appropriate connector for the given repository type. 776 777 Note that the self._lock must be held when calling this method. 778 """ 779 if self._connectors is None: 780 # build an environment-level cache for the preferred connectors 781 self._connectors = {} 782 for connector in self.connectors: 783 for type_, prio in connector.get_supported_types() or []: 784 keep = (connector, prio) 785 if type_ in self._connectors and \ 786 prio <= self._connectors[type_][1]: 787 keep = None 788 if keep: 789 self._connectors[type_] = keep 790 if rtype in self._connectors: 791 connector, prio = self._connectors[rtype] 792 if prio >= 0: # no error condition 793 return connector 794 else: 795 raise TracError( 796 _('Unsupported version control system "%(name)s"' 797 ': %(error)s', name=rtype, 798 error=to_unicode(connector.error))) 799 else: 800 raise TracError( 801 _('Unsupported version control system "%(name)s": ' 802 'Can\'t find an appropriate component, maybe the ' 803 'corresponding plugin was not enabled? ', name=rtype))
804 805
806 -class NoSuchChangeset(ResourceNotFound):
807 - def __init__(self, rev):
808 ResourceNotFound.__init__(self, 809 _('No changeset %(rev)s in the repository', 810 rev=rev), 811 _('No such changeset'))
812 813
814 -class NoSuchNode(ResourceNotFound):
815 - def __init__(self, path, rev, msg=None):
816 if msg is None: 817 msg = _("No node %(path)s at revision %(rev)s", path=path, rev=rev) 818 else: 819 msg = _("%(msg)s: No node %(path)s at revision %(rev)s", 820 msg=msg, path=path, rev=rev) 821 ResourceNotFound.__init__(self, msg, _('No such node'))
822 823
824 -class Repository(object):
825 """Base class for a repository provided by a version control system.""" 826 827 has_linear_changesets = False 828 829 scope = '/' 830
831 - def __init__(self, name, params, log):
832 """Initialize a repository. 833 834 :param name: a unique name identifying the repository, usually a 835 type-specific prefix followed by the path to the 836 repository. 837 :param params: a `dict` of parameters for the repository. Contains 838 the name of the repository under the key "name" and 839 the surrogate key that identifies the repository in 840 the database under the key "id". 841 :param log: a logger instance. 842 843 :raises InvalidRepository: if the repository cannot be opened. 844 """ 845 self.name = name 846 self.params = params 847 self.reponame = params['name'] 848 self.id = params['id'] 849 self.log = log 850 self.resource = Resource('repository', self.reponame)
851
852 - def close(self):
853 """Close the connection to the repository.""" 854 raise NotImplementedError
855
856 - def get_base(self):
857 """Return the name of the base repository for this repository. 858 859 This function returns the name of the base repository to which scoped 860 repositories belong. For non-scoped repositories, it returns the 861 repository name. 862 """ 863 return self.name
864
865 - def clear(self, youngest_rev=None):
866 """Clear any data that may have been cached in instance properties. 867 868 `youngest_rev` can be specified as a way to force the value 869 of the `youngest_rev` property (''will change in 0.12''). 870 """ 871 pass
872
873 - def sync(self, rev_callback=None, clean=False):
874 """Perform a sync of the repository cache, if relevant. 875 876 If given, `rev_callback` must be a callable taking a `rev` parameter. 877 The backend will call this function for each `rev` it decided to 878 synchronize, once the synchronization changes are committed to the 879 cache. When `clean` is `True`, the cache is cleaned first. 880 """ 881 pass
882
883 - def sync_changeset(self, rev):
884 """Resync the repository cache for the given `rev`, if relevant. 885 886 Returns a "metadata-only" changeset containing the metadata prior to 887 the resync, or `None` if the old values cannot be retrieved (typically 888 when the repository is not cached). 889 """ 890 return None
891
892 - def get_quickjump_entries(self, rev):
893 """Generate a list of interesting places in the repository. 894 895 `rev` might be used to restrict the list of available locations, 896 but in general it's best to produce all known locations. 897 898 The generated results must be of the form (category, name, path, rev). 899 """ 900 return []
901
902 - def get_path_url(self, path, rev):
903 """Return the repository URL for the given path and revision. 904 905 The returned URL can be `None`, meaning that no URL has been specified 906 for the repository, an absolute URL, or a scheme-relative URL starting 907 with `//`, in which case the scheme of the request should be prepended. 908 """ 909 return None
910
911 - def get_changeset(self, rev):
912 """Retrieve a Changeset corresponding to the given revision `rev`.""" 913 raise NotImplementedError
914
915 - def get_changeset_uid(self, rev):
916 """Return a globally unique identifier for the ''rev'' changeset. 917 918 Two changesets from different repositories can sometimes refer to 919 the ''very same'' changeset (e.g. the repositories are clones). 920 """
921
922 - def get_changesets(self, start, stop):
923 """Generate Changeset belonging to the given time period (start, stop). 924 """ 925 rev = self.youngest_rev 926 while rev: 927 chgset = self.get_changeset(rev) 928 if chgset.date < start: 929 return 930 if chgset.date < stop: 931 yield chgset 932 rev = self.previous_rev(rev)
933
934 - def has_node(self, path, rev=None):
935 """Tell if there's a node at the specified (path,rev) combination. 936 937 When `rev` is `None`, the latest revision is implied. 938 """ 939 try: 940 self.get_node(path, rev) 941 return True 942 except TracError: 943 return False
944
945 - def get_node(self, path, rev=None):
946 """Retrieve a Node from the repository at the given path. 947 948 A Node represents a directory or a file at a given revision in the 949 repository. 950 If the `rev` parameter is specified, the Node corresponding to that 951 revision is returned, otherwise the Node corresponding to the youngest 952 revision is returned. 953 """ 954 raise NotImplementedError
955
956 - def get_oldest_rev(self):
957 """Return the oldest revision stored in the repository.""" 958 raise NotImplementedError
959 oldest_rev = property(lambda self: self.get_oldest_rev()) 960
961 - def get_youngest_rev(self):
962 """Return the youngest revision in the repository.""" 963 raise NotImplementedError
964 youngest_rev = property(lambda self: self.get_youngest_rev()) 965
966 - def previous_rev(self, rev, path=''):
967 """Return the revision immediately preceding the specified revision. 968 969 If `path` is given, filter out ancestor revisions having no changes 970 below `path`. 971 972 In presence of multiple parents, this follows the first parent. 973 """ 974 raise NotImplementedError
975
976 - def next_rev(self, rev, path=''):
977 """Return the revision immediately following the specified revision. 978 979 If `path` is given, filter out descendant revisions having no changes 980 below `path`. 981 982 In presence of multiple children, this follows the first child. 983 """ 984 raise NotImplementedError
985
986 - def parent_revs(self, rev):
987 """Return a list of parents of the specified revision.""" 988 parent = self.previous_rev(rev) 989 return [parent] if parent is not None else []
990
991 - def rev_older_than(self, rev1, rev2):
992 """Provides a total order over revisions. 993 994 Return `True` if `rev1` is an ancestor of `rev2`. 995 """ 996 raise NotImplementedError
997
998 - def get_path_history(self, path, rev=None, limit=None):
999 """Retrieve all the revisions containing this path. 1000 1001 If given, `rev` is used as a starting point (i.e. no revision 1002 ''newer'' than `rev` should be returned). 1003 The result format should be the same as the one of Node.get_history() 1004 """ 1005 raise NotImplementedError
1006
1007 - def normalize_path(self, path):
1008 """Return a canonical representation of path in the repos.""" 1009 raise NotImplementedError
1010
1011 - def normalize_rev(self, rev):
1012 """Return a (unique) canonical representation of a revision. 1013 1014 It's up to the backend to decide which string values of `rev` 1015 (usually provided by the user) should be accepted, and how they 1016 should be normalized. Some backends may for instance want to match 1017 against known tags or branch names. 1018 1019 In addition, if `rev` is `None` or '', the youngest revision should 1020 be returned. 1021 1022 :raise NoSuchChangeset: If the given `rev` isn't found. 1023 """ 1024 raise NotImplementedError
1025
1026 - def short_rev(self, rev):
1027 """Return a compact representation of a revision in the repos. 1028 1029 :raise NoSuchChangeset: If the given `rev` isn't found. 1030 """ 1031 return self.normalize_rev(rev)
1032
1033 - def display_rev(self, rev):
1034 """Return a representation of a revision in the repos for displaying to 1035 the user. 1036 1037 This can be a shortened revision string, e.g. for repositories using 1038 long hashes. 1039 1040 :raise NoSuchChangeset: If the given `rev` isn't found. 1041 """ 1042 return self.normalize_rev(rev)
1043
1044 - def get_changes(self, old_path, old_rev, new_path, new_rev, 1045 ignore_ancestry=1):
1046 """Generates changes corresponding to generalized diffs. 1047 1048 Generator that yields change tuples (old_node, new_node, kind, change) 1049 for each node change between the two arbitrary (path,rev) pairs. 1050 1051 The old_node is assumed to be None when the change is an ADD, 1052 the new_node is assumed to be None when the change is a DELETE. 1053 """ 1054 raise NotImplementedError
1055
1056 - def is_viewable(self, perm):
1057 """Return True if view permission is granted on the repository.""" 1058 return 'BROWSER_VIEW' in perm(self.resource.child('source', '/'))
1059 1060 can_view = is_viewable # 0.12 compatibility
1061 1062
1063 -class Node(object):
1064 """Represents a directory or file in the repository at a given revision.""" 1065 1066 DIRECTORY = "dir" 1067 FILE = "file" 1068 1069 resource = property(lambda self: Resource('source', self.path, 1070 version=self.rev, 1071 parent=self.repos.resource)) 1072 1073 # created_path and created_rev properties refer to the Node "creation" 1074 # in the Subversion meaning of a Node in a versioned tree (see #3340). 1075 # 1076 # Those properties must be set by subclasses. 1077 # 1078 created_rev = None 1079 created_path = None 1080
1081 - def __init__(self, repos, path, rev, kind):
1082 assert kind in (Node.DIRECTORY, Node.FILE), \ 1083 "Unknown node kind %s" % kind 1084 self.repos = repos 1085 self.path = to_unicode(path) 1086 self.rev = rev 1087 self.kind = kind
1088
1089 - def get_content(self):
1090 """Return a stream for reading the content of the node. 1091 1092 This method will return `None` for directories. 1093 The returned object must support a `read([len])` method. 1094 """ 1095 raise NotImplementedError
1096
1097 - def get_processed_content(self, keyword_substitution=True, eol_hint=None):
1098 """Return a stream for reading the content of the node, with some 1099 standard processing applied. 1100 1101 :param keyword_substitution: if `True`, meta-data keywords 1102 present in the content like ``$Rev$`` are substituted 1103 (which keyword are substituted and how they are 1104 substituted is backend specific) 1105 1106 :param eol_hint: which style of line ending is expected if 1107 `None` was explicitly specified for the file itself in 1108 the version control backend (for example in Subversion, 1109 if it was set to ``'native'``). It can be `None`, 1110 ``'LF'``, ``'CR'`` or ``'CRLF'``. 1111 """ 1112 return self.get_content()
1113
1114 - def get_entries(self):
1115 """Generator that yields the immediate child entries of a directory. 1116 1117 The entries are returned in no particular order. 1118 If the node is a file, this method returns `None`. 1119 """ 1120 raise NotImplementedError
1121
1122 - def get_history(self, limit=None):
1123 """Provide backward history for this Node. 1124 1125 Generator that yields `(path, rev, chg)` tuples, one for each revision 1126 in which the node was changed. This generator will follow copies and 1127 moves of a node (if the underlying version control system supports 1128 that), which will be indicated by the first element of the tuple 1129 (i.e. the path) changing. 1130 Starts with an entry for the current revision. 1131 1132 :param limit: if given, yield at most ``limit`` results. 1133 """ 1134 raise NotImplementedError
1135
1136 - def get_previous(self):
1137 """Return the change event corresponding to the previous revision. 1138 1139 This returns a `(path, rev, chg)` tuple. 1140 """ 1141 skip = True 1142 for p in self.get_history(2): 1143 if skip: 1144 skip = False 1145 else: 1146 return p
1147
1148 - def get_annotations(self):
1149 """Provide detailed backward history for the content of this Node. 1150 1151 Retrieve an array of revisions, one `rev` for each line of content 1152 for that node. 1153 Only expected to work on (text) FILE nodes, of course. 1154 """ 1155 raise NotImplementedError
1156
1157 - def get_properties(self):
1158 """Returns the properties (meta-data) of the node, as a dictionary. 1159 1160 The set of properties depends on the version control system. 1161 """ 1162 raise NotImplementedError
1163
1164 - def get_content_length(self):
1165 """The length in bytes of the content. 1166 1167 Will be `None` for a directory. 1168 """ 1169 raise NotImplementedError
1170 content_length = property(lambda self: self.get_content_length()) 1171
1172 - def get_content_type(self):
1173 """The MIME type corresponding to the content, if known. 1174 1175 Will be `None` for a directory. 1176 """ 1177 raise NotImplementedError
1178 content_type = property(lambda self: self.get_content_type()) 1179
1180 - def get_name(self):
1181 return self.path.split('/')[-1]
1182 name = property(lambda self: self.get_name()) 1183
1184 - def get_last_modified(self):
1185 raise NotImplementedError
1186 last_modified = property(lambda self: self.get_last_modified()) 1187 1188 isdir = property(lambda self: self.kind == Node.DIRECTORY) 1189 isfile = property(lambda self: self.kind == Node.FILE) 1190
1191 - def is_viewable(self, perm):
1192 """Return True if view permission is granted on the node.""" 1193 return ('BROWSER_VIEW' if self.isdir else 'FILE_VIEW') \ 1194 in perm(self.resource)
1195 1196 can_view = is_viewable # 0.12 compatibility
1197 1198
1199 -class Changeset(object):
1200 """Represents a set of changes committed at once in a repository.""" 1201 1202 ADD = 'add' 1203 COPY = 'copy' 1204 DELETE = 'delete' 1205 EDIT = 'edit' 1206 MOVE = 'move' 1207 1208 # change types which can have diff associated to them 1209 DIFF_CHANGES = (EDIT, COPY, MOVE) # MERGE 1210 OTHER_CHANGES = (ADD, DELETE) 1211 ALL_CHANGES = DIFF_CHANGES + OTHER_CHANGES 1212 1213 resource = property(lambda self: Resource('changeset', self.rev, 1214 parent=self.repos.resource)) 1215
1216 - def __init__(self, repos, rev, message, author, date):
1217 self.repos = repos 1218 self.rev = rev 1219 self.message = message or '' 1220 self.author = author or '' 1221 self.date = date
1222
1223 - def get_properties(self):
1224 """Returns the properties (meta-data) of the node, as a dictionary. 1225 1226 The set of properties depends on the version control system. 1227 1228 Warning: this used to yield 4-elements tuple (besides `name` and 1229 `text`, there were `wikiflag` and `htmlclass` values). 1230 This is now replaced by the usage of IPropertyRenderer (see #1601). 1231 """ 1232 return []
1233
1234 - def get_changes(self):
1235 """Generator that produces a tuple for every change in the changeset. 1236 1237 The tuple will contain `(path, kind, change, base_path, base_rev)`, 1238 where `change` can be one of Changeset.ADD, Changeset.COPY, 1239 Changeset.DELETE, Changeset.EDIT or Changeset.MOVE, 1240 and `kind` is one of Node.FILE or Node.DIRECTORY. 1241 The `path` is the targeted path for the `change` (which is 1242 the ''deleted'' path for a DELETE change). 1243 The `base_path` and `base_rev` are the source path and rev for the 1244 action (`None` and `-1` in the case of an ADD change). 1245 """ 1246 raise NotImplementedError
1247
1248 - def get_branches(self):
1249 """Yield branches to which this changeset belong. 1250 Each branch is given as a pair `(name, head)`, where `name` is 1251 the branch name and `head` a flag set if the changeset is a head 1252 for this branch (i.e. if it has no children changeset). 1253 """ 1254 return []
1255
1256 - def get_tags(self):
1257 """Yield tags associated with this changeset. 1258 1259 .. versionadded :: 1.0 1260 """ 1261 return []
1262
1263 - def is_viewable(self, perm):
1264 """Return True if view permission is granted on the changeset.""" 1265 return 'CHANGESET_VIEW' in perm(self.resource)
1266 1267 can_view = is_viewable # 0.12 compatibility
1268 1269 1270 # Note: Since Trac 0.12, Exception PermissionDenied class is gone, 1271 # and class Authorizer is gone as well. 1272 # 1273 # Fine-grained permissions are now handled via normal permission policies. 1274