~loggerhead-team/loggerhead/trunk-rich

« back to all changes in this revision

Viewing changes to loggerhead/history.py

  • Committer: Michael Hudson
  • Date: 2007-05-29 12:51:34 UTC
  • mfrom: (128.1.11 testing)
  • Revision ID: michael.hudson@canonical.com-20070529125134-fffimo5o8ynk64uu
merge from my testing branch:
 includes unit tests, but more interestingly the fix for #116869

Show diffs side-by-side

added added

removed removed

Lines of Context:
1
1
#
2
 
# Copyright (C) 2008  Canonical Ltd. 
3
 
#                     (Authored by Martin Albisetti <argentina@gmail.com>)
4
2
# Copyright (C) 2006  Robey Pointer <robey@lag.net>
5
3
# Copyright (C) 2006  Goffredo Baroncelli <kreijack@inwind.it>
6
4
# Copyright (C) 2005  Jake Edge <jake@edge2.net>
29
27
 
30
28
 
31
29
import bisect
 
30
import cgi
32
31
import datetime
33
32
import logging
 
33
import os
 
34
import posixpath
34
35
import re
 
36
import shelve
 
37
import sys
35
38
import textwrap
36
39
import threading
37
40
import time
38
41
from StringIO import StringIO
39
42
 
40
 
from loggerhead import search
41
43
from loggerhead import util
42
 
from loggerhead.wholehistory import compute_whole_history_data
 
44
from loggerhead.util import decorator
43
45
 
44
46
import bzrlib
 
47
import bzrlib.annotate
45
48
import bzrlib.branch
 
49
import bzrlib.bundle.serializer
 
50
import bzrlib.decorators
46
51
import bzrlib.diff
47
52
import bzrlib.errors
48
53
import bzrlib.progress
49
 
import bzrlib.revision
 
54
import bzrlib.textfile
50
55
import bzrlib.tsort
51
56
import bzrlib.ui
52
57
 
 
58
 
 
59
with_branch_lock = util.with_lock('_lock', 'branch')
 
60
 
 
61
@decorator
 
62
def with_bzrlib_read_lock(unbound):
 
63
    def bzrlib_read_locked(self, *args, **kw):
 
64
        #self.log.debug('-> %r bzr lock', id(threading.currentThread()))
 
65
        self._branch.repository.lock_read()
 
66
        try:
 
67
            return unbound(self, *args, **kw)
 
68
        finally:
 
69
            self._branch.repository.unlock()
 
70
            #self.log.debug('<- %r bzr lock', id(threading.currentThread()))
 
71
    return bzrlib_read_locked
 
72
 
 
73
 
53
74
# bzrlib's UIFactory is not thread-safe
54
75
uihack = threading.local()
55
76
 
79
100
    """
80
101
    turn a normal unified-style diff (post-processed by parse_delta) into a
81
102
    side-by-side diff structure.  the new structure is::
82
 
 
 
103
    
83
104
        chunks: list(
84
105
            diff: list(
85
106
                old_lineno: int,
93
114
    out_chunk_list = []
94
115
    for chunk in chunk_list:
95
116
        line_list = []
96
 
        wrap_char = '<wbr/>'
97
117
        delete_list, insert_list = [], []
98
118
        for line in chunk.diff:
99
 
            # Add <wbr/> every X characters so we can wrap properly
100
 
            wrap_line = re.findall(r'.{%d}|.+$' % 78, line.line)
101
 
            wrap_lines = [util.html_clean(_line) for _line in wrap_line]
102
 
            wrapped_line = wrap_char.join(wrap_lines)
103
 
 
104
119
            if line.type == 'context':
105
120
                if len(delete_list) or len(insert_list):
106
 
                    _process_side_by_side_buffers(line_list, delete_list, 
107
 
                                                  insert_list)
 
121
                    _process_side_by_side_buffers(line_list, delete_list, insert_list)
108
122
                    delete_list, insert_list = [], []
109
 
                line_list.append(util.Container(old_lineno=line.old_lineno, 
110
 
                                                new_lineno=line.new_lineno,
111
 
                                                old_line=wrapped_line, 
112
 
                                                new_line=wrapped_line,
113
 
                                                old_type=line.type, 
114
 
                                                new_type=line.type))
 
123
                line_list.append(util.Container(old_lineno=line.old_lineno, new_lineno=line.new_lineno,
 
124
                                                old_line=line.line, new_line=line.line,
 
125
                                                old_type=line.type, new_type=line.type))
115
126
            elif line.type == 'delete':
116
 
                delete_list.append((line.old_lineno, wrapped_line, line.type))
 
127
                delete_list.append((line.old_lineno, line.line, line.type))
117
128
            elif line.type == 'insert':
118
 
                insert_list.append((line.new_lineno, wrapped_line, line.type))
 
129
                insert_list.append((line.new_lineno, line.line, line.type))
119
130
        if len(delete_list) or len(insert_list):
120
131
            _process_side_by_side_buffers(line_list, delete_list, insert_list)
121
132
        out_chunk_list.append(util.Container(diff=line_list))
131
142
 
132
143
 
133
144
def clean_message(message):
134
 
    """Clean up a commit message and return it and a short (1-line) version.
135
 
 
136
 
    Commit messages that are long single lines are reflowed using the textwrap
137
 
    module (Robey, the original author of this code, apparently favored this
138
 
    style of message).
139
 
    """
 
145
    # clean up a commit message and return it and a short (1-line) version
140
146
    message = message.splitlines()
141
 
 
142
147
    if len(message) == 1:
 
148
        # robey-style 1-line long message
143
149
        message = textwrap.wrap(message[0])
144
 
 
145
 
    if len(message) == 0:
146
 
        # We can end up where when (a) the commit message was empty or (b)
147
 
        # when the message consisted entirely of whitespace, in which case
148
 
        # textwrap.wrap() returns an empty list.
149
 
        return [''], ''
150
 
 
151
 
    # Make short form of commit message.
 
150
    elif len(message) == 0:
 
151
        # sometimes a commit may have NO message!
 
152
        message = ['']
 
153
        
 
154
    # make short form of commit message
152
155
    short_message = message[0]
153
156
    if len(short_message) > 60:
154
157
        short_message = short_message[:60] + '...'
155
 
 
 
158
    
156
159
    return message, short_message
157
160
 
158
161
 
159
 
def rich_filename(path, kind):
160
 
    if kind == 'directory':
161
 
        path += '/'
162
 
    if kind == 'symlink':
163
 
        path += '@'
164
 
    return path
165
 
 
166
 
 
167
 
 
168
162
# from bzrlib
169
163
class _RevListToTimestamps(object):
170
164
    """This takes a list of revisions, and allows you to bisect by date"""
184
178
 
185
179
 
186
180
class History (object):
187
 
    """Decorate a branch to provide information for rendering.
