/b-gtk/fix-viz

To get this branch, use:
bzr branch http://gegoxaren.bato24.eu/bzr/b-gtk/fix-viz
1 by Scott James Remnant
Commit the first version of bzrk.
1
#!/usr/bin/python
2
# -*- coding: UTF-8 -*-
3
"""Directed graph production.
4
5
This module contains the code to produce an ordered directed graph of a
6
bzr branch, such as we display in the tree view at the top of the bzrk
7
window.
8
"""
9
10
__copyright__ = "Copyright © 2005 Canonical Ltd."
11
__author__    = "Scott James Remnant <scott@ubuntu.com>"
12
13
14
from bzrlib.errors import NoSuchRevision
15
16
17
class DummyRevision(object):
18
    """Dummy bzr revision.
19
20
    Sometimes, especially in older bzr branches, a revision is referenced
21
    as the parent of another but not actually present in the branch's store.
22
    When this happens we use an instance of this class instead of the real
23
    Revision object (which we can't get).
24
    """
25
26
    def __init__(self, revid):
27
        self.revision_id = revid
28
        self.parent_ids = []
29
        self.committer = None
30
        self.message = self.revision_id
31
32
27 by David Allouche
refactor distances
33
class DistanceMethod(object):
34
35
    def __init__(self, branch, start):
36
        self.branch = branch
37
        self.start = start
38
        self.revisions = {}
39
        self.children = {}
40
        self.children_of_id = {start: set()}
41
        self.parent_ids_of = {}
42
        self.colours = { start: 0 }
43
        self.last_colour = 0
44
        self.direct_parent_of = {}
45
28 by David Allouche
optimise by filling caches first
46
    def fill_caches(self):
47
        branch = self.branch
48
        revisions = self.revisions
49
        todo = set([self.start])
50
        while todo:
51
            revid = todo.pop()
27 by David Allouche
refactor distances
52
            try:
28 by David Allouche
optimise by filling caches first
53
                revision = branch.get_revision(revid)
27 by David Allouche
refactor distances
54
            except NoSuchRevision:
55
                revision = DummyRevision(revid)
28 by David Allouche
optimise by filling caches first
56
            self.cache_revision(revid, revision)
57
            for parent_id in revision.parent_ids:
58
                if parent_id not in revisions:
59
                    todo.add(parent_id)
27 by David Allouche
refactor distances
60
28 by David Allouche
optimise by filling caches first
61
    def cache_revision(self, revid, revision):
27 by David Allouche
refactor distances
62
        "Set the caches for a newly retrieved revision."""
63
        # Build a revision cache
64
        self.revisions[revid] = revision
65
        # Build a children dictionnary
1 by Scott James Remnant
Commit the first version of bzrk.
66
        for parent_id in revision.parent_ids:
27 by David Allouche
refactor distances
67
            self.children_of_id.setdefault(parent_id, set()).add(revision)
68
        # Build a parents dictionnary, where redundant parents will be removed,
69
        # and that will be passed along tothe rest of program.
20 by David Allouche
ignore redundent parents
70
        if len(revision.parent_ids) == len(set(revision.parent_ids)):
27 by David Allouche
refactor distances
71
            self.parent_ids_of[revision] = list(revision.parent_ids)
20 by David Allouche
ignore redundent parents
72
        else:
27 by David Allouche
refactor distances
73
            # Remove duplicate parents
20 by David Allouche
ignore redundent parents
74
            parent_ids = []
75
            parent_ids_set = set()
76
            for parent_id in revision.parent_ids:
77
                if parent_id in parent_ids_set:
78
                    continue
79
                parent_ids.append(parent_id)
80
                parent_ids_set.add(parent_id)
27 by David Allouche
refactor distances
81
            self.parent_ids_of[revision] = parent_ids
82
83
    def make_children_map(self):
84
        revisions = self.revisions
85
        return dict((revisions[revid], c)
86
                    for (revid, c) in self.children_of_id.iteritems())
