/loggerhead/trunk

To get this branch, use:
bzr branch http://gegoxaren.bato24.eu/bzr/loggerhead/trunk

« back to all changes in this revision

Viewing changes to loggerhead/history.py

  • Committer: Colin Watson
  • Date: 2022-09-01 15:47:31 UTC
  • Revision ID: cjwatson@canonical.com-20220901154731-i0tyrmtyv6q8ih9o
Back to development: 2.0.2.

Show diffs side-by-side

added added

removed removed

Lines of Context:
1
 
#
 
1
# Copyright (C) 2006-2011 Canonical Ltd.
 
2
#                     (Authored by Martin Albisetti <argentina@gmail.com>)
2
3
# Copyright (C) 2006  Robey Pointer <robey@lag.net>
 
4
# Copyright (C) 2006  Goffredo Baroncelli <kreijack@inwind.it>
 
5
# Copyright (C) 2005  Jake Edge <jake@edge2.net>
 
6
# Copyright (C) 2005  Matt Mackall <mpm@selenic.com>
3
7
#
4
8
# This program is free software; you can redistribute it and/or modify
5
9
# it under the terms of the GNU General Public License as published by
13
17
#
14
18
# You should have received a copy of the GNU General Public License
15
19
# along with this program; if not, write to the Free Software
16
 
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
17
 
#
18
 
 
 
20
# Foundation, Inc., 51 Franklin Street, Suite 500, Boston, MA 02110-1335  USA
 
21
#
 
22
 
 
23
#
 
24
# This file (and many of the web templates) contains work based on the
 
25
# "bazaar-webserve" project by Goffredo Baroncelli, which is in turn based
 
26
# on "hgweb" by Jake Edge and Matt Mackall.
 
27
#
 
28
 
 
29
 
 
30
import bisect
19
31
import datetime
 
32
import logging
 
33
import re
20
34
import textwrap
21
 
 
22
 
import bzrlib
23
 
import bzrlib.branch
24
 
import bzrlib.errors
25
 
import bzrlib.tsort
26
 
 
27
 
from loggerhead import util
28
 
 
29
 
 
30
 
class History (object):
31
 
    
32
 
    def __init__(self):
33
 
        pass
34
 
 
35
 
    @classmethod
36
 
    def from_branch(cls, branch):
37
 
        self = cls()
 
35
import threading
 
36
 
 
37
from breezy import tag
 
38
import breezy.branch
 
39
import breezy.delta
 
40
import breezy.errors
 
41
import breezy.foreign
 
42
import breezy.osutils
 
43
import breezy.revision
 
44
 
 
45
try:
 
46
    from breezy.transport import NoSuchFile
 
47
except ImportError:
 
48
    from breezy.errors import NoSuchFile
 
49
 
 
50
from . import search
 
51
from . import util
 
52
from .wholehistory import compute_whole_history_data
 
53
 
 
54
 
 
55
def is_branch(folder):
 
56
    try:
 
57
        breezy.branch.Branch.open(folder)
 
58
        return True
 
59
    except breezy.errors.NotBranchError:
 
60
        return False
 
61
 
 
62
 
 
63
def clean_message(message):
 
64
    """Clean up a commit message and return it and a short (1-line) version.
 
65
 
 
66
    Commit messages that are long single lines are reflowed using the textwrap
 
67
    module (Robey, the original author of this code, apparently favored this
 
68
    style of message).
 
69
    """
 
70
    message = message.lstrip().splitlines()
 
71
 
 
72
    if len(message) == 1:
 
73
        message = textwrap.wrap(message[0])
 
74
 
 
75
    if len(message) == 0:
 
76
        # We can end up where when (a) the commit message was empty or (b)
 
77
        # when the message consisted entirely of whitespace, in which case
 
78
        # textwrap.wrap() returns an empty list.
 
79
        return [''], ''
 
80
 
 
81
    # Make short form of commit message.
 
82
    short_message = message[0]
 
83
    if len(short_message) > 60:
 
84
        short_message = short_message[:60] + '...'
 
85
 
 
86
    return message, short_message
 
87
 
 
88
 
 
89
def rich_filename(path, kind):
 
90
    if kind == 'directory':
 
91
        path += '/'
 
92
    if kind == 'symlink':
 
93
        path += '@'
 
94
    return path
 
95
 
 
96
 
 
97
class _RevListToTimestamps(object):
 
98
    """This takes a list of revisions, and allows you to bisect by date"""
 
99
 
 
100
    __slots__ = ['revid_list', 'repository']
 
101
 
 
102
    def __init__(self, revid_list, repository):
 
103
        self.revid_list = revid_list
 
104
        self.repository = repository
 
105
 
 
106
    def __getitem__(self, index):
 
107
        """Get the date of the index'd item"""
 
108
        return datetime.datetime.fromtimestamp(self.repository.get_revision(
 
109
                   self.revid_list[index]).timestamp)
 
110
 
 
111
    def __len__(self):
 
112
        return len(self.revid_list)
 
113
 
 
114
 
 
115
class FileChangeReporter(object):
 
116
 
 
117
    def __init__(self, old_tree, new_tree):
 
118
        self.added = []
 
119
        self.modified = []
 
120
        self.renamed = []
 
