~bzr-pqm/bzr/bzr.dev

« back to all changes in this revision

Viewing changes to bzrlib/log.py

  • Committer: Canonical.com Patch Queue Manager
  • Date: 2011-08-17 18:13:57 UTC
  • mfrom: (5268.7.29 transport-segments)
  • Revision ID: pqm@pqm.ubuntu.com-20110817181357-y5q5eth1hk8bl3om
(jelmer) Allow specifying the colocated branch to use in the branch URL,
 and retrieving the branch name using ControlDir._get_selected_branch.
 (Jelmer Vernooij)

Show diffs side-by-side

added added

removed removed

Lines of Context:
1
 
# Copyright (C) 2005, 2006, 2007 Canonical Ltd
 
1
# Copyright (C) 2005-2011 Canonical Ltd
2
2
#
3
3
# This program is free software; you can redistribute it and/or modify
4
4
# it under the terms of the GNU General Public License as published by
12
12
#
13
13
# You should have received a copy of the GNU General Public License
14
14
# along with this program; if not, write to the Free Software
15
 
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
 
15
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
16
16
 
17
17
 
18
18
 
50
50
"""
51
51
 
52
52
import codecs
 
53
from cStringIO import StringIO
53
54
from itertools import (
 
55
    chain,
54
56
    izip,
55
57
    )
56
58
import re
63
65
lazy_import(globals(), """
64
66
 
65
67
from bzrlib import (
 
68
    bzrdir,
66
69
    config,
 
70
    diff,
67
71
    errors,
 
72
    foreign,
68
73
    repository as _mod_repository,
69
74
    revision as _mod_revision,
70
75
    revisionspec,
71
 
    trace,
72
76
    tsort,
 
77
    i18n,
73
78
    )
74
79
""")
75
80
 
76
81
from bzrlib import (
 
82
    lazy_regex,
77
83
    registry,
78
84
    )
79
85
from bzrlib.osutils import (
80
86
    format_date,
 
87
    format_date_with_offset_in_original_timezone,
 
88
    get_diff_header_encoding,
81
89
    get_terminal_encoding,
82
90
    terminal_width,
83
91
    )
98
106
    last_path = None
99
107
    revno = 1
100
108
    for revision_id in branch.revision_history():
101
 
        this_inv = branch.repository.get_revision_inventory(revision_id)
102
 
        if file_id in this_inv:
 
109
        this_inv = branch.repository.get_inventory(revision_id)
 
110
        if this_inv.has_id(file_id):
103
111
            this_ie = this_inv[file_id]
104
112
            this_path = this_inv.id2path(file_id)
105
113
        else:
144
152
             start_revision=None,
145
153
             end_revision=None,
146
154
             search=None,
147
 
             limit=None):
 
155
             limit=None,
 
156
             show_diff=False,
 
157
             match=None):
148
158
    """Write out human-readable log of commits to this branch.
149
159
 
150
 
    lf
151
 
        LogFormatter object to show the output.
152
 
 
153
 
    specific_fileid
154
 
        If true, list only the commits affecting the specified
155
 
        file, rather than all commits.
156
 
 
157
 
    verbose
158
 
        If true show added/changed/deleted/renamed files.
159
 
 
160
 
    direction
161
 
        'reverse' (default) is latest to earliest;
162
 
        'forward' is earliest to latest.
163
 
 
164
 
    start_revision
165
 
        If not None, only show revisions >= start_revision
166
 
 
167
 
    end_revision
168
 
        If not None, only show revisions <= end_revision
169
 
 
170
 
    search
171
 
        If not None, only show revisions with matching commit messages
172
 
 
173
 
    limit
174
 
        If not None or 0, only show limit revisions
175
 
    """
176
 
    branch.lock_read()
177
 
    try:
178
 
        if getattr(lf, 'begin_log', None):
179
 
            lf.begin_log()
180
 
 
181
 
        _show_log(branch, lf, specific_fileid, verbose, direction,
182
 
                  start_revision, end_revision, search, limit)
183
 
 
184
 
        if getattr(lf, 'end_log', None):
185
 
            lf.end_log()
186
 
    finally:
187
 
        branch.unlock()
188
 
 
189
 
 
190
 
def _show_log(branch,
191
 
             lf,
192
 
             specific_fileid=None,
193
 
             verbose=False,
194
 
             direction='reverse',
195
 
             start_revision=None,
196
 
             end_revision=None,
197
 
             search=None,
198
 
             limit=None):
199
 
    """Worker function for show_log - see show_log."""
200
 
    if not isinstance(lf, LogFormatter):
201
 
        warn("not a LogFormatter instance: %r" % lf)
202
 
 
203
 
    if specific_fileid:
204
 
        trace.mutter('get log for file_id %r', specific_fileid)
205
 
    generate_merge_revisions = getattr(lf, 'supports_merge_revisions', False)
206
 
    allow_single_merge_revision = getattr(lf,
207
 
        'supports_single_merge_revision', False)
208
 
    view_revisions = calculate_view_revisions(branch, start_revision,
209
 
                                              end_revision, direction,
210
 
                                              specific_fileid,
211
 
                                              generate_merge_revisions,
212
 
                                              allow_single_merge_revision)
213
 
    rev_tag_dict = {}
214
 
    generate_tags = getattr(lf, 'supports_tags', False)
215
 
    if generate_tags:
216
 
        if branch.supports_tags():
217
 
            rev_tag_dict = branch.tags.get_reverse_tag_dict()
218
 
 
219
 
    generate_delta = verbose and getattr(lf, 'supports_delta', False)
220
 
 
221
 
    # now we just print all the revisions
222
 
    log_count = 0
223
 
    revision_iterator = make_log_rev_iterator(branch, view_revisions,
224
 
        generate_delta, search)
225
 
    for revs in revision_iterator:
226
 
        for (rev_id, revno, merge_depth), rev, delta in revs:
227
 
            lr = LogRevision(rev, revno, merge_depth, delta,
228
 
                             rev_tag_dict.get(rev_id))
 
160
    This function is being retained for backwards compatibility but
 
161
    should not be extended with new parameters. Use the new Logger class
 
162
    instead, eg. Logger(branch, rqst).show(lf), adding parameters to the
 
163
    make_log_request_dict function.
 
164
 
 
165
    :param lf: The LogFormatter object showing the output.
 
166
 
 
167
    :param specific_fileid: If not None, list only the commits affecting the
 
168
        specified file, rather than all commits.
 
169
 
 
170
    :param verbose: If True show added/changed/deleted/renamed files.
 
171
 
 
172
    :param direction: 'reverse' (default) is latest to earliest; 'forward' is
 
173
        earliest to latest.
 
174
 
 
175
    :param start_revision: If not None, only show revisions >= start_revision
 
176
 
 
177
    :param end_revision: If not None, only show revisions <= end_revision
 
178
 
 
179
    :param search: If not None, only show revisions with matching commit
 
180
        messages
 
181
 
 
182
    :param limit: If set, shows only 'limit' revisions, all revisions are shown
 
183
        if None or 0.
 
184
 
 
185
    :param show_diff: If True, output a diff after each revision.
 
186
 
 
187
    :param match: Dictionary of search lists to use when matching revision
 
188
      properties.
 
189
    """
 
190
    # Convert old-style parameters to new-style parameters
 
191
    if specific_fileid is not None:
 
192
        file_ids = [specific_fileid]
 
193
    else:
 
194
        file_ids = None
 
195
    if verbose:
 
196
        if file_ids:
 
197
            delta_type = 'partial'
 
198
        else:
 
199
            delta_type = 'full'
 
200
    else:
 
201
        delta_type = None
 
202
    if show_diff:
 
203
        if file_ids:
 
204
            diff_type = 'partial'
 
205
        else:
 
206
            diff_type = 'full'
 
207
    else:
 
208
        diff_type = None
 
209
 
 
210
    # Build the request and execute it
 
211
    rqst = make_log_request_dict(direction=direction, specific_fileids=file_ids,
 
212
        start_revision=start_revision, end_revision=end_revision,
 
213
        limit=limit, message_search=search,
 
214
        delta_type=delta_type, diff_type=diff_type)
 
215
    Logger(branch, rqst).show(lf)
 
216
 
 
217
 
 
218
# Note: This needs to be kept this in sync with the defaults in
 
219
# make_log_request_dict() below
 
220
_DEFAULT_REQUEST_PARAMS = {
 
221
    'direction': 'reverse',
 
222
    'levels': None,
 
223
    'generate_tags': True,
 
224
    'exclude_common_ancestry': False,
 
225
    '_match_using_deltas': True,
 
226
    }
 
227
 
 
228
 
 
229
def make_log_request_dict(direction='reverse', specific_fileids=None,
 
230
                          start_revision=None, end_revision=None, limit=None,
 
231
                          message_search=None, levels=None, generate_tags=True,
 
232
                          delta_type=None,
 
233
                          diff_type=None, _match_using_deltas=True,
 
234
                          exclude_common_ancestry=False, match=None,
 
235
                          signature=False,
 
236
                          ):
 
237
    """Convenience function for making a logging request dictionary.
 
238
 
 
239
    Using this function may make code slightly safer by ensuring
 
240
    parameters have the correct names. It also provides a reference
 
241
    point for documenting the supported parameters.
 
242
 
 
243
    :param direction: 'reverse' (default) is latest to earliest;
 
244
      'forward' is earliest to latest.
 
245
 
 
246
    :param specific_fileids: If not None, only include revisions
 
247
      affecting the specified files, rather than all revisions.
 
248
 
 
249
    :param start_revision: If not None, only generate
 
250
      revisions >= start_revision
 
251
 
 
252
    :param end_revision: If not None, only generate
 
253
      revisions <= end_revision
 
254
 
 
255
    :param limit: If set, generate only 'limit' revisions, all revisions
 
256
      are shown if None or 0.
 
257
 
 
258
    :param message_search: If not None, only include revisions with
 
259
      matching commit messages
 
260
 
 
261
    :param levels: the number of levels of revisions to
 
262
      generate; 1 for just the mainline; 0 for all levels, or None for
 
263
      a sensible default.
 
264
 
 
265
    :param generate_tags: If True, include tags for matched revisions.
 
266
`
 
267
    :param delta_type: Either 'full', 'partial' or None.
 
268
      'full' means generate the complete delta - adds/deletes/modifies/etc;
 
269
      'partial' means filter the delta using specific_fileids;
 
270
      None means do not generate any delta.
 
271
 
 
272
    :param diff_type: Either 'full', 'partial' or None.
 
273
      'full' means generate the complete diff - adds/deletes/modifies/etc;
 
274
      'partial' means filter the diff using specific_fileids;
 
275
      None means do not generate any diff.
 
276
 
 
277
    :param _match_using_deltas: a private parameter controlling the
 
278
      algorithm used for matching specific_fileids. This parameter
 
279
      may be removed in the future so bzrlib client code should NOT
 
280
      use it.
 
281
 
 
282
    :param exclude_common_ancestry: Whether -rX..Y should be interpreted as a
 
283
      range operator or as a graph difference.
 
284
 
 
285
    :param signature: show digital signature information
 
286
 
 
287
    :param match: Dictionary of list of search strings to use when filtering
 
288
      revisions. Keys can be 'message', 'author', 'committer', 'bugs' or
 
289
      the empty string to match any of the preceding properties.
 
290
 
 
291
    """
 
292
    # Take care of old style message_search parameter
 
293
    if message_search:
 
294
        if match:
 
295
            if 'message' in match:
 
296
                match['message'].append(message_search)
 
297
            else:
 
298
                match['message'] = [message_search]
 
299
        else:
 
300
            match={ 'message': [message_search] }
 
301
    return {
 
302
        'direction': direction,
 
303
        'specific_fileids': specific_fileids,
 
304
        'start_revision': start_revision,
 
305
        'end_revision': end_revision,
 
306
        'limit': limit,
 
307
        'levels': levels,
 
308
        'generate_tags': generate_tags,
 
309
        'delta_type': delta_type,
 
310
        'diff_type': diff_type,
 
311
        'exclude_common_ancestry': exclude_common_ancestry,
 
312
        'signature': signature,
 
313
        'match': match,
 
314
        # Add 'private' attributes for features that may be deprecated
 
315
        '_match_using_deltas': _match_using_deltas,
 
316
    }
 
317
 
 
318
 
 
319
def _apply_log_request_defaults(rqst):
 
320
    """Apply default values to a request dictionary."""
 
321
    result = _DEFAULT_REQUEST_PARAMS.copy()
 
322
    if rqst:
 
323
        result.update(rqst)
 
324
    return result
 
325
 
 
326
 
 
327
def format_signature_validity(rev_id, repo):
 