87
88
    def first_ancestry_traversal(self):
89
        # Sort the revisions; the fastest way to do this is to visit each node
90
        # as few times as possible (by keeping the todo list in a set) and
91
        # record the largest distance to it before queuing up the children if
92
        # we increased the distance. This produces the sort order we desire
93
        distances = { self.start: 0 }
94
        todo = set([self.start])
28 by David Allouche
optimise by filling caches first
95
        revisions = self.revisions
27 by David Allouche
refactor distances
96
        while todo:
97
            revid = todo.pop()
28 by David Allouche
optimise by filling caches first
98
            revision = revisions[revid]
27 by David Allouche
refactor distances
99
            distance = distances[revid] + 1
100
            for parent_id in revision.parent_ids:
101
                if parent_id in distances and distances[parent_id] >= distance:
102
                    continue
103
                distances[parent_id] = distance
104
                todo.add(parent_id)
105
        # Topologically sorted revids, with the most recent revisions first.
106
        # A revision occurs only after all of its children.
107
        return sorted(distances, key=distances.get)
108
109
    def remove_redundant_parents(self, sorted_revids):
110
        children_of_id = self.children_of_id
111
        revisions = self.revisions
112
        parent_ids_of = self.parent_ids_of
113
        
114
        # Count the number of children of each revision, so we can release
115
        # memory for ancestry data as soon as it's not going to be needed
116
        # anymore.
117
        pending_count_of = {}
118
        for parent_id, children in children_of_id.iteritems():
119
            pending_count_of[parent_id] = len(children)
120
121
        # Build the ancestry dictionnary by examining older revisions first,
122
        # and remove revision parents that are ancestors of other parents of
123
        # the same revision.
124
        ancestor_ids_of = {}
125
        for revid in reversed(sorted_revids):
126
            revision = revisions[revid]
127
            parent_ids = parent_ids_of[revision]
128
            # ignore candidate parents which are an ancestor of another parent,
129
            # but never ignore the leftmost parent
130
            redundant_ids = []
131
            ignorable_parent_ids = parent_ids[1:] # never ignore leftmost
132
            for candidate_id in ignorable_parent_ids: 
133
                for parent_id in list(parent_ids):
134
                    if candidate_id in ancestor_ids_of[parent_id]:
135
                        redundant_ids.append(candidate_id)
136
                        parent_ids.remove(candidate_id)
137
                        children_of_candidate = children_of_id[candidate_id]
138
                        children_of_candidate.remove(revision)
139
                        break
140
            # save the set of ancestors of that revision
141
            ancestor_ids = set(parent_ids)
142
            for parent_id in parent_ids:
143
                ancestor_ids.update(ancestor_ids_of[parent_id])
144
            ancestor_ids_of[revid] = ancestor_ids
145
            # discard ancestry data for revisions whose children are already
146
            # done
147
            for parent_id in parent_ids + redundant_ids:
148
                pending_count = pending_count_of[parent_id] - 1
149
                pending_count_of[parent_id] = pending_count
150
                if pending_count == 0:
151
                    ancestor_ids_of[parent_id] = None
152
153
    def sort_revisions_and_set_colours(self, sorted_revids):
154
        revisions = self.revisions
155
        parent_ids_of = self.parent_ids_of
156
        children_of_id = self.children_of_id
157
        # Try to compact sequences of revisions on the same branch.
158
        distances = {}
159
        skipped_revids = []
160
        expected_id = sorted_revids[0]
161
        pending_ids = []
162
        while True:
163
            revid = sorted_revids.pop(0)
164
            if revid != expected_id:
165
                skipped_revids.append(revid)
166
                continue
167
            revision = revisions[revid]
168
            for child in children_of_id[revid]:
169
                # postpone if any child is missing
170
                if child.revision_id not in distances:
171
                    if expected_id not in pending_ids:
172
                        pending_ids.append(expected_id)
