hgext/graphlog.py
author Dirkjan Ochtman <dirkjan@ochtman.nl>
Sat, 15 Nov 2008 12:27:40 +0100
changeset 7379 ef22cb8896d6
parent 7374 ccec5ae82282
child 7383 b501c7f3c2ad
permissions -rw-r--r--
graphlog: fix python2.3 incompatibility (used genexp, sorted())

# ASCII graph log extension for Mercurial
#
# Copyright 2007 Joel Rosdahl <joel@rosdahl.net>
#
# This software may be used and distributed according to the terms of
# the GNU General Public License, incorporated herein by reference.
'''show revision graphs in terminal windows'''

import os
import sys
from mercurial.cmdutil import revrange, show_changeset
from mercurial.commands import templateopts
from mercurial.i18n import _
from mercurial.node import nullrev
from mercurial.util import Abort, canonpath

def revisions(repo, start, stop):
    """cset DAG generator yielding (rev, node, [parents]) tuples

    This generator function walks through the revision history from revision
    start to revision stop (which must be less than or equal to start).
    """
    assert start >= stop
    cur = start
    while cur >= stop:
        ctx = repo[cur]
        parents = [p.rev() for p in ctx.parents() if p.rev() != nullrev]
        parents.sort()
        yield (ctx, parents)
        cur -= 1

def filerevs(repo, path, start, stop):
    """file cset DAG generator yielding (rev, node, [parents]) tuples

    This generator function walks through the revision history of a single
    file from revision start to revision stop (which must be less than or
    equal to start).
    """
    assert start >= stop
    filerev = len(repo.file(path)) - 1
    while filerev >= 0:
        fctx = repo.filectx(path, fileid=filerev)
        parents = [f.filerev() for f in fctx.parents() if f.path() == path]
        parents.sort()
        if fctx.rev() <= start:
            yield (fctx, parents)
        if fctx.rev() <= stop:
            break
        filerev -= 1

def grapher(nodes):
    """grapher for asciigraph on a list of nodes and their parents

    nodes must generate tuples (node, parents, char, lines) where
     - parents must generate the parents of node, in sorted order,
       and max length 2,
     - char is the char to print as the node symbol, and
     - lines are the lines to display next to the node.
    """
    seen = []
    for node, parents, char, lines in nodes:
        if node not in seen:
            seen.append(node)
        nodeidx = seen.index(node)

        knownparents = []
        newparents = []
        for parent in parents:
            if parent in seen:
                knownparents.append(parent)
            else:
                newparents.append(parent)

        ncols = len(seen)
        nextseen = seen[:]
        nextseen[nodeidx:nodeidx + 1] = newparents
        edges = [(nodeidx, nextseen.index(p)) for p in knownparents]

        if len(newparents) > 0:
            edges.append((nodeidx, nodeidx))
        if len(newparents) > 1:
            edges.append((nodeidx, nodeidx + 1))
        nmorecols = len(nextseen) - ncols
        seen = nextseen
        yield (char, lines, nodeidx, edges, ncols, nmorecols)

def fix_long_right_edges(edges):
    for (i, (start, end)) in enumerate(edges):
        if end > start:
            edges[i] = (start, end + 1)

def get_nodeline_edges_tail(
        node_index, p_node_index, n_columns, n_columns_diff, p_diff, fix_tail):
    if fix_tail and n_columns_diff == p_diff and n_columns_diff != 0:
        # Still going in the same non-vertical direction.
        if n_columns_diff == -1:
            start = max(node_index + 1, p_node_index)
            tail = ["|", " "] * (start - node_index - 1)
            tail.extend(["/", " "] * (n_columns - start))
            return tail
        else:
            return ["\\", " "] * (n_columns - node_index - 1)
    else:
        return ["|", " "] * (n_columns - node_index - 1)

def draw_edges(edges, nodeline, interline):
    for (start, end) in edges:
        if start == end + 1:
            interline[2 * end + 1] = "/"
        elif start == end - 1:
            interline[2 * start + 1] = "\\"
        elif start == end:
            interline[2 * start] = "|"
        else:
            nodeline[2 * end] = "+"
            if start > end:
                (start, end) = (end,start)
            for i in range(2 * start + 1, 2 * end):
                if nodeline[i] != "+":
                    nodeline[i] = "-"

def get_padding_line(ni, n_columns, edges):
    line = []
    line.extend(["|", " "] * ni)
    if (ni, ni - 1) in edges or (ni, ni) in edges:
        # (ni, ni - 1)      (ni, ni)
        # | | | |           | | | |
        # +---o |           | o---+
        # | | c |           | c | |
        # | |/ /            | |/ /
        # | | |             | | |
        c = "|"
    else:
        c = " "
    line.extend([c, " "])
    line.extend(["|", " "] * (n_columns - ni - 1))
    return line

