1# -*- coding: utf-8 -*- 2# 3# Copyright (C) 2003-2021 Edgewall Software 4# Copyright (C) 2003-2005 Jonas Borgström <jonas@edgewall.com> 5# Copyright (C) 2005-2006 Christian Boos <cboos@edgewall.org> 6# All rights reserved. 7# 8# This software is licensed as described in the file COPYING, which 9# you should have received as part of this distribution. The terms 10# are also available at https://trac.edgewall.org/wiki/TracLicense. 11# 12# This software consists of voluntary contributions made by many 13# individuals. For the exact contribution history, see the revision 14# history and logs, available at https://trac.edgewall.org/log/. 15# 16# Author: Jonas Borgström <jonas@edgewall.com> 17# Christian Boos <cboos@edgewall.org> 18 19import re 20 21from trac.config import IntOption, ListOption 22from trac.core import * 23from trac.perm import IPermissionRequestor 24from trac.resource import ResourceNotFound 25from trac.util import Ranges 26from trac.util.html import Markup, tag 27from trac.util.text import to_unicode, wrap 28from trac.util.translation import _ 29from trac.versioncontrol.api import (Changeset, NoSuchChangeset, 30 RepositoryManager) 31from trac.versioncontrol.web_ui.changeset import ChangesetModule 32from trac.versioncontrol.web_ui.util import * 33from trac.web.api import IRequestHandler 34from trac.web.chrome import (INavigationContributor, add_ctxtnav, add_link, 35 add_script, add_script_data, add_stylesheet, 36 auth_link, web_context) 37from trac.wiki import IWikiSyntaxProvider, WikiParser 38 39 40class LogModule(Component): 41 42 implements(INavigationContributor, IPermissionRequestor, IRequestHandler, 43 IWikiSyntaxProvider) 44 45 realm = RepositoryManager.changeset_realm 46 47 default_log_limit = IntOption('revisionlog', 'default_log_limit', 100, 48 """Default value for the limit argument in the TracRevisionLog. 49 """) 50 51 graph_colors = ListOption('revisionlog', 'graph_colors', 52 ['#cc0', '#0c0', '#0cc', '#00c', '#c0c', '#c00'], 53 doc="""Comma-separated list of colors to use for the TracRevisionLog 54 graph display. (''since 1.0'')""") 55 56 # INavigationContributor methods 57 58 def get_active_navigation_item(self, req): 59 return 'browser' 60 61 def get_navigation_items(self, req): 62 return [] 63 64 # IPermissionRequestor methods 65 66 def get_permission_actions(self): 67 return ['LOG_VIEW'] 68 69 # IRequestHandler methods 70 71 def match_request(self, req): 72 match = re.match(r'/log(/.*)?$', req.path_info) 73 if match: 74 req.args['path'] = match.group(1) or '/' 75 return True 76 77 def process_request(self, req): 78 req.perm.require('LOG_VIEW') 79 80 mode = req.args.get('mode', 'stop_on_copy') 81 path = req.args.get('path', '/') 82 rev = req.args.get('rev') 83 stop_rev = req.args.get('stop_rev') 84 revs = req.args.get('revs') 85 format = req.args.get('format') 86 verbose = req.args.get('verbose') 87 limit = req.args.getint('limit', self.default_log_limit) 88 89 rm = RepositoryManager(self.env) 90 reponame, repos, path = rm.get_repository_by_path(path) 91 92 if not repos: 93 if path == '/': 94 raise TracError(_("No repository specified and no default" 95 " repository configured.")) 96 else: 97 raise ResourceNotFound(_("Repository '%(repo)s' not found", 98 repo=reponame or path.strip('/'))) 99 100 if reponame != repos.reponame: # Redirect alias 101 qs = req.query_string 102 req.redirect(req.href.log(repos.reponame or None, path) 103 + ('?' + qs if qs else '')) 104 105 normpath = repos.normalize_path(path) 106 107 # if `revs` parameter is given, then we're restricted to the 108 # corresponding revision ranges. 109 # If not, then we're considering all revisions since `rev`, 110 # on that path, in which case `revranges` will be None. 111 if revs: 112 revranges = RevRanges(repos, revs, resolve=True) 113 rev = revranges.b 114 else: 115 revranges = None 116 rev = repos.normalize_rev(rev) 117 118 # The `history()` method depends on the mode: 119 # * for ''stop on copy'' and ''follow copies'', it's `Node.history()` 120 # unless explicit ranges have been specified 121 # * for ''show only add, delete'' we're using 122 # `Repository.get_path_history()` 123 cset_resource = repos.resource.child(self.realm) 124 show_graph = False 125 curr_revrange = [] 126 if mode == 'path_history': 127 def history(): 128 for h in repos.get_path_history(path, rev): 129 if 'CHANGESET_VIEW' in req.perm(cset_resource(id=h[1])): 130 yield h 131 132 elif revranges: 133 show_graph = path == '/' and not verbose \ 134 and not repos.has_linear_changesets \ 135 and len(revranges) == 1 136 137 def history(): 138 separator = False 139 for a, b in reversed(revranges.pairs): 140 curr_revrange[:] = (a, b) 141 node = get_existing_node(req, repos, path, b) 142 for p, rev, chg in node.get_history(): 143 if repos.rev_older_than(rev, a): 144 break 145 if 'CHANGESET_VIEW' in req.perm(cset_resource(id=rev)): 146 separator = True 147 yield p, rev, chg 148 else: 149 separator = False 150 if separator: 151 yield p, rev, None 152 else: 153 show_graph = path == '/' and not verbose \ 154 and not repos.has_linear_changesets 155 156 def history(): 157 node = get_existing_node(req, repos, path, rev) 158 for h in node.get_history(): 159 if 'CHANGESET_VIEW' in req.perm(cset_resource(id=h[1])): 160 yield h 161 162 # -- retrieve history, asking for limit+1 results 163 info = [] 164 depth = 1 165 previous_path = normpath 166 count = 0 167 history_remaining = True 168 for old_path, old_rev, old_chg in history(): 169 if stop_rev and repos.rev_older_than(old_rev, stop_rev): 170 break 171 old_path = repos.normalize_path(old_path) 172 173 item = { 174 'path': old_path, 'rev': old_rev, 'existing_rev': old_rev, 175 'change': old_chg, 'depth': depth, 176 } 177 178 if old_chg == Changeset.DELETE: 179 item['existing_rev'] = repos.previous_rev(old_rev, old_path) 180 if not (mode == 'path_history' and old_chg == Changeset.EDIT): 181 info.append(item) 182 if old_path and old_path != previous_path and \ 183 not (mode == 'path_history' and old_path == normpath): 184 depth += 1 185 item['depth'] = depth 186 item['copyfrom_path'] = old_path 187 if mode == 'stop_on_copy': 188 break 189 elif mode == 'path_history': 190 depth -= 1 191 if old_chg is None: # separator entry 192 stop_limit = limit 193 else: 194 count += 1 195 stop_limit = limit + 1 196 if count >= stop_limit: 197 break 198 previous_path = old_path 199 else: 200 history_remaining = False 201 if not info: 202 node = get_existing_node(req, repos, path, rev) 203 if repos.rev_older_than(stop_rev, node.created_rev): 204 # FIXME: we should send a 404 error here 205 raise TracError(_("The file or directory '%(path)s' doesn't " 206 "exist at revision %(rev)s or at any " 207 "previous revision.", path=path, 208 rev=repos.display_rev(rev)), 209 _('Nonexistent path')) 210 211 # Generate graph data 212 graph = {} 213 if show_graph: 214 threads, vertices, columns = \ 215 make_log_graph(repos, (item['rev'] for item in info)) 216 graph.update(threads=threads, vertices=vertices, columns=columns, 217 colors=self.graph_colors, 218 line_width=0.04, dot_radius=0.1) 219 add_script(req, 'common/js/log_graph.js') 220 add_script_data(req, graph=graph) 221 222 def make_log_href(path, **args): 223 link_rev = rev 224 if rev == str(repos.youngest_rev): 225 link_rev = None 226 params = {'rev': link_rev, 'mode': mode, 'limit': limit} 227 params.update(args) 228 if verbose: 229 params['verbose'] = verbose 230 return req.href.log(repos.reponame or None, path, **params) 231 232 if format in ('rss', 'changelog'): 233 info = [i for i in info if i['change']] # drop separators 234 if info and count > limit: 235 del info[-1] 236 elif info and history_remaining and count >= limit: 237 # stop_limit reached, there _might_ be some more 238 next_rev = info[-1]['rev'] 239 next_path = info[-1]['path'] 240 next_revranges = None 241 if curr_revrange: 242 new_revrange = (curr_revrange[0], next_rev) \ 243 if info[-1]['change'] else None 244 next_revranges = revranges.truncate(curr_revrange, 245 new_revrange) 246 next_revranges = str(next_revranges) or None 247 if next_revranges or not revranges: 248 older_revisions_href = make_log_href( 249 next_path, rev=next_rev, revs=next_revranges) 250 add_link(req, 'next', older_revisions_href, 251 _('Revision Log (restarting at %(path)s, rev. ' 252 '%(rev)s)', path=next_path, 253 rev=repos.display_rev(next_rev))) 254 # only show fully 'limit' results, use `change == None` as a marker 255 info[-1]['change'] = None 256 257 revisions = [i['rev'] for i in info] 258 changes = get_changes(repos, revisions, self.log) 259 extra_changes = {} 260 261 if format == 'changelog': 262 for rev in revisions: 263 changeset = changes[rev] 264 cs = {} 265 cs['message'] = wrap(changeset.message, 70, 266 initial_indent='\t', 267 subsequent_indent='\t') 268 files = [] 269 actions = [] 270 for cpath, kind, chg, bpath, brev in changeset.get_changes(): 271 files.append(bpath if chg == Changeset.DELETE else cpath) 272 actions.append(chg) 273 cs['files'] = files 274 cs['actions'] = actions 275 extra_changes[rev] = cs 276 277 data = { 278 'context': web_context(req, 'source', path, parent=repos.resource), 279 'reponame': repos.reponame or None, 'repos': repos, 280 'path': path, 'rev': rev, 'stop_rev': stop_rev, 281 'display_rev': repos.display_rev, 'revranges': revranges, 282 'mode': mode, 'verbose': verbose, 'limit': limit, 283 'items': info, 'changes': changes, 'extra_changes': extra_changes, 284 'graph': graph, 285 'wiki_format_messages': self.config['changeset'] 286 .getbool('wiki_format_messages') 287 } 288 289 if format == 'changelog': 290 return 'revisionlog.txt', data, {'content_type': 'text/plain'} 291 elif format == 'rss': 292 data['context'] = web_context(req, 'source', 293 path, parent=repos.resource, 294 absurls=True) 295 return ('revisionlog.rss', data, 296 {'content_type': 'application/rss+xml'}) 297 298 item_ranges = [] 299 range = [] 300 for item in info: 301 if item['change'] is None: # separator 302 if range: # start new range 303 range.append(item) 304 item_ranges.append(range) 305 range = [] 306 else: 307 range.append(item) 308 if range: 309 item_ranges.append(range) 310 data['item_ranges'] = item_ranges 311 312 add_stylesheet(req, 'common/css/diff.css') 313 add_stylesheet(req, 'common/css/browser.css') 314 315 path_links = get_path_links(req.href, repos.reponame, path, rev) 316 if path_links: 317 data['path_links'] = path_links 318 if path != '/': 319 add_link(req, 'up', path_links[-2]['href'], _('Parent directory')) 320 321 rss_href = make_log_href(path, format='rss', revs=revs, 322 stop_rev=stop_rev) 323 add_link(req, 'alternate', auth_link(req, rss_href), _('RSS Feed'), 324 'application/rss+xml', 'rss') 325 changelog_href = make_log_href(path, format='changelog', revs=revs, 326 stop_rev=stop_rev) 327 add_link(req, 'alternate', changelog_href, _('ChangeLog'), 328 'text/plain') 329 330 add_ctxtnav(req, _('View Latest Revision'), 331 href=req.href.browser(repos.reponame or None, path)) 332 if 'next' in req.chrome['links']: 333 next = req.chrome['links']['next'][0] 334 add_ctxtnav(req, tag.span(tag.a(_('Older Revisions'), 335 href=next['href']), 336 Markup(' →'))) 337 338 return 'revisionlog.html', data 339 340 # IWikiSyntaxProvider methods 341 342 # int rev ranges or any kind of rev range 343 REV_RANGE = r"(?:%(int)s|%(cset)s(?:[:-]%(cset)s)?)" % \ 344 {'int': Ranges.RE_STR, 'cset': ChangesetModule.CHANGESET_ID} 345 346 def get_wiki_syntax(self): 347 yield ( 348 # [...] form, starts with optional intertrac: [T... or [trac ... 349 r"!?\[(?P<it_log>%s\s*)" % WikiParser.INTERTRAC_SCHEME + 350 # <from>:<to> + optional path restriction 351 r"(?P<log_revs>%s)(?P<log_path>[/?][^\]]*)?\]" % self.REV_RANGE, 352 lambda x, y, z: self._format_link(x, 'log1', y[1:-1], y, z)) 353 yield ( 354 # r<from>:<to> form + optional path restriction (no intertrac) 355 r"(?:\b|!)r%s\b(?:/[a-zA-Z0-9_/+-]+)?" % Ranges.RE_STR, 356 lambda x, y, z: self._format_link(x, 'log2', '@' + y[1:], y)) 357 358 def get_link_resolvers(self): 359 yield ('log', self._format_link) 360 361 LOG_LINK_RE = re.compile(r"([^@:]*)[@:]%s?" % REV_RANGE) 362 363 def _format_link(self, formatter, ns, match, label, fullmatch=None): 364 if ns == 'log1': 365 groups = fullmatch.groupdict() 366 it_log = groups.get('it_log') 367 revs = groups.get('log_revs') 368 path = groups.get('log_path') or '/' 369 target = '%s%s@%s' % (it_log, path, revs) 370 # prepending it_log is needed, as the helper expects it there 371 intertrac = formatter.shorthand_intertrac_helper( 372 'log', target, label, fullmatch) 373 if intertrac: 374 return intertrac 375 path, query, fragment = formatter.split_link(path) 376 else: 377 assert ns in ('log', 'log2') 378 if ns == 'log': 379 match, query, fragment = formatter.split_link(match) 380 else: 381 query = fragment = '' 382 match = ''.join(reversed(match.split('/', 1))) 383 path = match 384 revs = '' 385 if self.LOG_LINK_RE.match(match): 386 indexes = [sep in match and match.index(sep) for sep in ':@'] 387 idx = min([i for i in indexes if i is not False]) 388 path, revs = match[:idx], match[idx+1:] 389 390 rm = RepositoryManager(self.env) 391 try: 392 reponame, repos, path = rm.get_repository_by_path(path) 393 if not reponame: 394 reponame = rm.get_default_repository(formatter.context) 395 if reponame is not None: 396 repos = rm.get_repository(reponame) 397 398 if repos: 399 path = path or '/' 400 if 'LOG_VIEW' in formatter.perm(repos.resource 401 .child('source', path)): 402 reponame = repos.reponame or None 403 revranges = RevRanges(repos, revs) 404 if revranges.has_ranges(): 405 href = formatter.href.log(reponame, path, 406 revs=str(revranges)) 407 else: 408 # try to resolve if single rev 409 repos.normalize_rev(revs) 410 href = formatter.href.log(reponame, path, 411 rev=revs or None) 412 if query and '?' in href: 413 query = '&' + query[1:] 414 return tag.a(label, class_='source', 415 href=href + query + fragment) 416 errmsg = _("No permission to view change log") 417 elif reponame: 418 errmsg = _("Repository '%(repo)s' not found", repo=reponame) 419 else: 420 errmsg = _("No default repository defined") 421 except TracError as e: 422 errmsg = to_unicode(e) 423 return tag.a(label, class_='missing source', title=errmsg) 424 425 426class RevRanges(object): 427 428 def __init__(self, repos, revs=None, resolve=False): 429 self.repos = repos 430 self.resolve = resolve 431 self.pairs = [] 432 self.a = self.b = None 433 if revs: 434 self._append(revs) 435 436 def has_ranges(self): 437 n = len(self.pairs) 438 return n > 1 or n == 1 and self.a != self.b 439 440 def truncate(self, curr_pair, new_pair=None): 441 curr_pair = tuple(curr_pair) 442 if new_pair: 443 new_pair = tuple(new_pair) 444 revranges = RevRanges(self.repos, resolve=self.resolve) 445 pairs = revranges.pairs 446 for pair in self.pairs: 447 if pair == curr_pair: 448 if new_pair: 449 pairs.append(new_pair) 450 break 451 pairs.append(pair) 452 if pairs: 453 revranges.a = pairs[0][0] 454 revranges.b = pairs[-1][1] 455 revranges._reduce() 456 return revranges 457 458 def _normrev(self, rev): 459 if not rev: 460 raise NoSuchChangeset(rev) 461 if self.resolve: 462 return self.repos.normalize_rev(rev) 463 elif self.repos.has_linear_changesets: 464 try: 465 return int(rev) 466 except (ValueError, TypeError): 467 return rev 468 else: 469 return rev 470 471 _cset_range_re = re.compile(r"""(?: 472 %(cset)s[:-]%(cset)s | # int or hexa revs 473 [0-9]+[:-][A-Za-z_0-9]+ | # e.g. 42-head 474 [A-Za-z_0-9]+[:-][0-9]+ | # e.g. head-42 475 [^:]+:[^:]+ # e.g. master:dev-42 476 )\Z 477 """ % {'cset': ChangesetModule.CHANGESET_ID}, re.VERBOSE) 478 479 def _append(self, revs): 480 if not revs: 481 return 482 483 pairs = [] 484 for rev in re.split(',\u200b?', revs): 485 a = b = None 486 if self._cset_range_re.match(rev): 487 for sep in ':-': 488 if sep in rev: 489 a, b = rev.split(sep) 490 break 491 if a is None: 492 a = b = self._normrev(rev) 493 elif a == b: 494 a = b = self._normrev(a) 495 else: 496 a = self._normrev(a) 497 b = self._normrev(b) 498 pairs.append((a, b)) 499 self.pairs.extend(pairs) 500 self._reduce() 501 502 def _reduce(self): 503 if all(isinstance(pair[0], int) and isinstance(pair[1], int) 504 for pair in self.pairs): 505 try: 506 ranges = Ranges(str(self), reorder=True) 507 except: 508 pass 509 else: 510 self.pairs[:] = ranges.pairs 511 else: 512 seen = set() 513 pairs = self.pairs[:] 514 for idx, pair in enumerate(pairs): 515 if pair in seen: 516 pairs[idx] = None 517 else: 518 seen.add(pair) 519 if len(pairs) != len(seen): 520 self.pairs[:] = filter(None, pairs) 521 if self.pairs: 522 self.a = self.pairs[0][0] 523 self.b = self.pairs[-1][1] 524 else: 525 self.a = self.b = None 526 527 def __len__(self): 528 return len(self.pairs) 529 530 def __str__(self): 531 sep = '-' if self.repos.has_linear_changesets else ':' 532 return ','.join(sep.join(map(str, pair)) if pair[0] != pair[1] 533 else str(pair[0]) 534 for pair in self.pairs) 535