188
 
 
189
 
    History objects are expected to be short lived -- when serving a request
190
 
    for a particular branch, open it, read-lock it, wrap a History object
191
 
    around it, serve the request, throw the History object away, unlock the
192
 
    branch and throw it away.
193
 
 
194
 
    :ivar _file_change_cache: xx
195
 
    """
196
 
 
197
 
    def __init__(self, branch, whole_history_data_cache):
198
 
        assert branch.is_locked(), (
199
 
            "Can only construct a History object with a read-locked branch.")
200
 
        self._file_change_cache = None
 
181
    
 
182
    def __init__(self):
 
183
        self._change_cache = None
 
184
        self._index = None
 
185
        self._lock = threading.RLock()
 
186
    
 
187
    @classmethod
 
188
    def from_branch(cls, branch, name=None):
 
189
        z = time.time()
 
190
        self = cls()
201
191
        self._branch = branch
202
 
        self._inventory_cache = {}
203
 
        self.log = logging.getLogger('loggerhead.%s' % (branch.nick,))
204
 
 
205
 
        self.last_revid = branch.last_revision()
206
 
 
207
 
        whole_history_data = whole_history_data_cache.get(self.last_revid)
208
 
        if whole_history_data is None:
209
 
            whole_history_data = compute_whole_history_data(branch)
210
 
            whole_history_data_cache[self.last_revid] = whole_history_data
211
 
 
212
 
        (self._revision_graph, self._full_history, self._revision_info,
213
 
         self._revno_revid, self._merge_sort, self._where_merged
214
 
         ) = whole_history_data
215
 
 
216
 
 
217
 
    def use_file_cache(self, cache):
218
 
        self._file_change_cache = cache
219
 
 
220
 
    @property
221
 
    def has_revisions(self):
222
 
        return not bzrlib.revision.is_null(self.last_revid)
223
 
 
 
192
        self._history = branch.revision_history()
 
193
        self._last_revid = self._history[-1]
 
194
        self._revision_graph = branch.repository.get_revision_graph(self._last_revid)
 
195
        
 
196
        if name is None:
 
197
            name = self._branch.nick
 
198
        self._name = name
 
199
        self.log = logging.getLogger('loggerhead.%s' % (name,))
 
200
        
 
201
        self._full_history = []
 
202
        self._revision_info = {}
 
203
        self._revno_revid = {}
 
204
        self._merge_sort = bzrlib.tsort.merge_sort(self._revision_graph, self._last_revid, generate_revno=True)
 
205
        count = 0
 
206
        for (seq, revid, merge_depth, revno, end_of_merge) in self._merge_sort:
 
207
            self._full_history.append(revid)
 
208
            revno_str = '.'.join(str(n) for n in revno)
 
209
            self._revno_revid[revno_str] = revid
 
210
            self._revision_info[revid] = (seq, revid, merge_depth, revno_str, end_of_merge)
 
211
            count += 1
 
212
        self._count = count
 
213
 
 
214
        # cache merge info
 
215
        self._where_merged = {}
 
216
        for revid in self._revision_graph.keys():
 
217
            if not revid in self._full_history: 
 
218
                continue
 
219
            for parent in self._revision_graph[revid]:
 
220
                self._where_merged.setdefault(parent, set()).add(revid)
 
221
 
 
222
        self.log.info('built revision graph cache: %r secs' % (time.time() - z,))
 
223
        return self
 
224
    
 
225
    @classmethod
 
226
    def from_folder(cls, path, name=None):
 
227
        b = bzrlib.branch.Branch.open(path)
 
228
        return cls.from_branch(b, name)
 
229
 
 
230
    @with_branch_lock
 
231
    def out_of_date(self):
 
232
        if self._branch.revision_history()[-1] != self._last_revid:
 
233
            return True
 
234
        return False
 
235
 
 
236
    def use_cache(self, cache):
 
237
        self._change_cache = cache
 
238
    
 
239
    def use_search_index(self, index):
 
240
        self._index = index
 
241
 
 
242
    @with_branch_lock
 
243
    def detach(self):
 
244
        # called when a new history object needs to be created, because the
 
245
        # branch history has changed.  we need to immediately close and stop
 
246
        # using our caches, because a new history object will be created to
 
247
        # replace us, using the same cache files.
 
248
        # (may also be called during server shutdown.)
 
249
        if self._change_cache is not None:
 
250
            self._change_cache.close()
 
251
            self._change_cache = None
 
252
        if self._index is not None:
 
253
            self._index.close()
 
254
            self._index = None
 
255
 
 
256
    def flush_cache(self):
 
257
        if self._change_cache is None:
 
258
            return
 
259
        self._change_cache.flush()
 
260
    
 
261
    def check_rebuild(self):
 
262
        if self._change_cache is not None:
 
263
            self._change_cache.check_rebuild()
 
264
        if self._index is not None:
 
265
            self._index.check_rebuild()
 
266
    
 
267
    last_revid = property(lambda self: self._last_revid, None, None)
 
268
    
 
269
    count = property(lambda self: self._count, None, None)
 
270
 
 
271
    @with_branch_lock
224
272
    def get_config(self):
225
273
        return self._branch.get_config()
226
 
 
 
274
    
 
275
    @with_branch_lock
 
276
    def get_revision(self, revid):
 
277
        return self._branch.repository.get_revision(revid)
 
278
    
227
279
    def get_revno(self, revid):
228
280
        if revid not in self._revision_info:
229
281
            # ghost parent?
231
283
        seq, revid, merge_depth, revno_str, end_of_merge = self._revision_info[revid]
232
284
        return revno_str
233
285
 
234
 
    def get_revids_from(self, revid_list, start_revid):
235
 
        """
236
 
        Yield the mainline (wrt start_revid) revisions that merged each
237
 
        revid in revid_list.