173
                    assert len(pending_ids) > 1
174
                    expected_id = pending_ids.pop(0)
175
                    skipped_revids.append(revid)
176
                    sorted_revids[:0] = skipped_revids
177
                    skipped_revids = []
178
                    break
179
            else:
180
                # all children are here, push!
181
                distances[revid] = len(distances)
182
                self.choose_colour(revision, distances)
183
                # all parents will need to be pushed as soon as possible
184
                for parent in parent_ids_of[revision]:
185
                    if parent not in pending_ids:
186
                        pending_ids.insert(0, parent)
187
                if not pending_ids:
188
                    break
22 by David Allouche
sort revisions to be grouped by branch
189
                expected_id = pending_ids.pop(0)
27 by David Allouche
refactor distances
190
                # if the next expected revid has already been skipped, requeue
191
                # it and its potential ancestors.
192
                if expected_id in skipped_revids:
193
                    pos = skipped_revids.index(expected_id)
194
                    sorted_revids[:0] = skipped_revids[pos:]
195
                    del skipped_revids[pos:]
196
        return sorted(distances, key=distances.get)
197
198
    def choose_colour(self, revision, distances):
199
        revid = revision.revision_id
200
        children_of_id = self.children_of_id
201
        parent_ids_of = self.parent_ids_of
202
        colours = self.colours
203
        # choose colour
204
        the_children = children_of_id[revid]
205
        if len(the_children) == 1:
206
            [child] = the_children
207
            if len(parent_ids_of[child]) == 1:
208
                # one-one relationship between parent and child, same
209
                # colour
210
                colours[revid] = colours[child.revision_id]
211
            else:
212
                self.choose_colour_one_child(revision, child)
213
        else:
214
            self.choose_colour_many_children(revision, the_children, distances)
215
216
    def choose_colour_one_child(self, revision, child):
217
        revid = revision.revision_id
218
        direct_parent_of = self.direct_parent_of
219
        revisions = self.revisions
220
        # one child with multiple parents, the first parent with
221
        # the same committer gets the colour
222
        direct_parent = direct_parent_of.get(child)
223
        if direct_parent is None:
224
            # if it has not been found yet, find it now and remember
225
            for parent_id in self.parent_ids_of[child]:
226
                parent_revision = revisions[parent_id]
227
                if parent_revision.committer == child.committer:
228
                    # found the first parent with the same committer
229
                    direct_parent = parent_revision
230
                    direct_parent_of[child] = direct_parent
231
                    break
232
        if direct_parent == revision:
233
            self.colours[revid] = self.colours[child.revision_id]
234
        else:
235
            self.colours[revid] = self.last_colour = self.last_colour + 1
236
237
    def choose_colour_many_children(self, revision, the_children, distances):
238
        revid = revision.revision_id
239
        direct_parent_of = self.direct_parent_of
240
        # multiple children, get the colour of the last displayed child
241
        # with the same committer which does not already have its colour
242
        # taken
243
        available = {}
244
        for child in the_children:
245
            if child.committer != revision.committer:
246
                continue
247
            direct_parent = direct_parent_of.get(child)
248
            if direct_parent == revision:
249
                self.colours[revid] = self.colours[child.revision_id]
250
                break
251
            if direct_parent is None:
252
                available[child] = distances[child.revision_id]
253
        else:
254
            if available:
255
                sorted_children = sorted(available, key=available.get)
256
                child = sorted_children[-1]
257
                direct_parent_of[child] = revision
258
                self.colours[revid] = self.colours[child.revision_id]
259
            else:
260
                # no candidate children is available, pick the next
261
                # colour
262
                self.colours[revid] = self.last_colour = self.last_colour + 1
263
264
265
def distances(branch, start):
266
    """Sort the revisions.
267
268
    Traverses the branch revision tree starting at start and produces an
269
    ordered list of revisions such that a revision always comes after
270
    any revision it is the parent of.
271
272
    Returns a tuple of (revids, revisions, colours, children)
273
    """
