Package trac :: Package versioncontrol :: Package web_ui :: Module changeset

Source Code for Module trac.versioncontrol.web_ui.changeset

   1  # -*- coding: utf-8 -*- 
   2  # 
   3  # Copyright (C) 2003-2023 Edgewall Software 
   4  # Copyright (C) 2003-2005 Jonas Borgström <[email protected]> 
   5  # Copyright (C) 2004-2006 Christopher Lenz <[email protected]> 
   6  # Copyright (C) 2005-2006 Christian Boos <[email protected]> 
   7  # All rights reserved. 
   8  # 
   9  # This software is licensed as described in the file COPYING, which 
  10  # you should have received as part of this distribution. The terms 
  11  # are also available at https://trac.edgewall.org/wiki/TracLicense. 
  12  # 
  13  # This software consists of voluntary contributions made by many 
  14  # individuals. For the exact contribution history, see the revision 
  15  # history and logs, available at https://trac.edgewall.org/log/. 
  16  # 
  17  # Author: Jonas Borgström <[email protected]> 
  18  #         Christopher Lenz <[email protected]> 
  19  #         Christian Boos <[email protected]> 
  20   
  21  from functools import partial 
  22  from itertools import groupby 
  23  import os 
  24  import posixpath 
  25  import re 
  26   
  27  from trac.config import BoolOption, IntOption, Option 
  28  from trac.core import * 
  29  from trac.mimeview.api import Mimeview 
  30  from trac.perm import IPermissionRequestor 
  31  from trac.resource import ResourceNotFound 
  32  from trac.search import ISearchSource, search_to_sql, shorten_result 
  33  from trac.timeline.api import ITimelineEventProvider 
  34  from trac.util import as_bool, content_disposition, embedded_numbers, pathjoin 
  35  from trac.util.datefmt import from_utimestamp, pretty_timedelta 
  36  from trac.util.html import tag 
  37  from trac.util.presentation import to_json 
  38  from trac.util.text import CRLF, exception_to_unicode, shorten_line, \ 
  39                             to_unicode, unicode_urlencode 
  40  from trac.util.translation import _, ngettext, tag_ 
  41  from trac.versioncontrol.api import Changeset, NoSuchChangeset, Node, \ 
  42                                      RepositoryManager 
  43  from trac.versioncontrol.diff import diff_blocks, get_diff_options, \ 
  44                                       unified_diff 
  45  from trac.versioncontrol.web_ui.browser import BrowserModule 
  46  from trac.versioncontrol.web_ui.util import content_closing, render_zip 
  47  from trac.web import IRequestHandler, RequestDone 
  48  from trac.web.chrome import (Chrome, INavigationContributor, add_ctxtnav, 
  49                               add_link, add_script, add_stylesheet, 
  50                               prevnext_nav, web_context) 
  51  from trac.wiki.api import IWikiSyntaxProvider, WikiParser 
  52  from trac.wiki.formatter import format_to 
  53   
  54   