238
 
        """
239
 
        if revid_list is None:
240
 
            revid_list = self._full_history
241
 
        revid_set = set(revid_list)
242
 
        revid = start_revid
243
 
        def introduced_revisions(revid):
244
 
            r = set([revid])
245
 
            seq, revid, md, revno, end_of_merge = self._revision_info[revid]
246
 
            i = seq + 1
247
 
            while i < len(self._merge_sort) and self._merge_sort[i][2] > md:
248
 
                r.add(self._merge_sort[i][1])
249
 
                i += 1
250
 
            return r
251
 
        while 1:
252
 
            if bzrlib.revision.is_null(revid):
253
 
                return
254
 
            if introduced_revisions(revid) & revid_set:
 
286
    def get_sequence(self, revid):
 
287
        seq, revid, merge_depth, revno_str, end_of_merge = self._revision_info[revid]
 
288
        return seq
 
289
    
 
290
    def get_revision_history(self):
 
291
        return self._full_history
 
292
    
 
293
    def get_revid_sequence(self, revid_list, revid):
 
294
        """
 
295
        given a list of revision ids, return the sequence # of this revid in
 
296
        the list.
 
297
        """
 
298
        seq = 0
 
299
        for r in revid_list:
 
300
            if revid == r:
 
301
                return seq
 
302
            seq += 1
 
303
    
 
304
    def get_revids_from(self, revid_list, revid):
 
305
        """
 
306
        given a list of revision ids, yield revisions in graph order,
 
307
        starting from revid.  the list can be None if you just want to travel
 
308
        across all revisions.
 
309
        """
 
310
        while True:
 
311
            if (revid_list is None) or (revid in revid_list):
255
312
                yield revid
 
313
            if not self._revision_graph.has_key(revid):
 
314
                return
256
315
            parents = self._revision_graph[revid]
257
316
            if len(parents) == 0:
258
317
                return
259
318
            revid = parents[0]
260
 
 
 
319
    
 
320
    @with_branch_lock
261
321
    def get_short_revision_history_by_fileid(self, file_id):
 
322
        # wow.  is this really the only way we can get this list?  by
 
323
        # man-handling the weave store directly? :-0
262
324
        # FIXME: would be awesome if we could get, for a folder, the list of
263
 
        # revisions where items within that folder changed.i
264
 
        try:
265
 
            # FIXME: Workaround for bzr versions prior to 1.6b3. 
266
 
            # Remove me eventually pretty please  :)
267
 
            w = self._branch.repository.weave_store.get_weave(file_id, self._branch.repository.get_transaction())
268
 
            w_revids = w.versions() 
269
 
            revids = [r for r in self._full_history if r in w_revids] 
270
 
        except AttributeError:
271
 
            possible_keys = [(file_id, revid) for revid in self._full_history]
272
 
            existing_keys = self._branch.repository.texts.get_parent_map(possible_keys)
273
 
            revids = [revid for _, revid in existing_keys.iterkeys()]
 
325
        # revisions where items within that folder changed.
 
326
        w = self._branch.repository.weave_store.get_weave(file_id, self._branch.repository.get_transaction())
 
327
        w_revids = w.versions()
 
328
        revids = [r for r in self._full_history if r in w_revids]
274
329
        return revids
275
330
 
 
331
    @with_branch_lock
276
332
    def get_revision_history_since(self, revid_list, date):
277
333
        # if a user asks for revisions starting at 01-sep, they mean inclusive,
278
334
        # so start at midnight on 02-sep.
285
341
        revid_list.reverse()
286
342
        index = -index
287
343
        return revid_list[index:]
 
344
    
 
345
    @with_branch_lock
 
346
    def get_revision_history_matching(self, revid_list, text):
 
347
        self.log.debug('searching %d revisions for %r', len(revid_list), text)
 
348
        z = time.time()
 
349
        # this is going to be painfully slow. :(
 
350
        out = []
 
351
        text = text.lower()
 
352
        for revid in revid_list:
 
353
            change = self.get_changes([ revid ])[0]
 
354
            if text in change.comment.lower():
 
355
                out.append(revid)
 
356
        self.log.debug('searched %d revisions for %r in %r secs', len(revid_list), text, time.time() - z)
 
357
        return out
288
358
 
 
359
    def get_revision_history_matching_indexed(self, revid_list, text):
 
360
        self.log.debug('searching %d revisions for %r', len(revid_list), text)
 
361
        z = time.time()
 
362
        if self._index is None:
 
363
            return self.get_revision_history_matching(revid_list, text)
 
364
        out = self._index.find(text, revid_list)
 
365
        self.log.debug('searched %d revisions for %r in %r secs: %d results', len(revid_list), text, time.time() - z, len(out))
 
366
        # put them in some coherent order :)
 
367
        out = [r for r in self._full_history if r in out]
 
368
        return out
 
369
    
 
370
    @with_branch_lock
289
371
    def get_search_revid_list(self, query, revid_list):
290
372
        """
291
373
        given a "quick-search" query, try a few obvious possible meanings:
292
 
 
 
374
        
293
375
            - revision id or # ("128.1.3")
294
376
            - date (US style "mm/dd/yy", earth style "dd-mm-yy", or iso style "yyyy-mm-dd")
295
377
            - comment text as a fallback
299
381
        # FIXME: there is some silliness in this action.  we have to look up
300
382
        # all the relevant changes (time-consuming) only to return a list of
301
383
        # revids which will be used to fetch a set of changes again.
302
 
 
 
384
        
303
385
        # if they entered a revid, just jump straight there; ignore the passed-in revid_list
304
386
        revid = self.fix_revid(query)
305
387
        if revid is not None:
306
 
            if isinstance(revid, unicode):
307
 
                revid = revid.encode('utf-8')
308
388
            changes = self.get_changes([ revid ])
309
389
            if (changes is not None) and (len(changes) > 0):
310
390
                return [ revid ]
311
 
 
 
391
        
312
392
        date = None
313
393
        m = self.us_date_re.match(query)
314
394
        if m is not None:
324
404
        if date is not None:
325
405
            if revid_list is None:
326
406
                # if no limit to the query was given, search only the direct-parent path.
327
 
                revid_list = list(self.get_revids_from(None, self.last_revid))
 
407
                revid_list = list(self.get_revids_from(None, self._last_revid))
328
408
            return self.get_revision_history_since(revid_list, date)
329
 
 
 
409
        
 
410
        # check comment fields.
 
411
        if revid_list is None:
 
412
            revid_list = self._full_history
 
413
        return self.get_revision_history_matching_indexed(revid_list, query)
 
414
    
330
415
    revno_re = re.compile(r'^[\d\.]+$')
331
416
    # the date regex are without a final '$' so that queries like
332
417
    # "2006-11-30 12:15" still mostly work.  (i think it's better to give
