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