~abentley/bzrtools/bzrtools.dev

« back to all changes in this revision

Viewing changes to graph.py

  • Committer: Aaron Bentley
  • Date: 2005-05-15 22:31:18 UTC
  • Revision ID: abentley@bruiser-20050515223118-216927c08abfb1b6
Made bzr revision ids predictable

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