328
    """get the signature validity
 
329
 
 
330
    :param rev_id: revision id to validate
 
331
    :param repo: repository of revision
 
332
    :return: human readable string to print to log
 
333
    """
 
334
    from bzrlib import gpg
 
335
 
 
336
    gpg_strategy = gpg.GPGStrategy(None)
 
337
    result = repo.verify_revision(rev_id, gpg_strategy)
 
338
    if result[0] == gpg.SIGNATURE_VALID:
 
339
        return "valid signature from {0}".format(result[1])
 
340
    if result[0] == gpg.SIGNATURE_KEY_MISSING:
 
341
        return "unknown key {0}".format(result[1])
 
342
    if result[0] == gpg.SIGNATURE_NOT_VALID:
 
343
        return "invalid signature!"
 
344
    if result[0] == gpg.SIGNATURE_NOT_SIGNED:
 
345
        return "no signature"
 
346
 
 
347
 
 
348
class LogGenerator(object):
 
349
    """A generator of log revisions."""
 
350
 
 
351
    def iter_log_revisions(self):
 
352
        """Iterate over LogRevision objects.
 
353
 
 
354
        :return: An iterator yielding LogRevision objects.
 
355
        """
 
356
        raise NotImplementedError(self.iter_log_revisions)
 
357
 
 
358
 
 
359
class Logger(object):
 
360
    """An object that generates, formats and displays a log."""
 
361
 
 
362
    def __init__(self, branch, rqst):
 
363
        """Create a Logger.
 
364
 
 
365
        :param branch: the branch to log
 
366
        :param rqst: A dictionary specifying the query parameters.
 
367
          See make_log_request_dict() for supported values.
 
368
        """
 
369
        self.branch = branch
 
370
        self.rqst = _apply_log_request_defaults(rqst)
 
371
 
 
372
    def show(self, lf):
 
373
        """Display the log.
 
374
 
 
375
        :param lf: The LogFormatter object to send the output to.
 
376
        """
 
377
        if not isinstance(lf, LogFormatter):
 
378
            warn("not a LogFormatter instance: %r" % lf)
 
379
 
 
380
        self.branch.lock_read()
 
381
        try:
 
382
            if getattr(lf, 'begin_log', None):
 
383
                lf.begin_log()
 
384
            self._show_body(lf)
 
385
            if getattr(lf, 'end_log', None):
 
386
                lf.end_log()
 
387
        finally:
 
388
            self.branch.unlock()
 
389
 
 
390
    def _show_body(self, lf):
 
391
        """Show the main log output.
 
392
 
 
393
        Subclasses may wish to override this.
 
394
        """
 
395
        # Tweak the LogRequest based on what the LogFormatter can handle.
 
396
        # (There's no point generating stuff if the formatter can't display it.)
 
397
        rqst = self.rqst
 
398
        if rqst['levels'] is None or lf.get_levels() > rqst['levels']:
 
399
            # user didn't specify levels, use whatever the LF can handle:
 
400
            rqst['levels'] = lf.get_levels()
 
401
 
 
402
        if not getattr(lf, 'supports_tags', False):
 
403
            rqst['generate_tags'] = False
 
404
        if not getattr(lf, 'supports_delta', False):
 
405
            rqst['delta_type'] = None
 
406
        if not getattr(lf, 'supports_diff', False):
 
407
            rqst['diff_type'] = None
 
408
        if not getattr(lf, 'supports_signatures', False):
 
409
            rqst['signature'] = False
 
410
 
 
411
        # Find and print the interesting revisions
 
412
        generator = self._generator_factory(self.branch, rqst)
 
413
        for lr in generator.iter_log_revisions():
229
414
            lf.log_revision(lr)
230
 
            if limit:
231
 
                log_count += 1
232
 
                if log_count >= limit:
233
 
                    return
234
 
 
235
 
 
236
 
def calculate_view_revisions(branch, start_revision, end_revision, direction,
237
 
                             specific_fileid, generate_merge_revisions,
238
 
                             allow_single_merge_revision):
239
 
    if (not generate_merge_revisions and start_revision is end_revision is
240
 
        None and direction == 'reverse' and specific_fileid is None):
241
 
        return _linear_view_revisions(branch)
242
 
 
243
 
    mainline_revs, rev_nos, start_rev_id, end_rev_id = \
244
 
        _get_mainline_revs(branch, start_revision, end_revision)
245
 
    if not mainline_revs:
 
415
        lf.show_advice()
 
416
 
 
417
    def _generator_factory(self, branch, rqst):
 
418
        """Make the LogGenerator object to use.
 
419
 
 
420
        Subclasses may wish to override this.
 
421
        """
 
422
        return _DefaultLogGenerator(branch, rqst)
 
423
 
 
424
 
 
425
class _StartNotLinearAncestor(Exception):
 
426
    """Raised when a start revision is not found walking left-hand history."""
 
427
 
 
428
 
 
429
class _DefaultLogGenerator(LogGenerator):
 
430
    """The default generator of log revisions."""
 
431
 
 
432
    def __init__(self, branch, rqst):
 
433
        self.branch = branch
 
434
        self.rqst = rqst
 
435
        if rqst.get('generate_tags') and branch.supports_tags():
 
436
            self.rev_tag_dict = branch.tags.get_reverse_tag_dict()
 
437
        else:
 
438
            self.rev_tag_dict = {}
 
439
 
 
440
    def iter_log_revisions(self):
 
441
        """Iterate over LogRevision objects.
 
442
 
 
443
        :return: An iterator yielding LogRevision objects.
 
444
        """
 
445
        rqst = self.rqst
 
446
        levels = rqst.get('levels')
 
447
        limit = rqst.get('limit')
 
448
        diff_type = rqst.get('diff_type')
 
449
        show_signature = rqst.get('signature')
 
450
        log_count = 0
 
451
        revision_iterator = self._create_log_revision_iterator()
 
452
        for revs in revision_iterator:
 
453
            for (rev_id, revno, merge_depth), rev, delta in revs:
 
454
                # 0 levels means show everything; merge_depth counts from 0
 
455
                if levels != 0 and merge_depth >= levels:
 
456
                    continue
 
457
                if diff_type is None:
 
458
                    diff = None
 
459
                else:
 
460
                    diff = self._format_diff(rev, rev_id, diff_type)
 
461
                if show_signature:
 
462
                    signature = format_signature_validity(rev_id,
 
463
                                                self.branch.repository)
 
464
                else:
 
465
                    signature = None
 
466
                yield LogRevision(rev, revno, merge_depth, delta,
 
467
                    self.rev_tag_dict.get(rev_id), diff, signature)
 
468
                if limit:
 
469
                    log_count += 1
 
470
                    if log_count >= limit:
 
471
                        return
 
472
 
 
473
    def _format_diff(self, rev, rev_id, diff_type):
 
474
        repo = self.branch.repository
 
475
        if len(rev.parent_ids) == 0:
 
476
            ancestor_id = _mod_revision.NULL_REVISION
 
477
        else:
 
478
            ancestor_id = rev.parent_ids[0]
 
479
        tree_1 = repo.revision_tree(ancestor_id)
 
480
        tree_2 = repo.revision_tree(rev_id)
 
481
        file_ids = self.rqst.get('specific_fileids')
 
482
        if diff_type == 'partial' and file_ids is not None:
 
483
            specific_files = [tree_2.id2path(id) for id in file_ids]
 
484
        else:
 
485
            specific_files = None
 
486
        s = StringIO()
 
487
        path_encoding = get_diff_header_encoding()
 
488
        diff.show_diff_trees(tree_1, tree_2, s, specific_files, old_label='',
 
489
            new_label='', path_encoding=path_encoding)
 
490
        return s.getvalue()
 
491
 
 
492
    def _create_log_revision_iterator(self):
 
493
        """Create a revision iterator for log.
 
494
 
 
495
        :return: An iterator over lists of ((rev_id, revno, merge_depth), rev,
 
496
            delta).
 
497
        """
 
498
        self.start_rev_id, self.end_rev_id = _get_revision_limits(
 
499
            self.branch, self.rqst.get('start_revision'),
 
500
            self.rqst.get('end_revision'))
 
501
        if self.rqst.get('_match_using_deltas'):
 
502
            return self._log_revision_iterator_using_delta_matching()
 
503
        else:
 
504
            # We're using the per-file-graph algorithm. This scales really
 
505
            # well but only makes sense if there is a single file and it's
 
506
            # not a directory
 
507
            file_count = len(self.rqst.get('specific_fileids'))
 
508
            if file_count != 1:
 
509
                raise BzrError("illegal LogRequest: must match-using-deltas "
 
510
                    "when logging %d files" % file_count)
 
511
            return self._log_revision_iterator_using_per_file_graph()
 
512
 
 
513
    def _log_revision_iterator_using_delta_matching(self):
 
514
        # Get the base revisions, filtering by the revision range
 
515
        rqst = self.rqst
 
516
        generate_merge_revisions = rqst.get('levels') != 1
 
517
        delayed_graph_generation = not rqst.get('specific_fileids') and (
 
518
                rqst.get('limit') or self.start_rev_id or self.end_rev_id)
 
519
        view_revisions = _calc_view_revisions(
 
520
            self.branch, self.start_rev_id, self.end_rev_id,
 
521
            rqst.get('direction'),
 
522
            generate_merge_revisions=generate_merge_revisions,
 
523
            delayed_graph_generation=delayed_graph_generation,
 
524
            exclude_common_ancestry=rqst.get('exclude_common_ancestry'))
 
525
 
 
526
        # Apply the other filters
 
527
        return make_log_rev_iterator(self.branch, view_revisions,
 
528
            rqst.get('delta_type'), rqst.get('match'),
 
529
            file_ids=rqst.get('specific_fileids'),
 
530
            direction=rqst.get('direction'))
 
531
 
 
532
    def _log_revision_iterator_using_per_file_graph(self):
 
533
        # Get the base revisions, filtering by the revision range.
 
534
        # Note that we always generate the merge revisions because
 
535
        # filter_revisions_touching_file_id() requires them ...
 
536
        rqst = self.rqst
 
537
        view_revisions = _calc_view_revisions(
 
538
            self.branch, self.start_rev_id, self.end_rev_id,
 
539
            rqst.get('direction'), generate_merge_revisions=True,
 
540
            exclude_common_ancestry=rqst.get('exclude_common_ancestry'))
 
541
        if not isinstance(view_revisions, list):
 
542
            view_revisions = list(view_revisions)
 
543
        view_revisions = _filter_revisions_touching_file_id(self.branch,
 
544
            rqst.get('specific_fileids')[0], view_revisions,
 
545
            include_merges=rqst.get('levels') != 1)
 
546
        return make_log_rev_iterator(self.branch, view_revisions,
 
547
            rqst.get('delta_type'), rqst.get('match'))
 
548
 
 
549
 
 
550
def _calc_view_revisions(branch, start_rev_id, end_rev_id, direction,
 
551
                         generate_merge_revisions,
 
552
                         delayed_graph_generation=False,
 
553
                         exclude_common_ancestry=False,
 
554
                         ):
 
555
    """Calculate the revisions to view.
 
556
 
 
557
    :return: An iterator of (revision_id, dotted_revno, merge_depth) tuples OR
 
558
             a list of the same tuples.
 
559
    """
 
560
    if (exclude_common_ancestry and start_rev_id == end_rev_id):
 
561
        raise errors.BzrCommandError(
 
562
            '--exclude-common-ancestry requires two different revisions')
 
563
    if direction not in ('reverse', 'forward'):
 
564
        raise ValueError('invalid direction %r' % direction)
 
565
    br_revno, br_rev_id = branch.last_revision_info()
 
566
    if br_revno == 0:
246
567
        return []
247
568
 
248
 
    if direction == 'reverse':
249
 
        start_rev_id, end_rev_id = end_rev_id, start_rev_id
250
 
 
251
 
    generate_single_revision = False
252
 
    if ((not generate_merge_revisions)
253
 
        and ((start_rev_id and (start_rev_id not in rev_nos))
254
 
            or (end_rev_id and (end_rev_id not in rev_nos)))):
255
 
        generate_single_revision = ((start_rev_id == end_rev_id)
256
 
            and allow_single_merge_revision)
257
 
        if not generate_single_revision:
258
 
            raise errors.BzrCommandError('Selected log formatter only supports'
259
 
                ' mainline revisions.')
260
 
        generate_merge_revisions = generate_single_revision
261
 
    view_revs_iter = get_view_revisions(mainline_revs, rev_nos, branch,
262
 
                          direction, include_merges=generate_merge_revisions)