340
425
        if revid is None:
341
426
            return revid
342
427
        if revid == 'head:':
343
 
            return self.last_revid
344
 
        try:
345
 
            if self.revno_re.match(revid):
346
 
                revid = self._revno_revid[revid]
347
 
        except KeyError:
348
 
            raise bzrlib.errors.NoSuchRevision(self._branch.nick, revid)
 
428
            return self._last_revid
 
429
        if self.revno_re.match(revid):
 
430
            revid = self._revno_revid[revid]
349
431
        return revid
350
 
 
 
432
    
 
433
    @with_branch_lock
351
434
    def get_file_view(self, revid, file_id):
352
435
        """
353
 
        Given a revid and optional path, return a (revlist, revid) for
354
 
        navigation through the current scope: from the revid (or the latest
355
 
        revision) back to the original revision.
356
 
 
 
436
        Given an optional revid and optional path, return a (revlist, revid)
 
437
        for navigation through the current scope: from the revid (or the
 
438
        latest revision) back to the original revision.
 
439
        
357
440
        If file_id is None, the entire revision history is the list scope.
 
441
        If revid is None, the latest revision is used.
358
442
        """
359
443
        if revid is None:
360
 
            revid = self.last_revid
 
444
            revid = self._last_revid
361
445
        if file_id is not None:
362
 
            # since revid is 'start_revid', possibly should start the path
363
 
            # tracing from revid... FIXME
 
446
            # since revid is 'start_revid', possibly should start the path tracing from revid... FIXME
 
447
            inv = self._branch.repository.get_revision_inventory(revid)
364
448
            revlist = list(self.get_short_revision_history_by_fileid(file_id))
365
449
            revlist = list(self.get_revids_from(revlist, revid))
366
450
        else:
367
451
            revlist = list(self.get_revids_from(None, revid))
368
 
        return revlist
369
 
 
 
452
        if revid is None:
 
453
            revid = revlist[0]
 
454
        return revlist, revid
 
455
    
 
456
    @with_branch_lock
370
457
    def get_view(self, revid, start_revid, file_id, query=None):
371
458
        """
372
459
        use the URL parameters (revid, start_revid, file_id, and query) to
373
460
        determine the revision list we're viewing (start_revid, file_id, query)
374
461
        and where we are in it (revid).
375
 
 
376
 
            - if a query is given, we're viewing query results.
377
 
            - if a file_id is given, we're viewing revisions for a specific
378
 
              file.
379
 
            - if a start_revid is given, we're viewing the branch from a
380
 
              specific revision up the tree.
381
 
 
382
 
        these may be combined to view revisions for a specific file, from
383
 
        a specific revision, with a specific search query.
384
 
 
385
 
        returns a new (revid, start_revid, revid_list) where:
386
 
 
 
462
        
 
463
        if a query is given, we're viewing query results.
 
464
        if a file_id is given, we're viewing revisions for a specific file.
 
465
        if a start_revid is given, we're viewing the branch from a
 
466
            specific revision up the tree.
 
467
        (these may be combined to view revisions for a specific file, from
 
468
            a specific revision, with a specific search query.)
 
469
            
 
470
        returns a new (revid, start_revid, revid_list, scan_list) where:
 
471
        
387
472
            - revid: current position within the view
388
473
            - start_revid: starting revision of this view
389
474
            - revid_list: list of revision ids for this view
390
 
 
 
475
        
391
476
        file_id and query are never changed so aren't returned, but they may
392
477
        contain vital context for future url navigation.
393
478
        """
394
 
        if start_revid is None:
395
 
            start_revid = self.last_revid
396
 
 
397
479
        if query is None:
398
 
            revid_list = self.get_file_view(start_revid, file_id)
 
480
            revid_list, start_revid = self.get_file_view(start_revid, file_id)
399
481
            if revid is None:
400
482
                revid = start_revid
401
483
            if revid not in revid_list:
402
484
                # if the given revid is not in the revlist, use a revlist that
403
485
                # starts at the given revid.
404
 
                revid_list = self.get_file_view(revid, file_id)
405
 
                start_revid = revid
 
486
                revid_list, start_revid = self.get_file_view(revid, file_id)
406
487
            return revid, start_revid, revid_list
407
 
 
 
488
        
408
489
        # potentially limit the search
409
 
        if file_id is not None:
410
 
            revid_list = self.get_file_view(start_revid, file_id)
 
490
        if (start_revid is not None) or (file_id is not None):
 
491
            revid_list, start_revid = self.get_file_view(start_revid, file_id)
411
492
        else:
412
493
            revid_list = None
413
 
        revid_list = search.search_revisions(self._branch, query)
414
 
        if revid_list and len(revid_list) > 0:
 
494
 
 
495
        revid_list = self.get_search_revid_list(query, revid_list)
 
496
        if len(revid_list) > 0:
415
497
            if revid not in revid_list:
416
498
                revid = revid_list[0]
417
499
            return revid, start_revid, revid_list
418
500
        else:
419
 
            # XXX: This should return a message saying that the search could
420
 
            # not be completed due to either missing the plugin or missing a
421
 
            # search index.
 
501
            # no results
422
502
            return None, None, []
423
503
 
 
504
    @with_branch_lock
424
505
    def get_inventory(self, revid):
425
 
        if revid not in self._inventory_cache:
426
 
            self._inventory_cache[revid] = (
427
 
                self._branch.repository.get_revision_inventory(revid))
428
 
        return self._inventory_cache[revid]
 
506
        return self._branch.repository.get_revision_inventory(revid)
429
507
 
 
508
    @with_branch_lock
430
509
    def get_path(self, revid, file_id):
431
510
        if (file_id is None) or (file_id == ''):
432
511
            return ''
433
 
        path = self.get_inventory(revid).id2path(file_id)
 
512
        path = self._branch.repository.get_revision_inventory(revid).id2path(file_id)
434
513
        if (len(path) > 0) and not path.startswith('/'):
435
514
            path = '/' + path
436
515
        return path
437
 
 
 
516
    
 
517
    @with_branch_lock
438
518
    def get_file_id(self, revid, path):
439
519
        if (len(path) > 0) and not path.startswith('/'):
440
520
            path = '/' + path
441
 
        return self.get_inventory(revid).path2id(path)
442
 
 
 
521
        return self._branch.repository.get_revision_inventory(revid).path2id(path)
 
522
    
 
523
    def get_where_merged(self, revid):
 
524
        try:
 