55 -class IPropertyDiffRenderer(Interface):
56 """Render node properties in TracBrowser and TracChangeset views.""" 57
58 - def match_property_diff(name):
59 """Indicate whether this renderer can treat the given property diffs 60 61 Returns a quality number, ranging from 0 (unsupported) to 9 62 (''perfect'' match). 63 """
64
65 - def render_property_diff(name, old_context, old_props, 66 new_context, new_props, options):
67 """Render the given diff of property to HTML. 68 69 `name` is the property name as given to `match_property_diff()`, 70 `old_context` corresponds to the old node being render 71 (useful when the rendering depends on the node kind) 72 and `old_props` is the corresponding collection of all properties. 73 Same for `new_node` and `new_props`. 74 `options` are the current diffs options. 75 76 The rendered result can be one of the following: 77 - `None`: the property change will be shown the normal way 78 (''changed from `old` to `new`'') 79 - an `unicode` value: the change will be shown as textual content 80 - `Markup` or `Fragment`: the change will shown as block markup 81 """
82 83
84 -class DefaultPropertyDiffRenderer(Component):
85 """Default version control property difference renderer.""" 86 87 implements(IPropertyDiffRenderer) 88
89 - def match_property_diff(self, name):
90 return 1
91
92 - def render_property_diff(self, name, old_context, old_props, 93 new_context, new_props, options):
94 old, new = old_props[name], new_props[name] 95 # Render as diff only if multiline (see #3002) 96 if '\n' not in old and '\n' not in new: 97 return None 98 unidiff = '--- \n+++ \n' + \ 99 '\n'.join(unified_diff(old.splitlines(), new.splitlines(), 100 options.get('contextlines', 3))) 101 return tag.li(tag_("Property %(name)s", name=tag.strong(name)), 102 Mimeview(self.env).render(old_context, 'text/x-diff', 103 unidiff))
104 105
106 -class ChangesetModule(Component):
107 """Renderer providing flexible functionality for showing sets of 108 differences. 109 110 If the differences shown are coming from a specific changeset, 111 then that changeset information can be shown too. 112 113 In addition, it is possible to show only a subset of the changeset: 114 Only the changes affecting a given path will be shown. This is called 115 the ''restricted'' changeset. 116 117 But the differences can also be computed in a more general way, 118 between two arbitrary paths and/or between two arbitrary revisions. 119 In that case, there's no changeset information displayed. 120 """ 121 122 implements(INavigationContributor, IPermissionRequestor, IRequestHandler, 123 ITimelineEventProvider, IWikiSyntaxProvider, ISearchSource) 124 125 property_diff_renderers = ExtensionPoint(IPropertyDiffRenderer) 126 127 realm = RepositoryManager.changeset_realm 128 129 timeline_show_files = Option('timeline', 'changeset_show_files', '0', 130 """Number of files to show (`-1` for unlimited, `0` to disable). 131 132 This can also be `location`, for showing the common prefix for the 133 changed files. 134 """) 135 136 timeline_long_messages = BoolOption('timeline', 'changeset_long_messages', 137 'false', 138 """Whether wiki-formatted changeset messages should be multiline or 139 not. 140 141 If this option is not specified or is false and `wiki_format_messages` 142 is set to true, changeset messages will be single line only, losing 143 some formatting (bullet points, etc).""") 144 145 timeline_collapse = BoolOption('timeline', 'changeset_collapse_events', 146 'false', 147 """Whether consecutive changesets from the same author having 148 exactly the same message should be presented as one event. 149 That event will link to the range of changesets in the log view. 150 """) 151 152 max_diff_files = IntOption('changeset', 'max_diff_files', 0, 153 """Maximum number of modified files for which the changeset view will 154 attempt to show the diffs inlined.""") 155 156 max_diff_bytes = IntOption('changeset', 'max_diff_bytes', 10000000, 157 """Maximum total size in bytes of the modified files (their old size 158 plus their new size) for which the changeset view will attempt to show 159 the diffs inlined.""") 160 161 wiki_format_messages = BoolOption('changeset', 'wiki_format_messages', 162 'true', 163 """Whether wiki formatting should be applied to changeset messages. 164 165 If this option is disabled, changeset messages will be rendered as 166 pre-formatted text.""") 167 168 # INavigationContributor methods 169
170 - def get_active_navigation_item(self, req):
171 return 'browser'
172
173 - def get_navigation_items(self, req):
174 return []
175 176 # IPermissionRequestor methods 177
178 - def get_permission_actions(self):
179 return ['CHANGESET_VIEW']
180 181 # IRequestHandler methods 182 183 _request_re = re.compile(r"/changeset(?:/([^/]+)(/.*)?)?$") 184
185 - def match_request(self, req):
186 match = re.match(self._request_re, req.path_info) 187 if match: 188 new, new_path = match.groups() 189 if new: 190 req.args['new'] = new 191 if new_path: 192 req.args['new_path'] = new_path 193 return True
194
195 - def process_request(self, req):
196 """The appropriate mode of operation is inferred from the request 197 parameters: 198 199 * If `new_path` and `old_path` are equal (or `old_path` is omitted) 200 and `new` and `old` are equal (or `old` is omitted), 201 then we're about to view a revision Changeset: `chgset` is True. 202 Furthermore, if the path is not the root, the changeset is 203 ''restricted'' to that path (only the changes affecting that path, 204 its children or its ancestor directories will be shown). 205 * In any other case, the set of changes corresponds to arbitrary 206 differences between path@rev pairs. If `new_path` and `old_path` 207 are equal, the ''restricted'' flag will also be set, meaning in this 208 case that the differences between two revisions are restricted to 209 those occurring on that path. 210 211 In any case, either path@rev pairs must exist. 212 """ 213 req.perm.require('CHANGESET_VIEW') 214 215 # -- retrieve arguments 216 new_path = req.args.get('new_path') 217 new = req.args.get('new') 218 old_path = req.args.get('old_path') 219 old = req.args.get('old') 220 reponame = req.args.get('reponame') 221 222 # -- support for the revision log ''View changes'' form, 223 # where we need to give the path and revision at the same time 224 if old and '@' in old: 225 old, old_path = old.split('@', 1) 226 if new and '@' in new: 227 new, new_path = new.split('@', 1) 228 229 rm = RepositoryManager(self.env) 230 if reponame: 231 repos = rm.get_repository(reponame) 232 else: 233 reponame, repos, new_path = rm.get_repository_by_path(new_path) 234 235 if old_path: 236 old_reponame, old_repos, old_path = \ 237 rm.get_repository_by_path(old_path) 238 if old_repos != repos: 239 raise TracError(_("Can't compare across different " 240 "repositories: %(old)s vs. %(new)s", 241 old=old_reponame, new=reponame)) 242 243 if not repos: 244 if reponame or (new_path and new_path != '/'): 245 raise TracError(_("Repository '%(repo)s' not found", 246 repo=reponame or new_path.strip('/'))) 247 else: 248 raise TracError(_("No repository specified and no default " 249 "repository configured.")) 250 251 # -- normalize and check for special case 252 try: 253 new = repos.normalize_rev(new) 254 old = repos.normalize_rev(old or new) 255 except NoSuchChangeset as e: 256 raise ResourceNotFound(e, _("Invalid Changeset Number")) 257 new_path = repos.normalize_path(new_path) 258 old_path = repos.normalize_path(old_path or new_path) 259 full_new_path = '/' + pathjoin(repos.reponame, new_path) 260 full_old_path = '/' + pathjoin(repos.reponame, old_path) 261 262 if old_path == new_path and old == new: # revert to Changeset 263 old_path = old = None 264 265 style, options, diff_data = get_diff_options(req) 266 diff_opts = diff_data['options'] 267 268 # -- setup the `chgset` and `restricted` flags, see docstring above. 269 chgset = not old and old_path is None 270 if chgset: 271 restricted = new_path not in ('', '/') # (subset or not) 272 else: 273 restricted = old_path == new_path # (same path or not) 274 275 # -- redirect if changing the diff options or alias requested 276 if 'update' in req.args or reponame != repos.reponame: 277 contextall = diff_opts['contextall'] or None 278 reponame = repos.reponame or None 279 if chgset: 280 if restricted: 281 req.redirect(req.href.changeset(new, reponame, new_path, 282 contextall=contextall)) 283 else: 284 req.redirect(req.href.changeset(new, reponame, 285 contextall=contextall)) 286 else: 287 req.redirect(req.href.changeset(new, reponame, 288 new_path, old=old, 289 old_path=full_old_path, 290 contextall=contextall)) 291 292 # -- preparing the data 293 if chgset: 294 prev = repos.get_node(new_path, new).get_previous() 295 if prev: 296 prev_path, prev_rev = prev[:2] 297 else: 298 prev_path, prev_rev = new_path, repos.previous_rev(new) 299 data = {'old_path': prev_path, 'old_rev': prev_rev, 300 'new_path': new_path, 'new_rev': new} 301 else: 302 if not new: 303 new = repos.youngest_rev 304 elif not old: 305 old = repos.youngest_rev 306 if old_path is None: 307 old_path = new_path 308 data = {'old_path': old_path, 'old_rev': old, 309 'new_path': new_path, 'new_rev': new} 310 data.update({'repos': repos, 'reponame': repos.reponame or None, 311 'diff': diff_data, 312 'wiki_format_messages': self.wiki_format_messages}) 313 314 if chgset: 315 chgset = repos.get_changeset(new) 316 req.perm(chgset.resource).require('CHANGESET_VIEW') 317 318 # TODO: find a cheaper way to reimplement r2636 319 req.check_modified(chgset.date, [ 320 style, ''.join(options), repos.name, 321 diff_opts['contextlines'], diff_opts['contextall'], 322 repos.rev_older_than(new, repos.youngest_rev), 323 chgset.message, req.is_xhr, 324 pretty_timedelta(chgset.date, None, 3600)]) 325 326 format = req.args.get('format') 327 328 if format in ['diff', 'zip']: 329 # choosing an appropriate filename 330 rpath = new_path.replace('/', '_') 331 if chgset: 332 if restricted: 333 filename = 'changeset_%s_%s' % (rpath, new) 334 else: 335 filename = 'changeset_%s' % new 336 else: 337 if restricted: 338 filename = 'diff-%s-from-%s-to-%s' % (rpath, old, new) 339 else: 340 filename = 'diff-from-%s-%s-to-%s-%s' \ 341 % (old_path.replace('/', '_'), old, rpath, new) 342 if format == 'diff': 343 self._render_diff(req, filename, repos, data) 344 elif format == 'zip': 345 render_zip(req, filename + '.zip', repos, None, 346 partial(self._zip_iter_nodes, req, repos, data)) 347 348 # -- HTML format 349 self._render_html(req, repos, chgset, restricted, data) 350 351 if chgset: 352 diff_params = 'new=%s' % new 353 else: 354 diff_params = unicode_urlencode({ 355 'new_path': full_new_path, 'new': new, 356 'old_path': full_old_path, 'old': old}) 357 add_link(req, 'alternate', '?format=diff&' + diff_params, 358 _('Unified Diff'), 'text/plain', 'diff') 359 add_link(req, 'alternate', '?format=zip&' + diff_params, 360 _('Zip Archive'), 'application/zip', 'zip') 361 add_script(req, 'common/js/diff.js') 362 add_stylesheet(req, 'common/css/changeset.css') 363 add_stylesheet(req, 'common/css/diff.css') 364 add_stylesheet(req, 'common/css/code.css') 365 if chgset: 366 if restricted: 367 prevnext_nav(req, _('Previous Change'), _('Next Change')) 368 else: 369 prevnext_nav(req, _('Previous Changeset'), _('Next Changeset')) 370 else: 371 rev_href = req.href.changeset(old, full_old_path, 372 old=new, old_path=full_new_path) 373 add_ctxtnav(req, _('Reverse Diff'), href=rev_href) 374 375 return 'changeset.html', data
376 377 # Internal methods 378
379 - def _render_html(self, req, repos, chgset, restricted, data):
380 """HTML version""" 381 data['restricted'] = restricted 382 display_rev = repos.display_rev 383 data['display_rev'] = display_rev 384 browser = BrowserModule(self.env) 385 reponame = repos.reponame or None 386 387 if chgset: # Changeset Mode (possibly restricted on a path) 388 path, rev = data['new_path'], data['new_rev'] 389 390 # -- getting the change summary from the Changeset.get_changes 391 def get_changes(): 392 for npath, kind, change, opath, orev in chgset.get_changes(): 393 old_node = new_node = None 394 if (restricted and 395 not (npath == path or # same path 396 npath.startswith(path + '/') or # npath is below 397 path.startswith(npath + '/'))): # npath is above 398 continue 399 if change != Changeset.ADD: 400 old_node = repos.get_node(opath, orev) 401 if change != Changeset.DELETE: 402 new_node = repos.get_node(npath, rev) 403 else: 404 # support showing paths deleted below a copy target 405 old_node.path = npath 406 yield old_node, new_node, kind, change
407 408 def _changeset_title(rev): 409 rev = display_rev(rev) 410 if restricted: 411 return _('Changeset %(id)s for %(path)s', id=rev, 412 path=path) 413 else: 414 return _('Changeset %(id)s', id=rev)
415 416 data['changeset'] = chgset 417 title = _changeset_title(rev) 418 419 # Support for revision properties (#2545) 420 context = web_context(req, self.realm, chgset.rev, 421 parent=repos.resource) 422 data['context'] = context 423 revprops = chgset.get_properties() 424 data['properties'] = browser.render_properties('revprop', context, 425 revprops) 426 oldest_rev = repos.oldest_rev 427 if chgset.rev != oldest_rev: 428 if restricted: 429 prev = repos.get_node(path, rev).get_previous() 430 if prev: 431 prev_path, prev_rev = prev[:2] 432 if prev_rev: 433 prev_href = req.href.changeset(prev_rev, reponame, 434 prev_path) 435 else: 436 prev_path = prev_rev = None 437 else: 438 add_link(req, 'first', 439 req.href.changeset(oldest_rev, reponame), 440 _('Changeset %(id)s', id=display_rev(oldest_rev))) 441 prev_path = data['old_path'] 442 prev_rev = repos.previous_rev(chgset.rev) 443 if prev_rev: 444 prev_href = req.href.changeset(prev_rev, reponame) 445 if prev_rev: 446 add_link(req, 'prev', prev_href, 447 _changeset_title(prev_rev)) 448 youngest_rev = repos.youngest_rev 449 if str(chgset.rev) != str(youngest_rev): 450 if restricted: 451 next_rev = repos.next_rev(chgset.rev, path) 452 if next_rev: 453 if repos.has_node(path, next_rev): 454 next_href = req.href.changeset(next_rev, reponame, 455 path) 456 else: # must be 'D'elete or 'R'ename, show full cset 457 next_href = req.href.changeset(next_rev, reponame) 458 else: 459 add_link(req, 'last', 460 req.href.changeset(youngest_rev, reponame), 461 _('Changeset %(id)s', 462 id=display_rev(youngest_rev))) 463 next_rev = repos.next_rev(chgset.rev) 464 if next_rev: 465 next_href = req.href.changeset(next_rev, reponame) 466 if next_rev: 467 add_link(req, 'next', next_href, 468 _changeset_title(next_rev)) 469 else: # Diff Mode 470 # -- getting the change summary from the Repository.get_changes 471 def get_changes(): 472 for d in repos.get_changes( 473 new_path=data['new_path'], new_rev=data['new_rev'], 474 old_path=data['old_path'], old_rev=data['old_rev']): 475 yield d 476 title = self.title_for_diff(data) 477 data['changeset'] = False 478 479 data['title'] = title 480 481 if 'BROWSER_VIEW' not in req.perm: 482 return 483 484 def node_info(node, annotated): 485 href = req.href.browser( 486 reponame, node.created_path, rev=node.created_rev, 487 annotate='blame' if annotated else None) 488 title = _("Show revision %(rev)s of this file in browser", 489 rev=display_rev(node.rev)) 490 return {'path': node.path, 'rev': node.rev, 491 'shortrev': repos.short_rev(node.rev), 492 'href': href, 'title': title} 493 # Reminder: node.path may not exist at node.rev 494 # as long as node.rev==node.created_rev 495 # ... and data['old_rev'] may have nothing to do 496 # with _that_ node specific history... 497 498 options = data['diff']['options'] 499 500 def _prop_changes(old_node, new_node): 501 old_props = old_node.get_properties() 502 new_props = new_node.get_properties() 503 old_ctx = web_context(req, old_node.resource) 504 new_ctx = web_context(req, new_node.resource) 505 changed_properties = [] 506 if old_props != new_props: 507 for k, v in sorted(old_props.items()): 508 new = old = diff = None 509 if not k in new_props: 510 old = v # won't be displayed, no need to render it 511 elif v != new_props[k]: 512 diff = self.render_property_diff( 513 k, old_ctx, old_props, new_ctx, new_props, options) 514 if not diff: 515 old = browser.render_property(k, 'changeset', 516 old_ctx, old_props) 517 new = browser.render_property(k, 'changeset', 518 new_ctx, new_props) 519 if new or old or diff: 520 changed_properties.append({'name': k, 'old': old, 521 'new': new, 'diff': diff}) 522 for k, v in sorted(new_props.items()): 523 if not k in old_props: 524 new = browser.render_property(k, 'changeset', 525 new_ctx, new_props) 526 if new is not None: 527 changed_properties.append({'name': k, 'new': new, 528 'old': None}) 529 return changed_properties 530 531 def _estimate_changes(old_node, new_node): 532 old_size = old_node.get_content_length() 533 new_size = new_node.get_content_length() 534 return old_size + new_size 535 536 def _content_changes(old_node, new_node): 537 """Returns the list of differences. 538 539 The list is empty when no differences between comparable files 540 are detected, but the return value is None for non-comparable 541 files. 542 """ 543 mview = Mimeview(self.env) 544 if mview.is_binary(old_node.content_type, old_node.path): 545 return None 546 if mview.is_binary(new_node.content_type, new_node.path): 547 return None 548 old_content = _read_content(old_node) 549 if mview.is_binary(content=old_content): 550 return None 551 new_content = _read_content(new_node) 552 if mview.is_binary(content=new_content): 553 return None 554 555 old_content = mview.to_unicode(old_content, old_node.content_type) 556 new_content = mview.to_unicode(new_content, new_node.content_type) 557 558 if old_content != new_content: 559 context = options.get('contextlines', 3) 560 if context < 0 or options.get('contextall'): 561 context = None 562 tabwidth = self.config.getint('mimeviewer', 'tab_width', 8) 563 ignore_blank_lines = options.get('ignoreblanklines') 564 ignore_case = options.get('ignorecase') 565 ignore_space = options.get('ignorewhitespace') 566 return diff_blocks(old_content.splitlines(), 567 new_content.splitlines(), 568 context, tabwidth, 569 ignore_blank_lines=ignore_blank_lines, 570 ignore_case=ignore_case, 571 ignore_space_changes=ignore_space) 572 else: 573 return [] 574 575 diff_changes = list(get_changes()) 576 # XHR is used for blame support: display the changeset view without 577 # the navigation and with the changes concerning the annotated file 578 diff_bytes = diff_files = 0 579 annotated = None 580 if req.is_xhr: 581 show_diffs = None 582 annotated = repos.normalize_path(req.args.get('annotate')) 583 else: 584 if self.