263
 
    view_revisions = _filter_revision_range(list(view_revs_iter),
264
 
                                            start_rev_id,
265
 
                                            end_rev_id)
266
 
    if view_revisions and generate_single_revision:
267
 
        view_revisions = view_revisions[0:1]
268
 
    if specific_fileid:
269
 
        view_revisions = _filter_revisions_touching_file_id(branch,
270
 
                                                         specific_fileid,
271
 
                                                         view_revisions,
272
 
                                                         direction)
273
 
 
274
 
    # rebase merge_depth - unless there are no revisions or 
275
 
    # either the first or last revision have merge_depth = 0.
 
569
    if (end_rev_id and start_rev_id == end_rev_id
 
570
        and (not generate_merge_revisions
 
571
             or not _has_merges(branch, end_rev_id))):
 
572
        # If a single revision is requested, check we can handle it
 
573
        iter_revs = _generate_one_revision(branch, end_rev_id, br_rev_id,
 
574
                                           br_revno)
 
575
    elif not generate_merge_revisions:
 
576
        # If we only want to see linear revisions, we can iterate ...
 
577
        iter_revs = _generate_flat_revisions(branch, start_rev_id, end_rev_id,
 
578
                                             direction, exclude_common_ancestry)
 
579
        if direction == 'forward':
 
580
            iter_revs = reversed(iter_revs)
 
581
    else:
 
582
        iter_revs = _generate_all_revisions(branch, start_rev_id, end_rev_id,
 
583
                                            direction, delayed_graph_generation,
 
584
                                            exclude_common_ancestry)
 
585
        if direction == 'forward':
 
586
            iter_revs = _rebase_merge_depth(reverse_by_depth(list(iter_revs)))
 
587
    return iter_revs
 
588
 
 
589
 
 
590
def _generate_one_revision(branch, rev_id, br_rev_id, br_revno):
 
591
    if rev_id == br_rev_id:
 
592
        # It's the tip
 
593
        return [(br_rev_id, br_revno, 0)]
 
594
    else:
 
595
        revno_str = _compute_revno_str(branch, rev_id)
 
596
        return [(rev_id, revno_str, 0)]
 
597
 
 
598
 
 
599
def _generate_flat_revisions(branch, start_rev_id, end_rev_id, direction,
 
600
                             exclude_common_ancestry=False):
 
601
    result = _linear_view_revisions(
 
602
        branch, start_rev_id, end_rev_id,
 
603
        exclude_common_ancestry=exclude_common_ancestry)
 
604
    # If a start limit was given and it's not obviously an
 
605
    # ancestor of the end limit, check it before outputting anything
 
606
    if direction == 'forward' or (start_rev_id
 
607
        and not _is_obvious_ancestor(branch, start_rev_id, end_rev_id)):
 
608
        try:
 
609
            result = list(result)
 
610
        except _StartNotLinearAncestor:
 
611
            raise errors.BzrCommandError('Start revision not found in'
 
612
                ' left-hand history of end revision.')
 
613
    return result
 
614
 
 
615
 
 
616
def _generate_all_revisions(branch, start_rev_id, end_rev_id, direction,
 
617
                            delayed_graph_generation,
 
618
                            exclude_common_ancestry=False):
 
619
    # On large trees, generating the merge graph can take 30-60 seconds
 
620
    # so we delay doing it until a merge is detected, incrementally
 
621
    # returning initial (non-merge) revisions while we can.
 
622
 
 
623
    # The above is only true for old formats (<= 0.92), for newer formats, a
 
624
    # couple of seconds only should be needed to load the whole graph and the
 
625
    # other graph operations needed are even faster than that -- vila 100201
 
626
    initial_revisions = []
 
627
    if delayed_graph_generation:
 
628
        try:
 
629
            for rev_id, revno, depth in  _linear_view_revisions(
 
630
                branch, start_rev_id, end_rev_id, exclude_common_ancestry):
 
631
                if _has_merges(branch, rev_id):
 
632
                    # The end_rev_id can be nested down somewhere. We need an
 
633
                    # explicit ancestry check. There is an ambiguity here as we
 
634
                    # may not raise _StartNotLinearAncestor for a revision that
 
635
                    # is an ancestor but not a *linear* one. But since we have
 
636
                    # loaded the graph to do the check (or calculate a dotted
 
637
                    # revno), we may as well accept to show the log...  We need
 
638
                    # the check only if start_rev_id is not None as all
 
639
                    # revisions have _mod_revision.NULL_REVISION as an ancestor
 
640
                    # -- vila 20100319
 
641
                    graph = branch.repository.get_graph()
 
642
                    if (start_rev_id is not None
 
643
                        and not graph.is_ancestor(start_rev_id, end_rev_id)):
 
644
                        raise _StartNotLinearAncestor()
 
645
                    # Since we collected the revisions so far, we need to
 
646
                    # adjust end_rev_id.
 
647
                    end_rev_id = rev_id
 
648
                    break
 
649
                else:
 
650
                    initial_revisions.append((rev_id, revno, depth))
 
651
            else:
 
652
                # No merged revisions found
 
653
                return initial_revisions
 
654
        except _StartNotLinearAncestor:
 
655
            # A merge was never detected so the lower revision limit can't
 
656
            # be nested down somewhere
 
657
            raise errors.BzrCommandError('Start revision not found in'
 
658
                ' history of end revision.')
 
659
 
 
660
    # We exit the loop above because we encounter a revision with merges, from
 
661
    # this revision, we need to switch to _graph_view_revisions.
 
662
 
 
663
    # A log including nested merges is required. If the direction is reverse,
 
664
    # we rebase the initial merge depths so that the development line is
 
665
    # shown naturally, i.e. just like it is for linear logging. We can easily
 
666
    # make forward the exact opposite display, but showing the merge revisions
 
667
    # indented at the end seems slightly nicer in that case.
 
668
    view_revisions = chain(iter(initial_revisions),
 
669
        _graph_view_revisions(branch, start_rev_id, end_rev_id,
 
670
                              rebase_initial_depths=(direction == 'reverse'),
 
671
                              exclude_common_ancestry=exclude_common_ancestry))
 
672
    return view_revisions
 
673
 
 
674
 
 
675
def _has_merges(branch, rev_id):
 
676
    """Does a revision have multiple parents or not?"""
 
677
    parents = branch.repository.get_parent_map([rev_id]).get(rev_id, [])
 
678
    return len(parents) > 1
 
679
 
 
680
 
 
681
def _compute_revno_str(branch, rev_id):
 
682
    """Compute the revno string from a rev_id.
 
683
 
 
684
    :return: The revno string, or None if the revision is not in the supplied
 
685
        branch.
 
686
    """
 
687
    try:
 
688
        revno = branch.revision_id_to_dotted_revno(rev_id)
 
689
    except errors.NoSuchRevision:
 
690
        # The revision must be outside of this branch
 
691
        return None
 
692
    else:
 
693
        return '.'.join(str(n) for n in revno)
 
694
 
 
695
 
 
696
def _is_obvious_ancestor(branch, start_rev_id, end_rev_id):
 
697
    """Is start_rev_id an obvious ancestor of end_rev_id?"""
 
698
    if start_rev_id and end_rev_id:
 
699
        try:
 
700
            start_dotted = branch.revision_id_to_dotted_revno(start_rev_id)
 
701
            end_dotted = branch.revision_id_to_dotted_revno(end_rev_id)
 
702
        except errors.NoSuchRevision:
 
703
            # one or both is not in the branch; not obvious
 
704
            return False
 
705
        if len(start_dotted) == 1 and len(end_dotted) == 1:
 
706
            # both on mainline
 
707
            return start_dotted[0] <= end_dotted[0]
 
708
        elif (len(start_dotted) == 3 and len(end_dotted) == 3 and
 
709
            start_dotted[0:1] == end_dotted[0:1]):
 
710
            # both on same development line
 
711
            return start_dotted[2] <= end_dotted[2]
 
712
        else:
 
713
            # not obvious
 
714
            return False
 
715
    # if either start or end is not specified then we use either the first or
 
716
    # the last revision and *they* are obvious ancestors.
 
717
    return True
 
718
 
 
719
 
 
720
def _linear_view_revisions(branch, start_rev_id, end_rev_id,
 
721
                           exclude_common_ancestry=False):
 
722
    """Calculate a sequence of revisions to view, newest to oldest.
 
723
 
 
724
    :param start_rev_id: the lower revision-id
 
725
    :param end_rev_id: the upper revision-id
 
726
    :param exclude_common_ancestry: Whether the start_rev_id should be part of
 
727
        the iterated revisions.
 
728
    :return: An iterator of (revision_id, dotted_revno, merge_depth) tuples.
 
729
    :raises _StartNotLinearAncestor: if a start_rev_id is specified but
 
730
        is not found walking the left-hand history
 
731
    """
 
732
    br_revno, br_rev_id = branch.last_revision_info()
 
733
    repo = branch.repository
 
734
    graph = repo.get_graph()
 
735
    if start_rev_id is None and end_rev_id is None:
 
736
        cur_revno = br_revno
 
737
        for revision_id in graph.iter_lefthand_ancestry(br_rev_id,
 
738
            (_mod_revision.NULL_REVISION,)):
 
739
            yield revision_id, str(cur_revno), 0
 
740
            cur_revno -= 1
 
741
    else:
 
742
        if end_rev_id is None:
 
743
            end_rev_id = br_rev_id
 
744
        found_start = start_rev_id is None
 
745
        for revision_id in graph.iter_lefthand_ancestry(end_rev_id,
 
746
                (_mod_revision.NULL_REVISION,)):
 
747
            revno_str = _compute_revno_str(branch, revision_id)
 
748
            if not found_start and revision_id == start_rev_id:
 
749
                if not exclude_common_ancestry:
 
750
                    yield revision_id, revno_str, 0
 
751
                found_start = True
 
752
                break
 
753
            else:
 
754
                yield revision_id, revno_str, 0
 
755
        else:
 
756
            if not found_start:
 
757
                raise _StartNotLinearAncestor()
 
758
 
 
759
 
 
760
def _graph_view_revisions(branch, start_rev_id, end_rev_id,
 
761
                          rebase_initial_depths=True,
 
762
                          exclude_common_ancestry=False):
 
763
    """Calculate revisions to view including merges, newest to oldest.
 
764
 
 
765
    :param branch: the branch
 
766
    :param start_rev_id: the lower revision-id
 
767
    :param end_rev_id: the upper revision-id
 
768
    :param rebase_initial_depth: should depths be rebased until a mainline
 
769
      revision is found?
 
770
    :return: An iterator of (revision_id, dotted_revno, merge_depth) tuples.
 
771
    """
 
772
    if exclude_common_ancestry:
 
773
        stop_rule = 'with-merges-without-common-ancestry'
 
774
    else:
 
775
        stop_rule = 'with-merges'
 
776
    view_revisions = branch.iter_merge_sorted_revisions(
 
777
        start_revision_id=end_rev_id, stop_revision_id=start_rev_id,
 
778
        stop_rule=stop_rule)
 
779
    if not rebase_initial_depths:
 
780
        for (rev_id, merge_depth, revno, end_of_merge
 
781
             ) in view_revisions:
 
782
            yield rev_id, '.'.join(map(str, revno)), merge_depth
 
783
    else:
 
784
        # We're following a development line starting at a merged revision.
 
785
        # We need to adjust depths down by the initial depth until we find
 
786
        # a depth less than it. Then we use that depth as the adjustment.
 
787
        # If and when we reach the mainline, depth adjustment ends.
 
788
        depth_adjustment = None
 
789
        for (rev_id, merge_depth, revno, end_of_merge
 
790
             ) in view_revisions:
 
791
            if depth_adjustment is None:
 
792
                depth_adjustment = merge_depth
 
793
            if depth_adjustment:
 
794
                if merge_depth < depth_adjustment:
 
795
                    # From now on we reduce the depth adjustement, this can be
 
796
                    # surprising for users. The alternative requires two passes
 
797
                    # which breaks the fast display of the first revision
 
798
                    # though.
 
799
                    depth_adjustment = merge_depth
 
800
                merge_depth -= depth_adjustment
 
801
            yield rev_id, '.'.join(map(str, revno)), merge_depth
 
802
 
 
803
 
 
804
def _rebase_merge_depth(view_revisions):
 
805
    """Adjust depths upwards so the top level is 0."""
 
806
    # If either the first or last revision have a merge_depth of 0, we're done
276
807
    if view_revisions and view_revisions[0][2] and view_revisions[-1][2]:
277
808
        min_depth = min([d for r,n,d in view_revisions])
278
809
        if min_depth != 0:
280
811
    return view_revisions
281
812
 
282
813
 
283
 