525
            return self._where_merged[revid]
 
526
        except:
 
527
            return []
 
528
    
443
529
    def get_merge_point_list(self, revid):
444
530
        """
445
531
        Return the list of revids that have merged this node.
446
532
        """
447
 
        if '.' not in self.get_revno(revid):
 
533
        if revid in self._history:
448
534
            return []
449
 
 
 
535
        
450
536
        merge_point = []
451
537
        while True:
452
 
            children = self._where_merged.get(revid, [])
 
538
            children = self.get_where_merged(revid)
453
539
            nexts = []
454
540
            for child in children:
455
541
                child_parents = self._revision_graph[child]
469
555
                merge_point.extend(merge_point_next)
470
556
 
471
557
            revid = nexts[0]
472
 
 
 
558
            
473
559
    def simplify_merge_point_list(self, revids):
474
560
        """if a revision is already merged, don't show further merge points"""
475
561
        d = {}
512
598
                    p.branch_nick = p_change_dict[p.revid].branch_nick
513
599
                else:
514
600
                    p.branch_nick = '(missing)'
515
 
 
516
 
    def get_changes(self, revid_list):
517
 
        """Return a list of changes objects for the given revids.
518
 
 
519
 
        Revisions not present and NULL_REVISION will be ignored.
520
 
        """
521
 
        changes = self.get_changes_uncached(revid_list)
522
 
        if len(changes) == 0:
 
601
    
 
602
    @with_branch_lock
 
603
    def get_changes(self, revid_list, get_diffs=False):
 
604
        if self._change_cache is None:
 
605
            changes = self.get_changes_uncached(revid_list, get_diffs)
 
606
        else:
 
607
            changes = self._change_cache.get_changes(revid_list, get_diffs)
 
608
        if changes is None:
523
609
            return changes
524
 
 
 
610
        
525
611
        # some data needs to be recalculated each time, because it may
526
612
        # change as new revisions are added.
527
 
        for change in changes:
528
 
            merge_revids = self.simplify_merge_point_list(self.get_merge_point_list(change.revid))
 
613
        for i in xrange(len(revid_list)):
 
614
            revid = revid_list[i]
 
615
            change = changes[i]
 
616
            merge_revids = self.simplify_merge_point_list(self.get_merge_point_list(revid))
529
617
            change.merge_points = [util.Container(revid=r, revno=self.get_revno(r)) for r in merge_revids]
530
 
            if len(change.parents) > 0:
531
 
                change.parents = [util.Container(revid=r, 
532
 
                    revno=self.get_revno(r)) for r in change.parents]
533
 
            change.revno = self.get_revno(change.revid)
534
 
 
535
 
        parity = 0
536
 
        for change in changes:
537
 
            change.parity = parity
538
 
            parity ^= 1
539
 
 
 
618
        
540
619
        return changes
541
620
 
542
 
    def get_changes_uncached(self, revid_list):
543
 
        # FIXME: deprecated method in getting a null revision
544
 
        revid_list = filter(lambda revid: not bzrlib.revision.is_null(revid),
545
 
                            revid_list)
546
 
        parent_map = self._branch.repository.get_graph().get_parent_map(revid_list)
547
 
        # We need to return the answer in the same order as the input,
548
 
        # less any ghosts.
549
 
        present_revids = [revid for revid in revid_list
550
 
                          if revid in parent_map]
551
 
        rev_list = self._branch.repository.get_revisions(present_revids)
552
 
 
553
 
        return [self._change_from_revision(rev) for rev in rev_list]
 
621
    # alright, let's profile this sucka.
 
622
    def _get_changes_profiled(self, revid_list, get_diffs=False):
 
623
        from loggerhead.lsprof import profile
 
624
        import cPickle
 
625
        ret, stats = profile(self.get_changes_uncached, revid_list, get_diffs)
 
626
        stats.sort()
 
627
        stats.freeze()
 
628
        cPickle.dump(stats, open('lsprof.stats', 'w'), 2)
 
629
        self.log.info('lsprof complete!')
 
630
        return ret
554
631
 
555
632
    def _get_deltas_for_revisions_with_trees(self, revisions):
556
 
        """Produce a list of revision deltas.
557
 
 
 
633
        """Produce a generator of revision deltas.
 
634
        
558
635
        Note that the input is a sequence of REVISIONS, not revision_ids.
559
636
        Trees will be held in memory until the generator exits.
560
637
        Each delta is relative to the revision's lefthand predecessor.
561
 
        (This is copied from bzrlib.)
562
638
        """
563
639
        required_trees = set()
564
640
        for revision in revisions:
565
 
            required_trees.add(revision.revid)
566
 
            required_trees.update([p.revid for p in revision.parents[:1]])
567
 
        trees = dict((t.get_revision_id(), t) for
 
641
            required_trees.add(revision.revision_id)
 
642
            required_trees.update(revision.parent_ids[:1])
 
643
        trees = dict((t.get_revision_id(), t) for 
568
644
                     t in self._branch.repository.revision_trees(required_trees))
569
645
        ret = []
570
 
        for revision in revisions:
571
 
            if not revision.parents:
572
 
                old_tree = self._branch.repository.revision_tree(
573
 
                    bzrlib.revision.NULL_REVISION)
574
 
            else:
575
 
                old_tree = trees[revision.parents[0].revid]
576
 
            tree = trees[revision.revid]
577
 
            ret.append(tree.changes_from(old_tree))
578
 
        return ret
579
 
 
580
 
    def _change_from_revision(self, revision):
581
 
        """
582
 
        Given a bzrlib Revision, return a processed "change" for use in
583
 
        templates.
