1#! /usr/bin/env python
   2# This program is free software; you can redistribute it and/or modify
   4# it under the terms of the GNU General Public License as published by
   5# the Free Software Foundation; either version 2 of the License, or
   6# (at your option) any later version.
   7""" gitview
   9GUI browser for git repository
  10This program is based on bzrk by Scott James Remnant <scott@ubuntu.com>
  11"""
  12__copyright__ = "Copyright (C) 2006 Hewlett-Packard Development Company, L.P."
  13__copyright__ = "Copyright (C) 2007 Aneesh Kumar K.V <aneesh.kumar@gmail.com"
  14__author__    = "Aneesh Kumar K.V <aneesh.kumar@gmail.com>"
  15import sys
  18import os
  19import gtk
  20import pygtk
  21import pango
  22import re
  23import time
  24import gobject
  25import cairo
  26import math
  27import string
  28import fcntl
  29have_gtksourceview2 = False
  31have_gtksourceview = False
  32try:
  33    import gtksourceview2
  34    have_gtksourceview2 = True
  35except ImportError:
  36    try:
  37        import gtksourceview
  38        have_gtksourceview = True
  39    except ImportError:
  40        print "Running without gtksourceview2 or gtksourceview module"
  41re_ident = re.compile('(author|committer) (?P<ident>.*) (?P<epoch>\d+) (?P<tz>[+-]\d{4})')
  43def list_to_string(args, skip):
  45        count = len(args)
  46        i = skip
  47        str_arg=" "
  48        while (i < count ):
  49                str_arg = str_arg + args[i]
  50                str_arg = str_arg + " "
  51                i = i+1
  52        return str_arg
  54def show_date(epoch, tz):
  56        secs = float(epoch)
  57        tzsecs = float(tz[1:3]) * 3600
  58        tzsecs += float(tz[3:5]) * 60
  59        if (tz[0] == "+"):
  60                secs += tzsecs
  61        else:
  62                secs -= tzsecs
  63        return time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(secs))
  65def get_source_buffer_and_view():
  67        if have_gtksourceview2:
  68                buffer = gtksourceview2.Buffer()
  69                slm = gtksourceview2.LanguageManager()
  70                gsl = slm.get_language("diff")
  71                buffer.set_highlight_syntax(True)
  72                buffer.set_language(gsl)
  73                view = gtksourceview2.View(buffer)
  74        elif have_gtksourceview:
  75                buffer = gtksourceview.SourceBuffer()
  76                slm = gtksourceview.SourceLanguagesManager()
  77                gsl = slm.get_language_from_mime_type("text/x-patch")
  78                buffer.set_highlight(True)
  79                buffer.set_language(gsl)
  80                view = gtksourceview.SourceView(buffer)
  81        else:
  82                buffer = gtk.TextBuffer()
  83                view = gtk.TextView(buffer)
  84        return (buffer, view)
  85class CellRendererGraph(gtk.GenericCellRenderer):
  88        """Cell renderer for directed graph.
  89        This module contains the implementation of a custom GtkCellRenderer that
  91        draws part of the directed graph based on the lines suggested by the code
  92        in graph.py.
  93        Because we're shiny, we use Cairo to do this, and because we're naughty
  95        we cheat and draw over the bits of the TreeViewColumn that are supposed to
  96        just be for the background.
  97        Properties:
  99        node              (column, colour, [ names ]) tuple to draw revision node,
 100        in_lines          (start, end, colour) tuple list to draw inward lines,
 101        out_lines         (start, end, colour) tuple list to draw outward lines.
 102        """
 103        __gproperties__ = {
 105        "node":         ( gobject.TYPE_PYOBJECT, "node",
 106                          "revision node instruction",
 107                          gobject.PARAM_WRITABLE
 108                        ),
 109        "in-lines":     ( gobject.TYPE_PYOBJECT, "in-lines",
 110                          "instructions to draw lines into the cell",
 111                          gobject.PARAM_WRITABLE
 112                        ),
 113        "out-lines":    ( gobject.TYPE_PYOBJECT, "out-lines",
 114                          "instructions to draw lines out of the cell",
 115                          gobject.PARAM_WRITABLE
 116                        ),
 117        }
 118        def do_set_property(self, property, value):
 120                """Set properties from GObject properties."""
 121                if property.name == "node":
 122                        self.node = value
 123                elif property.name == "in-lines":
 124                        self.in_lines = value
 125                elif property.name == "out-lines":
 126                        self.out_lines = value
 127                else:
 128                        raise AttributeError, "no such property: '%s'" % property.name
 129        def box_size(self, widget):
 131                """Calculate box size based on widget's font.
 132                Cache this as it's probably expensive to get.  It ensures that we
 134                draw the graph at least as large as the text.
 135                """
 136                try:
 137                        return self._box_size
 138                except AttributeError:
 139                        pango_ctx = widget.get_pango_context()
 140                        font_desc = widget.get_style().font_desc
 141                        metrics = pango_ctx.get_metrics(font_desc)
 142                        ascent = pango.PIXELS(metrics.get_ascent())
 144                        descent = pango.PIXELS(metrics.get_descent())
 145                        self._box_size = ascent + descent + 6
 147                        return self._box_size
 148        def set_colour(self, ctx, colour, bg, fg):
 150                """Set the context source colour.
 151                Picks a distinct colour based on an internal wheel; the bg
 153                parameter provides the value that should be assigned to the 'zero'
 154                colours and the fg parameter provides the multiplier that should be
 155                applied to the foreground colours.
 156                """
 157                colours = [
 158                    ( 1.0, 0.0, 0.0 ),
 159                    ( 1.0, 1.0, 0.0 ),
 160                    ( 0.0, 1.0, 0.0 ),
 161                    ( 0.0, 1.0, 1.0 ),
 162                    ( 0.0, 0.0, 1.0 ),
 163                    ( 1.0, 0.0, 1.0 ),
 164                    ]
 165                colour %= len(colours)
 167                red   = (colours[colour][0] * fg) or bg
 168                green = (colours[colour][1] * fg) or bg
 169                blue  = (colours[colour][2] * fg) or bg
 170                ctx.set_source_rgb(red, green, blue)
 172        def on_get_size(self, widget, cell_area):
 174                """Return the size we need for this cell.
 175                Each cell is drawn individually and is only as wide as it needs
 177                to be, we let the TreeViewColumn take care of making them all
 178                line up.
 179                """
 180                box_size = self.box_size(widget)
 181                cols = self.node[0]
 183                for start, end, colour in self.in_lines + self.out_lines:
 184                        cols = int(max(cols, start, end))
 185                (column, colour, names) = self.node
 187                names_len = 0
 188                if (len(names) != 0):
 189                        for item in names:
 190                                names_len += len(item)
 191                width = box_size * (cols + 1 ) + names_len
 193                height = box_size
 194                # FIXME I have no idea how to use cell_area properly
 196                return (0, 0, width, height)
 197        def on_render(self, window, widget, bg_area, cell_area, exp_area, flags):
 199                """Render an individual cell.
 200                Draws the cell contents using cairo, taking care to clip what we
 202                do to within the background area so we don't draw over other cells.
 203                Note that we're a bit naughty there and should really be drawing
 204                in the cell_area (or even the exposed area), but we explicitly don't
 205                want any gutter.
 206                We try and be a little clever, if the line we need to draw is going
 208                to cross other columns we actually draw it as in the .---' style
 209                instead of a pure diagonal ... this reduces confusion by an
 210                incredible amount.
 211                """
 212                ctx = window.cairo_create()
 213                ctx.rectangle(bg_area.x, bg_area.y, bg_area.width, bg_area.height)
 214                ctx.clip()
 215                box_size = self.box_size(widget)
 217                ctx.set_line_width(box_size / 8)
 219                ctx.set_line_cap(cairo.LINE_CAP_SQUARE)
 220                # Draw lines into the cell
 222                for start, end, colour in self.in_lines:
 223                        ctx.move_to(cell_area.x + box_size * start + box_size / 2,
 224                                        bg_area.y - bg_area.height / 2)
 225                        if start - end > 1:
 227                                ctx.line_to(cell_area.x + box_size * start, bg_area.y)
 228                                ctx.line_to(cell_area.x + box_size * end + box_size, bg_area.y)
 229                        elif start - end < -1:
 230                                ctx.line_to(cell_area.x + box_size * start + box_size,
 231                                                bg_area.y)
 232                                ctx.line_to(cell_area.x + box_size * end, bg_area.y)
 233                        ctx.line_to(cell_area.x + box_size * end + box_size / 2,
 235                                        bg_area.y + bg_area.height / 2)
 236                        self.set_colour(ctx, colour, 0.0, 0.65)
 238                        ctx.stroke()
 239                # Draw lines out of the cell
 241                for start, end, colour in self.out_lines:
 242                        ctx.move_to(cell_area.x + box_size * start + box_size / 2,
 243                                        bg_area.y + bg_area.height / 2)
 244                        if start - end > 1:
 246                                ctx.line_to(cell_area.x + box_size * start,
 247                                                bg_area.y + bg_area.height)
 248                                ctx.line_to(cell_area.x + box_size * end + box_size,
 249                                                bg_area.y + bg_area.height)
 250                        elif start - end < -1:
 251                                ctx.line_to(cell_area.x + box_size * start + box_size,
 252                                                bg_area.y + bg_area.height)
 253                                ctx.line_to(cell_area.x + box_size * end,
 254                                                bg_area.y + bg_area.height)
 255                        ctx.line_to(cell_area.x + box_size * end + box_size / 2,
 257                                        bg_area.y + bg_area.height / 2 + bg_area.height)
 258                        self.set_colour(ctx, colour, 0.0, 0.65)
 260                        ctx.stroke()
 261                # Draw the revision node in the right column
 263                (column, colour, names) = self.node
 264                ctx.arc(cell_area.x + box_size * column + box_size / 2,
 265                                cell_area.y + cell_area.height / 2,
 266                                box_size / 4, 0, 2 * math.pi)
 267                self.set_colour(ctx, colour, 0.0, 0.5)
 270                ctx.stroke_preserve()
 271                self.set_colour(ctx, colour, 0.5, 1.0)
 273                ctx.fill_preserve()
 274                if (len(names) != 0):
 276                        name = " "
 277                        for item in names:
 278                                name = name + item + " "
 279                        ctx.set_font_size(13)
 281                        if (flags & 1):
 282                                self.set_colour(ctx, colour, 0.5, 1.0)
 283                        else:
 284                                self.set_colour(ctx, colour, 0.0, 0.5)
 285                        ctx.show_text(name)
 286class Commit(object):
 288        """ This represent a commit object obtained after parsing the git-rev-list
 289        output """
 290        __slots__ = ['children_sha1', 'message', 'author', 'date', 'committer',
 292                                 'commit_date', 'commit_sha1', 'parent_sha1']
 293        children_sha1 = {}
 295        def __init__(self, commit_lines):
 297                self.message            = ""
 298                self.author             = ""
 299                self.date               = ""
 300                self.committer          = ""
 301                self.commit_date        = ""
 302                self.commit_sha1        = ""
 303                self.parent_sha1        = [ ]
 304                self.parse_commit(commit_lines)
 305        def parse_commit(self, commit_lines):
 308                # First line is the sha1 lines
 310                line = string.strip(commit_lines[0])
 311                sha1 = re.split(" ", line)
 312                self.commit_sha1 = sha1[0]
 313                self.parent_sha1 = sha1[1:]
 314                #build the child list
 316                for parent_id in self.parent_sha1:
 317                        try:
 318                                Commit.children_sha1[parent_id].append(self.commit_sha1)
 319                        except KeyError:
 320                                Commit.children_sha1[parent_id] = [self.commit_sha1]
 321                # IF we don't have parent
 323                if (len(self.parent_sha1) == 0):
 324                        self.parent_sha1 = [0]
 325                for line in commit_lines[1:]:
 327                        m = re.match("^ ", line)
 328                        if (m != None):
 329                                # First line of the commit message used for short log
 330                                if self.message == "":
 331                                        self.message = string.strip(line)
 332                                continue
 333                        m = re.match("tree", line)
 335                        if (m != None):
 336                                continue
 337                        m = re.match("parent", line)
 339                        if (m != None):
 340                                continue
 341                        m = re_ident.match(line)
 343                        if (m != None):
 344                                date = show_date(m.group('epoch'), m.group('tz'))
 345                                if m.group(1) == "author":
 346                                        self.author = m.group('ident')
 347                                        self.date = date
 348                                elif m.group(1) == "committer":
 349                                        self.committer = m.group('ident')
 350                                        self.commit_date = date
 351                                continue
 353        def get_message(self, with_diff=0):
 355                if (with_diff == 1):
 356                        message = self.diff_tree()
 357                else:
 358                        fp = os.popen("git cat-file commit " + self.commit_sha1)
 359                        message = fp.read()
 360                        fp.close()
 361                return message
 363        def diff_tree(self):
 365                fp = os.popen("git diff-tree --pretty --cc  -v -p --always " +  self.commit_sha1)
 366                diff = fp.read()
 367                fp.close()
 368                return diff
 369class AnnotateWindow(object):
 371        """Annotate window.
 372        This object represents and manages a single window containing the
 373        annotate information of the file
 374        """
 375        def __init__(self):
 377                self.window = gtk.Window(gtk.WINDOW_TOPLEVEL)
 378                self.window.set_border_width(0)
 379                self.window.set_title("Git repository browser annotation window")
 380                self.prev_read = ""
 381                # Use two thirds of the screen by default
 383                screen = self.window.get_screen()
 384                monitor = screen.get_monitor_geometry(0)
 385                width = int(monitor.width * 0.66)
 386                height = int(monitor.height * 0.66)
 387                self.window.set_default_size(width, height)
 388        def add_file_data(self, filename, commit_sha1, line_num):
 390                fp = os.popen("git cat-file blob " + commit_sha1 +":"+filename)
 391                i = 1;
 392                for line in fp.readlines():
 393                        line = string.rstrip(line)
 394                        self.model.append(None, ["HEAD", filename, line, i])
 395                        i = i+1
 396                fp.close()
 397                # now set the cursor position
 399                self.treeview.set_cursor(line_num-1)
 400                self.treeview.grab_focus()
 401        def _treeview_cursor_cb(self, *args):
 403                """Callback for when the treeview cursor changes."""
 404                (path, col) = self.treeview.get_cursor()
 405                commit_sha1 = self.model[path][0]
 406                commit_msg = ""
 407                fp = os.popen("git cat-file commit " + commit_sha1)
 408                for line in fp.readlines():
 409                        commit_msg =  commit_msg + line
 410                fp.close()
 411                self.commit_buffer.set_text(commit_msg)
 413        def _treeview_row_activated(self, *args):
 415                """Callback for when the treeview row gets selected."""
 416                (path, col) = self.treeview.get_cursor()
 417                commit_sha1 = self.model[path][0]
 418                filename    = self.model[path][1]
 419                line_num    = self.model[path][3]
 420                window = AnnotateWindow();
 422                fp = os.popen("git rev-parse "+ commit_sha1 + "~1")
 423                commit_sha1 = string.strip(fp.readline())
 424                fp.close()
 425                window.annotate(filename, commit_sha1, line_num)
 426        def data_ready(self, source, condition):
 428                while (1):
 429                        try :
 430                                # A simple readline doesn't work
 431                                # a readline bug ??
 432                                buffer = source.read(100)
 433                        except:
 435                                # resource temporary not available
 436                                return True
 437                        if (len(buffer) == 0):
 439                                gobject.source_remove(self.io_watch_tag)
 440                                source.close()
 441                                return False
 442                        if (self.prev_read != ""):
 444                                buffer = self.prev_read + buffer
 445                                self.prev_read = ""
 446                        if (buffer[len(buffer) -1] != '\n'):
 448                                try:
 449                                        newline_index = buffer.rindex("\n")
 450                                except ValueError:
 451                                        newline_index = 0
 452                                self.prev_read = buffer[newline_index:(len(buffer))]
 454                                buffer = buffer[0:newline_index]
 455                        for buff in buffer.split("\n"):
 457                                annotate_line = re.compile('^([0-9a-f]{40}) (.+) (.+) (.+)$')
 458                                m = annotate_line.match(buff)
 459                                if not m:
 460                                        annotate_line = re.compile('^(filename) (.+)$')
 461                                        m = annotate_line.match(buff)
 462                                        if not m:
 463                                                continue
 464                                        filename = m.group(2)
 465                                else:
 466                                        self.commit_sha1 = m.group(1)
 467                                        self.source_line = int(m.group(2))
 468                                        self.result_line = int(m.group(3))
 469                                        self.count          = int(m.group(4))
 470                                        #set the details only when we have the file name
 471                                        continue
 472                                while (self.count > 0):
 474                                        # set at result_line + count-1 the sha1 as commit_sha1
 475                                        self.count = self.count - 1
 476                                        iter = self.model.iter_nth_child(None, self.result_line + self.count-1)
 477                                        self.model.set(iter, 0, self.commit_sha1, 1, filename, 3, self.source_line)
 478        def annotate(self, filename, commit_sha1, line_num):
 481                # verify the commit_sha1 specified has this filename
 482                fp = os.popen("git ls-tree "+ commit_sha1 + " -- " + filename)
 484                line = string.strip(fp.readline())
 485                if line == '':
 486                        # pop up the message the file is not there as a part of the commit
 487                        fp.close()
 488                        dialog = gtk.MessageDialog(parent=None, flags=0,
 489                                        type=gtk.MESSAGE_WARNING, buttons=gtk.BUTTONS_CLOSE,
 490                                        message_format=None)
 491                        dialog.set_markup("The file %s is not present in the parent commit %s" % (filename, commit_sha1))
 492                        dialog.run()
 493                        dialog.destroy()
 494                        return
 495                fp.close()
 497                vpan = gtk.VPaned();
 499                self.window.add(vpan);
 500                vpan.show()
 501                scrollwin = gtk.ScrolledWindow()
 503                scrollwin.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
 504                scrollwin.set_shadow_type(gtk.SHADOW_IN)
 505                vpan.pack1(scrollwin, True, True);
 506                scrollwin.show()
 507                self.model = gtk.TreeStore(str, str, str, int)
 509                self.treeview = gtk.TreeView(self.model)
 510                self.treeview.set_rules_hint(True)
 511                self.treeview.set_search_column(0)
 512                self.treeview.connect("cursor-changed", self._treeview_cursor_cb)
 513                self.treeview.connect("row-activated", self._treeview_row_activated)
 514                scrollwin.add(self.treeview)
 515                self.treeview.show()
 516                cell = gtk.CellRendererText()
 518                cell.set_property("width-chars", 10)
 519                cell.set_property("ellipsize", pango.ELLIPSIZE_END)
 520                column = gtk.TreeViewColumn("Commit")
 521                column.set_resizable(True)
 522                column.pack_start(cell, expand=True)
 523                column.add_attribute(cell, "text", 0)
 524                self.treeview.append_column(column)
 525                cell = gtk.CellRendererText()
 527                cell.set_property("width-chars", 20)
 528                cell.set_property("ellipsize", pango.ELLIPSIZE_END)
 529                column = gtk.TreeViewColumn("File Name")
 530                column.set_resizable(True)
 531                column.pack_start(cell, expand=True)
 532                column.add_attribute(cell, "text", 1)
 533                self.treeview.append_column(column)
 534                cell = gtk.CellRendererText()
 536                cell.set_property("width-chars", 20)
 537                cell.set_property("ellipsize", pango.ELLIPSIZE_END)
 538                column = gtk.TreeViewColumn("Data")
 539                column.set_resizable(True)
 540                column.pack_start(cell, expand=True)
 541                column.add_attribute(cell, "text", 2)
 542                self.treeview.append_column(column)
 543                # The commit message window
 545                scrollwin = gtk.ScrolledWindow()
 546                scrollwin.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
 547                scrollwin.set_shadow_type(gtk.SHADOW_IN)
 548                vpan.pack2(scrollwin, True, True);
 549                scrollwin.show()
 550                commit_text = gtk.TextView()
 552                self.commit_buffer = gtk.TextBuffer()
 553                commit_text.set_buffer(self.commit_buffer)
 554                scrollwin.add(commit_text)
 555                commit_text.show()
 556                self.window.show()
 558                self.add_file_data(filename, commit_sha1, line_num)
 560                fp = os.popen("git blame --incremental -C -C -- " + filename + " " + commit_sha1)
 562                flags = fcntl.fcntl(fp.fileno(), fcntl.F_GETFL)
 563                fcntl.fcntl(fp.fileno(), fcntl.F_SETFL, flags | os.O_NONBLOCK)
 564                self.io_watch_tag = gobject.io_add_watch(fp, gobject.IO_IN, self.data_ready)
 565class DiffWindow(object):
 568        """Diff window.
 569        This object represents and manages a single window containing the
 570        differences between two revisions on a branch.
 571        """
 572        def __init__(self):
 574                self.window = gtk.Window(gtk.WINDOW_TOPLEVEL)
 575                self.window.set_border_width(0)
 576                self.window.set_title("Git repository browser diff window")
 577                # Use two thirds of the screen by default
 579                screen = self.window.get_screen()
 580                monitor = screen.get_monitor_geometry(0)
 581                width = int(monitor.width * 0.66)
 582                height = int(monitor.height * 0.66)
 583                self.window.set_default_size(width, height)
 584                self.construct()
 587        def construct(self):
 589                """Construct the window contents."""
 590                vbox = gtk.VBox()
 591                self.window.add(vbox)
 592                vbox.show()
 593                menu_bar = gtk.MenuBar()
 595                save_menu = gtk.ImageMenuItem(gtk.STOCK_SAVE)
 596                save_menu.connect("activate", self.save_menu_response, "save")
 597                save_menu.show()
 598                menu_bar.append(save_menu)
 599                vbox.pack_start(menu_bar, expand=False, fill=True)
 600                menu_bar.show()
 601                hpan = gtk.HPaned()
 603                scrollwin = gtk.ScrolledWindow()
 605                scrollwin.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
 606                scrollwin.set_shadow_type(gtk.SHADOW_IN)
 607                hpan.pack1(scrollwin, True, True)
 608                scrollwin.show()
 609                (self.buffer, sourceview) = get_source_buffer_and_view()
 611                sourceview.set_editable(False)
 613                sourceview.modify_font(pango.FontDescription("Monospace"))
 614                scrollwin.add(sourceview)
 615                sourceview.show()
 616                # The file hierarchy: a scrollable treeview
 618                scrollwin = gtk.ScrolledWindow()
 619                scrollwin.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
 620                scrollwin.set_shadow_type(gtk.SHADOW_IN)
 621                scrollwin.set_size_request(20, -1)
 622                hpan.pack2(scrollwin, True, True)
 623                scrollwin.show()
 624                self.model = gtk.TreeStore(str, str, str)
 626                self.treeview = gtk.TreeView(self.model)
 627                self.treeview.set_search_column(1)
 628                self.treeview.connect("cursor-changed", self._treeview_clicked)
 629                scrollwin.add(self.treeview)
 630                self.treeview.show()
 631                cell = gtk.CellRendererText()
 633                cell.set_property("width-chars", 20)
 634                column = gtk.TreeViewColumn("Select to annotate")
 635                column.pack_start(cell, expand=True)
 636                column.add_attribute(cell, "text", 0)
 637                self.treeview.append_column(column)
 638                vbox.pack_start(hpan, expand=True, fill=True)
 640                hpan.show()
 641        def _treeview_clicked(self, *args):
 643                """Callback for when the treeview cursor changes."""
 644                (path, col) = self.treeview.get_cursor()
 645                specific_file = self.model[path][1]
 646                commit_sha1 =  self.model[path][2]
 647                if specific_file ==  None :
 648                        return
 649                elif specific_file ==  "" :
 650                        specific_file =  None
 651                window = AnnotateWindow();
 653                window.annotate(specific_file, commit_sha1, 1)
 654        def commit_files(self, commit_sha1, parent_sha1):
 657                self.model.clear()
 658                add  = self.model.append(None, [ "Added", None, None])
 659                dele = self.model.append(None, [ "Deleted", None, None])
 660                mod  = self.model.append(None, [ "Modified", None, None])
 661                diff_tree = re.compile('^(:.{6}) (.{6}) (.{40}) (.{40}) (A|D|M)\s(.+)$')
 662                fp = os.popen("git diff-tree -r --no-commit-id " + parent_sha1 + " " + commit_sha1)
 663                while 1:
 664                        line = string.strip(fp.readline())
 665                        if line == '':
 666                                break
 667                        m = diff_tree.match(line)
 668                        if not m:
 669                                continue
 670                        attr = m.group(5)
 672                        filename = m.group(6)
 673                        if attr == "A":
 674                                self.model.append(add,  [filename, filename, commit_sha1])
 675                        elif attr == "D":
 676                                self.model.append(dele, [filename, filename, commit_sha1])
 677                        elif attr == "M":
 678                                self.model.append(mod,  [filename, filename, commit_sha1])
 679                fp.close()
 680                self.treeview.expand_all()
 682        def set_diff(self, commit_sha1, parent_sha1, encoding):
 684                """Set the differences showed by this window.
 685                Compares the two trees and populates the window with the
 686                differences.
 687                """
 688                # Diff with the first commit or the last commit shows nothing
 689                if (commit_sha1 == 0 or parent_sha1 == 0 ):
 690                        return
 691                fp = os.popen("git diff-tree -p " + parent_sha1 + " " + commit_sha1)
 693                self.buffer.set_text(unicode(fp.read(), encoding).encode('utf-8'))
 694                fp.close()
 695                self.commit_files(commit_sha1, parent_sha1)
 696                self.window.show()
 697        def save_menu_response(self, widget, string):
 699                dialog = gtk.FileChooserDialog("Save..", None, gtk.FILE_CHOOSER_ACTION_SAVE,
 700                                (gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL,
 701                                        gtk.STOCK_SAVE, gtk.RESPONSE_OK))
 702                dialog.set_default_response(gtk.RESPONSE_OK)
 703                response = dialog.run()
 704                if response == gtk.RESPONSE_OK:
 705                        patch_buffer = self.buffer.get_text(self.buffer.get_start_iter(),
 706                                        self.buffer.get_end_iter())
 707                        fp = open(dialog.get_filename(), "w")
 708                        fp.write(patch_buffer)
 709                        fp.close()
 710                dialog.destroy()
 711class GitView(object):
 713        """ This is the main class
 714        """
 715        version = "0.9"
 716        def __init__(self, with_diff=0):
 718                self.with_diff = with_diff
 719                self.window =   gtk.Window(gtk.WINDOW_TOPLEVEL)
 720                self.window.set_border_width(0)
 721                self.window.set_title("Git repository browser")
 722                self.get_encoding()
 724                self.get_bt_sha1()
 725                # Use three-quarters of the screen by default
 727                screen = self.window.get_screen()
 728                monitor = screen.get_monitor_geometry(0)
 729                width = int(monitor.width * 0.75)
 730                height = int(monitor.height * 0.75)
 731                self.window.set_default_size(width, height)
 732                # FIXME AndyFitz!
 734                icon = self.window.render_icon(gtk.STOCK_INDEX, gtk.ICON_SIZE_BUTTON)
 735                self.window.set_icon(icon)
 736                self.accel_group = gtk.AccelGroup()
 738                self.window.add_accel_group(self.accel_group)
 739                self.accel_group.connect_group(0xffc2, 0, gtk.ACCEL_LOCKED, self.refresh);
 740                self.accel_group.connect_group(0xffc1, 0, gtk.ACCEL_LOCKED, self.maximize);
 741                self.accel_group.connect_group(0xffc8, 0, gtk.ACCEL_LOCKED, self.fullscreen);
 742                self.accel_group.connect_group(0xffc9, 0, gtk.ACCEL_LOCKED, self.unfullscreen);
 743                self.window.add(self.construct())
 745        def refresh(self, widget, event=None, *arguments, **keywords):
 747                self.get_encoding()
 748                self.get_bt_sha1()
 749                Commit.children_sha1 = {}
 750                self.set_branch(sys.argv[without_diff:])
 751                self.window.show()
 752                return True
 753        def maximize(self, widget, event=None, *arguments, **keywords):
 755                self.window.maximize()
 756                return True
 757        def fullscreen(self, widget, event=None, *arguments, **keywords):
 759                self.window.fullscreen()
 760                return True
 761        def unfullscreen(self, widget, event=None, *arguments, **keywords):
 763                self.window.unfullscreen()
 764                return True
 765        def get_bt_sha1(self):
 767                """ Update the bt_sha1 dictionary with the
 768                respective sha1 details """
 769                self.bt_sha1 = { }
 771                ls_remote = re.compile('^(.{40})\trefs/([^^]+)(?:\\^(..))?$');
 772                fp = os.popen('git ls-remote "${GIT_DIR-.git}"')
 773                while 1:
 774                        line = string.strip(fp.readline())
 775                        if line == '':
 776                                break
 777                        m = ls_remote.match(line)
 778                        if not m:
 779                                continue
 780                        (sha1, name) = (m.group(1), m.group(2))
 781                        if not self.bt_sha1.has_key(sha1):
 782                                self.bt_sha1[sha1] = []
 783                        self.bt_sha1[sha1].append(name)
 784                fp.close()
 785        def get_encoding(self):
 787                fp = os.popen("git config --get i18n.commitencoding")
 788                self.encoding=string.strip(fp.readline())
 789                fp.close()
 790                if (self.encoding == ""):
 791                        self.encoding = "utf-8"
 792        def construct(self):
 795                """Construct the window contents."""
 796                vbox = gtk.VBox()
 797                paned = gtk.VPaned()
 798                paned.pack1(self.construct_top(), resize=False, shrink=True)
 799                paned.pack2(self.construct_bottom(), resize=False, shrink=True)
 800                menu_bar = gtk.MenuBar()
 801                menu_bar.set_pack_direction(gtk.PACK_DIRECTION_RTL)
 802                help_menu = gtk.MenuItem("Help")
 803                menu = gtk.Menu()
 804                about_menu = gtk.MenuItem("About")
 805                menu.append(about_menu)
 806                about_menu.connect("activate", self.about_menu_response, "about")
 807                about_menu.show()
 808                help_menu.set_submenu(menu)
 809                help_menu.show()
 810                menu_bar.append(help_menu)
 811                menu_bar.show()
 812                vbox.pack_start(menu_bar, expand=False, fill=True)
 813                vbox.pack_start(paned, expand=True, fill=True)
 814                paned.show()
 815                vbox.show()
 816                return vbox
 817        def construct_top(self):
 820                """Construct the top-half of the window."""
 821                vbox = gtk.VBox(spacing=6)
 822                vbox.set_border_width(12)
 823                vbox.show()
 824                scrollwin = gtk.ScrolledWindow()
 827                scrollwin.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
 828                scrollwin.set_shadow_type(gtk.SHADOW_IN)
 829                vbox.pack_start(scrollwin, expand=True, fill=True)
 830                scrollwin.show()
 831                self.treeview = gtk.TreeView()
 833                self.treeview.set_rules_hint(True)
 834                self.treeview.set_search_column(4)
 835                self.treeview.connect("cursor-changed", self._treeview_cursor_cb)
 836                scrollwin.add(self.treeview)
 837                self.treeview.show()
 838                cell = CellRendererGraph()
 840                column = gtk.TreeViewColumn()
 841                column.set_resizable(True)
 842                column.pack_start(cell, expand=True)
 843                column.add_attribute(cell, "node", 1)
 844                column.add_attribute(cell, "in-lines", 2)
 845                column.add_attribute(cell, "out-lines", 3)
 846                self.treeview.append_column(column)
 847                cell = gtk.CellRendererText()
 849                cell.set_property("width-chars", 65)
 850                cell.set_property("ellipsize", pango.ELLIPSIZE_END)
 851                column = gtk.TreeViewColumn("Message")
 852                column.set_resizable(True)
 853                column.pack_start(cell, expand=True)
 854                column.add_attribute(cell, "text", 4)
 855                self.treeview.append_column(column)
 856                cell = gtk.CellRendererText()
 858                cell.set_property("width-chars", 40)
 859                cell.set_property("ellipsize", pango.ELLIPSIZE_END)
 860                column = gtk.TreeViewColumn("Author")
 861                column.set_resizable(True)
 862                column.pack_start(cell, expand=True)
 863                column.add_attribute(cell, "text", 5)
 864                self.treeview.append_column(column)
 865                cell = gtk.CellRendererText()
 867                cell.set_property("ellipsize", pango.ELLIPSIZE_END)
 868                column = gtk.TreeViewColumn("Date")
 869                column.set_resizable(True)
 870                column.pack_start(cell, expand=True)
 871                column.add_attribute(cell, "text", 6)
 872                self.treeview.append_column(column)
 873                return vbox
 875        def about_menu_response(self, widget, string):
 877                dialog = gtk.AboutDialog()
 878                dialog.set_name("Gitview")
 879                dialog.set_version(GitView.version)
 880                dialog.set_authors(["Aneesh Kumar K.V <aneesh.kumar@gmail.com>"])
 881                dialog.set_website("http://www.kernel.org/pub/software/scm/git/")
 882                dialog.set_copyright("Use and distribute under the terms of the GNU General Public License")
 883                dialog.set_wrap_license(True)
 884                dialog.run()
 885                dialog.destroy()
 886        def construct_bottom(self):
 889                """Construct the bottom half of the window."""
 890                vbox = gtk.VBox(False, spacing=6)
 891                vbox.set_border_width(12)
 892                (width, height) = self.window.get_size()
 893                vbox.set_size_request(width, int(height / 2.5))
 894                vbox.show()
 895                self.table = gtk.Table(rows=4, columns=4)
 897                self.table.set_row_spacings(6)
 898                self.table.set_col_spacings(6)
 899                vbox.pack_start(self.table, expand=False, fill=True)
 900                self.table.show()
 901                align = gtk.Alignment(0.0, 0.5)
 903                label = gtk.Label()
 904                label.set_markup("<b>Revision:</b>")
 905                align.add(label)
 906                self.table.attach(align, 0, 1, 0, 1, gtk.FILL, gtk.FILL)
 907                label.show()
 908                align.show()
 909                align = gtk.Alignment(0.0, 0.5)
 911                self.revid_label = gtk.Label()
 912                self.revid_label.set_selectable(True)
 913                align.add(self.revid_label)
 914                self.table.attach(align, 1, 2, 0, 1, gtk.EXPAND | gtk.FILL, gtk.FILL)
 915                self.revid_label.show()
 916                align.show()
 917                align = gtk.Alignment(0.0, 0.5)
 919                label = gtk.Label()
 920                label.set_markup("<b>Committer:</b>")
 921                align.add(label)
 922                self.table.attach(align, 0, 1, 1, 2, gtk.FILL, gtk.FILL)
 923                label.show()
 924                align.show()
 925                align = gtk.Alignment(0.0, 0.5)
 927                self.committer_label = gtk.Label()
 928                self.committer_label.set_selectable(True)
 929                align.add(self.committer_label)
 930                self.table.attach(align, 1, 2, 1, 2, gtk.EXPAND | gtk.FILL, gtk.FILL)
 931                self.committer_label.show()
 932                align.show()
 933                align = gtk.Alignment(0.0, 0.5)
 935                label = gtk.Label()
 936                label.set_markup("<b>Timestamp:</b>")
 937                align.add(label)
 938                self.table.attach(align, 0, 1, 2, 3, gtk.FILL, gtk.FILL)
 939                label.show()
 940                align.show()
 941                align = gtk.Alignment(0.0, 0.5)
 943                self.timestamp_label = gtk.Label()
 944                self.timestamp_label.set_selectable(True)
 945                align.add(self.timestamp_label)
 946                self.table.attach(align, 1, 2, 2, 3, gtk.EXPAND | gtk.FILL, gtk.FILL)
 947                self.timestamp_label.show()
 948                align.show()
 949                align = gtk.Alignment(0.0, 0.5)
 951                label = gtk.Label()
 952                label.set_markup("<b>Parents:</b>")
 953                align.add(label)
 954                self.table.attach(align, 0, 1, 3, 4, gtk.FILL, gtk.FILL)
 955                label.show()
 956                align.show()
 957                self.parents_widgets = []
 958                align = gtk.Alignment(0.0, 0.5)
 960                label = gtk.Label()
 961                label.set_markup("<b>Children:</b>")
 962                align.add(label)
 963                self.table.attach(align, 2, 3, 3, 4, gtk.FILL, gtk.FILL)
 964                label.show()
 965                align.show()
 966                self.children_widgets = []
 967                scrollwin = gtk.ScrolledWindow()
 969                scrollwin.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
 970                scrollwin.set_shadow_type(gtk.SHADOW_IN)
 971                vbox.pack_start(scrollwin, expand=True, fill=True)
 972                scrollwin.show()
 973                (self.message_buffer, sourceview) = get_source_buffer_and_view()
 975                sourceview.set_editable(False)
 977                sourceview.modify_font(pango.FontDescription("Monospace"))
 978                scrollwin.add(sourceview)
 979                sourceview.show()
 980                return vbox
 982        def _treeview_cursor_cb(self, *args):
 984                """Callback for when the treeview cursor changes."""
 985                (path, col) = self.treeview.get_cursor()
 986                commit = self.model[path][0]
 987                if commit.committer is not None:
 989                        committer = commit.committer
 990                        timestamp = commit.commit_date
 991                        message   =  commit.get_message(self.with_diff)
 992                        revid_label = commit.commit_sha1
 993                else:
 994                        committer = ""
 995                        timestamp = ""
 996                        message = ""
 997                        revid_label = ""
 998                self.revid_label.set_text(revid_label)
