Edgewall Software

source: trunk/trac/versioncontrol/web_ui/changeset.py@ 16773

Last change on this file since 16773 was 16773, checked in by Ryan J Ollos, 5 years ago

1.3.4dev: Merge r16771 from 1.2-stable

[skip ci]

Refs #9567.

  • Property svn:eol-style set to native
File size: 54.0 KB
Line 
1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2003-2018 Edgewall Software
4# Copyright (C) 2003-2005 Jonas Borgström <jonas@edgewall.com>
5# Copyright (C) 2004-2006 Christopher Lenz <cmlenz@gmx.de>
6# Copyright (C) 2005-2006 Christian Boos <cboos@edgewall.org>
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 http://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 http://trac.edgewall.org/log/.
16#
17# Author: Jonas Borgström <jonas@edgewall.com>
18# Christopher Lenz <cmlenz@gmx.de>
19# Christian Boos <cboos@edgewall.org>
20
21from functools import partial
22from itertools import groupby
23import os
24import posixpath
25import re
26
27from trac.config import BoolOption, IntOption, Option
28from trac.core import *
29from trac.mimeview.api import Mimeview
30from trac.perm import IPermissionRequestor
31from trac.resource import ResourceNotFound
32from trac.search import ISearchSource, search_to_sql, shorten_result
33from trac.timeline.api import ITimelineEventProvider
34from trac.util import as_bool, content_disposition, embedded_numbers, pathjoin
35from trac.util.datefmt import from_utimestamp, pretty_timedelta
36from trac.util.html import tag
37from trac.util.presentation import to_json
38from trac.util.text import CRLF, exception_to_unicode, shorten_line, \
39 to_unicode, unicode_urlencode
40from trac.util.translation import _, ngettext, tag_
41from trac.versioncontrol.api import Changeset, NoSuchChangeset, Node, \
42 RepositoryManager
43from trac.versioncontrol.diff import diff_blocks, get_diff_options, \
44 unified_diff
45from trac.versioncontrol.web_ui.browser import BrowserModule
46from trac.versioncontrol.web_ui.util import content_closing, render_zip
47from trac.web import IRequestHandler, RequestDone
48from trac.web.chrome import (Chrome, INavigationContributor, add_ctxtnav,
49 add_link, add_script, add_stylesheet,
50 prevnext_nav, web_context)
51from trac.wiki.api import IWikiSyntaxProvider, WikiParser
52from trac.wiki.formatter import format_to
53
54
55class 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
84class 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
106class 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.max_diff_bytes or self.max_diff_files:
585 for old_node, new_node, kind, change in diff_changes:
586 if change in Changeset.DIFF_CHANGES and \
587 kind == Node.FILE and \
588 old_node.is_viewable(req.perm) and \
589 new_node.is_viewable(req.perm):
590 diff_files += 1
591 diff_bytes += _estimate_changes(old_node, new_node)
592 show_diffs = (not self.max_diff_files or
593 0 < diff_files <= self.max_diff_files) and \
594 (not self.max_diff_bytes or
595 diff_bytes <= self.max_diff_bytes or
596 diff_files == 1)
597
598 has_diffs = False
599 filestats = self._prepare_filestats()
600 changes = []
601 files = []
602 for old_node, new_node, kind, change in diff_changes:
603 props = []
604 diffs = []
605 show_old = old_node and old_node.is_viewable(req.perm)
606 show_new = new_node and new_node.is_viewable(req.perm)
607 show_entry = change != Changeset.EDIT
608 show_diff = show_diffs or (new_node and new_node.path == annotated)
609
610 if change in Changeset.DIFF_CHANGES and show_old and show_new:
611 assert old_node and new_node
612 props = _prop_changes(old_node, new_node)
613 if props:
614 show_entry = True
615 if kind == Node.FILE and show_diff:
616 diffs = _content_changes(old_node, new_node)
617 if diffs != []:
618 if diffs:
619 has_diffs = True
620 # elif None (means: manually compare to (previous))
621 show_entry = True
622 if (show_old or show_new) and (show_entry or not show_diff):
623 info = {'change': change,
624 'old': old_node and node_info(old_node, annotated),
625 'new': new_node and node_info(new_node, annotated),
626 'props': props,
627 'diffs': diffs}
628 files.append(new_node.path if new_node else
629 old_node.path if old_node else '')
630 filestats[change] += 1
631 if change in Changeset.DIFF_CHANGES:
632 if chgset:
633 href = req.href.changeset(new_node.rev, reponame,
634 new_node.path)
635 title = _('Show the changeset %(id)s restricted to '
636 '%(path)s', id=display_rev(new_node.rev),
637 path=new_node.path)
638 else:
639 href = req.href.changeset(
640 new_node.created_rev, reponame,
641 new_node.created_path,
642 old=old_node.created_rev,
643 old_path=pathjoin(repos.reponame,
644 old_node.created_path))
645 title = _('Show the %(range)s differences restricted '
646 'to %(path)s', range='[%s:%s]' % (
647 display_rev(old_node.rev),
648 display_rev(new_node.rev)),
649 path=new_node.path)
650 info['href'] = href
651 info['title'] = old_node and title
652 if change in Changeset.DIFF_CHANGES and not show_diff:
653 info['hide_diff'] = True
654 else:
655 info = None
656 changes.append(info) # the sequence should be immutable
657
658 data.update({
659 'has_diffs': has_diffs,
660 'show_diffs': show_diffs,
661 'diff_files': diff_files,
662 'diff_bytes': diff_bytes,
663 'max_diff_files': self.max_diff_files,
664 'max_diff_bytes': self.max_diff_bytes,
665 'changes': changes,
666 'filestats': filestats,
667 'annotated': annotated,
668 'files': files,
669 'location': self._get_parent_location(files),
670 'longcol': 'Revision',
671 'shortcol': 'r'
672 })
673
674 if req.is_xhr: # render and return the content only
675 stream = Chrome(self.env).generate_fragment(
676 req, 'changeset_content.html', data)
677 req.send(stream)
678
679 return data
680
681 def _render_diff(self, req, filename, repos, data):
682 """Raw Unified Diff version"""
683
684 output = (line.encode('utf-8') if isinstance(line, unicode) else line
685 for line in self._iter_diff_lines(req, repos, data))
686 if Chrome(self.env).use_chunked_encoding:
687 length = None
688 else:
689 output = ''.join(output)
690 length = len(output)
691
692 req.send_response(200)
693 req.send_header('Content-Type', 'text/x-patch;charset=utf-8')
694 req.send_header('Content-Disposition',
695 content_disposition('attachment', filename + '.diff'))
696 if length is not None:
697 req.send_header('Content-Length', length)
698 req.end_headers()
699 req.write(output)
700 raise RequestDone
701
702 def _iter_diff_lines(self, req, repos, data):
703 mimeview = Mimeview(self.env)
704
705 for old_node, new_node, kind, change in repos.get_changes(
706 new_path=data['new_path'], new_rev=data['new_rev'],
707 old_path=data['old_path'], old_rev=data['old_rev']):
708 # TODO: Property changes
709
710 # Content changes
711 if kind == Node.DIRECTORY:
712 continue
713
714 new_content = old_content = ''
715 new_node_info = old_node_info = ('', '')
716
717 if old_node:
718 if not old_node.is_viewable(req.perm):
719 continue
720 if mimeview.is_binary(old_node.content_type, old_node.path):
721 continue
722 old_content = _read_content(old_node)
723 if mimeview.is_binary(content=old_content):
724 continue
725 old_node_info = (old_node.path, old_node.rev)
726 old_content = mimeview.to_unicode(old_content,
727 old_node.content_type)
728 if new_node:
729 if not new_node.is_viewable(req.perm):
730 continue
731 if mimeview.is_binary(new_node.content_type, new_node.path):
732 continue
733 new_content = _read_content(new_node)
734 if mimeview.is_binary(content=new_content):
735 continue
736 new_node_info = (new_node.path, new_node.rev)
737 new_path = new_node.path
738 new_content = mimeview.to_unicode(new_content,
739 new_node.content_type)
740 else:
741 old_node_path = repos.normalize_path(old_node.path)
742 diff_old_path = repos.normalize_path(data['old_path'])
743 new_path = pathjoin(data['new_path'],
744 old_node_path[len(diff_old_path) + 1:])
745
746 if old_content != new_content:
747 options = data['diff']['options']
748 context = options.get('contextlines', 3)
749 if context < 0 or options.get('contextall'):
750 context = 3 # FIXME: unified_diff bugs with context=None
751 ignore_blank_lines = options.get('ignoreblanklines')
752 ignore_case = options.get('ignorecase')
753 ignore_space = options.get('ignorewhitespace')
754 if not old_node_info[0]:
755 old_node_info = new_node_info # support for 'A'dd changes
756 yield 'Index: ' + new_path + CRLF
757 yield '=' * 67 + CRLF
758 yield '--- %s\t(revision %s)' % old_node_info + CRLF
759 yield '+++ %s\t(revision %s)' % new_node_info + CRLF
760 for line in unified_diff(old_content.splitlines(),
761 new_content.splitlines(), context,
762 ignore_blank_lines=ignore_blank_lines,
763 ignore_case=ignore_case,
764 ignore_space_changes=ignore_space):
765 yield line + CRLF
766
767 def _zip_iter_nodes(self, req, repos, data, root_node):
768 """Node iterator yielding all the added and/or modified files."""
769 for old_node, new_node, kind, change in repos.get_changes(
770 new_path=data['new_path'], new_rev=data['new_rev'],
771 old_path=data['old_path'], old_rev=data['old_rev']):
772 if kind in (Node.FILE, Node.DIRECTORY) and \
773 change != Changeset.DELETE \
774 and new_node.is_viewable(req.perm):
775 yield new_node
776
777 def title_for_diff(self, data):
778 # TRANSLATOR: 'latest' (revision)
779 latest = _('latest')
780 if data['new_path'] == data['old_path']:
781 # ''diff between 2 revisions'' mode
782 return _('Diff [%(old_rev)s:%(new_rev)s] for %(path)s',
783 old_rev=data['old_rev'] or latest,
784 new_rev=data['new_rev'] or latest,
785 path=data['new_path'] or '/')
786 else:
787 # ''generalized diff'' mode
788 return _('Diff from %(old_path)s@%(old_rev)s to %(new_path)s@'
789 '%(new_rev)s',
790 old_path=data['old_path'] or '/',
791 old_rev=data['old_rev'] or latest,
792 new_path=data['new_path'] or '/',
793 new_rev=data['new_rev'] or latest)
794
795 def render_property_diff(self, name, old_node, old_props,
796 new_node, new_props, options):
797 """Renders diffs of a node property to HTML."""
798 if name in BrowserModule(self.env).hidden_properties:
799 return
800 candidates = []
801 for renderer in self.property_diff_renderers:
802 quality = renderer.match_property_diff(name)
803 if quality > 0:
804 candidates.append((quality, renderer))
805 candidates.sort(reverse=True)
806 for (quality, renderer) in candidates:
807 try:
808 return renderer.render_property_diff(name, old_node, old_props,
809 new_node, new_props,
810 options)
811 except Exception as e:
812 self.log.warning('Diff rendering failed for property %s with '
813 'renderer %s: %s', name,
814 renderer.__class__.__name__,
815 exception_to_unicode(e, traceback=True))
816
817 def _get_location(self, files):
818 """Return the deepest common path for the given files.
819 If all the files are actually the same, return that location."""
820 if len(files) == 1:
821 return files[0]
822 else:
823 return '/'.join(os.path.commonprefix([f.split('/')
824 for f in files]))
825
826 def _get_parent_location(self, files):
827 """Only get a location when there are different files,
828 otherwise return the empty string."""
829 if files:
830 files.sort()
831 prev = files[0]
832 for f in files[1:]:
833 if f != prev:
834 return self._get_location(files)
835 return ''
836
837 def _prepare_filestats(self):
838 filestats = {}
839 for chg in Changeset.ALL_CHANGES:
840 filestats[chg] = 0
841 return filestats
842
843 # ITimelineEventProvider methods
844
845 def get_timeline_filters(self, req):
846 if 'CHANGESET_VIEW' in req.perm:
847 # Non-'hidden' repositories will be listed as additional
848 # repository filters, unless there is only a single repository.
849 filters = []
850 rm = RepositoryManager(self.env)
851 repositories = rm.get_real_repositories()
852 if len(repositories) > 1:
853 filters = [
854 ('repo-' + repos.reponame,
855 u"\xa0\xa0-\xa0" + (repos.reponame or _('(default)')))
856 for repos in repositories
857 if not as_bool(repos.params.get('hidden'))
858 and repos.is_viewable(req.perm)]
859 filters.sort()
860 add_script(req, 'common/js/timeline_multirepos.js')
861 changeset_label = _('Changesets in all repositories')
862 else:
863 changeset_label = _('Repository changesets')
864 filters.insert(0, ('changeset', changeset_label))
865 return filters
866 else:
867 return []
868
869 def get_timeline_events(self, req, start, stop, filters):
870 all_repos = 'changeset' in filters
871 repo_filters = {f for f in filters if f.startswith('repo-')}
872 if all_repos or repo_filters:
873 show_files = self.timeline_show_files
874 show_location = show_files == 'location'
875 if show_files in ('-1', 'unlimited'):
876 show_files = -1
877 elif show_files.isdigit():
878 show_files = int(show_files)
879 else:
880 show_files = 0 # disabled
881
882 if self.timeline_collapse:
883 collapse_changesets = lambda c: (c.author, c.message)
884 else:
885 collapse_changesets = lambda c: c.rev
886
887 uids_seen = {}
888 def generate_changesets(repos):
889 for _, changesets in groupby(repos.get_changesets(start, stop),
890 key=collapse_changesets):
891 viewable_changesets = []
892 for cset in changesets:
893 if cset.is_viewable(req.perm):
894 repos_for_uid = [repos.reponame]
895 uid = repos.get_changeset_uid(cset.rev)
896 if uid:
897 # uid can be seen in multiple repositories
898 if uid in uids_seen:
899 uids_seen[uid].append(repos.reponame)
900 continue # already viewable, just append
901 uids_seen[uid] = repos_for_uid
902 viewable_changesets.append((cset, cset.resource,
903 repos_for_uid))
904 if viewable_changesets:
905 cset = viewable_changesets[-1][0]
906 yield ('changeset', cset.date, cset.author,
907 (viewable_changesets,
908 show_location, show_files))
909
910 rm = RepositoryManager(self.env)
911 for repos in sorted(rm.get_real_repositories(),
912 key=lambda repos: repos.reponame):
913 if all_repos or ('repo-' + repos.reponame) in repo_filters:
914 try:
915 for event in generate_changesets(repos):
916 yield event
917 except TracError as e:
918 self.log.error("Timeline event provider for repository"
919 " '%s' failed: %r",
920 repos.reponame, exception_to_unicode(e))
921
922 def render_timeline_event(self, context, field, event):
923 changesets, show_location, show_files = event[3]
924 cset, cset_resource, repos_for_uid = changesets[0]
925 older_cset = changesets[-1][0]
926 message = cset.message or ''
927 reponame = cset_resource.parent.id
928 rev_b, rev_a = cset.rev, older_cset.rev
929
930 if field == 'url':
931 if rev_a == rev_b:
932 return context.href.changeset(rev_a, reponame or None)
933 else:
934 return context.href.log(reponame or None, rev=rev_b,
935 stop_rev=rev_a)
936
937 elif field == 'description':
938 if self.wiki_format_messages:
939 markup = ''
940 if self.timeline_long_messages: # override default flavor
941 context = context.child()
942 context.set_hints(wiki_flavor='html',
943 preserve_newlines=True)
944 else:
945 markup = message
946 message = None
947 if 'BROWSER_VIEW' in context.perm:
948 files = []
949 if show_location:
950 filestats = self._prepare_filestats()
951 for c, r, repos_for_c in changesets:
952 for chg in c.get_changes():
953 resource = c.resource.parent.child('source',
954 chg[0] or '/',
955 r.id)
956 if not 'FILE_VIEW' in context.perm(resource):
957 continue
958 filestats[chg[2]] += 1
959 files.append(chg[0])
960 stats = [(tag.div(class_=kind),
961 tag.span(count, ' ',
962 count > 1 and
963 (kind == 'copy' and
964 'copies' or kind + 's') or kind))
965 for kind in Changeset.ALL_CHANGES
966 for count in (filestats[kind],) if count]
967 markup = tag.ul(
968 tag.li(stats, ' in ',
969 tag.strong(self._get_location(files) or '/')),
970 markup, class_="changes")
971 elif show_files:
972 unique_files = set()
973 for c, r, repos_for_c in changesets:
974 for chg in c.get_changes():
975 resource = c.resource.parent.child('source',
976 chg[0] or '/',
977 r.id)
978 if not 'FILE_VIEW' in context.perm(resource):
979 continue
980 if 0 < show_files < len(files):
981 break
982 unique_files.add((chg[0], chg[2]))
983 files = [tag.li(tag.div(class_=mod), path or '/')
984 for path, mod in sorted(unique_files)]
985 if 0 < show_files < len(files):
986 files = files[:show_files] + [tag.li(u'\u2026')]
987 markup = tag(tag.ul(files, class_="changes"), markup)
988 if message:
989 markup += format_to(self.env, None,
990 context.child(cset_resource), message)
991 return markup
992
993 single = rev_a == rev_b
994 if not repos_for_uid[0]:
995 repos_for_uid[0] = _('(default)')
996 if reponame or len(repos_for_uid) > 1:
997 title = ngettext('Changeset in %(repo)s ',
998 'Changesets in %(repo)s ',
999 1 if single else 2, repo=', '.join(repos_for_uid))
1000 else:
1001 title = ngettext('Changeset ', 'Changesets ', 1 if single else 2)
1002 drev_a = older_cset.repos.display_rev(rev_a)
1003 if single:
1004 title = tag(title, tag.em('[%s]' % drev_a))
1005 else:
1006 drev_b = cset.repos.display_rev(rev_b)
1007 title = tag(title, tag.em('[%s-%s]' % (drev_a, drev_b)))
1008 if field == 'title':
1009 labels = []
1010 for name, head in cset.get_branches():
1011 if not head and name in ('default', 'master'):
1012 continue
1013 class_ = 'branch'
1014 if head:
1015 class_ += ' head'
1016 labels.append(tag.span(name, class_=class_))
1017 for name in cset.get_tags():
1018 labels.append(tag.span(name, class_='tag'))
1019 for name in cset.get_bookmarks():
1020 labels.append(tag.span(name, class_='trac-bookmark'))
1021 return title if not labels else tag(title, labels)
1022 elif field == 'summary':
1023 return tag_("%(title)s: %(message)s",
1024 title=title, message=shorten_line(message))
1025
1026 # IWikiSyntaxProvider methods
1027
1028 CHANGESET_ID = r"(?:[0-9]+|[a-fA-F0-9]{8,})" # only "long enough" hex ids
1029
1030 def get_wiki_syntax(self):
1031 yield (
1032 # [...] form: start with optional intertrac: [T... or [trac ...
1033 r"!?\[(?P<it_changeset>%s\s*)" % WikiParser.INTERTRAC_SCHEME +
1034 # hex digits + optional /path for the restricted changeset
1035 # + optional query and fragment
1036 r"%s(?:/[^\]]*)?(?:\?[^\]]*)?(?:#[^\]]*)?\]|" % self.CHANGESET_ID +
1037 # r... form: allow r1 but not r1:2 (handled by the log syntax)
1038 r"(?:\b|!)r[0-9]+\b(?!:[0-9])(?:/[a-zA-Z0-9_/+-]+)?",
1039 lambda x, y, z:
1040 self._format_changeset_link(x, 'changeset',
1041 y[1:] if y[0] == 'r' else y[1:-1],
1042 y, z))
1043
1044 def get_link_resolvers(self):
1045 yield ('changeset', self._format_changeset_link)
1046 yield ('diff', self._format_diff_link)
1047
1048 def _format_changeset_link(self, formatter, ns, chgset, label,
1049 fullmatch=None):
1050 intertrac = formatter.shorthand_intertrac_helper(ns, chgset, label,
1051 fullmatch)
1052 if intertrac:
1053 return intertrac
1054
1055 # identifying repository
1056 rm = RepositoryManager(self.env)
1057 chgset, params, fragment = formatter.split_link(chgset)
1058 sep = chgset.find('/')
1059 if sep > 0:
1060 rev, path = chgset[:sep], chgset[sep:]
1061 else:
1062 rev, path = chgset, '/'
1063 try:
1064 reponame, repos, path = rm.get_repository_by_path(path)
1065 if not reponame:
1066 reponame = rm.get_default_repository(formatter.context)
1067 if reponame is not None:
1068 repos = rm.get_repository(reponame)
1069 if path == '/':
1070 path = None
1071
1072 # rendering changeset link
1073 if repos:
1074 changeset = repos.get_changeset(rev)
1075 if changeset.is_viewable(formatter.perm):
1076 href = formatter.href.changeset(rev,
1077 repos.reponame or None,
1078 path)
1079 return tag.a(label, class_="changeset",
1080 title=shorten_line(changeset.message),
1081 href=href + params + fragment)
1082 errmsg = _("No permission to view changeset %(rev)s "
1083 "on %(repos)s", rev=rev,
1084 repos=reponame or _('(default)'))
1085 elif reponame:
1086 errmsg = _("Repository '%(repo)s' not found", repo=reponame)
1087 else:
1088 errmsg = _("No default repository defined")
1089 except TracError as e:
1090 errmsg = to_unicode(e)
1091 return tag.a(label, class_="missing changeset", title=errmsg)
1092
1093 def _format_diff_link(self, formatter, ns, target, label):
1094 params, query, fragment = formatter.split_link(target)
1095 def pathrev(path):
1096 if '@' in path:
1097 return path.split('@', 1)
1098 else:
1099 return path, None
1100 if '//' in params:
1101 p1, p2 = params.split('//', 1)
1102 old, new = pathrev(p1), pathrev(p2)
1103 data = {'old_path': old[0], 'old_rev': old[1],
1104 'new_path': new[0], 'new_rev': new[1]}
1105 else:
1106 old_path, old_rev = pathrev(params)
1107 new_rev = None
1108 if old_rev and ':' in old_rev:
1109 old_rev, new_rev = old_rev.split(':', 1)
1110 data = {'old_path': old_path, 'old_rev': old_rev,
1111 'new_path': old_path, 'new_rev': new_rev}
1112 title = self.title_for_diff(data)
1113 href = None
1114 if any(data.values()):
1115 if query:
1116 query = '&' + query[1:]
1117 href = formatter.href.changeset(new_path=data['new_path'] or None,
1118 new=data['new_rev'],
1119 old_path=data['old_path'] or None,
1120 old=data['old_rev']) + query
1121 return tag.a(label, class_="changeset", title=title, href=href)
1122
1123 # ISearchSource methods
1124
1125 ### FIXME: move this specific implementation into cache.py
1126
1127 def get_search_filters(self, req):
1128 if 'CHANGESET_VIEW' in req.perm:
1129 yield ('changeset', _('Changesets'))
1130
1131 def get_search_results(self, req, terms, filters):
1132 if not 'changeset' in filters:
1133 return
1134 rm = RepositoryManager(self.env)
1135 repositories = {repos.params['id']: repos
1136 for repos in rm.get_real_repositories()}
1137 uids_seen = set()
1138 with self.env.db_query as db:
1139 sql, args = search_to_sql(db, ['rev', 'message', 'author'], terms)
1140 for id, rev, ts, author, log in db("""
1141 SELECT repos, rev, time, author, message
1142 FROM revision WHERE """ + sql, args):
1143 repos = repositories.get(id)
1144 if not repos:
1145 continue # revisions for a no longer active repository
1146 try:
1147 rev = repos.normalize_rev(rev)
1148 drev = repos.display_rev(rev)
1149 except NoSuchChangeset:
1150 continue
1151 uid = repos.get_changeset_uid(rev)
1152 if uid in uids_seen:
1153 continue
1154 cset = repos.resource.child(self.realm, rev)
1155 if 'CHANGESET_VIEW' in req.perm(cset):
1156 uids_seen.add(uid)
1157 yield (req.href.changeset(rev, repos.reponame or None),
1158 '[%s]: %s' % (drev, shorten_line(log)),
1159 from_utimestamp(ts), author,
1160 shorten_result(log, terms))
1161
1162
1163class AnyDiffModule(Component):
1164
1165 implements(IRequestHandler)
1166
1167 # IRequestHandler methods
1168
1169 def match_request(self, req):
1170 return req.path_info == '/diff'
1171
1172 def process_request(self, req):
1173 rm = RepositoryManager(self.env)
1174
1175 if req.is_xhr:
1176 dirname, prefix = posixpath.split(req.args.get('term'))
1177 prefix = prefix.lower()
1178 reponame, repos, path = rm.get_repository_by_path(dirname)
1179 # an entry is a (isdir, name, path) tuple
1180 def kind_order(entry):
1181 return not entry[0], embedded_numbers(entry[1])
1182
1183 entries = []
1184 if repos:
1185 entries.extend((e.isdir, e.name,
1186 '/' + pathjoin(repos.reponame, e.path))
1187 for e in repos.get_node(path).get_entries()
1188 if e.is_viewable(req.perm))
1189 if not reponame:
1190 entries.extend((True, repos.reponame, '/' + repos.reponame)
1191 for repos in rm.get_real_repositories()
1192 if repos.is_viewable(req.perm))
1193
1194 paths = [{'label': path + ('/' if isdir else ''), 'value': path,
1195 'isdir': isdir}
1196 for isdir, name, path in sorted(entries, key=kind_order)
1197 if name.lower().startswith(prefix)]
1198
1199 content = to_json(paths)
1200 req.send(content, 'application/json', 200)
1201
1202 # -- retrieve arguments
1203 new_path = req.args.get('new_path')
1204 new_rev = req.args.get('new_rev')
1205 old_path = req.args.get('old_path')
1206 old_rev = req.args.get('old_rev')
1207
1208 # -- normalize and prepare rendering
1209 new_reponame, new_repos, new_path = \
1210 rm.get_repository_by_path(new_path)
1211 old_reponame, old_repos, old_path = \
1212 rm.get_repository_by_path(old_path)
1213
1214 data = {}
1215 if new_repos:
1216 data.update(new_path='/' + pathjoin(new_repos.reponame, new_path),
1217 new_rev=new_repos.normalize_rev(new_rev))
1218 else:
1219 data.update(new_path=req.args.get('new_path'), new_rev=new_rev)
1220 if old_repos:
1221 data.update(old_path='/' + pathjoin(old_repos.reponame, old_path),
1222 old_rev=old_repos.normalize_rev(old_rev))
1223 else:
1224 data.update(old_path=req.args.get('old_path'), old_rev=old_rev)
1225
1226 Chrome(self.env).add_jquery_ui(req)
1227 add_stylesheet(req, 'common/css/diff.css')
1228 return 'diff_form.html', data
1229
1230
1231def _read_content(node):
1232 with content_closing(node.get_content()) as content:
1233 return content.read()
Note: See TracBrowser for help on using the repository browser.