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(' &rarr;')))
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