121
        self.removed = []
 
122
        self.text_changes = []
 
123
        self.old_tree = old_tree
 
124
        self.new_tree = new_tree
 
125
 
 
126
    def revid(self, tree, path):
 
127
        if path is None:
 
128
            return breezy.revision.NULL_REVISION
 
129
        try:
 
130
            return tree.get_file_revision(path)
 
131
        except NoSuchFile:
 
132
            return breezy.revision.NULL_REVISION
 
133
 
 
134
    def report(self, paths, versioned, renamed, copied, modified,
 
135
               exe_change, kind):
 
136
        return self._report(
 
137
                paths, versioned, renamed, copied,
 
138
                modified, exe_change, kind)
 
139
 
 
140
    def _report(self, paths, versioned, renamed, copied, modified,
 
141
                exe_change, kind):
 
142
        if modified not in ('unchanged', 'kind changed'):
 
143
            if versioned == 'removed':
 
144
                filename = rich_filename(paths[0], kind[0])
 
145
            else:
 
146
                filename = rich_filename(paths[1], kind[1])
 
147
            self.text_changes.append(util.Container(
 
148
                filename=filename,
 
149
                old_revision=self.revid(self.old_tree, paths[0]),
 
150
                new_revision=self.revid(self.new_tree, paths[1])))
 
151
        if versioned == 'added':
 
152
            self.added.append(util.Container(
 
153
                filename=rich_filename(paths[1], kind), kind=kind[1]))
 
154
        elif versioned == 'removed':
 
155
            self.removed.append(util.Container(
 
156
                filename=rich_filename(paths[0], kind), kind=kind[0]))
 
157
        elif renamed:
 
158
            self.renamed.append(util.Container(
 
159
                old_filename=rich_filename(paths[0], kind[0]),
 
160
                new_filename=rich_filename(paths[1], kind[1]),
 
161
                text_modified=modified == 'modified', exe_change=exe_change))
 
162
        else:
 
163
            self.modified.append(util.Container(
 
164
                filename=rich_filename(paths[1], kind),
 
165
                text_modified=modified == 'modified', exe_change=exe_change))
 
166
 
 
167
 
 
168
# The lru_cache is not thread-safe, so we need a lock around it for
 
169
# all threads.
 
170
rev_info_memory_cache_lock = threading.RLock()
 
171
 
 
172
 
 
173
class RevInfoMemoryCache(object):
 
174
    """A store that validates values against the revids they were stored with.
 
175
 
 
176
    We use a unique key for each branch.
 
177
 
 
178
    The reason for not just using the revid as the key is so that when a new
 
179
    value is provided for a branch, we replace the old value used for the
 
180
    branch.
 
181
 
 
182
    There is another implementation of the same interface in
 
183
    loggerhead.changecache.RevInfoDiskCache.
 
184
    """
 
185
 
 
186
    def __init__(self, cache):
 
187
        self._cache = cache
 
188
 
 
189
    def get(self, key, revid):
 
190
        """Return the data associated with `key`, subject to a revid check.
 
191
 
 
192
        If a value was stored under `key`, with the same revid, return it.
 
193
        Otherwise return None.
 
194
        """
 
195
        rev_info_memory_cache_lock.acquire()
 
196
        try:
 
197
            cached = self._cache.get(key)
 
198
        finally:
 
199
            rev_info_memory_cache_lock.release()
 
200
        if cached is None:
 
201
            return None
 
202
        stored_revid, data = cached
 
203
        if revid == stored_revid:
 
204
            return data
 
205
        else:
 
206
            return None
 
207
 
 
208
    def set(self, key, revid, data):
 
209
        """Store `data` under `key`, to be checked against `revid` on get().
 
210
        """
 
211
        rev_info_memory_cache_lock.acquire()
 
212
        try:
 
213
            self._cache[key] = (revid, data)
 
214
        finally:
 
215
            rev_info_memory_cache_lock.release()
 
216
 
 
217
# Used to store locks that prevent multiple threads from building a
 
218
# revision graph for the same branch at the same time, because that can
 
219
# cause severe performance issues that are so bad that the system seems
 
220
# to hang.
 
221
revision_graph_locks = {}
 
222
revision_graph_check_lock = threading.Lock()
 
223
 
 
224
class History(object):
 
225
    """Decorate a branch to provide information for rendering.
 
226
 
 
227
    History objects are expected to be short lived -- when serving a request
 
228
    for a particular branch, open it, read-lock it, wrap a History object
 
229
    around it, serve the request, throw the History object away, unlock the
 
230
    branch and throw it away.
 
231
 
 
232
    :ivar _rev_info: A list of information about revisions.  This is by far
 
233
        the most cryptic data structure in loggerhead.  At the top level, it
 
234
        is a list of 3-tuples [(merge-info, where-merged, parents)].
 
235
        `merge-info` is (seq, revid, merge_depth, revno_str, end_of_merge) --
 
236
        like a merged sorted list, but the revno is stringified.
 
237
        `where-merged` is a tuple of revisions that have this revision as a
 
238
        non-lefthand parent.  Finally, `parents` is just the usual list of
 
239
        parents of this revision.
 
240
    :ivar _rev_indices: A dictionary mapping each revision id to the index of
 
241
        the information about it in _rev_info.
 
242
    :ivar _revno_revid: A dictionary mapping stringified revnos to revision
 
243
        ids.
 
244
    """
 