def _linear_view_revisions(branch):
284
 
    start_revno, start_revision_id = branch.last_revision_info()
285
 
    repo = branch.repository
286
 
    revision_ids = repo.iter_reverse_revision_history(start_revision_id)
287
 
    for num, revision_id in enumerate(revision_ids):
288
 
        yield revision_id, str(start_revno - num), 0
289
 
 
290
 
 
291
 
def make_log_rev_iterator(branch, view_revisions, generate_delta, search):
 
814
def make_log_rev_iterator(branch, view_revisions, generate_delta, search,
 
815
        file_ids=None, direction='reverse'):
292
816
    """Create a revision iterator for log.
293
817
 
294
818
    :param branch: The branch being logged.
295
819
    :param view_revisions: The revisions being viewed.
296
820
    :param generate_delta: Whether to generate a delta for each revision.
 
821
      Permitted values are None, 'full' and 'partial'.
297
822
    :param search: A user text search string.
 
823
    :param file_ids: If non empty, only revisions matching one or more of
 
824
      the file-ids are to be kept.
 
825
    :param direction: the direction in which view_revisions is sorted
298
826
    :return: An iterator over lists of ((rev_id, revno, merge_depth), rev,
299
827
        delta).
300
828
    """
311
839
                yield (view, None, None)
312
840
        log_rev_iterator = iter([_convert()])
313
841
    for adapter in log_adapters:
314
 
        log_rev_iterator = adapter(branch, generate_delta, search,
315
 
            log_rev_iterator)
 
842
        # It would be nicer if log adapters were first class objects
 
843
        # with custom parameters. This will do for now. IGC 20090127
 
844
        if adapter == _make_delta_filter:
 
845
            log_rev_iterator = adapter(branch, generate_delta,
 
846
                search, log_rev_iterator, file_ids, direction)
 
847
        else:
 
848
            log_rev_iterator = adapter(branch, generate_delta,
 
849
                search, log_rev_iterator)
316
850
    return log_rev_iterator
317
851
 
318
852
 
319
 
def _make_search_filter(branch, generate_delta, search, log_rev_iterator):
 
853
def _make_search_filter(branch, generate_delta, match, log_rev_iterator):
320
854
    """Create a filtered iterator of log_rev_iterator matching on a regex.
321
855
 
322
856
    :param branch: The branch being logged.
323
857
    :param generate_delta: Whether to generate a delta for each revision.
 
858
    :param match: A dictionary with properties as keys and lists of strings
 
859
        as values. To match, a revision may match any of the supplied strings
 
860
        within a single property but must match at least one string for each
 
861
        property.
 
862
    :param log_rev_iterator: An input iterator containing all revisions that
 
863
        could be displayed, in lists.
 
864
    :return: An iterator over lists of ((rev_id, revno, merge_depth), rev,
 
865
        delta).
 
866
    """
 
867
    if match is None:
 
868
        return log_rev_iterator
 
869
    searchRE = [(k, [re.compile(x, re.IGNORECASE) for x in v])
 
870
                for (k,v) in match.iteritems()]
 
871
    return _filter_re(searchRE, log_rev_iterator)
 
872
 
 
873
 
 
874
def _filter_re(searchRE, log_rev_iterator):
 
875
    for revs in log_rev_iterator:
 
876
        new_revs = [rev for rev in revs if _match_filter(searchRE, rev[1])]
 
877
        if new_revs:
 
878
            yield new_revs
 
879
 
 
880
def _match_filter(searchRE, rev):
 
881
    strings = {
 
882
               'message': (rev.message,),
 
883
               'committer': (rev.committer,),
 
884
               'author': (rev.get_apparent_authors()),
 
885
               'bugs': list(rev.iter_bugs())
 
886
               }
 
887
    strings[''] = [item for inner_list in strings.itervalues()
 
888
                   for item in inner_list]
 
889
    for (k,v) in searchRE:
 
890
        if k in strings and not _match_any_filter(strings[k], v):
 
891
            return False
 
892
    return True
 
893
 
 
894
def _match_any_filter(strings, res):
 
895
    return any([filter(None, map(re.search, strings)) for re in res])
 
896
 
 
897
def _make_delta_filter(branch, generate_delta, search, log_rev_iterator,
 
898
    fileids=None, direction='reverse'):
 
899
    """Add revision deltas to a log iterator if needed.
 
900
 
 
901
    :param branch: The branch being logged.
 
902
    :param generate_delta: Whether to generate a delta for each revision.
 
903
      Permitted values are None, 'full' and 'partial'.
324
904
    :param search: A user text search string.
325
905
    :param log_rev_iterator: An input iterator containing all revisions that
326
906
        could be displayed, in lists.
 
907
    :param fileids: If non empty, only revisions matching one or more of
 
908
      the file-ids are to be kept.
 
909
    :param direction: the direction in which view_revisions is sorted
327
910
    :return: An iterator over lists of ((rev_id, revno, merge_depth), rev,
328
911
        delta).
329
912
    """
330
 
    if search is None:
 
913
    if not generate_delta and not fileids:
331
914
        return log_rev_iterator
332
 
    # Compile the search now to get early errors.
333
 
    searchRE = re.compile(search, re.IGNORECASE)
334
 
    return _filter_message_re(searchRE, log_rev_iterator)
335
 
 
336
 
 
337
 
def _filter_message_re(searchRE, log_rev_iterator):
 
915
    return _generate_deltas(branch.repository, log_rev_iterator,
 
916
        generate_delta, fileids, direction)
 
917
 
 
918
 
 
919
def _generate_deltas(repository, log_rev_iterator, delta_type, fileids,
 
920
    direction):
 
921
    """Create deltas for each batch of revisions in log_rev_iterator.
 
922
 
 
923
    If we're only generating deltas for the sake of filtering against
 
924
    file-ids, we stop generating deltas once all file-ids reach the
 
925
    appropriate life-cycle point. If we're receiving data newest to
 
926
    oldest, then that life-cycle point is 'add', otherwise it's 'remove'.
 
927
    """
 
928
    check_fileids = fileids is not None and len(fileids) > 0
 
929
    if check_fileids:
 
930
        fileid_set = set(fileids)
 
931
        if direction == 'reverse':
 
932
            stop_on = 'add'
 
933
        else:
 
934
            stop_on = 'remove'
 
935
    else:
 
936
        fileid_set = None
338
937
    for revs in log_rev_iterator:
 
938
        # If we were matching against fileids and we've run out,
 
939
        # there's nothing left to do
 
940
        if check_fileids and not fileid_set:
 
941
            return
 
942
        revisions = [rev[1] for rev in revs]
339
943
        new_revs = []
340
 
        for (rev_id, revno, merge_depth), rev, delta in revs:
341
 
            if searchRE.search(rev.message):
342
 
                new_revs.append(((rev_id, revno, merge_depth), rev, delta))
 
944
        if delta_type == 'full' and not check_fileids:
 
945
            deltas = repository.get_deltas_for_revisions(revisions)
 
946
            for rev, delta in izip(revs, deltas):
 
947
                new_revs.append((rev[0], rev[1], delta))
 
948
        else:
 
949
            deltas = repository.get_deltas_for_revisions(revisions, fileid_set)
 
950
            for rev, delta in izip(revs, deltas):
 
951
                if check_fileids:
 
952
                    if delta is None or not delta.has_changed():
 
953
                        continue
 
954
                    else:
 
955
                        _update_fileids(delta, fileid_set, stop_on)
 
956
                        if delta_type is None:
 
957
                            delta = None
 
958
                        elif delta_type == 'full':
 
959
                            # If the file matches all the time, rebuilding
 
960
                            # a full delta like this in addition to a partial
 
961
                            # one could be slow. However, it's likely that
 
962
                            # most revisions won't get this far, making it
 
963
                            # faster to filter on the partial deltas and
 
964
                            # build the occasional full delta than always
 
965
                            # building full deltas and filtering those.
 
966
                            rev_id = rev[0][0]
 
967
                            delta = repository.get_revision_delta(rev_id)
 
968
                new_revs.append((rev[0], rev[1], delta))
343
969
        yield new_revs
344
970
 
345
971
 
346
 
def _make_delta_filter(branch, generate_delta, search, log_rev_iterator):
347
 
    """Add revision deltas to a log iterator if needed.
 
972
def _update_fileids(delta, fileids, stop_on):
 
973
    """Update the set of file-ids to search based on file lifecycle events.
348
974
 
349
 
    :param branch: The branch being logged.
350
 
    :param generate_delta: Whether to generate a delta for each revision.
351
 
    :param search: A user text search string.
352
 
    :param log_rev_iterator: An input iterator containing all revisions that
353
 
        could be displayed, in lists.
354
 
    :return: An iterator over lists of ((rev_id, revno, merge_depth), rev,
355
 
        delta).
 
975
    :param fileids: a set of fileids to update
 
976
    :param stop_on: either 'add' or 'remove' - take file-ids out of the
 
977
      fileids set once their add or remove entry is detected respectively
356
978
    """
357
 
    if not generate_delta:
358
 
        return log_rev_iterator
359
 
    return _generate_deltas(branch.repository, log_rev_iterator)
360
 
 
361
 
 
362
 
def _generate_deltas(repository, log_rev_iterator):
363
 
    """Create deltas for each batch of revisions in log_rev_iterator."""
364
 
    for revs in log_rev_iterator:
365
 
        revisions = [rev[1] for rev in revs]
366
 
        deltas = repository.get_deltas_for_revisions(revisions)
367
 
        revs = [(rev[0], rev[1], delta) for rev, delta in izip(revs, deltas)]
368
 
        yield revs
 
979
    if stop_on == 'add':
 
980
        for item in delta.added:
 
981
            if item[1] in fileids:
 
982
                fileids.remove(item[1])
 
983
    elif stop_on == 'delete':
 
984
        for item in delta.removed:
 
985
            if item[1] in fileids:
 
986
                fileids.remove(item[1])
369
987
 
370
988
 
371
989
def _make_revision_objects(branch, generate_delta, search, log_rev_iterator):
397
1015
    :param search: A user text search string.
398
1016
    :param log_rev_iterator: An input iterator containing all revisions that
399
1017
        could be displayed, in lists.
400
 
    :return: An iterator over lists of ((rev_id, revno, merge_depth), rev, delta).
 
1018
    :return: An iterator over lists of ((rev_id, revno, merge_depth), rev,
 
1019
        delta).
401
1020
    """
402
 
    repository = branch.repository
403
1021
    num = 9
404
1022
    for batch in log_rev_iterator:
405
1023
        batch = iter(batch)
411
1029
            num = min(int(num * 1.5), 200)
412
1030
 
413
1031
 
 
1032
def _get_revision_limits(branch, start_revision, end_revision):
 
1033
    """Get and check revision limits.
 
1034
 
 
1035
    :param  branch: The branch containing the revisions.
 
1036
 
 
1037
    :param  start_revision: The first revision to be logged.
 
1038
            For backwards compatibility this may be a mainline integer revno,
 
1039
            but for merge revision support a RevisionInfo is expected.
 
1040
 
 
1041
    :param  end_revision: The last revision to be logged.
 
1042
            For backwards compatibility this may be a mainline integer revno,
 
1043
            but for merge revision support a RevisionInfo is expected.
 
1044
 
 
1045
    :return: (start_rev_id, end_rev_id) tuple.
 
1046
    """
 
1047
    branch_revno, branch_rev_id = branch.last_revision_info()
 
1048
    start_rev_id = None
 
1049
    if start_revision is None:
 
1050
        start_revno = 1
 
1051
    else:
 
1052
        if isinstance(start_revision, revisionspec.RevisionInfo):
 
1053
            start_rev_id = start_revision.rev_id
 
1054
            start_revno = start_revision.revno or 1
 
1055
        else:
 
1056
            branch.check_real_revno(start_revision)
 
1057
            start_revno = start_revision
 
1058
            start_rev_id = branch.get_rev_id(start_revno)
 
1059
 
 
1060
    end_rev_id = None
 
1061
    if end_revision is None:
 
1062
        end_revno = branch_revno
 
1063
    else:
 
1064
        if isinstance(end_revision, revisionspec.RevisionInfo):
 
1065
            end_rev_id = end_revision.rev_id
 
1066
            end_revno = end_revision.revno or branch_revno
 
1067
        else:
 
1068
            branch.check_real_revno(end_revision)
 
1069
            end_revno = end_revision
 
1070
            end_rev_id = branch.get_rev_id(end_revno)
 
1071
 
 
1072
    if branch_revno != 0:
 
1073
        if (start_rev_id == _mod_revision.NULL_REVISION
 
1074
            or end_rev_id == _mod_revision.NULL_REVISION):
 