def ascii(ui, grapher):
    """prints an ASCII graph of the DAG returned by the grapher

    grapher is a generator that emits tuples with the following elements:

      - Character to use as node's symbol.
      - List of lines to display as the node's text.
      - Column of the current node in the set of ongoing edges.
      - Edges; a list of (col, next_col) indicating the edges between
        the current node and its parents.
      - Number of columns (ongoing edges) in the current revision.
      - The difference between the number of columns (ongoing edges)
        in the next revision and the number of columns (ongoing edges)
        in the current revision. That is: -1 means one column removed;
        0 means no columns added or removed; 1 means one column added.
    """
    prev_n_columns_diff = 0
    prev_node_index = 0
    for (node_ch, node_lines, node_index, edges, n_columns, n_columns_diff) in grapher:

        assert -2 < n_columns_diff < 2
        if n_columns_diff == -1:
            # Transform
            #
            #     | | |        | | |
            #     o | |  into  o---+
            #     |X /         |/ /
            #     | |          | |
            fix_long_right_edges(edges)

        # add_padding_line says whether to rewrite
        #
        #     | | | |        | | | |
        #     | o---+  into  | o---+
        #     |  / /         |   | |  # <--- padding line
        #     o | |          |  / /
        #                    o | |
        add_padding_line = (len(node_lines) > 2 and
                            n_columns_diff == -1 and
                            [x for (x, y) in edges if x + 1 < y])

        # fix_nodeline_tail says whether to rewrite
        #
        #     | | o | |        | | o | |
        #     | | |/ /         | | |/ /
        #     | o | |    into  | o / /   # <--- fixed nodeline tail
        #     | |/ /           | |/ /
        #     o | |            o | |
        fix_nodeline_tail = len(node_lines) <= 2 and not add_padding_line

        # nodeline is the line containing the node character (typically o)
        nodeline = ["|", " "] * node_index
        nodeline.extend([node_ch, " "])

        nodeline.extend(
            get_nodeline_edges_tail(
                node_index, prev_node_index, n_columns, n_columns_diff,
                prev_n_columns_diff, fix_nodeline_tail))

        # shift_interline is the line containing the non-vertical
        # edges between this entry and the next
        shift_interline = ["|", " "] * node_index
        if n_columns_diff == -1:
            n_spaces = 1
            edge_ch = "/"
        elif n_columns_diff == 0:
            n_spaces = 2
            edge_ch = "|"
        else:
            n_spaces = 3
            edge_ch = "\\"
        shift_interline.extend(n_spaces * [" "])
        shift_interline.extend([edge_ch, " "] * (n_columns - node_index - 1))

        # draw edges from the current node to its parents
        draw_edges(edges, nodeline, shift_interline)

        # lines is the list of all graph lines to print
        lines = [nodeline]
        if add_padding_line:
            lines.append(get_padding_line(node_index, n_columns, edges))
        lines.append(shift_interline)

        # make sure that there are as many graph lines as there are
        # log strings
        while len(node_lines) < len(lines):
            node_lines.append("")
        if len(lines) < len(node_lines):
            extra_interline = ["|", " "] * (n_columns + n_columns_diff)
            while len(lines) < len(node_lines):
                lines.append(extra_interline)

        # print lines
        indentation_level = max(n_columns, n_columns + n_columns_diff)
        for (line, logstr) in zip(lines, node_lines):
            ln = "%-*s %s" % (2 * indentation_level, "".join(line), logstr)
            ui.write(ln.rstrip() + '\n')

        # ... and start over
        prev_node_index = node_index
        prev_n_columns_diff = n_columns_diff

def get_limit(limit_opt):
    if limit_opt:
        try:
            limit = int(limit_opt)
        except ValueError:
            raise Abort(_("limit must be a positive integer"))
        if limit <= 0:
            raise Abort(_("limit must be positive"))
    else:
        limit = sys.maxint
    return limit

def get_revs(repo, rev_opt):
    if rev_opt:
        revs = revrange(repo, rev_opt)
        return (max(revs), min(revs))
    else:
        return (len(repo) - 1, 0)

def graphlog(ui, repo, path=None, **opts):
    """show revision history alongside an ASCII revision graph

    Print a revision history alongside a revision graph drawn with
    ASCII characters.

    Nodes printed as an @ character are parents of the working
    directory.
    """

    limit = get_limit(opts["limit"])
    start, stop = get_revs(repo, opts["rev"])
    stop = max(stop, start - limit + 1)
    if start == nullrev:
        return

    if path:
        path = canonpath(repo.root, os.getcwd(), path)
    if path: # could be reset in canonpath
        revdag = filerevs(repo, path, start, stop)
    else:
        revdag = revisions(repo, start, stop)

    repo_parents = repo.dirstate.parents()
    displayer = show_changeset(ui, repo, opts, buffered=True)
    def graphabledag():
        for (ctx, parents) in revdag:
            # log_strings is the list of all log strings to draw alongside
            # the graph.
            displayer.show(ctx)
            lines = displayer.hunk.pop(ctx.rev()).split("\n")[:-1]
            char = ctx.node() in repo_parents and '@' or 'o'
            yield (ctx.rev(), parents, char, lines)

    ascii(ui, grapher(graphabledag()))

cmdtable = {
    "glog":
        (graphlog,
         [('l', 'limit', '', _('limit number of changes displayed')),
          ('p', 'patch', False, _('show patch')),
          ('r', 'rev', [], _('show the specified revision or range')),
         ] + templateopts,
         _('hg glog [OPTION]... [FILE]')),
}