245
 
 
246
    def _load_whole_history_data(self, caches, cache_key):
 
247
        """Set the attributes relating to the whole history of the branch.
 
248
 
 
249
        :param caches: a list of caches with interfaces like
 
250
            `RevInfoMemoryCache` and be ordered from fastest to slowest.
 
251
        :param cache_key: the key to use with the caches.
 
252
        """
 
253
        self._rev_indices = None
 
254
        self._rev_info = None
 
255
 
 
256
        missed_caches = []
 
257
        def update_missed_caches():
 
258
            for cache in missed_caches:
 
259
                cache.set(cache_key, self.last_revid, self._rev_info)
 
260
 
 
261
        # Theoretically, it's possible for two threads to race in creating
 
262
        # the Lock() object for their branch, so we put a lock around
 
263
        # creating the per-branch Lock().
 
264
        revision_graph_check_lock.acquire()
 
265
        try:
 
266
            if cache_key not in revision_graph_locks:
 
267
                revision_graph_locks[cache_key] = threading.Lock()
 
268
        finally:
 
269
            revision_graph_check_lock.release()
 
270
 
 
271
        revision_graph_locks[cache_key].acquire()
 
272
        try:
 
273
            for cache in caches:
 
274
                data = cache.get(cache_key, self.last_revid)
 
275
                if data is not None:
 
276
                    self._rev_info = data
 
277
                    update_missed_caches()
 
278
                    break
 
279
                else:
 
280
                    missed_caches.append(cache)
 
281
            else:
 
282
                whole_history_data = compute_whole_history_data(self._branch)
 
283
                self._rev_info, self._rev_indices = whole_history_data
 
284
                update_missed_caches()
 
285
        finally:
 
286
            revision_graph_locks[cache_key].release()
 
287
 
 
288
        if self._rev_indices is not None:
 
289
            self._revno_revid = {}
 
290
            for ((_, revid, _, revno_str, _), _, _) in self._rev_info:
 
291
                self._revno_revid[revno_str] = revid
 
292
        else:
 
293
            self._revno_revid = {}
 
294
            self._rev_indices = {}
 
295
            for ((seq, revid, _, revno_str, _), _, _) in self._rev_info:
 
296
                self._rev_indices[revid] = seq
 
297
                self._revno_revid[revno_str] = revid
 
298
 
 
299
    def __init__(self, branch, whole_history_data_cache,
 
300
                 revinfo_disk_cache=None, cache_key=None):
 
301
        assert branch.is_locked(), (
 
302
            "Can only construct a History object with a read-locked branch.")
38
303
        self._branch = branch
39
 
        self._history = branch.revision_history()
40
 
        self._revision_graph = branch.repository.get_revision_graph()
41
 
        self._last_revid = self._history[-1]
42
 
        
43
 
        self._full_history = []
44
 
        self._revision_info = {}
45
 
        self._revno_revid = {}
46
 
        self._merge_sort = bzrlib.tsort.merge_sort(self._revision_graph, self._last_revid, generate_revno=True)
47
 
        count = 0
48
 
        for (seq, revid, merge_depth, revno, end_of_merge) in self._merge_sort:
49
 
            self._full_history.append(revid)
50
 
            revno_str = '.'.join(str(n) for n in revno)
51
 
            self._revno_revid[revno_str] = revid
52
 
            self._revision_info[revid] = (seq, revid, merge_depth, revno_str, end_of_merge)
53
 
            count += 1
54
 
        self._count = count
55
 
 
56
 
        # cache merge info
57
 
        self._where_merged = {}
58
 
        for revid in self._revision_graph.keys():
59
 
            if not revid in self._full_history: 
60
 
                continue
61
 
            for parent in self._revision_graph[revid]:
62
 
                self._where_merged.setdefault(parent, set()).add(revid)
63
 
 
64
 
        return self
65
 
    
66
 
    @classmethod
67
 
    def from_folder(cls, path):
68
 
        b = bzrlib.branch.Branch.open(path)
69
 
        return cls.from_branch(b)
70
 
    
71
 
    last_revid = property(lambda self: self._last_revid, None, None)
72
 
    count = property(lambda self: self._count, None, None)
73
 
    
 
304
        self._branch_tags = None
 
305
        self._inventory_cache = {}
 
306
        self._branch_nick = self._branch.get_config().get_nickname()
 
307
        self.log = logging.getLogger('loggerhead.%s' % (self._branch_nick,))
 
308
 
 
309
        self.last_revid = branch.last_revision()
 
310
 
 
311
        caches = [RevInfoMemoryCache(whole_history_data_cache)]
 
312
        if revinfo_disk_cache:
 
313
            caches.append(revinfo_disk_cache)
 
314
        self._load_whole_history_data(caches, cache_key)
 
315
 
 
316
    @property
 
317
    def has_revisions(self):
 
318
        return not breezy.revision.is_null(self.last_revid)
 
319
 
 
320
    def get_config(self):
 
321
        return self._branch.get_config()
 
322
 
74
323
    def get_revno(self, revid):