584
 
        """
 
646
        self._branch.repository.lock_read()
 
647
        try:
 
648
            for revision in revisions:
 
649
                if not revision.parent_ids:
 
650
                    old_tree = self._branch.repository.revision_tree(None)
 
651
                else:
 
652
                    old_tree = trees[revision.parent_ids[0]]
 
653
                tree = trees[revision.revision_id]
 
654
                ret.append((tree, old_tree, tree.changes_from(old_tree)))
 
655
            return ret
 
656
        finally:
 
657
            self._branch.repository.unlock()
 
658
    
 
659
    def entry_from_revision(self, revision):
585
660
        commit_time = datetime.datetime.fromtimestamp(revision.timestamp)
586
 
 
 
661
        
587
662
        parents = [util.Container(revid=r, revno=self.get_revno(r)) for r in revision.parent_ids]
588
663
 
 
664
        if len(parents) == 0:
 
665
            left_parent = None
 
666
        else:
 
667
            left_parent = revision.parent_ids[0]
 
668
        
589
669
        message, short_message = clean_message(revision.message)
590
670
 
591
671
        entry = {
592
672
            'revid': revision.revision_id,
 
673
            'revno': self.get_revno(revision.revision_id),
593
674
            'date': commit_time,
594
 
            'author': revision.get_apparent_author(),
 
675
            'author': revision.committer,
595
676
            'branch_nick': revision.properties.get('branch-nick', None),
596
677
            'short_comment': short_message,
597
678
            'comment': revision.message,
598
679
            'comment_clean': [util.html_clean(s) for s in message],
599
 
            'parents': revision.parent_ids,
 
680
            'parents': parents,
600
681
        }
601
682
        return util.Container(entry)
602
683
 
603
 
    def get_file_changes_uncached(self, entries):
604
 
        delta_list = self._get_deltas_for_revisions_with_trees(entries)
605
 
 
606
 
        return [self.parse_delta(delta) for delta in delta_list]
607
 
 
608
 
    def get_file_changes(self, entries):
609
 
        if self._file_change_cache is None:
610
 
            return self.get_file_changes_uncached(entries)
611
 
        else:
612
 
            return self._file_change_cache.get_file_changes(entries)
613
 
 
614
 
    def add_changes(self, entries):
615
 
        changes_list = self.get_file_changes(entries)
616
 
 
617
 
        for entry, changes in zip(entries, changes_list):
618
 
            entry.changes = changes
619
 
 
620
 
    def get_change_with_diff(self, revid, compare_revid=None):
621
 
        change = self.get_changes([revid])[0]
622
 
 
623
 
        if compare_revid is None:
624
 
            if change.parents:
625
 
                compare_revid = change.parents[0].revid
626
 
            else:
627
 
                compare_revid = 'null:'
628
 
 
629
 
        rev_tree1 = self._branch.repository.revision_tree(compare_revid)
630
 
        rev_tree2 = self._branch.repository.revision_tree(revid)
 
684
    @with_branch_lock
 
685
    @with_bzrlib_read_lock
 
686
    def get_changes_uncached(self, revid_list, get_diffs=False):
 
687
        done = False
 
688
        while not done:
 
689
            try:
 
690
                rev_list = self._branch.repository.get_revisions(revid_list)
 
691
                done = True
 
692
            except (KeyError, bzrlib.errors.NoSuchRevision), e:
 
693
                # this sometimes happens with arch-converted branches.
 
694
                # i don't know why. :(
 
695
                self.log.debug('No such revision (skipping): %s', e)
 
696
                revid_list.remove(e.revision)
 
697
        
 
698
        delta_list = self._get_deltas_for_revisions_with_trees(rev_list)
 
699
        combined_list = zip(rev_list, delta_list)
 
700
        
 
701
        entries = []
 
702
        for rev, (new_tree, old_tree, delta) in combined_list:
 
703
            entry = self.entry_from_revision(rev)
 
704
            entry.changes = self.parse_delta(delta, get_diffs, old_tree, new_tree)
 
705
            entries.append(entry)
 
706
        
 
707
        return entries
 
708
 
 
709
    @with_bzrlib_read_lock
 
710
    def _get_diff(self, revid1, revid2):
 
711
        rev_tree1 = self._branch.repository.revision_tree(revid1)
 
712
        rev_tree2 = self._branch.repository.revision_tree(revid2)
631
713
        delta = rev_tree2.changes_from(rev_tree1)
632
 
 
633
 
        change.changes = self.parse_delta(delta)
634
 
        change.changes.modified = self._parse_diffs(rev_tree1, rev_tree2, delta)
635
 
 
636
 
        return change
637
 
 
 
714
        return rev_tree1, rev_tree2, delta
 
715
    
 
716
    def get_diff(self, revid1, revid2):
 
717
        rev_tree1, rev_tree2, delta = self._get_diff(revid1, revid2)
 
718
        entry = self.get_changes([ revid2 ], False)[0]
 
719
        entry.changes = self.parse_delta(delta, True, rev_tree1, rev_tree2)
 
720
        return entry
 
721
    
 
722
    @with_branch_lock
638
723
    def get_file(self, file_id, revid):
639
724
        "returns (path, filename, data)"
640
725
        inv = self.get_inventory(revid)
644
729
        if not path.startswith('/'):
645
730
            path = '/' + path
646
731
        return path, inv_entry.name, rev_tree.get_file_text(file_id)
647
 
 
648
 
    def _parse_diffs(self, old_tree, new_tree, delta):
 
732
    
 
733
    @with_branch_lock
 
734
    def parse_delta(self, delta, get_diffs=True, old_tree=None, new_tree=None):
649
735
        """
650
 
        Return a list of processed diffs, in the format::
651
 
 
652
 
            list(
 
736
        Return a nested data structure containing the changes in a delta::
 
737
        
 
738
            added: list((filename, file_id)),
 
739
            renamed: list((old_filename, new_filename, file_id)),
 
740
            deleted: list((filename, file_id)),
 
741
            modified: list(
653
742
                filename: str,
654
743
                file_id: str,
655
744
                chunks: list(
661
750
                    ),
662
751
                ),
663
752
            )
 
753
        
 
754
        if C{get_diffs} is false, the C{chunks} will be omitted.
