~abentley/bzrtools/bzrtools.dev

« back to all changes in this revision

Viewing changes to graph.py

  • Committer: Aaron Bentley
  • Date: 2011-06-27 22:03:53 UTC
  • mfrom: (770.1.1 trunk)
  • Revision ID: aaron@aaronbentley.com-20110627220353-c7ikthkaap2amfzm
Support importing .tar.xz and .tar.lzma files.  (Jelmer)

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
# Copyright (C) 2005, 2008 Aaron Bentley
 
2
# <aaron@aaronbentley.com>
 
3
#
 
4
#    This program is free software; you can redistribute it and/or modify
 
5
#    it under the terms of the GNU General Public License as published by
 
6
#    the Free Software Foundation; either version 2 of the License, or
 
7
#    (at your option) any later version.
 
8
#
 
9
#    This program is distributed in the hope that it will be useful,
 
10
#    but WITHOUT ANY WARRANTY; without even the implied warranty of
 
11
#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 
12
#    GNU General Public License for more details.
 
13
#
 
14
#    You should have received a copy of the GNU General Public License
 
15
#    along with this program; if not, write to the Free Software
 
16
#    Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
 
17
 
 
18
 
 
19
import time
 
20
 
 
21
from bzrlib.branch import Branch
 
22
from bzrlib.errors import BzrCommandError, NoSuchRevision
 
23
from bzrlib.revision import NULL_REVISION
 
24
 
 
25
from bzrtools import short_committer
 
26
from dotgraph import (
 
27
    dot_output,
 
28
    DOT_OUTPUT_TYPES,
 
29
    Edge,
 
30
    invoke_dot,
 
31
    invoke_dot_aa,
 
32
    invoke_dot_html,
 
33
    Node,
 
34
    NoDot,
 
35
    NoRsvg,
 
36
    RSVG_OUTPUT_TYPES,
 
37
    )
 
38
 
 
39
 
 
40
def max_distance(node, ancestors, distances, root_descendants):
 
41
    """Calculate the max distance to an ancestor.
 
42
    Return None if not all possible ancestors have known distances"""
 
43
    best = None
 
44
    if node in distances:
 
45
        best = distances[node]
 
46
    for ancestor in ancestors[node]:
 
47
        # skip ancestors we will never traverse:
 
48
        if root_descendants is not None and ancestor not in root_descendants:
 
49
            continue
 
50
        # An ancestor which is not listed in ancestors will never be in
 
51
        # distances, so we pretend it never existed.
 
52
        if ancestor not in ancestors:
 
53
            continue
 
54
        if ancestor not in distances:
 
55
            return None
 
56
        if best is None or distances[ancestor]+1 > best:
 
57
            best = distances[ancestor] + 1
 
58
    return best
 
59
 
 
60
 
 
61
def node_distances(graph, ancestors, start, root_descendants=None):
 
62
    """Produce a list of nodes, sorted by distance from a start node.
 
63
    This is an algorithm devised by Aaron Bentley, because applying Dijkstra
 
64
    backwards seemed too complicated.
 
65
 
 
66
    For each node, we walk its descendants.  If all the descendant's ancestors
 
67
    have a max-distance-to-start, (excluding ones that can never reach start),
 
68
    we calculate their max-distance-to-start, and schedule their descendants.
 
69
 
 
70
    So when a node's last parent acquires a distance, it will acquire a
 
71
    distance on the next iteration.
 
72
 
 
73
    Once we know the max distances for all nodes, we can return a list sorted
 
74
    by distance, farthest first.
 
75
    """
 
76
    distances = {start: 0}
 
77
    lines = set([start])
 
78
    while len(lines) > 0:
 
79
        new_lines = set()
 
80
        for line in lines:
 
81
            line_descendants = graph[line]
 
82
            for descendant in line_descendants:
 
83
                distance = max_distance(descendant, ancestors, distances,
 
84
                                        root_descendants)
 
85
                if distance is None:
 
86
                    continue
 
87
                distances[descendant] = distance
 
88
                new_lines.add(descendant)
 
89
        lines = new_lines
 
90
    return distances
 
91
 
 
92
 
 
93
def nodes_by_distance(distances):
 
94
    """Return a list of nodes sorted by distance"""
 
95
    def by_distance(n):
 
96
        return distances[n],n
 
97
 
 
98
    node_list = distances.keys()
 
99
    node_list.sort(key=by_distance, reverse=True)
 
100
    return node_list
 