75
 
        seq, revid, merge_depth, revno_str, end_of_merge = self._revision_info[revid]
76
 
        return revno_str
77
 
 
78
 
    def get_sequence(self, revid):
79
 
        seq, revid, merge_depth, revno_str, end_of_merge = self._revision_info[revid]
80
 
        return seq
81
 
        
82
 
    def get_short_revision_history(self):
83
 
        return self.get_short_revision_history_from(self._last_revid)
84
 
    
85
 
    def get_short_revision_history_from(self, revid):
86
 
        """return the short revision_history, starting from revid"""
87
 
        while True:
88
 
            yield revid
89
 
            if not self._revision_graph.has_key(revid):
 
324
        if revid not in self._rev_indices:
 
325
            # ghost parent?
 
326
            return 'unknown'
 
327
        seq = self._rev_indices[revid]
 
328
        revno = self._rev_info[seq][0][3]
 
329
        return revno
 
330
 
 
331
    def get_revids_from(self, revid_list, start_revid):
 
332
        """
 
333
        Yield the mainline (wrt start_revid) revisions that merged each
 
334
        revid in revid_list.
 
335
        """
 
336
        if revid_list is None:
 
337
            # Just yield the mainline, starting at start_revid
 
338
            revid = start_revid
 
339
            is_null = breezy.revision.is_null
 
340
            while not is_null(revid):
 
341
                yield revid
 
342
                parents = self._rev_info[self._rev_indices[revid]][2]
 
343
                if not parents:
 
344
                    return
 
345
                revid = parents[0]
 
346
            return
 
347
        revid_set = set(revid_list)
 
348
        revid = start_revid
 
349
 
 
350
        def introduced_revisions(revid):
 
351
            r = set([revid])
 
352
            seq = self._rev_indices[revid]
 
353
            md = self._rev_info[seq][0][2]
 
354
            i = seq + 1
 
355
            while i < len(self._rev_info) and self._rev_info[i][0][2] > md:
 
356
                r.add(self._rev_info[i][0][1])
 
357
                i += 1
 
358
            return r
 
359
        while revid_set:
 
360
            if breezy.revision.is_null(revid):
90
361
                return
91
 
            parents = self._revision_graph[revid]
 
362
            rev_introduced = introduced_revisions(revid)
 
363
            matching = rev_introduced.intersection(revid_set)
 
364
            if matching:
 
365
                # We don't need to look for these anymore.
 
366
                revid_set.difference_update(matching)
 
367
                yield revid
 
368
            parents = self._rev_info[self._rev_indices[revid]][2]
92
369
            if len(parents) == 0:
93
370
                return
94
 
            revid = self._revision_graph[revid][0]
95
 
 
96
 
    def get_where_merged(self, revid):
97
 
        try:
98
 
            return self._where_merged[revid]
99
 
        except:
 
371
            revid = parents[0]
 
372
 
 
373
    def get_short_revision_history_by_fileid(self, file_id):
 
374
        # FIXME: would be awesome if we could get, for a folder, the list of
 
375
        # revisions where items within that folder changed.i
 
376
        # TODO(jelmer): Avoid versionedfile-specific texts
 
377
        possible_keys = [(file_id, revid) for revid in self._rev_indices]
 
378
        get_parent_map = self._branch.repository.texts.get_parent_map
 
379
        # We chunk the requests as this works better with GraphIndex.
 
380
        # See _filter_revisions_touching_file_id in breezy/log.py
 
381
        # for more information.
 
382
        revids = []
 
383
        chunk_size = 1000
 
384
        for start in range(0, len(possible_keys), chunk_size):
 
385
            next_keys = possible_keys[start:start + chunk_size]
 
386
            revids += [k[1] for k in get_parent_map(next_keys)]
 
387
        del possible_keys, next_keys
 
388
        return revids
 
389
 
 
390
    def get_revision_history_since(self, revid_list, date):
 
391
        # if a user asks for revisions starting at 01-sep, they mean inclusive,
 
392
        # so start at midnight on 02-sep.
 
393
        date = date + datetime.timedelta(days=1)
 
394
        # our revid list is sorted in REVERSE date order,
 
395
        # so go thru some hoops here...
 
396
        revid_list.reverse()
 
397
        index = bisect.bisect(_RevListToTimestamps(revid_list,
 
398
                                                   self._branch.repository),
 
399
                              date)
 
400
        if index == 0:
100
401
            return []
 
402
        revid_list.reverse()
 
403
        index = -index
 
404
        return revid_list[index:]
 
405
 
 
406
    def get_search_revid_list(self, query, revid_list):
 
407
        """
 
408
        given a "quick-search" query, try a few obvious possible meanings:
 
409
 
 
410
            - revision id or # ("128.1.3")
 
411
            - date (US style "mm/dd/yy", earth style "dd-mm-yy", or \
 
412
iso style "yyyy-mm-dd")
 
413
            - comment text as a fallback
 
414
 
 
415
        and return a revid list that matches.
 
416
        """
 
417
        # FIXME: there is some silliness in this action.  we have to look up
 
418
        # all the relevant changes (time-consuming) only to return a list of
 
419
        # revids which will be used to fetch a set of changes again.
 
420
 
 
421
        # if they entered a revid, just jump straight there;
 