1075
            raise errors.BzrCommandError('Logging revision 0 is invalid.')
 
1076
        if start_revno > end_revno:
 
1077
            raise errors.BzrCommandError("Start revision must be older than "
 
1078
                                         "the end revision.")
 
1079
    return (start_rev_id, end_rev_id)
 
1080
 
 
1081
 
414
1082
def _get_mainline_revs(branch, start_revision, end_revision):
415
1083
    """Get the mainline revisions from the branch.
416
 
    
 
1084
 
417
1085
    Generates the list of mainline revisions for the branch.
418
 
    
419
 
    :param  branch: The branch containing the revisions. 
 
1086
 
 
1087
    :param  branch: The branch containing the revisions.
420
1088
 
421
1089
    :param  start_revision: The first revision to be logged.
422
1090
            For backwards compatibility this may be a mainline integer revno,
432
1100
    if branch_revno == 0:
433
1101
        return None, None, None, None
434
1102
 
435
 
    # For mainline generation, map start_revision and end_revision to 
436
 
    # mainline revnos. If the revision is not on the mainline choose the 
437
 
    # appropriate extreme of the mainline instead - the extra will be 
 
1103
    # For mainline generation, map start_revision and end_revision to
 
1104
    # mainline revnos. If the revision is not on the mainline choose the
 
1105
    # appropriate extreme of the mainline instead - the extra will be
438
1106
    # filtered later.
439
1107
    # Also map the revisions to rev_ids, to be used in the later filtering
440
1108
    # stage.
441
 
    start_rev_id = None 
 
1109
    start_rev_id = None
442
1110
    if start_revision is None:
443
1111
        start_revno = 1
444
1112
    else:
448
1116
        else:
449
1117
            branch.check_real_revno(start_revision)
450
1118
            start_revno = start_revision
451
 
    
 
1119
 
452
1120
    end_rev_id = None
453
1121
    if end_revision is None:
454
1122
        end_revno = branch_revno
472
1140
    cur_revno = branch_revno
473
1141
    rev_nos = {}
474
1142
    mainline_revs = []
475
 
    for revision_id in branch.repository.iter_reverse_revision_history(
476
 
                        branch_last_revision):
 
1143
    graph = branch.repository.get_graph()
 
1144
    for revision_id in graph.iter_lefthand_ancestry(
 
1145
            branch_last_revision, (_mod_revision.NULL_REVISION,)):
477
1146
        if cur_revno < start_revno:
478
1147
            # We have gone far enough, but we always add 1 more revision
479
1148
            rev_nos[revision_id] = cur_revno
493
1162
    return mainline_revs, rev_nos, start_rev_id, end_rev_id
494
1163
 
495
1164
 
496
 
def _filter_revision_range(view_revisions, start_rev_id, end_rev_id):
497
 
    """Filter view_revisions based on revision ranges.
498
 
 
499
 
    :param view_revisions: A list of (revision_id, dotted_revno, merge_depth) 
500
 
            tuples to be filtered.
501
 
 
502
 
    :param start_rev_id: If not NONE specifies the first revision to be logged.
503
 
            If NONE then all revisions up to the end_rev_id are logged.
504
 
 
505
 
    :param end_rev_id: If not NONE specifies the last revision to be logged.
506
 
            If NONE then all revisions up to the end of the log are logged.
507
 
 
508
 
    :return: The filtered view_revisions.
509
 
    """
510
 
    if start_rev_id or end_rev_id: 
511
 
        revision_ids = [r for r, n, d in view_revisions]
512
 
        if start_rev_id:
513
 
            start_index = revision_ids.index(start_rev_id)
514
 
        else:
515
 
            start_index = 0
516
 
        if start_rev_id == end_rev_id:
517
 
            end_index = start_index
518
 
        else:
519
 
            if end_rev_id:
520
 
                end_index = revision_ids.index(end_rev_id)
521
 
            else:
522
 
                end_index = len(view_revisions) - 1
523
 
        # To include the revisions merged into the last revision, 
524
 
        # extend end_rev_id down to, but not including, the next rev
525
 
        # with the same or lesser merge_depth
526
 
        end_merge_depth = view_revisions[end_index][2]
527
 
        try:
528
 
            for index in xrange(end_index+1, len(view_revisions)+1):
529
 
                if view_revisions[index][2] <= end_merge_depth:
530
 
                    end_index = index - 1
531
 
                    break
532
 
        except IndexError:
533
 
            # if the search falls off the end then log to the end as well
534
 
            end_index = len(view_revisions) - 1
535
 
        view_revisions = view_revisions[start_index:end_index+1]
536
 
    return view_revisions
537
 
 
538
 
 
539
1165
def _filter_revisions_touching_file_id(branch, file_id, view_revisions,
540
 
                                       direction):
 
1166
    include_merges=True):
541
1167
    r"""Return the list of revision ids which touch a given file id.
542
1168
 
543
1169
    The function filters view_revisions and returns a subset.
544
1170
    This includes the revisions which directly change the file id,
545
1171
    and the revisions which merge these changes. So if the
546
1172
    revision graph is::
 
1173
 
547
1174
        A-.
548
1175
        |\ \
549
1176
        B C E
562
1189
    This will also be restricted based on a subset of the mainline.
563
1190
 
564
1191
    :param branch: The branch where we can get text revision information.
 
1192
 
565
1193
    :param file_id: Filter out revisions that do not touch file_id.
 
1194
 
566
1195
    :param view_revisions: A list of (revision_id, dotted_revno, merge_depth)
567
1196
        tuples. This is the list of revisions which will be filtered. It is
568
 
        assumed that view_revisions is in merge_sort order (either forward or
569
 
        reverse).
570
 
    :param direction: The direction of view_revisions.  See also
571
 
        reverse_by_depth, and get_view_revisions
 
1197
        assumed that view_revisions is in merge_sort order (i.e. newest
 
1198
        revision first ).
 
1199
 
 
1200
    :param include_merges: include merge revisions in the result or not
 
1201
 
572
1202
    :return: A list of (revision_id, dotted_revno, merge_depth) tuples.
573
1203
    """
574
1204
    # Lookup all possible text keys to determine which ones actually modified
575
1205
    # the file.
 
1206
    graph = branch.repository.get_file_graph()
 
1207
    get_parent_map = graph.get_parent_map
576
1208
    text_keys = [(file_id, rev_id) for rev_id, revno, depth in view_revisions]
 
1209
    next_keys = None
577
1210
    # Looking up keys in batches of 1000 can cut the time in half, as well as
578
1211
    # memory consumption. GraphIndex *does* like to look for a few keys in
579
1212
    # parallel, it just doesn't like looking for *lots* of keys in parallel.
581
1214
    #       indexing layer. We might consider passing in hints as to the known
582
1215
    #       access pattern (sparse/clustered, high success rate/low success
583
1216
    #       rate). This particular access is clustered with a low success rate.
584
 
    get_parent_map = branch.repository.texts.get_parent_map
585
1217
    modified_text_revisions = set()
586
1218
    chunk_size = 1000
587
1219
    for start in xrange(0, len(text_keys), chunk_size):
592
1224
    del text_keys, next_keys
593
1225
 
594
1226
    result = []
595
 
    if direction == 'forward':
596
 
        # TODO: The algorithm for finding 'merges' of file changes expects
597
 
        #       'reverse' order (the default from 'merge_sort()'). Instead of
598
 
        #       forcing this, we could just use the reverse_by_depth order.
599
 
        view_revisions = reverse_by_depth(view_revisions)
600
1227
    # Track what revisions will merge the current revision, replace entries
601
1228
    # with 'None' when they have been added to result
602
1229
    current_merge_stack = [None]
613
1240
            for idx in xrange(len(current_merge_stack)):
614
1241
                node = current_merge_stack[idx]
615
1242
                if node is not None:
616
 
                    result.append(node)
617
 
                    current_merge_stack[idx] = None
618
 
    if direction == 'forward':
619
 
        result = reverse_by_depth(result)
 
1243
                    if include_merges or node[2] == 0:
 
1244
                        result.append(node)
 
1245
                        current_merge_stack[idx] = None
620
1246
    return result
621
1247
 
622
1248
 
623
 
def get_view_revisions(mainline_revs, rev_nos, branch, direction,
624
 
                       include_merges=True):
625
 
    """Produce an iterator of revisions to show
626
 
    :return: an iterator of (revision_id, revno, merge_depth)
627
 
    (if there is no revno for a revision, None is supplied)
628
 
    """
629
 
    if include_merges is False:
630
 
        revision_ids = mainline_revs[1:]
631
 
        if direction == 'reverse':
632
 
            revision_ids.reverse()
633
 
        for revision_id in revision_ids:
634
 
            yield revision_id, str(rev_nos[revision_id]), 0
635
 
        return
636
 
    graph = branch.repository.get_graph()
637
 
    # This asks for all mainline revisions, which means we only have to spider
638
 
    # sideways, rather than depth history. That said, its still size-of-history
639
 
    # and should be addressed.
640
 
    # mainline_revisions always includes an extra revision at the beginning, so
641
 
    # don't request it.
642
 
    parent_map = dict(((key, value) for key, value in
643
 
        graph.iter_ancestry(mainline_revs[1:]) if value is not None))
644
 
    # filter out ghosts; merge_sort errors on ghosts.
645
 
    rev_graph = _mod_repository._strip_NULL_ghosts(parent_map)
646
 
    merge_sorted_revisions = tsort.merge_sort(
647
 
        rev_graph,
648
 
        mainline_revs[-1],
649
 
        mainline_revs,
650
 
        generate_revno=True)
651
 
 
652
 
    if direction == 'forward':
653
 
        # forward means oldest first.
654
 
        merge_sorted_revisions = reverse_by_depth(merge_sorted_revisions)
655
 
    elif direction != 'reverse':
656
 
        raise ValueError('invalid direction %r' % direction)
657
 
 
658
 
    for sequence, rev_id, merge_depth, revno, end_of_merge in merge_sorted_revisions:
659
 
        yield rev_id, '.'.join(map(str, revno)), merge_depth
660
 
 
661
 
 
662
1249
def reverse_by_depth(merge_sorted_revisions, _depth=0):
663
1250
    """Reverse revisions by depth.
664
1251
 
666
1253
    revision of that depth.  There may be no topological justification for this,
667
1254
    but it looks much nicer.
668
1255
    """
 
1256
    # Add a fake revision at start so that we can always attach sub revisions
 
1257
    merge_sorted_revisions = [(None, None, _depth)] + merge_sorted_revisions
669
1258
    zd_revisions = []
670
1259
    for val in merge_sorted_revisions:
671
1260
        if val[2] == _depth:
 
1261
            # Each revision at the current depth becomes a chunk grouping all
 
1262
            # higher depth revisions.
672
1263
            zd_revisions.append([val])
673
1264
        else:
674
1265
            zd_revisions[-1].append(val)
675
1266
    for revisions in zd_revisions:
676
1267
        if len(revisions) > 1:
 
1268
            # We have higher depth revisions, let reverse them locally
677
1269
            revisions[1:] = reverse_by_depth(revisions[1:], _depth + 1)
678
1270
    zd_revisions.reverse()
679
1271
    result = []
680
1272
    for chunk in zd_revisions:
681
1273
        result.extend(chunk)
 
1274
    if _depth == 0:
 
1275
        # Top level call, get rid of the fake revisions that have been added
 
1276
        result = [r for r in result if r[0] is not None and r[1] is not None]
682
1277
    return result
683
1278
 
684
1279
 
686
1281
    """A revision to be logged (by LogFormatter.log_revision).
687
1282
 
688
1283
    A simple wrapper for the attributes of a revision to be logged.
689
 
    The attributes may or may not be populated, as determined by the 
 
1284
    The attributes may or may not be populated, as determined by the
690
1285
    logging options and the log formatter capabilities.
