The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
#!/usr/bin/env python

# ====================================================================
#    Licensed to the Apache Software Foundation (ASF) under one
#    or more contributor license agreements.  See the NOTICE file
#    distributed with this work for additional information
#    regarding copyright ownership.  The ASF licenses this file
#    to you under the Apache License, Version 2.0 (the
#    "License"); you may not use this file except in compliance
#    with the License.  You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
#    Unless required by applicable law or agreed to in writing,
#    software distributed under the License is distributed on an
#    "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
#    KIND, either express or implied.  See the License for the
#    specific language governing permissions and limitations
#    under the License.
# ====================================================================

# Config file format:
example = """
  [graph]
  filename = merge-sync-1.png
  title = Sync Merge: CC vs SVN
  # Branches: (branch name, branched from node, first rev, last rev).
  branches = [
    ('A', 'O0', 1, 4),
    ('O', None, 0, 0),
    ('B', 'O0', 1, 5)
    ]
  # Changes: nodes in which a change was committed; merge targets need not
  # be listed here.
  changes = [
    'A1', 'A2', 'A3', 'A4',
    'B1', 'B2', 'B3', 'B4', 'B5'
    ]
  # Merges: (base node, source-right node, target node, label).
  # Base is also known as source-left.
  merges = [
    ('O0', 'A:1', 'B3', 'sync'),
    ('A2', 'A:3', 'B5', 'sync'),
    ]
  # Annotations for nodes: (node, annotation text).
  annotations = [
    ('A2', 'cc:YCA')
    ]
"""

# Notes about different kinds of merge.
#
# A basic 3-way merge is ...
#
# The ClearCase style of merge is a 3-way merge.
#
# The Subversion style of merge (that is, one phase of a Subversion merge)
# is a three-way merge with its base (typically the YCA) on the source branch.


import sys
import pydot
from pydot import Node, Edge


def mergeinfo_to_node_list(mi):
  """Convert a mergeinfo string such as '/foo:1,3-5*' into a list of
     node names such as ['foo1', 'foo3', 'foo4', 'foo5'].
  """
  ### Doesn't yet strip the leading slash.
  l = []
  if mi:
    for mi_str in mi.split(' '):
      path, ranges = mi_str.split(':')
      for r in ranges.split(','):
        if r.endswith('*'):
          # TODO: store & use this 'non-inheritable' flag
          # Remove the flag
          r = r[:-1]
        rlist = r.split('-')
        r1 = int(rlist[0])
        if len(rlist) == 2:
          r2 = int(rlist[1])
        else:
          r2 = r1
        for rev in range(r1, r2 + 1):
          l.append(path + str(rev))
  return l


class MergeGraph(pydot.Graph):
  """Base class, not intended for direct use.  Use MergeDot for the main
     graph and MergeSubgraph for a subgraph.
  """

  def mk_origin_node(graph, name, label):
    """Add a node to the graph"""
    graph.add_node(Node(name, label=label, shape='plaintext'))

  def mk_invis_node(graph, name):
    """Add a node to the graph"""
    graph.add_node(Node(name, style='invis'))

  def mk_node(graph, name, label=None):
    """Add a node to the graph, if not already present"""
    if not graph.get_node(name):
      if not label:
        label = name
      if name in graph.changes:
        graph.add_node(Node(name, label=label))
      else:
        graph.add_node(Node(name, color='grey', label=''))

  def mk_merge_target(graph, target_node, important):
    """Add a merge target node to the graph."""
    if important:
      color = 'red'
    else:
      color = 'black'
    graph.add_node(Node(target_node, color=color, fontcolor=color, style='bold'))

  def mk_edge(graph, name1, name2, **attrs):
    """Add an ordinary edge to the graph"""
    graph.add_edge(Edge(name1, name2, dir='none', style='dotted', color='grey', **attrs))

  def mk_br_edge(graph, name1, name2):
    """Add a branch-creation edge to the graph"""
    # Constraint=false to avoid the Y-shape skewing the nice parallel branch lines
    graph.mk_edge(name1, name2, constraint='false')

  def mk_merge_edge(graph, src_node, tgt_node, kind, label, important):
    """Add a merge edge to the graph"""
    if important:
      color = 'red'
    else:
      color = 'grey'
    e = Edge(src_node, tgt_node, constraint='false',
             label='"' + label + '"',
             color=color, fontcolor=color,
             style='bold')
    if kind.startswith('cherry'):
      e.set_style('dashed')
    graph.add_edge(e)

  def mk_mergeinfo_edge(graph, base_node, src_node, important):
    """"""
    if important:
      color = 'red'
    else:
      color = 'grey'
    graph.add_edge(Edge(base_node, src_node,
                        dir='both', arrowtail='odot', arrowhead='tee',
                        color=color, constraint='false'))

  def mk_invis_edge(graph, name1, name2):
    """Add an invisible edge to the graph"""
    graph.add_edge(Edge(name1, name2, style='invis'))

  def add_merge(graph, merge, important):
    """Add a merge"""
    base_node, src_node, tgt_node, kind = merge

    if base_node and src_node:  # and not kind.startwith('cherry'):
      graph.mk_mergeinfo_edge(base_node, src_node, important)

    # Merge target node
    graph.mk_merge_target(tgt_node, important)

    # Merge edge
    graph.mk_merge_edge(src_node, tgt_node, kind, kind, important)

  def add_annotation(graph, node, label, color='lightblue'):
    """Add a graph node that serves as an annotation to a normal node.
       More than one annotation can be added to the same normal node.
    """
    subg_name = node + '_annotations'

    def get_subgraph(graph, name):
      """Equivalent to pydot.Graph.get_subgraph() when there is no more than
         one subgraph of the given name, but working aroung a bug in
         pydot.Graph.get_subgraph().
      """
      for subg in graph.get_subgraph_list():
        if subg.get_name() == name:
          return subg
      return None

    g = get_subgraph(graph, subg_name)
    if not g:
      g = pydot.Subgraph(subg_name, rank='same')
      graph.add_subgraph(g)

    ann_node = node + '_'
    while g.get_node(ann_node):
      ann_node = ann_node + '_'
    g.add_node(Node(ann_node, shape='box', style='filled', color=color,
                    label='"' + label + '"'))
    g.add_edge(Edge(ann_node, node, style='solid', color=color,
                    dir='none', constraint='false'))