422
        # ignore the passed-in revid_list
 
423
        revid = self.fix_revid(query)
 
424
        if revid is not None:
 
425
            changes = self.get_changes([revid])
 
426
            if (changes is not None) and (len(changes) > 0):
 
427
                return [revid]
 
428
 
 
429
        date = None
 
430
        m = self.us_date_re.match(query)
 
431
        if m is not None:
 
432
            date = datetime.datetime(util.fix_year(int(m.group(3))),
 
433
                                     int(m.group(1)),
 
434
                                     int(m.group(2)))
 
435
        else:
 
436
            m = self.earth_date_re.match(query)
 
437
            if m is not None:
 
438
                date = datetime.datetime(util.fix_year(int(m.group(3))),
 
439
                                         int(m.group(2)),
 
440
                                         int(m.group(1)))
 
441
            else:
 
442
                m = self.iso_date_re.match(query)
 
443
                if m is not None:
 
444
                    date = datetime.datetime(util.fix_year(int(m.group(1))),
 
445
                                             int(m.group(2)),
 
446
                                             int(m.group(3)))
 
447
        if date is not None:
 
448
            if revid_list is None:
 
449
                # if no limit to the query was given,
 
450
                # search only the direct-parent path.
 
451
                revid_list = list(self.get_revids_from(None, self.last_revid))
 
452
            return self.get_revision_history_since(revid_list, date)
 
453
 
 
454
    revno_re = re.compile(r'^[\d\.]+$')
 
455
    # the date regex are without a final '$' so that queries like
 
456
    # "2006-11-30 12:15" still mostly work.  (i think it's better to give
 
457
    # them 90% of what they want instead of nothing at all.)
 
458
    us_date_re = re.compile(r'^(\d{1,2})/(\d{1,2})/(\d\d(\d\d?))')
 
459
    earth_date_re = re.compile(r'^(\d{1,2})-(\d{1,2})-(\d\d(\d\d?))')
 
460
    iso_date_re = re.compile(r'^(\d\d\d\d)-(\d\d)-(\d\d)')
 
461
 
 
462
    def fix_revid(self, revid):
 
463
        # if a "revid" is actually a dotted revno, convert it to a revid
 
464
        if revid is None:
 
465
            return revid
 
466
        if not isinstance(revid, str):
 
467
            raise TypeError(revid)
 
468
        if revid == 'head:':
 
469
            return self.last_revid
 
470
        try:
 
471
            if self.revno_re.match(revid):
 
472
                revid = self._revno_revid[revid]
 
473
        except KeyError:
 
474
            raise breezy.errors.NoSuchRevision(self._branch_nick, revid)
 
475
        if not isinstance(revid, bytes):
 
476
            revid = revid.encode('utf-8')
 
477
        return revid
 
478
 
 
479
    @staticmethod
 
480
    def _iterate_sufficiently(iterable, stop_at, extra_rev_count):
 
481
        """Return a list of iterable.
 
482
 
 
483
        If extra_rev_count is None, fully consume iterable.
 
484
        Otherwise, stop at 'stop_at' + extra_rev_count.
 
485
 
 
486
        Example:
 
487
          iterate until you find stop_at, then iterate 10 more times.
 
488
        """
 
489
        if extra_rev_count is None:
 
490
            return list(iterable)
 
491
        result = []
 
492
        found = False
 
493
        for n in iterable:
 
494
            result.append(n)
 
495
            if n == stop_at:
 
496
                found = True
 
497
                break
 
498
        if found:
 
499
            for count, n in enumerate(iterable):
 
500
                if count >= extra_rev_count:
 
501
                    break
 
502
                result.append(n)
 
503
        return result
 
504
 
 
505
    def _get_file_view(self, revid, file_id):
 
506
        """
 
507
        Given a revid and optional path, return a (revlist, revid) for
 
508
        navigation through the current scope: from the revid (or the latest
 
509
        revision) back to the original revision.
 
510
 
 
511
        If file_id is None, the entire revision history is the list scope.
 
512
        """
 
513
        if revid is None:
 
514
            revid = self.last_revid
 
515
        if file_id is not None:
 
516
            revlist = list(
 
517
                self.get_short_revision_history_by_fileid(file_id))
 
518
            revlist = self.get_revids_from(revlist, revid)
 
519
        else:
 
520
            revlist = self.get_revids_from(None, revid)
 
521
        return revlist
 
522
 
 
523
    def get_view(self, revid, start_revid, path, query=None,
 
524
                 extra_rev_count=None):
 
525
        """
 
526
        use the URL parameters (revid, start_revid, path, and query) to
 
527
        determine the revision list we're viewing (start_revid, path, query)
 
528
        and where we are in it (revid).
 
529
 
 
530
            - if a query is given, we're viewing query results.
 
531
            - if a path is given, we're viewing revisions for a specific
 
532
              file.
 
533
            - if a start_revid is given, we're viewing the branch from a
 
534
              specific revision up the tree.
 
535
            - if extra_rev_count is given, find the view from start_revid =>
 
536
              revid, and continue an additional 'extra_rev_count'. If not
 
537
              given, then revid_list will contain the full history of
 
538
              start_revid
 
539
 
 
540
        these may be combined to view revisions for a specific file, from
 
541
        a specific revision, with a specific search query.
 
542
 
 
543
        returns a new (revid, start_revid, revid_list) where:
 
544
 
 
545
            - revid: current position within the view
 
546
            - start_revid: starting revision of this view
 
547
            - revid_list: list of revision ids for this view
 
548
 
 
549
        path and query are never changed so aren't returned, but they may
 
550
        contain vital context for future url navigation.
 
551
        """
 