664
755
        """
665
 
        process = []
666
 
        out = []
667
 
 
668
 
        for old_path, new_path, fid, kind, text_modified, meta_modified in delta.renamed:
669
 
            if text_modified:
670
 
                process.append((old_path, new_path, fid, kind))
671
 
        for path, fid, kind, text_modified, meta_modified in delta.modified:
672
 
            process.append((path, path, fid, kind))
673
 
 
674
 
        for old_path, new_path, fid, kind in process:
 
756
        added = []
 
757
        modified = []
 
758
        renamed = []
 
759
        removed = []
 
760
        
 
761
        def rich_filename(path, kind):
 
762
            if kind == 'directory':
 
763
                path += '/'
 
764
            if kind == 'symlink':
 
765
                path += '@'
 
766
            return path
 
767
        
 
768
        def process_diff(diff):
 
769
            chunks = []
 
770
            chunk = None
 
771
            for line in diff.splitlines():
 
772
                if len(line) == 0:
 
773
                    continue
 
774
                if line.startswith('+++ ') or line.startswith('--- '):
 
775
                    continue
 
776
                if line.startswith('@@ '):
 
777
                    # new chunk
 
778
                    if chunk is not None:
 
779
                        chunks.append(chunk)
 
780
                    chunk = util.Container()
 
781
                    chunk.diff = []
 
782
                    lines = [int(x.split(',')[0][1:]) for x in line.split(' ')[1:3]]
 
783
                    old_lineno = lines[0]
 
784
                    new_lineno = lines[1]
 
785
                elif line.startswith(' '):
 
786
                    chunk.diff.append(util.Container(old_lineno=old_lineno, new_lineno=new_lineno,
 
787
                                                     type='context', line=util.html_clean(line[1:])))
 
788
                    old_lineno += 1
 
789
                    new_lineno += 1
 
790
                elif line.startswith('+'):
 
791
                    chunk.diff.append(util.Container(old_lineno=None, new_lineno=new_lineno,
 
792
                                                     type='insert', line=util.html_clean(line[1:])))
 
793
                    new_lineno += 1
 
794
                elif line.startswith('-'):
 
795
                    chunk.diff.append(util.Container(old_lineno=old_lineno, new_lineno=None,
 
796
                                                     type='delete', line=util.html_clean(line[1:])))
 
797
                    old_lineno += 1
 
798
                else:
 
799
                    chunk.diff.append(util.Container(old_lineno=None, new_lineno=None,
 
800
                                                     type='unknown', line=util.html_clean(repr(line))))
 
801
            if chunk is not None:
 
802
                chunks.append(chunk)
 
803
            return chunks
 
804
                    
 
805
        def handle_modify(old_path, new_path, fid, kind):
 
806
            if not get_diffs:
 
807
                modified.append(util.Container(filename=rich_filename(new_path, kind), file_id=fid))
 
808
                return
675
809
            old_lines = old_tree.get_file_lines(fid)
676
810
            new_lines = new_tree.get_file_lines(fid)
677
811
            buffer = StringIO()
678
 
            if old_lines != new_lines:
679
 
                try:
680
 
                    bzrlib.diff.internal_diff(old_path, old_lines,
681
 
                                              new_path, new_lines, buffer)
682
 
                except bzrlib.errors.BinaryFile:
683
 
                    diff = ''
684
 
                else:
685
 
                    diff = buffer.getvalue()
686
 
            else:
 
812
            try:
 
813
                bzrlib.diff.internal_diff(old_path, old_lines,
 
814
                                          new_path, new_lines, buffer)
 
815
            except bzrlib.errors.BinaryFile:
687
816
                diff = ''
688
 
            out.append(util.Container(filename=rich_filename(new_path, kind), file_id=fid, chunks=self._process_diff(diff), raw_diff=diff))
689
 
 
690
 
        return out
691
 
 
692
 
    def _process_diff(self, diff):
693
 
        # doesn't really need to be a method; could be static.
694
 
        chunks = []
695
 
        chunk = None
696
 
        for line in diff.splitlines():
697
 
            if len(line) == 0:
698
 
                continue
699
 
            if line.startswith('+++ ') or line.startswith('--- '):
700
 
                continue
701
 
            if line.startswith('@@ '):
702
 
                # new chunk
703
 
                if chunk is not None:
704
 
                    chunks.append(chunk)
705
 
                chunk = util.Container()
706
 
                chunk.diff = []
707
 
                lines = [int(x.split(',')[0][1:]) for x in line.split(' ')[1:3]]
708
 
                old_lineno = lines[0]
709
 
                new_lineno = lines[1]
710
 
            elif line.startswith(' '):
711
 
                chunk.diff.append(util.Container(old_lineno=old_lineno, 
712
 
                                                 new_lineno=new_lineno,
713
 
                                                 type='context', 
714
 
                                                 line=line[1:]))
715
 
                old_lineno += 1
716
 
                new_lineno += 1
717
 
            elif line.startswith('+'):
718
 
                chunk.diff.append(util.Container(old_lineno=None, 
719
 
                                                 new_lineno=new_lineno,
720
 
                                                 type='insert', line=line[1:]))
721
 
                new_lineno += 1
722
 
            elif line.startswith('-'):
723
 
                chunk.diff.append(util.Container(old_lineno=old_lineno, 
724
 
                                                 new_lineno=None,
725
 
                                                 type='delete', line=line[1:]))
726
 
                old_lineno += 1
727
817
            else:
728
 
                chunk.diff.append(util.Container(old_lineno=None, 
729
 
                                                 new_lineno=None,
730
 
                                                 type='unknown', 
731
 
                                                 line=repr(line)))
732
 
        if chunk is not None:
733
 
            chunks.append(chunk)
734
 
        return chunks
735
 
 
736
 
    def parse_delta(self, delta):
737
 
        """
738
 
        Return a nested data structure containing the changes in a delta::
739
 
 
740
 
            added: list((filename, file_id)),
741
 
            renamed: list((old_filename, new_filename, file_id)),
742
 
            deleted: list((filename, file_id)),
743
 
            modified: list(
744
 
                filename: str,
745
 
                file_id: str,
746
 
            )
747
 
        """
748
 
        added = []
749
 
        modified = []
750
 
        renamed = []
751
 
        removed = []
 
818
                diff = buffer.getvalue()
 
819
            modified.append(util.Container(filename=rich_filename(new_path, kind), file_id=fid, chunks=process_diff(diff), raw_diff=diff))
752
820
 
753
821
        for path, fid, kind in delta.added:
754
822
            added.append((rich_filename(path, kind), fid))
755
 
 
 
823
        
756
824
        for path, fid, kind, text_modified, meta_modified in delta.modified:
757
 
            modified.append(util.Container(filename=rich_filename(path, kind), file_id=fid))
758
 
 
759
 
        for old_path, new_path, fid, kind, text_modified, meta_modified in delta.renamed:
760
 
            renamed.append((rich_filename(old_path, kind), rich_filename(new_path, kind), fid))
 
825
            handle_modify(path, path, fid, kind)
 
826
        
 
827
        for oldpath, newpath, fid, kind, text_modified, meta_modified in delta.renamed:
 
828
            renamed.append((rich_filename(oldpath, kind), rich_filename(newpath, kind), fid))
761
829
            if meta_modified or text_modified:
762
 
                modified.append(util.Container(filename=rich_filename(new_path, kind), file_id=fid))
763
 
 
 
830
                handle_modify(oldpath, newpath, fid, kind)
 
831
        
764
832
        for path, fid, kind in delta.removed:
765
833
            removed.append((rich_filename(path, kind), fid))
766
 
 
 
834
        
767
835
        return util.Container(added=added, renamed=renamed, removed=removed, modified=modified)
768
836
 
769
837
    @staticmethod
772
840
        for change in changes:
773
841
            for m in change.changes.modified:
774
842
                m.sbs_chunks = _make_side_by_side(m.chunks)
775
 
 
776
 
    def get_filelist(self, inv, file_id, sort_type=None):
 
843
    
 
844
    @with_branch_lock
 
845
    def get_filelist(self, inv, path, sort_type=None):
777
846
        """