28 by David Allouche
optimise by filling caches first
274
    distance = DistanceMethod(branch, start)
275
    distance.fill_caches()
276
    sorted_revids = distance.first_ancestry_traversal()
277
    distance.remove_redundant_parents(sorted_revids)
278
    sorted_revids = distance.sort_revisions_and_set_colours(sorted_revids)
27 by David Allouche
refactor distances
279
28 by David Allouche
optimise by filling caches first
280
    revisions = distance.revisions
281
    colours = distance.colours
282
    children = distance.make_children_map()
283
    parent_ids_of = distance.parent_ids_of
20 by David Allouche
ignore redundent parents
284
    return (sorted_revids, revisions, colours, children, parent_ids_of)
285
286
def graph(revids, revisions, colours, parent_ids):
3 by Scott James Remnant
Split the display in two with a pane, we'll use the bottom half to show
287
    """Produce a directed graph of a bzr branch.
288
289
    For each revision it then yields a tuple of (revision, node, lines).
290
    If the revision is only referenced in the branch and not present in the
291
    store, revision will be a DummyRevision object, otherwise it is the bzr
292
    Revision object with the meta-data for the revision.
293
294
    Node is a tuple of (column, colour) with column being a zero-indexed
295
    column number of the graph that this revision represents and colour
296
    being a zero-indexed colour (which doesn't specify any actual colour
297
    in particular) to draw the node in.
298
299
    Lines is a list of tuples which represent lines you should draw away
300
    from the revision, if you also need to draw lines into the revision
301
    you should use the lines list from the previous iteration.  Each
302
    typle in the list is in the form (start, end, colour) with start and
303
    end being zero-indexed column numbers and colour as in node.
304
305
    It's up to you how to actually draw the nodes and lines (straight,
306
    curved, kinked, etc.) and to pick the actual colours for each index.
307
    """
308
    hanging = revids[:1]
309
    for revid in revids:
1 by Scott James Remnant
Commit the first version of bzrk.
310
        lines = []
311
        node = None
312
313
        new_hanging = []
314
        for h_idx, hang in enumerate(hanging):
315
            if hang == revid:
316
                # We've matched a hanging revision, so need to output a node
317
                # at this point
318
                node = (h_idx, colours[revid])
319
320
                # Now we need to hang its parents, we put them at the point
321
                # the old column was so anything to the right of this has
322
                # to move outwards to make room.  We also try and collapse
323
                # hangs to keep the graph small.
20 by David Allouche
ignore redundent parents
324
                for parent_id in parent_ids[revisions[revid]]:
1 by Scott James Remnant
Commit the first version of bzrk.
325
                    try:
326
                        n_idx = new_hanging.index(parent_id)
327
                    except ValueError:
328
                        n_idx = len(new_hanging)
329
                        new_hanging.append(parent_id)
330
                    lines.append((h_idx, n_idx, colours[parent_id]))
331
            else:
332
                # Revision keeps on hanging, adjust for any change in the
333
                # graph shape and try to collapse hangs to keep the graph
334
                # small.
335
                try:
336
                    n_idx = new_hanging.index(hang)
337
                except ValueError:
338
                    n_idx = len(new_hanging)
339
                    new_hanging.append(hang)
340
                lines.append((h_idx, n_idx, colours[hang]))
341
        hanging = new_hanging
342
343
        yield (revisions[revid], node, lines)
2 by Scott James Remnant
Split the same branch functionality out into a separate function so
344
27 by David Allouche
refactor distances
345
2 by Scott James Remnant
Split the same branch functionality out into a separate function so
346
def same_branch(a, b):
347
    """Return whether we think revisions a and b are on the same branch."""
348
    if len(a.parent_ids) == 1:
349
        # Defacto same branch if only parent
350
        return True
351
    elif a.committer == b.committer:
352
        # Same committer so may as well be
353
        return True
354
    else:
355
        return False