552
        if start_revid is None:
 
553
            start_revid = self.last_revid
 
554
 
 
555
        if query is None:
 
556
            repo = self._branch.repository
 
557
            if path is not None:
 
558
                file_id = repo.revision_tree(start_revid).path2id(path)
 
559
            else:
 
560
                file_id = None
 
561
            revid_list = self._get_file_view(start_revid, file_id)
 
562
            revid_list = self._iterate_sufficiently(revid_list, revid,
 
563
                                                    extra_rev_count)
 
564
            if revid is None:
 
565
                revid = start_revid
 
566
            if revid not in revid_list:
 
567
                # if the given revid is not in the revlist, use a revlist that
 
568
                # starts at the given revid.
 
569
                revid_list = self._get_file_view(revid, file_id)
 
570
                revid_list = self._iterate_sufficiently(revid_list, revid,
 
571
                                                        extra_rev_count)
 
572
                start_revid = revid
 
573
            return revid, start_revid, revid_list
 
574
        else:
 
575
            file_id = None
 
576
 
 
577
        # potentially limit the search
 
578
        if file_id is not None:
 
579
            revid_list = self._get_file_view(start_revid, file_id)
 
580
        else:
 
581
            revid_list = None
 
582
        revid_list = search.search_revisions(self._branch, query)
 
583
        if revid_list and len(revid_list) > 0:
 
584
            if revid not in revid_list:
 
585
                revid = revid_list[0]
 
586
            return revid, start_revid, revid_list
 
587
        else:
 
588
            # XXX: This should return a message saying that the search could
 
589
            # not be completed due to either missing the plugin or missing a
 
590
            # search index.
 
591
            return None, None, []
 
592
 
 
593
    def revision_tree(self, revid):
 
594
        return self._branch.repository.revision_tree(revid)
 
595
 
 
596
    def file_exists(self, revid, path):
 
597
        if (len(path) > 0) and not path.startswith('/'):
 
598
            path = '/' + path
 
599
        try:
 
600
            return self.revision_tree(revid).has_filename(path)
 
601
        except breezy.errors.NoSuchRevision:
 
602
            return False
101
603
 
102
604
    def get_merge_point_list(self, revid):
103
605
        """
104
606
        Return the list of revids that have merged this node.
105
607
        """
106
 
        if revid in self._history:
 
608
        if '.' not in self.get_revno(revid):
107
609
            return []
108
 
        
 
610
 
109
611
        merge_point = []
110
 
        while True:
111
 
            children = self.get_where_merged(revid)
112
 
            nexts = []
 
612
        nexts = [revid]
 
613
        while nexts:
 
614
            revid = nexts.pop()
 
615
            children = self._rev_info[self._rev_indices[revid]][1]
113
616
            for child in children:
114
 
                child_parents = self._revision_graph[child]
 
617
                child_parents = self._rev_info[self._rev_indices[child]][2]
115
618
                if child_parents[0] == revid:
116
619
                    nexts.append(child)
117
620
                else:
118
621
                    merge_point.append(child)
119
 
 
120
 
            if len(nexts) == 0:
121
 
                # only merge
122
 
                return merge_point
123
 
 
124
 
            while len(nexts) > 1:
125
 
                # branch
126
 
                next = nexts.pop()
127
 
                merge_point_next = self.get_merge_point_list(next)
128
 
                merge_point.extend(merge_point_next)
129
 
 
130
 
            revid = nexts[0]
131
 
            
 
622
        return merge_point
 
623
 
132
624
    def simplify_merge_point_list(self, revids):
133
625
        """if a revision is already merged, don't show further merge points"""
134
626
        d = {}
137
629
            revnol = revno.split(".")
138
630
            revnos = ".".join(revnol[:-2])
139
631
            revnolast = int(revnol[-1])
140
 
            if d.has_key(revnos):
 
632
            if revnos in d:
141
633
                m = d[revnos][0]
142
634
                if revnolast < m:
143
 
                    d[revnos] = ( revnolast, revid )
144
 
            else:
145
 
                d[revnos] = ( revnolast, revid )
146
 
 
147
 
        return [ d[revnos][1] for revnos in d.keys() ]
148
 
            
149
 
    def get_changelist(self, revid_list):
 
635
                    d[revnos] = (revnolast, revid)
 
636
            else:
 
637
                d[revnos] = (revnolast, revid)
 
638
 
 
639
        return [revid for (_, revid) in d.values()]
 
640
 
 
641
    def add_branch_nicks(self, change):
 
642
        """
 
643
        given a 'change', fill in the branch nicks on all parents and merge
 
644
        points.
 
645
        """
 
646
        fetch_set = set()
 
647
        for p in change.parents:
 
648
            fetch_set.add(p.revid)
 
649
        for p in change.merge_points:
 
650
            fetch_set.add(p.revid)
 