101
 
 
102
 
 
103
def select_farthest(distances, common):
 
104
    """Return the farthest common node, or None if no node qualifies."""
 
105
    node_list = nodes_by_distance(distances)
 
106
    for node in node_list:
 
107
        if node in common:
 
108
            return node
 
109
 
 
110
 
 
111
mail_map = {'aaron.bentley@utoronto.ca'     : 'Aaron Bentley',
 
112
            'abentley@panoramicfeedback.com': 'Aaron Bentley',
 
113
            'abentley@lappy'                : 'Aaron Bentley',
 
114
            'john@arbash-meinel.com'        : 'John Arbash Meinel',
 
115
            'mbp@sourcefrog.net'            : 'Martin Pool',
 
116
            'robertc@robertcollins.net'     : 'Robert Collins',
 
117
            }
 
118
 
 
119
committer_alias = {'abentley': 'Aaron Bentley'}
 
120
def can_skip(rev_id, descendants, ancestors):
 
121
    if rev_id not in descendants:
 
122
        return False
 
123
    elif rev_id not in ancestors:
 
124
        return False
 
125
    elif len(ancestors[rev_id]) != 1:
 
126
        return False
 
127
    elif len(descendants[list(ancestors[rev_id])[0]]) != 1:
 
128
        return False
 
129
    elif len(descendants[rev_id]) != 1:
 
130
        return False
 
131
    else:
 
132
        return True
 
133
 
 
134
def compact_ancestors(descendants, ancestors, exceptions=()):
 
135
    new_ancestors={}
 
136
    skip = set()
 
137
    for me, my_parents in ancestors.iteritems():
 
138
        if me in skip:
 
139
            continue
 
140
        new_ancestors[me] = {}
 
141
        for parent in my_parents:
 
142
            new_parent = parent
 
143
            distance = 0
 
144
            while can_skip(new_parent, descendants, ancestors):
 
145
                if new_parent in exceptions:
 
146
                    break
 
147
                skip.add(new_parent)
 
148
                if new_parent in new_ancestors:
 
149
                    del new_ancestors[new_parent]
 
150
                new_parent = list(ancestors[new_parent])[0]
 
151
                distance += 1
 
152
            new_ancestors[me][new_parent] = distance
 
153
    return new_ancestors
 
154
 
 
155
def get_rev_info(rev_id, source):
 
156
    """Return the committer, message, and date of a revision."""
 
157
    committer = None
 
158
    message = None
 
159
    date = None
 
160
    if rev_id == 'null:':
 
161
        return None, 'Null Revision', None, None
 
162
    try:
 
163
        rev = source.get_revision(rev_id)
 
164
    except NoSuchRevision:
 
165
        try:
 
166
            committer = '-'.join(rev_id.split('-')[:-2]).strip(' ')
 
167
            if committer == '':
 
168
                return None, None, None, None
 
169
        except ValueError:
 
170
            return None, None, None, None
 
171
    else:
 
172
        committer = short_committer(rev.committer)
 
173
        if rev.message is not None:
 
174
            message = rev.message.split('\n')[0]
 
175
        gmtime = time.gmtime(rev.timestamp + (rev.timezone or 0))
 
176
        date = time.strftime('%Y/%m/%d', gmtime)
 
177
        nick = rev.properties.get('branch-nick')
 
178
    if '@' in committer:
 
179
        try:
 
180
            committer = mail_map[committer]
 
181
        except KeyError:
 
182
            pass
 
183
    try:
 
184
        committer = committer_alias[committer]
 
185
    except KeyError:
 
186
        pass
 
187
    return committer, message, nick, date
 
188
 
 
189
class Grapher(object):
 
190
 
 
191
    def __init__(self, branch, other_branch=None):
 
192
        object.__init__(self)
 
193
        self.branch = branch
 
194
        self.other_branch = other_branch
 
195
        if other_branch is not None:
 
196
            other_repo = other_branch.repository
 
197
            revision_b = self.other_branch.last_revision()
 
198
        else:
 
199
            other_repo = None
 
200
            revision_b = None
 
201
        self.graph = self.branch.repository.get_graph(other_repo)
 
202
        revision_a = self.branch.last_revision()
 
203
        self.scan_graph(revision_a, revision_b)
 
204
        self.n_history = branch.revision_history()
 
205
        self.n_revnos = branch.get_revision_id_to_revno_map()
 
206
        self.distances = node_distances(self.descendants, self.ancestors,
 
207
                                        self.root)
 