1000                self.committer_label.set_text(committer)
1001                self.timestamp_label.set_text(timestamp)
1002                self.message_buffer.set_text(unicode(message, self.encoding).encode('utf-8'))
1003                for widget in self.parents_widgets:
1005                        self.table.remove(widget)
1006                self.parents_widgets = []
1008                self.table.resize(4 + len(commit.parent_sha1) - 1, 4)
1009                for idx, parent_id in enumerate(commit.parent_sha1):
1010                        self.table.set_row_spacing(idx + 3, 0)
1011                        align = gtk.Alignment(0.0, 0.0)
1013                        self.parents_widgets.append(align)
1014                        self.table.attach(align, 1, 2, idx + 3, idx + 4,
1015                                        gtk.EXPAND | gtk.FILL, gtk.FILL)
1016                        align.show()
1017                        hbox = gtk.HBox(False, 0)
1019                        align.add(hbox)
1020                        hbox.show()
1021                        label = gtk.Label(parent_id)
1023                        label.set_selectable(True)
1024                        hbox.pack_start(label, expand=False, fill=True)
1025                        label.show()
1026                        image = gtk.Image()
1028                        image.set_from_stock(gtk.STOCK_JUMP_TO, gtk.ICON_SIZE_MENU)
1029                        image.show()
1030                        button = gtk.Button()
1032                        button.add(image)
1033                        button.set_relief(gtk.RELIEF_NONE)
1034                        button.connect("clicked", self._go_clicked_cb, parent_id)
1035                        hbox.pack_start(button, expand=False, fill=True)
1036                        button.show()
1037                        image = gtk.Image()
1039                        image.set_from_stock(gtk.STOCK_FIND, gtk.ICON_SIZE_MENU)
1040                        image.show()
1041                        button = gtk.Button()
1043                        button.add(image)
1044                        button.set_relief(gtk.RELIEF_NONE)
1045                        button.set_sensitive(True)
1046                        button.connect("clicked", self._show_clicked_cb,
1047                                        commit.commit_sha1, parent_id, self.encoding)
1048                        hbox.pack_start(button, expand=False, fill=True)
1049                        button.show()
1050                # Populate with child details
1052                for widget in self.children_widgets:
1053                        self.table.remove(widget)
1054                self.children_widgets = []
1056                try:
1057                        child_sha1 = Commit.children_sha1[commit.commit_sha1]
1058                except KeyError:
1059                        # We don't have child
1060                        child_sha1 = [ 0 ]
1061                if ( len(child_sha1) > len(commit.parent_sha1)):
1063                        self.table.resize(4 + len(child_sha1) - 1, 4)
1064                for idx, child_id in enumerate(child_sha1):
1066                        self.table.set_row_spacing(idx + 3, 0)
1067                        align = gtk.Alignment(0.0, 0.0)
1069                        self.children_widgets.append(align)
1070                        self.table.attach(align, 3, 4, idx + 3, idx + 4,
1071                                        gtk.EXPAND | gtk.FILL, gtk.FILL)
1072                        align.show()
1073                        hbox = gtk.HBox(False, 0)
1075                        align.add(hbox)
1076                        hbox.show()
1077                        label = gtk.Label(child_id)
1079                        label.set_selectable(True)
1080                        hbox.pack_start(label, expand=False, fill=True)
1081                        label.show()
1082                        image = gtk.Image()
1084                        image.set_from_stock(gtk.STOCK_JUMP_TO, gtk.ICON_SIZE_MENU)
1085                        image.show()
1086                        button = gtk.Button()
1088                        button.add(image)
1089                        button.set_relief(gtk.RELIEF_NONE)
1090                        button.connect("clicked", self._go_clicked_cb, child_id)
1091                        hbox.pack_start(button, expand=False, fill=True)
1092                        button.show()
1093                        image = gtk.Image()
1095                        image.set_from_stock(gtk.STOCK_FIND, gtk.ICON_SIZE_MENU)
1096                        image.show()
1097                        button = gtk.Button()
1099                        button.add(image)
1100                        button.set_relief(gtk.RELIEF_NONE)
1101                        button.set_sensitive(True)
1102                        button.connect("clicked", self._show_clicked_cb,
1103                                        child_id, commit.commit_sha1, self.encoding)
1104                        hbox.pack_start(button, expand=False, fill=True)
1105                        button.show()
1106        def _destroy_cb(self, widget):
1108                """Callback for when a window we manage is destroyed."""
1109                self.quit()
1110        def quit(self):
1113                """Stop the GTK+ main loop."""
1114                gtk.main_quit()
1115        def run(self, args):
1117                self.set_branch(args)
1118                self.window.connect("destroy", self._destroy_cb)
1119                self.window.show()
1120                gtk.main()
1121        def set_branch(self, args):
1123                """Fill in different windows with info from the reposiroty"""
1124                fp = os.popen("git rev-parse --sq --default HEAD " + list_to_string(args, 1))
1125                git_rev_list_cmd = fp.read()
1126                fp.close()
1127                fp = os.popen("git rev-list  --header --topo-order --parents " + git_rev_list_cmd)
1128                self.update_window(fp)
1129        def update_window(self, fp):
1131                commit_lines = []
1132                self.model = gtk.ListStore(gobject.TYPE_PYOBJECT, gobject.TYPE_PYOBJECT,
1134                                gobject.TYPE_PYOBJECT, gobject.TYPE_PYOBJECT, str, str, str)
1135                # used for cursor positioning
1137                self.index = {}
1138                self.colours = {}
1140                self.nodepos = {}
1141                self.incomplete_line = {}
1142                self.commits = []
1143                index = 0
1145                last_colour = 0
1146                last_nodepos = -1
1147                out_line = []
1148                input_line = fp.readline()
1149                while (input_line != ""):
1150                        # The commit header ends with '\0'
1151                        # This NULL is immediately followed by the sha1 of the
1152                        # next commit
1153                        if (input_line[0] != '\0'):
1154                                commit_lines.append(input_line)
1155                                input_line = fp.readline()
1156                                continue;
1157                        commit = Commit(commit_lines)
1159                        if (commit != None ):
1160                                self.commits.append(commit)
1161                        # Skip the '\0
1163                        commit_lines = []
1164                        commit_lines.append(input_line[1:])
1165                        input_line = fp.readline()
1166                fp.close()
1168                for commit in self.commits:
1170                        (out_line, last_colour, last_nodepos) = self.draw_graph(commit,
1171                                                                                index, out_line,
1172                                                                                last_colour,
1173                                                                                last_nodepos)
1174                        self.index[commit.commit_sha1] = index
1175                        index += 1
1176                self.treeview.set_model(self.model)
1178                self.treeview.show()
1179        def draw_graph(self, commit, index, out_line, last_colour, last_nodepos):
1181                in_line=[]
1182                #   |   -> outline
1184                #   X
1185                #   |\  <- inline
1186                # Reset nodepostion
1188                if (last_nodepos > 5):
1189                        last_nodepos = -1
1190                # Add the incomplete lines of the last cell in this
1192                try:
1193                        colour = self.colours[commit.commit_sha1]
1194                except KeyError:
1195                        self.colours[commit.commit_sha1] = last_colour+1
1196                        last_colour = self.colours[commit.commit_sha1]
1197                        colour =   self.colours[commit.commit_sha1]
1198                try:
1200                        node_pos = self.nodepos[commit.commit_sha1]
1201                except KeyError:
1202                        self.nodepos[commit.commit_sha1] = last_nodepos+1
1203                        last_nodepos = self.nodepos[commit.commit_sha1]
1204                        node_pos =  self.nodepos[commit.commit_sha1]
1205                #The first parent always continue on the same line
1207                try:
1208                        # check we alreay have the value
1209                        tmp_node_pos = self.nodepos[commit.parent_sha1[0]]
1210                except KeyError:
1211                        self.colours[commit.parent_sha1[0]] = colour
1212                        self.nodepos[commit.parent_sha1[0]] = node_pos
1213                for sha1 in self.incomplete_line.keys():
1215                        if (sha1 != commit.commit_sha1):
1216                                self.draw_incomplete_line(sha1, node_pos,
1217                                                out_line, in_line, index)
1218                        else:
1219                                del self.incomplete_line[sha1]
1220                for parent_id in commit.parent_sha1:
1223                        try:
1224                                tmp_node_pos = self.nodepos[parent_id]
1225                        except KeyError:
1226                                self.colours[parent_id] = last_colour+1
1227                                last_colour = self.colours[parent_id]
1228                                self.nodepos[parent_id] = last_nodepos+1
1229                                last_nodepos = self.nodepos[parent_id]
1230                        in_line.append((node_pos, self.nodepos[parent_id],
1232                                                self.colours[parent_id]))
1233                        self.add_incomplete_line(parent_id)
1234                try:
1236                        branch_tag = self.bt_sha1[commit.commit_sha1]
1237                except KeyError:
1238                        branch_tag = [ ]
1239                node = (node_pos, colour, branch_tag)
1242                self.model.append([commit, node, out_line, in_line,
1244                                commit.message, commit.author, commit.date])
1245                return (in_line, last_colour, last_nodepos)
1247        def add_incomplete_line(self, sha1):
1249                try:
1250                        self.incomplete_line[sha1].append(self.nodepos[sha1])
1251                except KeyError:
1252                        self.incomplete_line[sha1] = [self.nodepos[sha1]]
1253        def draw_incomplete_line(self, sha1, node_pos, out_line, in_line, index):
1255                for idx, pos in enumerate(self.incomplete_line[sha1]):
1256                        if(pos == node_pos):
1257                                #remove the straight line and add a slash
1258                                if ((pos, pos, self.colours[sha1]) in out_line):
1259                                        out_line.remove((pos, pos, self.colours[sha1]))
1260                                out_line.append((pos, pos+0.5, self.colours[sha1]))
1261                                self.incomplete_line[sha1][idx] = pos = pos+0.5
1262                        try:
1263                                next_commit = self.commits[index+1]
1264                                if (next_commit.commit_sha1 == sha1 and pos != int(pos)):
1265                                # join the line back to the node point
1266                                # This need to be done only if we modified it
1267                                        in_line.append((pos, pos-0.5, self.colours[sha1]))
1268                                        continue;
1269                        except IndexError:
1270                                pass
1271                        in_line.append((pos, pos, self.colours[sha1]))
1272        def _go_clicked_cb(self, widget, revid):
1275                """Callback for when the go button for a parent is clicked."""
1276                try:
1277                        self.treeview.set_cursor(self.index[revid])
1278                except KeyError:
1279                        dialog = gtk.MessageDialog(parent=None, flags=0,
1280                                        type=gtk.MESSAGE_WARNING, buttons=gtk.BUTTONS_CLOSE,
1281                                        message_format=None)
1282                        dialog.set_markup("Revision <b>%s</b> not present in the list" % revid)
1283                        # revid == 0 is the parent of the first commit
1284                        if (revid != 0 ):
1285                                dialog.format_secondary_text("Try running gitview without any options")
1286                        dialog.run()
1287                        dialog.destroy()
1288                self.treeview.grab_focus()
1290        def _show_clicked_cb(self, widget,  commit_sha1, parent_sha1, encoding):
1292                """Callback for when the show button for a parent is clicked."""
1293                window = DiffWindow()
1294                window.set_diff(commit_sha1, parent_sha1, encoding)
1295                self.treeview.grab_focus()
1296without_diff = 0
1298if __name__ == "__main__":
1299        if (len(sys.argv) > 1 ):
1301                if (sys.argv[1] == "--without-diff"):
1302                        without_diff = 1
1303        view = GitView( without_diff != 1)
1305        view.run(sys.argv[without_diff:])