651
        p_changes = self.get_changes(list(fetch_set))
 
652
        p_change_dict = {c.revid: c for c in p_changes}
 
653
        for p in change.parents:
 
654
            if p.revid in p_change_dict:
 
655
                p.branch_nick = p_change_dict[p.revid].branch_nick
 
656
            else:
 
657
                p.branch_nick = '(missing)'
 
658
        for p in change.merge_points:
 
659
            if p.revid in p_change_dict:
 
660
                p.branch_nick = p_change_dict[p.revid].branch_nick
 
661
            else:
 
662
                p.branch_nick = '(missing)'
 
663
 
 
664
    def get_changes(self, revid_list):
 
665
        """Return a list of changes objects for the given revids.
 
666
 
 
667
        Revisions not present and NULL_REVISION will be ignored.
 
668
        """
150
669
        for revid in revid_list:
151
 
            yield self.get_change(revid)
152
 
    
153
 
    def get_change(self, revid, parity=0):
154
 
        try:
155
 
            rev = self._branch.repository.get_revision(revid)
156
 
        except (KeyError, bzrlib.errors.NoSuchRevision):
157
 
            # ghosted parent?
158
 
            entry = {
159
 
                'revid': 'missing',
160
 
                'revno': '',
161
 
                'date': datetime.datetime.fromtimestamp(0),
162
 
                'author': 'missing',
163
 
                'age': 'unknown',
164
 
                'short_comment': 'missing',
165
 
                'parents': [],
166
 
            }
167
 
            return util.Container(entry)
168
 
            
169
 
        now = datetime.datetime.now()
170
 
        commit_time = datetime.datetime.fromtimestamp(rev.timestamp)
171
 
        
172
 
        # make short form of commit message
173
 
        short_comment = rev.message.splitlines(1)[0]
174
 
        if len(short_comment) >= 80:
175
 
            short_comment = textwrap.wrap(short_comment)[0] + '...'
176
 
        
177
 
        parents = [{
178
 
            'revid': parent_revid,
179
 
            'revno': self.get_revno(parent_revid),
180
 
        } for parent_revid in rev.parent_ids]
 
670
            if not isinstance(revid, bytes):
 
671
                raise TypeError(revid_list)
 
672
        changes = self.get_changes_uncached(revid_list)
 
673
        if len(changes) == 0:
 
674
            return changes
 
675
 
 
676
        # some data needs to be recalculated each time, because it may
 
677
        # change as new revisions are added.
 
678
        for change in changes:
 
679
            merge_revids = self.simplify_merge_point_list(
 
680
                               self.get_merge_point_list(change.revid))
 
681
            change.merge_points = [
 
682
                util.Container(revid=r,
 
683
                revno=self.get_revno(r)) for r in merge_revids]
 
684
            if len(change.parents) > 0:
 
685
                change.parents = [util.Container(revid=r,
 
686
                    revno=self.get_revno(r)) for r in change.parents]
 
687
            change.revno = self.get_revno(change.revid)
 
688
 
 
689
        parity = 0
 
690
        for change in changes:
 
691
            change.parity = parity
 
692
            parity ^= 1
 
693
 
 
694
        return changes
 
695
 
 
696
    def get_changes_uncached(self, revid_list):
 
697
        # FIXME: deprecated method in getting a null revision
 
698
        revid_list = list(filter(lambda revid: not breezy.revision.is_null(revid),
 
699
                            revid_list))
 
700
        parent_map = self._branch.repository.get_graph().get_parent_map(
 
701
                         revid_list)
 
702
        # We need to return the answer in the same order as the input,
 
703
        # less any ghosts.
 
704
        present_revids = [revid for revid in revid_list
 
705
                          if revid in parent_map]
 
706
        rev_list = self._branch.repository.get_revisions(present_revids)
 
707
 
 
708
        return [self._change_from_revision(rev) for rev in rev_list]
 
709
 
 
710
    def _change_from_revision(self, revision):
 
711
        """
 
712
        Given a breezy Revision, return a processed "change" for use in
 
713
        templates.
 
714
        """
 
715
        message, short_message = clean_message(revision.message)
 
716
 
 
717
        if self._branch_tags is None:
 
718
            self._branch_tags = self._branch.tags.get_reverse_tag_dict()
 
719
 
 
720
        revtags = None
 
721
        if revision.revision_id in self._branch_tags:
 
722
          # tag.sort_* functions expect (tag, data) pairs, so we generate them,
 
723
          # and then strip them
 
724
          tags = [(t, None) for t in self._branch_tags[revision.revision_id]]
 
725
          sort_func = getattr(tag, 'sort_natural', None)
 
726
          if sort_func is None:
 
727
              tags.sort()
 
728
          else:
 
729
              sort_func(self._branch, tags)
 
730
          revtags = u', '.join([t[0] for t in tags])
181
731
 