208
        if other_branch is not None:
 
209
            self.base = select_farthest(self.distances, self.common)
 
210
            self.m_history = other_branch.revision_history()
 
211
            self.m_revnos = other_branch.get_revision_id_to_revno_map()
 
212
            self.new_base = self.graph.find_unique_lca(revision_a,
 
213
                                                       revision_b)
 
214
            self.lcas = self.graph.find_lca(revision_a, revision_b)
 
215
        else:
 
216
            self.base = None
 
217
            self.new_base = None
 
218
            self.lcas = set()
 
219
            self.m_history = []
 
220
            self.m_revnos = {}
 
221
 
 
222
    def scan_graph(self, revision_a, revision_b):
 
223
        a_ancestors = dict(self.graph.iter_ancestry([revision_a]))
 
224
        self.ancestors = a_ancestors
 
225
        self.root = NULL_REVISION
 
226
        if revision_b is not None:
 
227
            b_ancestors = dict(self.graph.iter_ancestry([revision_b]))
 
228
            self.common = set(a_ancestors.keys())
 
229
            self.common.intersection_update(b_ancestors)
 
230
            self.ancestors.update(b_ancestors)
 
231
        else:
 
232
            self.common = []
 
233
            revision_b = None
 
234
        self.descendants = {}
 
235
        ghosts = set()
 
236
        for revision, parents in self.ancestors.iteritems():
 
237
            self.descendants.setdefault(revision, [])
 
238
            if parents is None:
 
239
                ghosts.add(revision)
 
240
                parents = [NULL_REVISION]
 
241
            for parent in parents:
 
242
                self.descendants.setdefault(parent, []).append(revision)
 
243
        for ghost in ghosts:
 
244
            self.ancestors[ghost] = [NULL_REVISION]
 
245
 
 
246
    @staticmethod
 
247
    def _get_revno_str(prefix, revno_map, revision_id):
 
248
        try:
 
249
            revno = revno_map[revision_id]
 
250
        except KeyError:
 
251
            return None
 
252
        return '%s%s' % (prefix, '.'.join(str(n) for n in revno))
 
253
 
 
254
    def dot_node(self, node, num):
 
255
        try:
 
256
            n_rev = self.n_history.index(node) + 1
 
257
        except ValueError:
 
258
            n_rev = None
 
259
        try:
 
260
            m_rev = self.m_history.index(node) + 1
 
261
        except ValueError:
 
262
            m_rev = None
 
263
        if (n_rev, m_rev) == (None, None):
 
264
            name = self._get_revno_str('r', self.n_revnos, node)
 
265
            if name is None:
 
266
                name = self._get_revno_str('R', self.m_revnos, node)
 
267
            if name is None:
 
268
                name = node[-5:]
 
269
            cluster = None
 
270
        elif n_rev == m_rev:
 
271
            name = "rR%d" % n_rev
 
272
        else:
 
273
            namelist = []
 
274
            for prefix, revno in (('r', n_rev), ('R', m_rev)):
 
275
                if revno is not None:
 
276
                    namelist.append("%s%d" % (prefix, revno))
 
277
            name = ' '.join(namelist)
 
278
        if None not in (n_rev, m_rev):
 
279
            cluster = "common_history"
 
280
            color = "#ff9900"
 
281
        elif (None, None) == (n_rev, m_rev):
 
282
            cluster = None
 
283
            if node in self.common:
 
284
                color = "#6699ff"
 
285
            else:
 
286
                color = "white"
 
287
        elif n_rev is not None:
 
288
            cluster = "my_history"
 
289
            color = "#ffff00"
 
290
        else:
 
291
            assert m_rev is not None
 
292
            cluster = "other_history"
 
293
            color = "#ff0000"
 
294
        if node in self.lcas:
 
295
            color = "#9933cc"
 
296
        if node == self.base:
 
297
            color = "#669933"
 
298
            if node == self.new_base:
 
299
                color = "#33ff33"
 
300
        if node == self.new_base:
 
301
            color = '#33cc99'
 
302
 
 
303
        label = [name]
 
304
        committer, message, nick, date = get_rev_info(node,
 
305
                                                      self.branch.repository)
 
306
        if committer is not None:
 
307
            label.append(committer)
 
308
 
 
309
        if nick is not None:
 
310
            label.append(nick)
 
311
 
 
312
        if date is not None:
 
313
            label.append(date)
 
314
 
 
315
        if node in self.distances:
 
316
            rank = self.distances[node]
 