691
1286
    """
692
1287
 
693
1288
    def __init__(self, rev=None, revno=None, merge_depth=0, delta=None,
694
 
                 tags=None):
 
1289
                 tags=None, diff=None, signature=None):
695
1290
        self.rev = rev
696
 
        self.revno = revno
 
1291
        if revno is None:
 
1292
            self.revno = None
 
1293
        else:
 
1294
            self.revno = str(revno)
697
1295
        self.merge_depth = merge_depth
698
1296
        self.delta = delta
699
1297
        self.tags = tags
 
1298
        self.diff = diff
 
1299
        self.signature = signature
700
1300
 
701
1301
 
702
1302
class LogFormatter(object):
707
1307
    If the LogFormatter needs to be informed of the beginning or end of
708
1308
    a log it should implement the begin_log and/or end_log hook methods.
709
1309
 
710
 
    A LogFormatter should define the following supports_XXX flags 
 
1310
    A LogFormatter should define the following supports_XXX flags
711
1311
    to indicate which LogRevision attributes it supports:
712
1312
 
713
1313
    - supports_delta must be True if this log formatter supports delta.
714
 
        Otherwise the delta attribute may not be populated.
715
 
    - supports_merge_revisions must be True if this log formatter supports 
716
 
        merge revisions.  If not, and if supports_single_merge_revisions is
717
 
        also not True, then only mainline revisions will be passed to the 
718
 
        formatter.
719
 
    - supports_single_merge_revision must be True if this log formatter
720
 
        supports logging only a single merge revision.  This flag is
721
 
        only relevant if supports_merge_revisions is not True.
 
1314
      Otherwise the delta attribute may not be populated.  The 'delta_format'
 
1315
      attribute describes whether the 'short_status' format (1) or the long
 
1316
      one (2) should be used.
 
1317
 
 
1318
    - supports_merge_revisions must be True if this log formatter supports
 
1319
      merge revisions.  If not, then only mainline revisions will be passed
 
1320
      to the formatter.
 
1321
 
 
1322
    - preferred_levels is the number of levels this formatter defaults to.
 
1323
      The default value is zero meaning display all levels.
 
1324
      This value is only relevant if supports_merge_revisions is True.
 
1325
 
722
1326
    - supports_tags must be True if this log formatter supports tags.
723
 
        Otherwise the tags attribute may not be populated.
 
1327
      Otherwise the tags attribute may not be populated.
 
1328
 
 
1329
    - supports_diff must be True if this log formatter supports diffs.
 
1330
      Otherwise the diff attribute may not be populated.
 
1331
 
 
1332
    - supports_signatures must be True if this log formatter supports GPG
 
1333
      signatures.
724
1334
 
725
1335
    Plugins can register functions to show custom revision properties using
726
1336
    the properties_handler_registry. The registered function
727
 
    must respect the following interface description:
 
1337
    must respect the following interface description::
 
1338
 
728
1339
        def my_show_properties(properties_dict):
729
 
            # code that returns a dict {'name':'value'} of the properties 
 
1340
            # code that returns a dict {'name':'value'} of the properties
730
1341
            # to be shown
731
1342
    """
732
 
 
733
 
    def __init__(self, to_file, show_ids=False, show_timezone='original'):
 
1343
    preferred_levels = 0
 
1344
 
 
1345
    def __init__(self, to_file, show_ids=False, show_timezone='original',
 
1346
                 delta_format=None, levels=None, show_advice=False,
 
1347
                 to_exact_file=None, author_list_handler=None):
 
1348
        """Create a LogFormatter.
 
1349
 
 
1350
        :param to_file: the file to output to
 
1351
        :param to_exact_file: if set, gives an output stream to which
 
1352
             non-Unicode diffs are written.
 
1353
        :param show_ids: if True, revision-ids are to be displayed
 
1354
        :param show_timezone: the timezone to use
 
1355
        :param delta_format: the level of delta information to display
 
1356
          or None to leave it to the formatter to decide
 
1357
        :param levels: the number of levels to display; None or -1 to
 
1358
          let the log formatter decide.
 
1359
        :param show_advice: whether to show advice at the end of the
 
1360
          log or not
 
1361
        :param author_list_handler: callable generating a list of
 
1362
          authors to display for a given revision
 
1363
        """
734
1364
        self.to_file = to_file
 
1365
        # 'exact' stream used to show diff, it should print content 'as is'
 
1366
        # and should not try to decode/encode it to unicode to avoid bug #328007
 
1367
        if to_exact_file is not None:
 
1368
            self.to_exact_file = to_exact_file
 
1369
        else:
 
1370
            # XXX: somewhat hacky; this assumes it's a codec writer; it's better
 
1371
            # for code that expects to get diffs to pass in the exact file
 
1372
            # stream
 
1373
            self.to_exact_file = getattr(to_file, 'stream', to_file)
735
1374
        self.show_ids = show_ids
736
1375
        self.show_timezone = show_timezone
737
 
 
738
 
# TODO: uncomment this block after show() has been removed.
739
 
# Until then defining log_revision would prevent _show_log calling show() 
740
 
# in legacy formatters.
741
 
#    def log_revision(self, revision):
742
 
#        """Log a revision.
743
 
#
744
 
#        :param  revision:   The LogRevision to be logged.
745
 
#        """
746
 
#        raise NotImplementedError('not implemented in abstract base')
 
1376
        if delta_format is None:
 
1377
            # Ensures backward compatibility
 
1378
            delta_format = 2 # long format
 
1379
        self.delta_format = delta_format
 
1380
        self.levels = levels
 
1381
        self._show_advice = show_advice
 
1382
        self._merge_count = 0
 
1383
        self._author_list_handler = author_list_handler
 
1384
 
 
1385
    def get_levels(self):
 
1386
        """Get the number of levels to display or 0 for all."""
 
1387
        if getattr(self, 'supports_merge_revisions', False):
 
1388
            if self.levels is None or self.levels == -1:
 
1389
                self.levels = self.preferred_levels
 
1390
        else:
 
1391
            self.levels = 1
 
1392
        return self.levels
 
1393
 
 
1394
    def log_revision(self, revision):
 
1395
        """Log a revision.
 
1396
 
 
1397
        :param  revision:   The LogRevision to be logged.
 
1398
        """
 
1399
        raise NotImplementedError('not implemented in abstract base')
 
1400
 
 
1401
    def show_advice(self):
 
1402
        """Output user advice, if any, when the log is completed."""
 
1403
        if self._show_advice and self.levels == 1 and self._merge_count > 0:
 
1404
            advice_sep = self.get_advice_separator()
 
1405
            if advice_sep:
 
1406
                self.to_file.write(advice_sep)
 
1407
            self.to_file.write(
 
1408
                "Use --include-merges or -n0 to see merged revisions.\n")
 
1409
 
 
1410
    def get_advice_separator(self):
 
1411
        """Get the text separating the log from the closing advice."""
 
1412
        return ''
747
1413
 
748
1414
    def short_committer(self, rev):
749
1415
        name, address = config.parse_username(rev.committer)
752
1418
        return address
753
1419
 
754
1420
    def short_author(self, rev):
755
 
        name, address = config.parse_username(rev.get_apparent_author())
756
 
        if name:
757
 
            return name
758
 
        return address
 
1421
        return self.authors(rev, 'first', short=True, sep=', ')
 
1422
 
 
1423
    def authors(self, rev, who, short=False, sep=None):
 
1424
        """Generate list of authors, taking --authors option into account.
 
1425
 
 
1426
        The caller has to specify the name of a author list handler,
 
1427
        as provided by the author list registry, using the ``who``
 
1428
        argument.  That name only sets a default, though: when the
 
1429
        user selected a different author list generation using the
 
1430
        ``--authors`` command line switch, as represented by the
 
1431
        ``author_list_handler`` constructor argument, that value takes
 
1432
        precedence.
 
1433
 
 
1434
        :param rev: The revision for which to generate the list of authors.
 
1435
        :param who: Name of the default handler.
 
1436
        :param short: Whether to shorten names to either name or address.
 
1437
        :param sep: What separator to use for automatic concatenation.
 
1438
        """
 
1439
        if self._author_list_handler is not None:
 
1440
            # The user did specify --authors, which overrides the default
 
1441
            author_list_handler = self._author_list_handler
 
1442
        else:
 
1443
            # The user didn't specify --authors, so we use the caller's default
 
1444
            author_list_handler = author_list_registry.get(who)
 
1445
        names = author_list_handler(rev)
 
1446
        if short:
 
1447
            for i in range(len(names)):
 
1448
                name, address = config.parse_username(names[i])
 
1449
                if name:
 
1450
                    names[i] = name
 
1451
                else:
 
1452
                    names[i] = address
 
1453
        if sep is not None:
 
1454
            names = sep.join(names)
 
1455
        return names
 
1456
 
 
1457
    def merge_marker(self, revision):
 
1458
        """Get the merge marker to include in the output or '' if none."""
 
1459
        if len(revision.rev.parent_ids) > 1:
 
1460
            self._merge_count += 1
 
1461
            return ' [merge]'
 
1462
        else:
 
1463
            return ''
759
1464
 
760
1465
    def show_properties(self, revision, indent):
761
1466
        """Displays the custom properties returned by each registered handler.
762
 
        
763
 
        If a registered handler raises an error it is propagated.
764
 
        """
 
1467
 
 
1468
        If a registered handler raises an error it is propagated.
 
1469
        """
 
1470
        for line in self.custom_properties(revision):
 
1471
            self.to_file.write("%s%s\n" % (indent, line))
 
1472
 
 
1473
    def custom_properties(self, revision):
 
1474
        """Format the custom properties returned by each registered handler.
 
1475
 
 
1476
        If a registered handler raises an error it is propagated.
 
1477
 
 
1478
        :return: a list of formatted lines (excluding trailing newlines)
 
1479
        """
 
1480
        lines = self._foreign_info_properties(revision)
765
1481
        for key, handler in properties_handler_registry.iteritems():
766
 
            for key, value in handler(revision).items():
767
 
                self.to_file.write(indent + key + ': ' + value + '\n')
 
1482
            lines.extend(self._format_properties(handler(revision)))
 
1483
        return lines
 
1484
 
 
1485
    def _foreign_info_properties(self, rev):
 
1486
        """Custom log displayer for foreign revision identifiers.
 
1487
 
 
1488
        :param rev: Revision object.
 