182
732
        entry = {
183
 
            'revid': revid,
184
 
            'revno': self.get_revno(revid),
185
 
            'date': commit_time,
186
 
            'author': rev.committer,
187
 
            'age': util.timespan(now - commit_time) + ' ago',
188
 
            'short_comment': short_comment,
189
 
            'comment': rev.message,
190
 
            'parents': [util.Container(p) for p in parents],
 
733
            'revid': revision.revision_id,
 
734
            'date': datetime.datetime.fromtimestamp(revision.timestamp),
 
735
            'utc_date': datetime.datetime.utcfromtimestamp(revision.timestamp),
 
736
            'timestamp': revision.timestamp,
 
737
            'committer': revision.committer,
 
738
            'authors': revision.get_apparent_authors(),
 
739
            'branch_nick': revision.properties.get('branch-nick', None),
 
740
            'short_comment': short_message,
 
741
            'comment': revision.message,
 
742
            'comment_clean': [util.html_clean(s) for s in message],
 
743
            'parents': revision.parent_ids,
 
744
            'bugs': [bug.split()[0] for bug in revision.properties.get('bugs', '').splitlines()],
 
745
            'tags': revtags,
191
746
        }
 
747
        if isinstance(revision, breezy.foreign.ForeignRevision):
 
748
            foreign_revid, mapping = (
 
749
                revision.foreign_revid, revision.mapping)
 
750
        elif b":" in revision.revision_id:
 
751
            try:
 
752
                foreign_revid, mapping = \
 
753
                    breezy.foreign.foreign_vcs_registry.parse_revision_id(
 
754
                        revision.revision_id)
 
755
            except breezy.errors.InvalidRevisionId:
 
756
                foreign_revid = None
 
757
                mapping = None
 
758
        else:
 
759
            foreign_revid = None
 
760
        if foreign_revid is not None:
 
761
            entry["foreign_vcs"] = mapping.vcs.abbreviation
 
762
            entry["foreign_revid"] = mapping.vcs.show_foreign_revid(foreign_revid)
192
763
        return util.Container(entry)
193
 
    
194
 
    def scan_range(self, revid, pagesize=20):
195
 
        """
196
 
        yield a list of (label, revid) for a scan range through the full
197
 
        branch history, centered around the given revid.
198
 
        
199
 
        example: [ ('(1)', 'first-revid'), ('-300', '...'), ... ]
200
 
        """
201
 
        count = self._count
202
 
        pos = self.get_sequence(revid)
203
 
        if pos < count - 1:
204
 
            yield ('<', self._full_history[min(count - 1, pos + pagesize)])
205
 
        else:
206
 
            yield ('<', None)
207
 
        yield ('(1)', self._full_history[-1])
208
 
        for offset in reversed([-x for x in util.scan_range(pos, count)]):
209
 
            yield ('%+d' % (offset,), self._full_history[pos - offset])
210
 
        if pos > 0:
211
 
            yield ('>', self._full_history[max(0, pos - pagesize)])
212
 
        else:
213
 
            yield ('>', None)
214
 
                
 
764
 
 
765
    def get_file_changes(self, entry):
 
766
        if entry.parents:
 
767
            old_revid = entry.parents[0].revid
 
768
        else:
 
769
            old_revid = breezy.revision.NULL_REVISION
 
770
        return self.file_changes_for_revision_ids(old_revid, entry.revid)
 
771
 
 
772
    def add_changes(self, entry):
 
773
        changes = self.get_file_changes(entry)
 
774
        entry.changes = changes
 
775
 
 
776
    def get_file(self, path, revid):
 
777
        """Returns (path, filename, file contents)"""
 
778
        if not isinstance(path, str):
 
779
            raise TypeError(path)
 
780
        if not isinstance(revid, bytes):
 
781
            raise TypeError(revid)
 
782
        rev_tree = self._branch.repository.revision_tree(revid)
 
783
        display_path = path
 
784
        if not display_path.startswith('/'):
 
785
            path = '/' + path
 
786
        return (display_path, breezy.osutils.basename(path),
 
787
                rev_tree.get_file_text(path))
 
788
 
 
789
    def file_changes_for_revision_ids(self, old_revid, new_revid):
 
790
        """
 
791
        Return a nested data structure containing the changes in a delta::
 
792
 
 
793
            added: list((filename)),
 
794
            renamed: list((old_filename, new_filename)),
 
795
            deleted: list((filename)),
 
796
            modified: list((filename)),
 
797
            text_changes: list((filename)),
 
798
        """
 
799
        repo = self._branch.repository
 
800
        if (breezy.revision.is_null(old_revid) or
 
801
                breezy.revision.is_null(new_revid) or
 
802
                old_revid == new_revid):
 
803
            old_tree, new_tree = map(
 
804
                repo.revision_tree, [old_revid, new_revid])
 
805
        else:
 
806
            old_tree, new_tree = repo.revision_trees([old_revid, new_revid])
 
807
 
 
808
        reporter = FileChangeReporter(old_tree, new_tree)
 
809
 
 
810
        breezy.delta.report_changes(new_tree.iter_changes(old_tree), reporter)
 
811
 
 
812
        return util.Container(
 
813
            added=sorted(reporter.added, key=lambda x: x.filename),
 
814
            renamed=sorted(reporter.renamed, key=lambda x: x.new_filename),
 
815
            removed=sorted(reporter.removed, key=lambda x: x.filename),
 
816
            modified=sorted(reporter.modified, key=lambda x: x.filename),
 
817
            text_changes=sorted(reporter.text_changes,
 
818
                                key=lambda x: x.filename))
 
819