778
847
        return the list of all files (and their attributes) within a given
779
848
        path subtree.
780
849
        """
781
 
 
782
 
        dir_ie = inv[file_id]
783
 
        path = inv.id2path(file_id)
 
850
        while path.endswith('/'):
 
851
            path = path[:-1]
 
852
        if path.startswith('/'):
 
853
            path = path[1:]
 
854
        
 
855
        entries = inv.entries()
 
856
        
784
857
        file_list = []
785
 
 
786
 
        revid_set = set()
787
 
 
788
 
        for filename, entry in dir_ie.children.iteritems():
789
 
            revid_set.add(entry.revision)
790
 
 
791
 
        change_dict = {}
792
 
        for change in self.get_changes(list(revid_set)):
793
 
            change_dict[change.revid] = change
794
 
 
795
 
        for filename, entry in dir_ie.children.iteritems():
 
858
        for filepath, entry in entries:
 
859
            if posixpath.dirname(filepath) != path:
 
860
                continue
 
861
            filename = posixpath.basename(filepath)
 
862
            rich_filename = filename
796
863
            pathname = filename
797
864
            if entry.kind == 'directory':
798
865
                pathname += '/'
799
866
 
800
867
            revid = entry.revision
 
868
            revision = self._branch.repository.get_revision(revid)
801
869
 
802
 
            file = util.Container(
803
 
                filename=filename, executable=entry.executable, kind=entry.kind,
804
 
                pathname=pathname, file_id=entry.file_id, size=entry.text_size,
805
 
                revid=revid, change=change_dict[revid])
 
870
            change = util.Container(date=datetime.datetime.fromtimestamp(revision.timestamp),
 
871
                                    revno=self.get_revno(revid))
 
872
            
 
873
            file = util.Container(filename=filename, rich_filename=rich_filename, executable=entry.executable, kind=entry.kind,
 
874
                                  pathname=pathname, file_id=entry.file_id, size=entry.text_size, revid=revid, change=change)
806
875
            file_list.append(file)
807
 
 
808
 
        if sort_type == 'filename' or sort_type is None:
809
 
            file_list.sort(key=lambda x: x.filename.lower()) # case-insensitive
 
876
        
 
877
        if sort_type == 'filename':
 
878
            file_list.sort(key=lambda x: x.filename)
810
879
        elif sort_type == 'size':
811
880
            file_list.sort(key=lambda x: x.size)
812
881
        elif sort_type == 'date':
813
882
            file_list.sort(key=lambda x: x.change.date)
814
883
        
815
 
        # Always sort by kind to get directories first
816
 
        file_list.sort(key=lambda x: x.kind != 'directory')
817
 
 
818
884
        parity = 0
819
885
        for file in file_list:
820
886
            file.parity = parity
825
891
 
826
892
    _BADCHARS_RE = re.compile(ur'[\x00-\x08\x0b\x0e-\x1f]')
827
893
 
 
894
    @with_branch_lock
828
895
    def annotate_file(self, file_id, revid):
829
896
        z = time.time()
830
897
        lineno = 1
831
898
        parity = 0
832
 
 
 
899
        
833
900
        file_revid = self.get_inventory(revid)[file_id].revision
834
901
        oldvalues = None
835
 
        tree = self._branch.repository.revision_tree(file_revid)
 
902
        
 
903
        # because we cache revision metadata ourselves, it's actually much
 
904
        # faster to call 'annotate_iter' on the weave directly than it is to
 
905
        # ask bzrlib to annotate for us.
 
906
        w = self._branch.repository.weave_store.get_weave(file_id, self._branch.repository.get_transaction())
 
907
        
836
908
        revid_set = set()
837
 
 
838
 
        for line_revid, text in tree.annotate_iter(file_id):
 
909
        for line_revid, text in w.annotate_iter(file_revid):
839
910
            revid_set.add(line_revid)
840
911
            if self._BADCHARS_RE.match(text):
841
912
                # bail out; this isn't displayable text
842
913
                yield util.Container(parity=0, lineno=1, status='same',
843
 
                                     text='(This is a binary file.)',
 
914
                                     text='<i>' + util.html_clean('(This is a binary file.)') + '</i>',
844
915
                                     change=util.Container())
845
916
                return
846
 
        change_cache = dict([(c.revid, c) \
847
 
                for c in self.get_changes(list(revid_set))])
848
 
 
 
917
        change_cache = dict([(c.revid, c) for c in self.get_changes(list(revid_set))])
 
918
        
849
919
        last_line_revid = None
850
 
        for line_revid, text in tree.annotate_iter(file_id):
 
920
        for line_revid, text in w.annotate_iter(file_revid):
851
921
            if line_revid == last_line_revid:
852
922
                # remember which lines have a new revno and which don't
853
923
                status = 'same'
859
929
                trunc_revno = change.revno
860
930
                if len(trunc_revno) > 10:
861
931
                    trunc_revno = trunc_revno[:9] + '...'
862
 
 
 
932
                
863
933
            yield util.Container(parity=parity, lineno=lineno, status=status,
864
 
                                 change=change, text=util.fixed_width(text))
 
934
                                 change=change, text=util.html_clean(text))
865
935
            lineno += 1
866
 
 
 
936
        
867
937
        self.log.debug('annotate: %r secs' % (time.time() - z,))
 
938
 
 
939
    @with_branch_lock
 
940
    @with_bzrlib_read_lock
 
941
    def get_bundle(self, revid, compare_revid=None):
 
942
        if compare_revid is None:
 
943
            parents = self._revision_graph[revid]
 
944
            if len(parents) > 0:
 
945
                compare_revid = parents[0]
 
946
            else:
 
947
                compare_revid = None
 
948
        s = StringIO()
 
949
        bzrlib.bundle.serializer.write_bundle(self._branch.repository, revid, compare_revid, s)
 
950
        return s.getvalue()
 
951