1489
        """
 
1490
        # Revision comes directly from a foreign repository
 
1491
        if isinstance(rev, foreign.ForeignRevision):
 
1492
            return self._format_properties(
 
1493
                rev.mapping.vcs.show_foreign_revid(rev.foreign_revid))
 
1494
 
 
1495
        # Imported foreign revision revision ids always contain :
 
1496
        if not ":" in rev.revision_id:
 
1497
            return []
 
1498
 
 
1499
        # Revision was once imported from a foreign repository
 
1500
        try:
 
1501
            foreign_revid, mapping = \
 
1502
                foreign.foreign_vcs_registry.parse_revision_id(rev.revision_id)
 
1503
        except errors.InvalidRevisionId:
 
1504
            return []
 
1505
 
 
1506
        return self._format_properties(
 
1507
            mapping.vcs.show_foreign_revid(foreign_revid))
 
1508
 
 
1509
    def _format_properties(self, properties):
 
1510
        lines = []
 
1511
        for key, value in properties.items():
 
1512
            lines.append(key + ': ' + value)
 
1513
        return lines
 
1514
 
 
1515
    def show_diff(self, to_file, diff, indent):
 
1516
        for l in diff.rstrip().split('\n'):
 
1517
            to_file.write(indent + '%s\n' % (l,))
 
1518
 
 
1519
 
 
1520
# Separator between revisions in long format
 
1521
_LONG_SEP = '-' * 60
768
1522
 
769
1523
 
770
1524
class LongLogFormatter(LogFormatter):
771
1525
 
772
1526
    supports_merge_revisions = True
 
1527
    preferred_levels = 1
773
1528
    supports_delta = True
774
1529
    supports_tags = True
 
1530
    supports_diff = True
 
1531
    supports_signatures = True
 
1532
 
 
1533
    def __init__(self, *args, **kwargs):
 
1534
        super(LongLogFormatter, self).__init__(*args, **kwargs)
 
1535
        if self.show_timezone == 'original':
 
1536
            self.date_string = self._date_string_original_timezone
 
1537
        else:
 
1538
            self.date_string = self._date_string_with_timezone
 
1539
 
 
1540
    def _date_string_with_timezone(self, rev):
 
1541
        return format_date(rev.timestamp, rev.timezone or 0,
 
1542
                           self.show_timezone)
 
1543
 
 
1544
    def _date_string_original_timezone(self, rev):
 
1545
        return format_date_with_offset_in_original_timezone(rev.timestamp,
 
1546
            rev.timezone or 0)
775
1547
 
776
1548
    def log_revision(self, revision):
777
1549
        """Log a revision, either merged or not."""
778
1550
        indent = '    ' * revision.merge_depth
779
 
        to_file = self.to_file
780
 
        to_file.write(indent + '-' * 60 + '\n')
 
1551
        lines = [_LONG_SEP]
781
1552
        if revision.revno is not None:
782
 
            to_file.write(indent + 'revno: %s\n' % (revision.revno,))
 
1553
            lines.append('revno: %s%s' % (revision.revno,
 
1554
                self.merge_marker(revision)))
783
1555
        if revision.tags:
784
 
            to_file.write(indent + 'tags: %s\n' % (', '.join(revision.tags)))
 
1556
            lines.append('tags: %s' % (', '.join(revision.tags)))
 
1557
        if self.show_ids or revision.revno is None:
 
1558
            lines.append('revision-id: %s' % (revision.rev.revision_id,))
785
1559
        if self.show_ids:
786
 
            to_file.write(indent + 'revision-id: ' + revision.rev.revision_id)
787
 
            to_file.write('\n')
788
1560
            for parent_id in revision.rev.parent_ids:
789
 
                to_file.write(indent + 'parent: %s\n' % (parent_id,))
790
 
        self.show_properties(revision.rev, indent)
 
1561
                lines.append('parent: %s' % (parent_id,))
 
1562
        lines.extend(self.custom_properties(revision.rev))
791
1563
 
792
 
        author = revision.rev.properties.get('author', None)
793
 
        if author is not None:
794
 
            to_file.write(indent + 'author: %s\n' % (author,))
795
 
        to_file.write(indent + 'committer: %s\n' % (revision.rev.committer,))
 
1564
        committer = revision.rev.committer
 
1565
        authors = self.authors(revision.rev, 'all')
 
1566
        if authors != [committer]:
 
1567
            lines.append('author: %s' % (", ".join(authors),))
 
1568
        lines.append('committer: %s' % (committer,))
796
1569
 
797
1570
        branch_nick = revision.rev.properties.get('branch-nick', None)
798
1571
        if branch_nick is not None:
799
 
            to_file.write(indent + 'branch nick: %s\n' % (branch_nick,))
800
 
 
801
 
        date_str = format_date(revision.rev.timestamp,
802
 
                               revision.rev.timezone or 0,
803
 
                               self.show_timezone)
804
 
        to_file.write(indent + 'timestamp: %s\n' % (date_str,))
805
 
 
806
 
        to_file.write(indent + 'message:\n')
 
1572
            lines.append('branch nick: %s' % (branch_nick,))
 
1573
 
 
1574
        lines.append('timestamp: %s' % (self.date_string(revision.rev),))
 
1575
 
 
1576
        if revision.signature is not None:
 
1577
            lines.append('signature: ' + revision.signature)
 
1578
 
 
1579
        lines.append('message:')
807
1580
        if not revision.rev.message:
808
 
            to_file.write(indent + '  (no message)\n')
 
1581
            lines.append('  (no message)')
809
1582
        else:
810
1583
            message = revision.rev.message.rstrip('\r\n')
811
1584
            for l in message.split('\n'):
812
 
                to_file.write(indent + '  %s\n' % (l,))
 
1585
                lines.append('  %s' % (l,))
 
1586
 
 
1587
        # Dump the output, appending the delta and diff if requested
 
1588
        to_file = self.to_file
 
1589
        to_file.write("%s%s\n" % (indent, ('\n' + indent).join(lines)))
813
1590
        if revision.delta is not None:
814
 
            revision.delta.show(to_file, self.show_ids, indent=indent)
 
1591
            # Use the standard status output to display changes
 
1592
            from bzrlib.delta import report_delta
 
1593
            report_delta(to_file, revision.delta, short_status=False,
 
1594
                         show_ids=self.show_ids, indent=indent)
 
1595
        if revision.diff is not None:
 
1596
            to_file.write(indent + 'diff:\n')
 
1597
            to_file.flush()
 
1598
            # Note: we explicitly don't indent the diff (relative to the
 
1599
            # revision information) so that the output can be fed to patch -p0
 
1600
            self.show_diff(self.to_exact_file, revision.diff, indent)
 
1601
            self.to_exact_file.flush()
 
1602
 
 
1603
    def get_advice_separator(self):
 
1604
        """Get the text separating the log from the closing advice."""
 
1605
        return '-' * 60 + '\n'
815
1606
 
816
1607
 
817
1608
class ShortLogFormatter(LogFormatter):
818
1609
 
 
1610
    supports_merge_revisions = True
 
1611
    preferred_levels = 1
819
1612
    supports_delta = True
820
 
    supports_single_merge_revision = True
 
1613
    supports_tags = True
 
1614
    supports_diff = True
 
1615
 
 
1616
    def __init__(self, *args, **kwargs):
 
1617
        super(ShortLogFormatter, self).__init__(*args, **kwargs)
 
1618
        self.revno_width_by_depth = {}
821
1619
 
822
1620
    def log_revision(self, revision):
 
1621
        # We need two indents: one per depth and one for the information
 
1622
        # relative to that indent. Most mainline revnos are 5 chars or
 
1623
        # less while dotted revnos are typically 11 chars or less. Once
 
1624
        # calculated, we need to remember the offset for a given depth
 
1625
        # as we might be starting from a dotted revno in the first column
 
1626
        # and we want subsequent mainline revisions to line up.
 
1627
        depth = revision.merge_depth
 
1628
        indent = '    ' * depth
 
1629
        revno_width = self.revno_width_by_depth.get(depth)
 
1630
        if revno_width is None:
 
1631
            if revision.revno is None or revision.revno.find('.') == -1:
 
1632
                # mainline revno, e.g. 12345
 
1633
                revno_width = 5
 
1634
            else:
 
1635
                # dotted revno, e.g. 12345.10.55
 
1636
                revno_width = 11
 
1637
            self.revno_width_by_depth[depth] = revno_width
 
1638
        offset = ' ' * (revno_width + 1)
 
1639
 
823
1640
        to_file = self.to_file
824
 
        is_merge = ''
825
 
        if len(revision.rev.parent_ids) > 1:
826
 
            is_merge = ' [merge]'
827
 
        to_file.write("%5s %s\t%s%s\n" % (revision.revno,
828
 
                self.short_author(revision.rev),
 
1641
        tags = ''
 
1642
        if revision.tags:
 
1643
            tags = ' {%s}' % (', '.join(revision.tags))
 
1644
        to_file.write(indent + "%*s %s\t%s%s%s\n" % (revno_width,
 
1645
                revision.revno or "", self.short_author(revision.rev),
829
1646
                format_date(revision.rev.timestamp,
830
1647
                            revision.rev.timezone or 0,
831
1648
                            self.show_timezone, date_fmt="%Y-%m-%d",
832
1649
                            show_offset=False),
833
 
                is_merge))
834
 
        if self.show_ids:
835
 
            to_file.write('      revision-id:%s\n' % (revision.rev.revision_id,))
 
1650
                tags, self.merge_marker(revision)))
 
1651
        self.show_properties(revision.rev, indent+offset)
 
1652
        if self.show_ids or revision.revno is None:
 
1653
            to_file.write(indent + offset + 'revision-id:%s\n'
 
1654
                          % (revision.rev.revision_id,))
836
1655
        if not revision.rev.message:
837
 
            to_file.write('      (no message)\n')
 
1656
            to_file.write(indent + offset + '(no message)\n')
838
1657
        else:
839
1658
            message = revision.rev.message.rstrip('\r\n')
840
1659
            for l in message.split('\n'):
841
 
                to_file.write('      %s\n' % (l,))
 
1660
                to_file.write(indent + offset + '%s\n' % (l,))
842
1661
 
843
 
        # TODO: Why not show the modified files in a shorter form as
844
 
        # well? rewrap them single lines of appropriate length
845
1662
        if revision.delta is not None:
846
 
            revision.delta.show(to_file, self.show_ids)
 
1663
            # Use the standard status output to display changes
 
1664
            from bzrlib.delta import report_delta
 
1665
            report_delta(to_file, revision.delta,
 
1666
                         short_status=self.delta_format==1,
 
1667
                         show_ids=self.show_ids, indent=indent + offset)
 
1668
        if revision.diff is not None:
 
1669
            self.show_diff(self.to_exact_file, revision.diff, '      ')
847
1670
        to_file.write('\n')
848
1671
 
849
1672
 
850
1673
class LineLogFormatter(LogFormatter):
851
1674
 
852
 
    supports_single_merge_revision = True
 
1675
    supports_merge_revisions = True
 
1676
    preferred_levels = 1
 
1677
    supports_tags = True
853
1678
 
854
1679
    def __init__(self, *args, **kwargs):
855
1680
        super(LineLogFormatter, self).__init__(*args, **kwargs)
856
 
        self._max_chars = terminal_width() - 1
 
1681
        width = terminal_width()
 
1682
        if width is not None:
 
1683
            # we need one extra space for terminals that wrap on last char
 
1684
            width = width - 1
 
1685
        self._max_chars = width
857
1686
 
858
1687
    def truncate(self, str, max_len):
859
 
        if len(str) <= max_len:
 
1688
        if max_len is None or len(str) <= max_len:
860
1689
            return str
861
 
        return str[:max_len-3]+'...'
 
1690
        return str[:max_len-3] + '...'
862
1691
 
863
1692
    def date_string(self, rev):
864
 
        return format_date(rev.timestamp, rev.timezone or 0, 
 
1693
        return format_date(rev.timestamp, rev.timezone or 0,
865
1694
                           self.show_timezone, date_fmt="%Y-%m-%d",
866
1695
                           show_offset=False)
867
1696
 
872
1701
            return rev.message
873
1702
 
874
1703
    def log_revision(self, revision):
 
1704
        indent = '  ' * revision.merge_depth
875
1705
        self.to_file.write(self.log_string(revision.revno, revision.rev,
876
 
                                              self._max_chars))
 
1706
            self._max_chars, revision.tags, indent))
877
1707
        self.to_file.write('\n')
878
1708
 
879
 
    def log_string(self, revno, rev, max_chars):
 
1709
    def log_string(self, revno, rev, max_chars, tags=None, prefix=''):
880
1710
        """Format log info into one string. Truncate tail of string
881
 
        :param  revno:      revision number or None.
882
 
                            Revision numbers counts from 1.
883
 
        :param  rev:        revision info object
884
 
        :param  max_chars:  maximum length of resulting string
885
 
        :return:            formatted truncated string
 
1711
 
 
1712
        :param revno:      revision number or None.
 
1713
                           Revision numbers counts from 1.
 
1714
        :param rev:        revision object
 
1715
        :param max_chars:  maximum length of resulting string
 
1716
        :param tags:       list of tags or None
 
1717
        :param prefix:     string to prefix each line
 
1718
        :return:           formatted truncated string
886
1719
        """
887
1720
        out = []
888
1721
        if revno:
889
1722
            # show revno only when is not None
890
1723
            out.append("%s:" % revno)
891
 
        out.append(self.truncate(self.short_author(rev), 20))
 
1724
        if max_chars is not None:
 
1725
            out.append(self.truncate(self.short_author(rev), (max_chars+3)/4))
 
1726
        else:
 
1727
            out.append(self.short_author(rev))
892
1728
        out.append(self.date_string(rev))
 
1729
        if len(rev.parent_ids) > 1:
 
1730
            out.append('[merge]')
 
1731
        if tags:
 
1732
            tag_str = '{%s}' % (', '.join(tags))
 
1733
            out.append(tag_str)
893
1734
        out.append(rev.get_summary())
894
 
        return self.truncate(" ".join(out).rstrip('\n'), max_chars)
 
1735
        return self.truncate(prefix + " ".join(out).rstrip('\n'), max_chars)
 
1736
 
 
1737
 
 
1738
class GnuChangelogLogFormatter(LogFormatter):
 
1739
 
 
1740
    supports_merge_revisions = True
 
1741
    supports_delta = True
 
1742
 
 
1743
    def log_revision(self, revision):
 
1744
        """Log a revision, either merged or not."""
 
1745
        to_file = self.to_file
 
1746
 
 
1747
        date_str = format_date(revision.rev.timestamp,
 
1748
                               revision.rev.timezone or 0,
 
1749
                               self.show_timezone,
 
1750
                               date_fmt='%Y-%m-%d',
 
1751
                               show_offset=False)
 
1752
        committer_str = self.authors(revision.rev, 'first', sep=', ')
 
1753
        committer_str = committer_str.replace(' <', '  <')
 
1754
        to_file.write('%s  %s\n\n' % (date_str,committer_str))
 
1755
 
 
1756
        if revision.delta is not None and revision.delta.has_changed():
 
1757
            for c in revision.delta.added + revision.delta.removed + revision.delta.modified:
 
1758
                path, = c[:1]
 
1759
                to_file.write('\t* %s:\n' % (path,))
 
1760
            for c in revision.delta.renamed:
 
1761
                oldpath,newpath = c[:2]
 
1762
                # For renamed files, show both the old and the new path
 
1763
                to_file.write('\t* %s:\n\t* %s:\n' % (oldpath,newpath))
 
1764
            to_file.write('\n')
 
1765
 
 
1766
        if not revision.rev.message:
 
1767
            to_file.write('\tNo commit message\n')
 
1768
        else:
 
1769
            message = revision.rev.message.rstrip('\r\n')
 
1770
            for l in message.split('\n'):
 
1771
                to_file.write('\t%s\n' % (l.lstrip(),))
 
1772
            to_file.write('\n')
895
1773
 
896
1774
 
897
1775
def line_log(rev, max_chars):
923
1801
                                'Detailed log format')
924
1802
log_formatter_registry.register('line', LineLogFormatter,
925
1803
                                'Log format with one line per revision')
 
1804
log_formatter_registry.register('gnu-changelog', GnuChangelogLogFormatter,
 
1805
                                'Format used by GNU ChangeLog files')
926
1806
 
927
1807
 
928
1808
def register_formatter(name, formatter):
941
1821
        raise errors.BzrCommandError("unknown log formatter: %r" % name)
942
1822
 
943
1823
 
944
 
def show_one_log(revno, rev, delta, verbose, to_file, show_timezone):
945
 
    # deprecated; for compatibility
946
 
    lf = LongLogFormatter(to_file=to_file, show_timezone=show_timezone)
947
 
    lf.show(revno, rev, delta)
 
1824
def author_list_all(rev):
 
1825
    return rev.get_apparent_authors()[:]
 
1826
 
 
1827
 
 
1828
def author_list_first(rev):
 
1829
    lst = rev.get_apparent_authors()
 
1830
    try:
 
1831
        return [lst[0]]
 
1832
    except IndexError:
 
1833
        return []
 
1834
 
 
1835
 
 
1836
def author_list_committer(rev):
 
1837
    return [rev.committer]
 
1838
 
 
1839
 
 
1840
author_list_registry = registry.Registry()
 
1841
 
 
1842
author_list_registry.register('all', author_list_all,
 
1843
                              'All authors')
 
1844
 
 
1845
author_list_registry.register('first', author_list_first,
 
1846
                              'The first author')
 
1847
 
 
1848
author_list_registry.register('committer', author_list_committer,
 
1849
                              'The committer')
948
1850
 
949
1851
 
950
1852
def show_changed_revisions(branch, old_rh, new_rh, to_file=None,
1002
1904
                 search=None)
1003
1905
 
1004
1906
 
 
1907
def get_history_change(old_revision_id, new_revision_id, repository):
 
1908
    """Calculate the uncommon lefthand history between two revisions.
 