class MergeSubgraph(MergeGraph, pydot.Subgraph):
  """"""
  def __init__(graph, **attrs):
    """"""
    MergeGraph.__init__(graph)
    pydot.Subgraph.__init__(graph, **attrs)

class MergeDot(MergeGraph, pydot.Dot):
  """
  # TODO: In the 'merges' input, find the predecessor automatically.
  """
  def __init__(graph, config_filename=None,
               filename=None, title=None, branches=None, changes=None,
               merges=[], annotations=[], **attrs):
    """Return a new MergeDot graph generated from a config file or args."""
    MergeGraph.__init__(graph)
    pydot.Dot.__init__(graph, **attrs)

    if config_filename:
      graph.read_config(config_filename)
    else:
      graph.filename = filename
      graph.title = title
      graph.branches = branches
      graph.changes = changes
      graph.merges = merges
      graph.annotations = annotations

    graph.construct()

  def read_config(graph, config_filename):
    """Initialize a MergeDot graph's input data from a config file."""
    import ConfigParser
    if config_filename.endswith('.txt'):
      default_basename = config_filename[:-4]
    else:
      default_basename = config_filename

    config = ConfigParser.SafeConfigParser({ 'basename': default_basename,
                                             'title': None,
                                             'merges': '[]',
                                             'annotations': '[]' })
    files_read = config.read(config_filename)
    if len(files_read) == 0:
      print >> sys.stderr, 'graph: unable to read graph config from "' + config_filename + '"'
      sys.exit(1)
    graph.basename = config.get('graph', 'basename')
    graph.title = config.get('graph', 'title')
    graph.branches = eval(config.get('graph', 'branches'))
    graph.changes = eval(config.get('graph', 'changes'))
    graph.merges = eval(config.get('graph', 'merges'))
    graph.annotations = eval(config.get('graph', 'annotations'))

  def construct(graph):
    """"""
    # Origin nodes (done first, in an attempt to set the order)
    for br, orig, r1, head in graph.branches:
      name = br + '0'
      if r1 > 0:
        graph.mk_origin_node(name, br)
      else:
        graph.mk_node(name, label=br)

    # Edges and target nodes for merges
    for merge in graph.merges:
      # Emphasize the last merge, as it's the important one
      important = (merge == graph.merges[-1])
      graph.add_merge(merge, important)

    # Parallel edges for basic lines of descent
    for br, orig, r1, head in graph.branches:
      sub_g = MergeSubgraph(ordering='out')
      for i in range(1, head + 1):
        prev_n = br + str(i - 1)
        this_n = br + str(i)

        # Normal edges and nodes
        if i < r1:
          graph.mk_invis_node(this_n)
        else:
          graph.mk_node(this_n)
        if i <= r1:
          graph.mk_invis_edge(prev_n, this_n)
        else:
          graph.mk_edge(prev_n, this_n)

      # Branch creation edges
      if orig:
        sub_g.mk_br_edge(orig, br + str(r1))

      graph.add_subgraph(sub_g)

    # Annotations
    for node, label in graph.annotations:
      graph.add_annotation(node, label)

    # A title for the graph (added last so it goes at the top)
    if graph.title:
      graph.add_node(Node('title', shape='plaintext', label='"' + graph.title + '"'))

  def save(graph, format='png', filename=None):
    """Save this merge graph to the given file format. If filename is None,
       construct a filename from the basename of the original file (as passed
       to the constructor and then stored in graph.basename) and the suffix
       according to the given format.
    """
    if not filename:
      filename = graph.basename + '.' + format
    if format == 'sh':
      import save_as_sh
      save_as_sh.write_sh_file(graph, filename)
    else:
      pydot.Dot.write(graph, filename, format=format)