317
            label.append('d%d' % self.distances[node])
 
318
        else:
 
319
            rank = None
 
320
 
 
321
        d_node = Node("n%d" % num, color=color, label="\\n".join(label),
 
322
                    rev_id=node, cluster=cluster, message=message,
 
323
                    date=date)
 
324
        d_node.rank = rank
 
325
 
 
326
        if node not in self.ancestors:
 
327
            d_node.node_style.append('dotted')
 
328
 
 
329
        return d_node
 
330
 
 
331
    def get_relations(self, collapse=False, max_distance=None):
 
332
        dot_nodes = {}
 
333
        node_relations = []
 
334
        num = 0
 
335
        if collapse:
 
336
            exceptions = self.lcas.union([self.base, self.new_base])
 
337
            visible_ancestors = compact_ancestors(self.descendants,
 
338
                                                  self.ancestors,
 
339
                                                  exceptions)
 
340
        else:
 
341
            visible_ancestors = {}
 
342
            for revision, parents in self.ancestors.iteritems():
 
343
                visible_ancestors[revision] = dict((p, 0) for p in parents)
 
344
        if max_distance is not None:
 
345
            min_distance = max(self.distances.values()) - max_distance
 
346
            visible_ancestors = dict((n, p) for n, p in
 
347
                                     visible_ancestors.iteritems() if
 
348
                                     self.distances[n] >= min_distance)
 
349
        for node, parents in visible_ancestors.iteritems():
 
350
            if node not in dot_nodes:
 
351
                dot_nodes[node] = self.dot_node(node, num)
 
352
                num += 1
 
353
            for parent, skipped in parents.iteritems():
 
354
                if parent not in dot_nodes:
 
355
                    dot_nodes[parent] = self.dot_node(parent, num)
 
356
                    num += 1
 
357
                edge = Edge(dot_nodes[parent], dot_nodes[node])
 
358
                if skipped != 0:
 
359
                    edge.label = "%d" % skipped
 
360
                node_relations.append(edge)
 
361
        return node_relations
 
362
 
 
363
 
 
364
def write_ancestry_file(branch, filename, collapse=True, antialias=True,
 
365
                        merge_branch=None, ranking="forced", max_distance=None):
 
366
    b = Branch.open_containing(branch)[0]
 
367
    if merge_branch is not None:
 
368
        m = Branch.open_containing(merge_branch)[0]
 
369
    else:
 
370
        m = None
 
371
    b.lock_write()
 
372
    try:
 
373
        if m is not None:
 
374
            m.lock_read()
 
375
        try:
 
376
            grapher = Grapher(b, m)
 
377
            relations = grapher.get_relations(collapse, max_distance)
 
378
        finally:
 
379
            if m is not None:
 
380
                m.unlock()
 
381
    finally:
 
382
        b.unlock()
 
383
 
 
384
    ext = filename.split('.')[-1]
 
385
    output = dot_output(relations, ranking)
 
386
    done = False
 
387
    if ext not in RSVG_OUTPUT_TYPES:
 
388
        antialias = False
 
389
    if antialias:
 
390
        output = list(output)
 
391
        try:
 
392
            invoke_dot_aa(output, filename, ext)
 
393
            done = True
 
394
        except NoDot, e:
 
395
            raise BzrCommandError("Can't find 'dot'.  Please ensure Graphviz"\
 
396
                " is installed correctly.")
 
397
        except NoRsvg, e:
 
398
            print "Not antialiasing because rsvg (from librsvg-bin) is not"\
 
399
                " installed."
 
400
            antialias = False
 
401
    if ext in DOT_OUTPUT_TYPES and not antialias and not done:
 
402
        try:
 
403
            invoke_dot(output, filename, ext)
 
404
            done = True
 
405
        except NoDot, e:
 
406
            raise BzrCommandError("Can't find 'dot'.  Please ensure Graphviz"\
 
407
                " is installed correctly.")
 
408
    elif ext == 'dot' and not done:
 
409
        my_file = file(filename, 'wb')
 
410
        for fragment in output:
 
411
            my_file.write(fragment.encode('utf-8'))
 
412
    elif ext == 'html':
 
413
        try:
 
414
            invoke_dot_html(output, filename)
 
415
        except NoDot, e:
 
416
            raise BzrCommandError("Can't find 'dot'.  Please ensure Graphviz"\
 
417
                " is installed correctly.")
 
418
    elif not done:
 
419
        print "Unknown file extension: %s" % ext