1909
 
 
1910
    :param old_revision_id: The original revision id.
 
1911
    :param new_revision_id: The new revision id.
 
1912
    :param repository: The repository to use for the calculation.
 
1913
 
 
1914
    return old_history, new_history
 
1915
    """
 
1916
    old_history = []
 
1917
    old_revisions = set()
 
1918
    new_history = []
 
1919
    new_revisions = set()
 
1920
    graph = repository.get_graph()
 
1921
    new_iter = graph.iter_lefthand_ancestry(new_revision_id)
 
1922
    old_iter = graph.iter_lefthand_ancestry(old_revision_id)
 
1923
    stop_revision = None
 
1924
    do_old = True
 
1925
    do_new = True
 
1926
    while do_new or do_old:
 
1927
        if do_new:
 
1928
            try:
 
1929
                new_revision = new_iter.next()
 
1930
            except StopIteration:
 
1931
                do_new = False
 
1932
            else:
 
1933
                new_history.append(new_revision)
 
1934
                new_revisions.add(new_revision)
 
1935
                if new_revision in old_revisions:
 
1936
                    stop_revision = new_revision
 
1937
                    break
 
1938
        if do_old:
 
1939
            try:
 
1940
                old_revision = old_iter.next()
 
1941
            except StopIteration:
 
1942
                do_old = False
 
1943
            else:
 
1944
                old_history.append(old_revision)
 
1945
                old_revisions.add(old_revision)
 
1946
                if old_revision in new_revisions:
 
1947
                    stop_revision = old_revision
 
1948
                    break
 
1949
    new_history.reverse()
 
1950
    old_history.reverse()
 
1951
    if stop_revision is not None:
 
1952
        new_history = new_history[new_history.index(stop_revision) + 1:]
 
1953
        old_history = old_history[old_history.index(stop_revision) + 1:]
 
1954
    return old_history, new_history
 
1955
 
 
1956
 
 
1957
def show_branch_change(branch, output, old_revno, old_revision_id):
 
1958
    """Show the changes made to a branch.
 
1959
 
 
1960
    :param branch: The branch to show changes about.
 
1961
    :param output: A file-like object to write changes to.
 
1962
    :param old_revno: The revno of the old tip.
 
1963
    :param old_revision_id: The revision_id of the old tip.
 
1964
    """
 
1965
    new_revno, new_revision_id = branch.last_revision_info()
 
1966
    old_history, new_history = get_history_change(old_revision_id,
 
1967
                                                  new_revision_id,
 
1968
                                                  branch.repository)
 
1969
    if old_history == [] and new_history == []:
 
1970
        output.write('Nothing seems to have changed\n')
 
1971
        return
 
1972
 
 
1973
    log_format = log_formatter_registry.get_default(branch)
 
1974
    lf = log_format(show_ids=False, to_file=output, show_timezone='original')
 
1975
    if old_history != []:
 
1976
        output.write('*'*60)
 
1977
        output.write('\nRemoved Revisions:\n')
 
1978
        show_flat_log(branch.repository, old_history, old_revno, lf)
 
1979
        output.write('*'*60)
 
1980
        output.write('\n\n')
 
1981
    if new_history != []:
 
1982
        output.write('Added Revisions:\n')
 
1983
        start_revno = new_revno - len(new_history) + 1
 
1984
        show_log(branch, lf, None, verbose=False, direction='forward',
 
1985
                 start_revision=start_revno,)
 
1986
 
 
1987
 
 
1988
def show_flat_log(repository, history, last_revno, lf):
 
1989
    """Show a simple log of the specified history.
 
1990
 
 
1991
    :param repository: The repository to retrieve revisions from.
 
1992
    :param history: A list of revision_ids indicating the lefthand history.
 
1993
    :param last_revno: The revno of the last revision_id in the history.
 
1994
    :param lf: The log formatter to use.
 
1995
    """
 
1996
    start_revno = last_revno - len(history) + 1
 
1997
    revisions = repository.get_revisions(history)
 
1998
    for i, rev in enumerate(revisions):
 
1999
        lr = LogRevision(rev, i + last_revno, 0, None)
 
2000
        lf.log_revision(lr)
 
2001
 
 
2002
 
 
2003
def _get_info_for_log_files(revisionspec_list, file_list, add_cleanup):
 
2004
    """Find file-ids and kinds given a list of files and a revision range.
 
2005
 
 
2006
    We search for files at the end of the range. If not found there,
 
2007
    we try the start of the range.
 
2008
 
 
2009
    :param revisionspec_list: revision range as parsed on the command line
 
2010
    :param file_list: the list of paths given on the command line;
 
2011
      the first of these can be a branch location or a file path,
 
2012
      the remainder must be file paths
 
2013
    :param add_cleanup: When the branch returned is read locked,
 
2014
      an unlock call will be queued to the cleanup.
 
2015
    :return: (branch, info_list, start_rev_info, end_rev_info) where
 
2016
      info_list is a list of (relative_path, file_id, kind) tuples where
 
2017
      kind is one of values 'directory', 'file', 'symlink', 'tree-reference'.
 
2018
      branch will be read-locked.
 
2019
    """
 
2020
    from builtins import _get_revision_range
 
2021
    tree, b, path = bzrdir.BzrDir.open_containing_tree_or_branch(file_list[0])
 
2022
    add_cleanup(b.lock_read().unlock)
 
2023
    # XXX: It's damn messy converting a list of paths to relative paths when
 
2024
    # those paths might be deleted ones, they might be on a case-insensitive
 
2025
    # filesystem and/or they might be in silly locations (like another branch).
 
2026
    # For example, what should "log bzr://branch/dir/file1 file2" do? (Is
 
2027
    # file2 implicitly in the same dir as file1 or should its directory be
 
2028
    # taken from the current tree somehow?) For now, this solves the common
 
2029
    # case of running log in a nested directory, assuming paths beyond the
 
2030
    # first one haven't been deleted ...
 
2031
    if tree:
 
2032
        relpaths = [path] + tree.safe_relpath_files(file_list[1:])
 
2033
    else:
 
2034
        relpaths = [path] + file_list[1:]
 
2035
    info_list = []
 
2036
    start_rev_info, end_rev_info = _get_revision_range(revisionspec_list, b,
 
2037
        "log")
 
2038
    if relpaths in ([], [u'']):
 
2039
        return b, [], start_rev_info, end_rev_info
 
2040
    if start_rev_info is None and end_rev_info is None:
 
2041
        if tree is None:
 
2042
            tree = b.basis_tree()
 
2043
        tree1 = None
 
2044
        for fp in relpaths:
 
2045
            file_id = tree.path2id(fp)
 
2046
            kind = _get_kind_for_file_id(tree, file_id)
 
2047
            if file_id is None:
 
2048
                # go back to when time began
 
2049
                if tree1 is None:
 
2050
                    try:
 
2051
                        rev1 = b.get_rev_id(1)
 
2052
                    except errors.NoSuchRevision:
 
2053
                        # No history at all
 
2054
                        file_id = None
 
2055
                        kind = None
 
2056
                    else:
 
2057
                        tree1 = b.repository.revision_tree(rev1)
 
2058
                if tree1:
 
2059
                    file_id = tree1.path2id(fp)
 
2060
                    kind = _get_kind_for_file_id(tree1, file_id)
 
2061
            info_list.append((fp, file_id, kind))
 
2062
 
 
2063
    elif start_rev_info == end_rev_info:
 
2064
        # One revision given - file must exist in it
 
2065
        tree = b.repository.revision_tree(end_rev_info.rev_id)
 
2066
        for fp in relpaths:
 
2067
            file_id = tree.path2id(fp)
 
2068
            kind = _get_kind_for_file_id(tree, file_id)
 
2069
            info_list.append((fp, file_id, kind))
 
2070
 
 
2071
    else:
 
2072
        # Revision range given. Get the file-id from the end tree.
 
2073
        # If that fails, try the start tree.
 
2074
        rev_id = end_rev_info.rev_id
 
2075
        if rev_id is None:
 
2076
            tree = b.basis_tree()
 
2077
        else:
 
2078
            tree = b.repository.revision_tree(rev_id)
 
2079
        tree1 = None
 
2080
        for fp in relpaths:
 
2081
            file_id = tree.path2id(fp)
 
2082
            kind = _get_kind_for_file_id(tree, file_id)
 
2083
            if file_id is None:
 
2084
                if tree1 is None:
 
2085
                    rev_id = start_rev_info.rev_id
 
2086
                    if rev_id is None:
 
2087
                        rev1 = b.get_rev_id(1)
 
2088
                        tree1 = b.repository.revision_tree(rev1)
 
2089
                    else:
 
2090
                        tree1 = b.repository.revision_tree(rev_id)
 
2091
                file_id = tree1.path2id(fp)
 
2092
                kind = _get_kind_for_file_id(tree1, file_id)
 
2093
            info_list.append((fp, file_id, kind))
 
2094
    return b, info_list, start_rev_info, end_rev_info
 
2095
 
 
2096
 
 
2097
def _get_kind_for_file_id(tree, file_id):
 
2098
    """Return the kind of a file-id or None if it doesn't exist."""
 
2099
    if file_id is not None:
 
2100
        return tree.kind(file_id)
 
2101
    else:
 
2102
        return None
 
2103
 
 
2104
 
1005
2105
properties_handler_registry = registry.Registry()
1006
2106
 
 
2107
# Use the properties handlers to print out bug information if available
 
2108
def _bugs_properties_handler(revision):
 
2109
    if revision.properties.has_key('bugs'):
 
2110
        bug_lines = revision.properties['bugs'].split('\n')
 
2111
        bug_rows = [line.split(' ', 1) for line in bug_lines]
 
2112
        fixed_bug_urls = [row[0] for row in bug_rows if
 
2113
                          len(row) > 1 and row[1] == 'fixed']
 
2114
 
 
2115
        if fixed_bug_urls:
 
2116
            return {'fixes bug(s)': ' '.join(fixed_bug_urls)}
 
2117
    return {}
 
2118
 
 
2119
properties_handler_registry.register('bugs_properties_handler',
 
2120
                                     _bugs_properties_handler)
 
2121
 
 
2122
 
1007
2123
# adapters which revision ids to log are filtered. When log is called, the
1008
2124
# log_rev_iterator is adapted through each of these factory methods.
1009
2125
# Plugins are welcome to mutate this list in any way they like - as long