Code

Merge branch 'ep/cvstag'
[git.git] / contrib / gitview / gitview
1 #! /usr/bin/env python
3 # 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.
8 """ gitview
9 GUI browser for git repository
10 This 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>"
17 import sys
18 import os
19 import gtk
20 import pygtk
21 import pango
22 import re
23 import time
24 import gobject
25 import cairo
26 import math
27 import string
28 import fcntl
30 try:
31     import gtksourceview
32     have_gtksourceview = True
33 except ImportError:
34     have_gtksourceview = False
35     print "Running without gtksourceview module"
37 re_ident = re.compile('(author|committer) (?P<ident>.*) (?P<epoch>\d+) (?P<tz>[+-]\d{4})')
39 def list_to_string(args, skip):
40         count = len(args)
41         i = skip
42         str_arg=" "
43         while (i < count ):
44                 str_arg = str_arg + args[i]
45                 str_arg = str_arg + " "
46                 i = i+1
48         return str_arg
50 def show_date(epoch, tz):
51         secs = float(epoch)
52         tzsecs = float(tz[1:3]) * 3600
53         tzsecs += float(tz[3:5]) * 60
54         if (tz[0] == "+"):
55                 secs += tzsecs
56         else:
57                 secs -= tzsecs
59         return time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(secs))
62 class CellRendererGraph(gtk.GenericCellRenderer):
63         """Cell renderer for directed graph.
65         This module contains the implementation of a custom GtkCellRenderer that
66         draws part of the directed graph based on the lines suggested by the code
67         in graph.py.
69         Because we're shiny, we use Cairo to do this, and because we're naughty
70         we cheat and draw over the bits of the TreeViewColumn that are supposed to
71         just be for the background.
73         Properties:
74         node              (column, colour, [ names ]) tuple to draw revision node,
75         in_lines          (start, end, colour) tuple list to draw inward lines,
76         out_lines         (start, end, colour) tuple list to draw outward lines.
77         """
79         __gproperties__ = {
80         "node":         ( gobject.TYPE_PYOBJECT, "node",
81                           "revision node instruction",
82                           gobject.PARAM_WRITABLE
83                         ),
84         "in-lines":     ( gobject.TYPE_PYOBJECT, "in-lines",
85                           "instructions to draw lines into the cell",
86                           gobject.PARAM_WRITABLE
87                         ),
88         "out-lines":    ( gobject.TYPE_PYOBJECT, "out-lines",
89                           "instructions to draw lines out of the cell",
90                           gobject.PARAM_WRITABLE
91                         ),
92         }
94         def do_set_property(self, property, value):
95                 """Set properties from GObject properties."""
96                 if property.name == "node":
97                         self.node = value
98                 elif property.name == "in-lines":
99                         self.in_lines = value
100                 elif property.name == "out-lines":
101                         self.out_lines = value
102                 else:
103                         raise AttributeError, "no such property: '%s'" % property.name
105         def box_size(self, widget):
106                 """Calculate box size based on widget's font.
108                 Cache this as it's probably expensive to get.  It ensures that we
109                 draw the graph at least as large as the text.
110                 """
111                 try:
112                         return self._box_size
113                 except AttributeError:
114                         pango_ctx = widget.get_pango_context()
115                         font_desc = widget.get_style().font_desc
116                         metrics = pango_ctx.get_metrics(font_desc)
118                         ascent = pango.PIXELS(metrics.get_ascent())
119                         descent = pango.PIXELS(metrics.get_descent())
121                         self._box_size = ascent + descent + 6
122                         return self._box_size
124         def set_colour(self, ctx, colour, bg, fg):
125                 """Set the context source colour.
127                 Picks a distinct colour based on an internal wheel; the bg
128                 parameter provides the value that should be assigned to the 'zero'
129                 colours and the fg parameter provides the multiplier that should be
130                 applied to the foreground colours.
131                 """
132                 colours = [
133                     ( 1.0, 0.0, 0.0 ),
134                     ( 1.0, 1.0, 0.0 ),
135                     ( 0.0, 1.0, 0.0 ),
136                     ( 0.0, 1.0, 1.0 ),
137                     ( 0.0, 0.0, 1.0 ),
138                     ( 1.0, 0.0, 1.0 ),
139                     ]
141                 colour %= len(colours)
142                 red   = (colours[colour][0] * fg) or bg
143                 green = (colours[colour][1] * fg) or bg
144                 blue  = (colours[colour][2] * fg) or bg
146                 ctx.set_source_rgb(red, green, blue)
148         def on_get_size(self, widget, cell_area):
149                 """Return the size we need for this cell.
151                 Each cell is drawn individually and is only as wide as it needs
152                 to be, we let the TreeViewColumn take care of making them all
153                 line up.
154                 """
155                 box_size = self.box_size(widget)
157                 cols = self.node[0]
158                 for start, end, colour in self.in_lines + self.out_lines:
159                         cols = int(max(cols, start, end))
161                 (column, colour, names) = self.node
162                 names_len = 0
163                 if (len(names) != 0):
164                         for item in names:
165                                 names_len += len(item)
167                 width = box_size * (cols + 1 ) + names_len
168                 height = box_size
170                 # FIXME I have no idea how to use cell_area properly
171                 return (0, 0, width, height)
173         def on_render(self, window, widget, bg_area, cell_area, exp_area, flags):
174                 """Render an individual cell.
176                 Draws the cell contents using cairo, taking care to clip what we
177                 do to within the background area so we don't draw over other cells.
178                 Note that we're a bit naughty there and should really be drawing
179                 in the cell_area (or even the exposed area), but we explicitly don't
180                 want any gutter.
182                 We try and be a little clever, if the line we need to draw is going
183                 to cross other columns we actually draw it as in the .---' style
184                 instead of a pure diagonal ... this reduces confusion by an
185                 incredible amount.
186                 """
187                 ctx = window.cairo_create()
188                 ctx.rectangle(bg_area.x, bg_area.y, bg_area.width, bg_area.height)
189                 ctx.clip()
191                 box_size = self.box_size(widget)
193                 ctx.set_line_width(box_size / 8)
194                 ctx.set_line_cap(cairo.LINE_CAP_SQUARE)
196                 # Draw lines into the cell
197                 for start, end, colour in self.in_lines:
198                         ctx.move_to(cell_area.x + box_size * start + box_size / 2,
199                                         bg_area.y - bg_area.height / 2)
201                         if start - end > 1:
202                                 ctx.line_to(cell_area.x + box_size * start, bg_area.y)
203                                 ctx.line_to(cell_area.x + box_size * end + box_size, bg_area.y)
204                         elif start - end < -1:
205                                 ctx.line_to(cell_area.x + box_size * start + box_size,
206                                                 bg_area.y)
207                                 ctx.line_to(cell_area.x + box_size * end, bg_area.y)
209                         ctx.line_to(cell_area.x + box_size * end + box_size / 2,
210                                         bg_area.y + bg_area.height / 2)
212                         self.set_colour(ctx, colour, 0.0, 0.65)
213                         ctx.stroke()
215                 # Draw lines out of the cell
216                 for start, end, colour in self.out_lines:
217                         ctx.move_to(cell_area.x + box_size * start + box_size / 2,
218                                         bg_area.y + bg_area.height / 2)
220                         if start - end > 1:
221                                 ctx.line_to(cell_area.x + box_size * start,
222                                                 bg_area.y + bg_area.height)
223                                 ctx.line_to(cell_area.x + box_size * end + box_size,
224                                                 bg_area.y + bg_area.height)
225                         elif start - end < -1:
226                                 ctx.line_to(cell_area.x + box_size * start + box_size,
227                                                 bg_area.y + bg_area.height)
228                                 ctx.line_to(cell_area.x + box_size * end,
229                                                 bg_area.y + bg_area.height)
231                         ctx.line_to(cell_area.x + box_size * end + box_size / 2,
232                                         bg_area.y + bg_area.height / 2 + bg_area.height)
234                         self.set_colour(ctx, colour, 0.0, 0.65)
235                         ctx.stroke()
237                 # Draw the revision node in the right column
238                 (column, colour, names) = self.node
239                 ctx.arc(cell_area.x + box_size * column + box_size / 2,
240                                 cell_area.y + cell_area.height / 2,
241                                 box_size / 4, 0, 2 * math.pi)
244                 self.set_colour(ctx, colour, 0.0, 0.5)
245                 ctx.stroke_preserve()
247                 self.set_colour(ctx, colour, 0.5, 1.0)
248                 ctx.fill_preserve()
250                 if (len(names) != 0):
251                         name = " "
252                         for item in names:
253                                 name = name + item + " "
255                         ctx.set_font_size(13)
256                         if (flags & 1):
257                                 self.set_colour(ctx, colour, 0.5, 1.0)
258                         else:
259                                 self.set_colour(ctx, colour, 0.0, 0.5)
260                         ctx.show_text(name)
262 class Commit(object):
263         """ This represent a commit object obtained after parsing the git-rev-list
264         output """
266         __slots__ = ['children_sha1', 'message', 'author', 'date', 'committer',
267                                  'commit_date', 'commit_sha1', 'parent_sha1']
269         children_sha1 = {}
271         def __init__(self, commit_lines):
272                 self.message            = ""
273                 self.author             = ""
274                 self.date               = ""
275                 self.committer          = ""
276                 self.commit_date        = ""
277                 self.commit_sha1        = ""
278                 self.parent_sha1        = [ ]
279                 self.parse_commit(commit_lines)
282         def parse_commit(self, commit_lines):
284                 # First line is the sha1 lines
285                 line = string.strip(commit_lines[0])
286                 sha1 = re.split(" ", line)
287                 self.commit_sha1 = sha1[0]
288                 self.parent_sha1 = sha1[1:]
290                 #build the child list
291                 for parent_id in self.parent_sha1:
292                         try:
293                                 Commit.children_sha1[parent_id].append(self.commit_sha1)
294                         except KeyError:
295                                 Commit.children_sha1[parent_id] = [self.commit_sha1]
297                 # IF we don't have parent
298                 if (len(self.parent_sha1) == 0):
299                         self.parent_sha1 = [0]
301                 for line in commit_lines[1:]:
302                         m = re.match("^ ", line)
303                         if (m != None):
304                                 # First line of the commit message used for short log
305                                 if self.message == "":
306                                         self.message = string.strip(line)
307                                 continue
309                         m = re.match("tree", line)
310                         if (m != None):
311                                 continue
313                         m = re.match("parent", line)
314                         if (m != None):
315                                 continue
317                         m = re_ident.match(line)
318                         if (m != None):
319                                 date = show_date(m.group('epoch'), m.group('tz'))
320                                 if m.group(1) == "author":
321                                         self.author = m.group('ident')
322                                         self.date = date
323                                 elif m.group(1) == "committer":
324                                         self.committer = m.group('ident')
325                                         self.commit_date = date
327                                 continue
329         def get_message(self, with_diff=0):
330                 if (with_diff == 1):
331                         message = self.diff_tree()
332                 else:
333                         fp = os.popen("git cat-file commit " + self.commit_sha1)
334                         message = fp.read()
335                         fp.close()
337                 return message
339         def diff_tree(self):
340                 fp = os.popen("git diff-tree --pretty --cc  -v -p --always " +  self.commit_sha1)
341                 diff = fp.read()
342                 fp.close()
343                 return diff
345 class AnnotateWindow(object):
346         """Annotate window.
347         This object represents and manages a single window containing the
348         annotate information of the file
349         """
351         def __init__(self):
352                 self.window = gtk.Window(gtk.WINDOW_TOPLEVEL)
353                 self.window.set_border_width(0)
354                 self.window.set_title("Git repository browser annotation window")
356                 # Use two thirds of the screen by default
357                 screen = self.window.get_screen()
358                 monitor = screen.get_monitor_geometry(0)
359                 width = int(monitor.width * 0.66)
360                 height = int(monitor.height * 0.66)
361                 self.window.set_default_size(width, height)
363         def add_file_data(self, filename, commit_sha1, line_num):
364                 fp = os.popen("git cat-file blob " + commit_sha1 +":"+filename)
365                 i = 1;
366                 for line in fp.readlines():
367                         line = string.rstrip(line)
368                         self.model.append(None, ["HEAD", filename, line, i])
369                         i = i+1
370                 fp.close()
372                 # now set the cursor position
373                 self.treeview.set_cursor(line_num-1)
374                 self.treeview.grab_focus()
376         def _treeview_cursor_cb(self, *args):
377                 """Callback for when the treeview cursor changes."""
378                 (path, col) = self.treeview.get_cursor()
379                 commit_sha1 = self.model[path][0]
380                 commit_msg = ""
381                 fp = os.popen("git cat-file commit " + commit_sha1)
382                 for line in fp.readlines():
383                         commit_msg =  commit_msg + line
384                 fp.close()
386                 self.commit_buffer.set_text(commit_msg)
388         def _treeview_row_activated(self, *args):
389                 """Callback for when the treeview row gets selected."""
390                 (path, col) = self.treeview.get_cursor()
391                 commit_sha1 = self.model[path][0]
392                 filename    = self.model[path][1]
393                 line_num    = self.model[path][3]
395                 window = AnnotateWindow();
396                 fp = os.popen("git rev-parse "+ commit_sha1 + "~1")
397                 commit_sha1 = string.strip(fp.readline())
398                 fp.close()
399                 window.annotate(filename, commit_sha1, line_num)
401         def data_ready(self, source, condition):
402                 while (1):
403                         try :
404                                 buffer = source.read(8192)
405                         except:
406                                 # resource temporary not available
407                                 return True
409                         if (len(buffer) == 0):
410                                 gobject.source_remove(self.io_watch_tag)
411                                 source.close()
412                                 return False
414                         for buff in buffer.split("\n"):
415                                 annotate_line = re.compile('^([0-9a-f]{40}) (.+) (.+) (.+)$')
416                                 m = annotate_line.match(buff)
417                                 if not m:
418                                         annotate_line = re.compile('^(filename) (.+)$')
419                                         m = annotate_line.match(buff)
420                                         if not m:
421                                                 continue
422                                         filename = m.group(2)
423                                 else:
424                                         self.commit_sha1 = m.group(1)
425                                         self.source_line = int(m.group(2))
426                                         self.result_line = int(m.group(3))
427                                         self.count          = int(m.group(4))
428                                         #set the details only when we have the file name
429                                         continue
431                                 while (self.count > 0):
432                                         # set at result_line + count-1 the sha1 as commit_sha1
433                                         self.count = self.count - 1
434                                         iter = self.model.iter_nth_child(None, self.result_line + self.count-1)
435                                         self.model.set(iter, 0, self.commit_sha1, 1, filename, 3, self.source_line)
438         def annotate(self, filename, commit_sha1, line_num):
439                 # verify the commit_sha1 specified has this filename
441                 fp = os.popen("git ls-tree "+ commit_sha1 + " -- " + filename)
442                 line = string.strip(fp.readline())
443                 if line == '':
444                         # pop up the message the file is not there as a part of the commit
445                         fp.close()
446                         dialog = gtk.MessageDialog(parent=None, flags=0,
447                                         type=gtk.MESSAGE_WARNING, buttons=gtk.BUTTONS_CLOSE,
448                                         message_format=None)
449                         dialog.set_markup("The file %s is not present in the parent commit %s" % (filename, commit_sha1))
450                         dialog.run()
451                         dialog.destroy()
452                         return
454                 fp.close()
456                 vpan = gtk.VPaned();
457                 self.window.add(vpan);
458                 vpan.show()
460                 scrollwin = gtk.ScrolledWindow()
461                 scrollwin.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
462                 scrollwin.set_shadow_type(gtk.SHADOW_IN)
463                 vpan.pack1(scrollwin, True, True);
464                 scrollwin.show()
466                 self.model = gtk.TreeStore(str, str, str, int)
467                 self.treeview = gtk.TreeView(self.model)
468                 self.treeview.set_rules_hint(True)
469                 self.treeview.set_search_column(0)
470                 self.treeview.connect("cursor-changed", self._treeview_cursor_cb)
471                 self.treeview.connect("row-activated", self._treeview_row_activated)
472                 scrollwin.add(self.treeview)
473                 self.treeview.show()
475                 cell = gtk.CellRendererText()
476                 cell.set_property("width-chars", 10)
477                 cell.set_property("ellipsize", pango.ELLIPSIZE_END)
478                 column = gtk.TreeViewColumn("Commit")
479                 column.set_resizable(True)
480                 column.pack_start(cell, expand=True)
481                 column.add_attribute(cell, "text", 0)
482                 self.treeview.append_column(column)
484                 cell = gtk.CellRendererText()
485                 cell.set_property("width-chars", 20)
486                 cell.set_property("ellipsize", pango.ELLIPSIZE_END)
487                 column = gtk.TreeViewColumn("File Name")
488                 column.set_resizable(True)
489                 column.pack_start(cell, expand=True)
490                 column.add_attribute(cell, "text", 1)
491                 self.treeview.append_column(column)
493                 cell = gtk.CellRendererText()
494                 cell.set_property("width-chars", 20)
495                 cell.set_property("ellipsize", pango.ELLIPSIZE_END)
496                 column = gtk.TreeViewColumn("Data")
497                 column.set_resizable(True)
498                 column.pack_start(cell, expand=True)
499                 column.add_attribute(cell, "text", 2)
500                 self.treeview.append_column(column)
502                 # The commit message window
503                 scrollwin = gtk.ScrolledWindow()
504                 scrollwin.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
505                 scrollwin.set_shadow_type(gtk.SHADOW_IN)
506                 vpan.pack2(scrollwin, True, True);
507                 scrollwin.show()
509                 commit_text = gtk.TextView()
510                 self.commit_buffer = gtk.TextBuffer()
511                 commit_text.set_buffer(self.commit_buffer)
512                 scrollwin.add(commit_text)
513                 commit_text.show()
515                 self.window.show()
517                 self.add_file_data(filename, commit_sha1, line_num)
519                 fp = os.popen("git blame --incremental -- " + filename + " " + commit_sha1)
520                 flags = fcntl.fcntl(fp.fileno(), fcntl.F_GETFL)
521                 fcntl.fcntl(fp.fileno(), fcntl.F_SETFL, flags | os.O_NONBLOCK)
522                 self.io_watch_tag = gobject.io_add_watch(fp, gobject.IO_IN, self.data_ready)
525 class DiffWindow(object):
526         """Diff window.
527         This object represents and manages a single window containing the
528         differences between two revisions on a branch.
529         """
531         def __init__(self):
532                 self.window = gtk.Window(gtk.WINDOW_TOPLEVEL)
533                 self.window.set_border_width(0)
534                 self.window.set_title("Git repository browser diff window")
536                 # Use two thirds of the screen by default
537                 screen = self.window.get_screen()
538                 monitor = screen.get_monitor_geometry(0)
539                 width = int(monitor.width * 0.66)
540                 height = int(monitor.height * 0.66)
541                 self.window.set_default_size(width, height)
544                 self.construct()
546         def construct(self):
547                 """Construct the window contents."""
548                 vbox = gtk.VBox()
549                 self.window.add(vbox)
550                 vbox.show()
552                 menu_bar = gtk.MenuBar()
553                 save_menu = gtk.ImageMenuItem(gtk.STOCK_SAVE)
554                 save_menu.connect("activate", self.save_menu_response, "save")
555                 save_menu.show()
556                 menu_bar.append(save_menu)
557                 vbox.pack_start(menu_bar, expand=False, fill=True)
558                 menu_bar.show()
560                 hpan = gtk.HPaned()
562                 scrollwin = gtk.ScrolledWindow()
563                 scrollwin.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
564                 scrollwin.set_shadow_type(gtk.SHADOW_IN)
565                 hpan.pack1(scrollwin, True, True)
566                 scrollwin.show()
568                 if have_gtksourceview:
569                         self.buffer = gtksourceview.SourceBuffer()
570                         slm = gtksourceview.SourceLanguagesManager()
571                         gsl = slm.get_language_from_mime_type("text/x-patch")
572                         self.buffer.set_highlight(True)
573                         self.buffer.set_language(gsl)
574                         sourceview = gtksourceview.SourceView(self.buffer)
575                 else:
576                         self.buffer = gtk.TextBuffer()
577                         sourceview = gtk.TextView(self.buffer)
580                 sourceview.set_editable(False)
581                 sourceview.modify_font(pango.FontDescription("Monospace"))
582                 scrollwin.add(sourceview)
583                 sourceview.show()
585                 # The file hierarchy: a scrollable treeview
586                 scrollwin = gtk.ScrolledWindow()
587                 scrollwin.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
588                 scrollwin.set_shadow_type(gtk.SHADOW_IN)
589                 scrollwin.set_size_request(20, -1)
590                 hpan.pack2(scrollwin, True, True)
591                 scrollwin.show()
593                 self.model = gtk.TreeStore(str, str, str)
594                 self.treeview = gtk.TreeView(self.model)
595                 self.treeview.set_search_column(1)
596                 self.treeview.connect("cursor-changed", self._treeview_clicked)
597                 scrollwin.add(self.treeview)
598                 self.treeview.show()
600                 cell = gtk.CellRendererText()
601                 cell.set_property("width-chars", 20)
602                 column = gtk.TreeViewColumn("Select to annotate")
603                 column.pack_start(cell, expand=True)
604                 column.add_attribute(cell, "text", 0)
605                 self.treeview.append_column(column)
607                 vbox.pack_start(hpan, expand=True, fill=True)
608                 hpan.show()
610         def _treeview_clicked(self, *args):
611                 """Callback for when the treeview cursor changes."""
612                 (path, col) = self.treeview.get_cursor()
613                 specific_file = self.model[path][1]
614                 commit_sha1 =  self.model[path][2]
615                 if specific_file ==  None :
616                         return
617                 elif specific_file ==  "" :
618                         specific_file =  None
620                 window = AnnotateWindow();
621                 window.annotate(specific_file, commit_sha1, 1)
624         def commit_files(self, commit_sha1, parent_sha1):
625                 self.model.clear()
626                 add  = self.model.append(None, [ "Added", None, None])
627                 dele = self.model.append(None, [ "Deleted", None, None])
628                 mod  = self.model.append(None, [ "Modified", None, None])
629                 diff_tree = re.compile('^(:.{6}) (.{6}) (.{40}) (.{40}) (A|D|M)\s(.+)$')
630                 fp = os.popen("git diff-tree -r --no-commit-id " + parent_sha1 + " " + commit_sha1)
631                 while 1:
632                         line = string.strip(fp.readline())
633                         if line == '':
634                                 break
635                         m = diff_tree.match(line)
636                         if not m:
637                                 continue
639                         attr = m.group(5)
640                         filename = m.group(6)
641                         if attr == "A":
642                                 self.model.append(add,  [filename, filename, commit_sha1])
643                         elif attr == "D":
644                                 self.model.append(dele, [filename, filename, commit_sha1])
645                         elif attr == "M":
646                                 self.model.append(mod,  [filename, filename, commit_sha1])
647                 fp.close()
649                 self.treeview.expand_all()
651         def set_diff(self, commit_sha1, parent_sha1, encoding):
652                 """Set the differences showed by this window.
653                 Compares the two trees and populates the window with the
654                 differences.
655                 """
656                 # Diff with the first commit or the last commit shows nothing
657                 if (commit_sha1 == 0 or parent_sha1 == 0 ):
658                         return
660                 fp = os.popen("git diff-tree -p " + parent_sha1 + " " + commit_sha1)
661                 self.buffer.set_text(unicode(fp.read(), encoding).encode('utf-8'))
662                 fp.close()
663                 self.commit_files(commit_sha1, parent_sha1)
664                 self.window.show()
666         def save_menu_response(self, widget, string):
667                 dialog = gtk.FileChooserDialog("Save..", None, gtk.FILE_CHOOSER_ACTION_SAVE,
668                                 (gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL,
669                                         gtk.STOCK_SAVE, gtk.RESPONSE_OK))
670                 dialog.set_default_response(gtk.RESPONSE_OK)
671                 response = dialog.run()
672                 if response == gtk.RESPONSE_OK:
673                         patch_buffer = self.buffer.get_text(self.buffer.get_start_iter(),
674                                         self.buffer.get_end_iter())
675                         fp = open(dialog.get_filename(), "w")
676                         fp.write(patch_buffer)
677                         fp.close()
678                 dialog.destroy()
680 class GitView(object):
681         """ This is the main class
682         """
683         version = "0.9"
685         def __init__(self, with_diff=0):
686                 self.with_diff = with_diff
687                 self.window =   gtk.Window(gtk.WINDOW_TOPLEVEL)
688                 self.window.set_border_width(0)
689                 self.window.set_title("Git repository browser")
691                 self.get_encoding()
692                 self.get_bt_sha1()
694                 # Use three-quarters of the screen by default
695                 screen = self.window.get_screen()
696                 monitor = screen.get_monitor_geometry(0)
697                 width = int(monitor.width * 0.75)
698                 height = int(monitor.height * 0.75)
699                 self.window.set_default_size(width, height)
701                 # FIXME AndyFitz!
702                 icon = self.window.render_icon(gtk.STOCK_INDEX, gtk.ICON_SIZE_BUTTON)
703                 self.window.set_icon(icon)
705                 self.accel_group = gtk.AccelGroup()
706                 self.window.add_accel_group(self.accel_group)
707                 self.accel_group.connect_group(0xffc2, 0, gtk.ACCEL_LOCKED, self.refresh);
708                 self.accel_group.connect_group(0xffc1, 0, gtk.ACCEL_LOCKED, self.maximize);
709                 self.accel_group.connect_group(0xffc8, 0, gtk.ACCEL_LOCKED, self.fullscreen);
710                 self.accel_group.connect_group(0xffc9, 0, gtk.ACCEL_LOCKED, self.unfullscreen);
712                 self.window.add(self.construct())
714         def refresh(self, widget, event=None, *arguments, **keywords):
715                 self.get_encoding()
716                 self.get_bt_sha1()
717                 Commit.children_sha1 = {}
718                 self.set_branch(sys.argv[without_diff:])
719                 self.window.show()
720                 return True
722         def maximize(self, widget, event=None, *arguments, **keywords):
723                 self.window.maximize()
724                 return True
726         def fullscreen(self, widget, event=None, *arguments, **keywords):
727                 self.window.fullscreen()
728                 return True
730         def unfullscreen(self, widget, event=None, *arguments, **keywords):
731                 self.window.unfullscreen()
732                 return True
734         def get_bt_sha1(self):
735                 """ Update the bt_sha1 dictionary with the
736                 respective sha1 details """
738                 self.bt_sha1 = { }
739                 ls_remote = re.compile('^(.{40})\trefs/([^^]+)(?:\\^(..))?$');
740                 fp = os.popen('git ls-remote "${GIT_DIR-.git}"')
741                 while 1:
742                         line = string.strip(fp.readline())
743                         if line == '':
744                                 break
745                         m = ls_remote.match(line)
746                         if not m:
747                                 continue
748                         (sha1, name) = (m.group(1), m.group(2))
749                         if not self.bt_sha1.has_key(sha1):
750                                 self.bt_sha1[sha1] = []
751                         self.bt_sha1[sha1].append(name)
752                 fp.close()
754         def get_encoding(self):
755                 fp = os.popen("git config --get i18n.commitencoding")
756                 self.encoding=string.strip(fp.readline())
757                 fp.close()
758                 if (self.encoding == ""):
759                         self.encoding = "utf-8"
762         def construct(self):
763                 """Construct the window contents."""
764                 vbox = gtk.VBox()
765                 paned = gtk.VPaned()
766                 paned.pack1(self.construct_top(), resize=False, shrink=True)
767                 paned.pack2(self.construct_bottom(), resize=False, shrink=True)
768                 menu_bar = gtk.MenuBar()
769                 menu_bar.set_pack_direction(gtk.PACK_DIRECTION_RTL)
770                 help_menu = gtk.MenuItem("Help")
771                 menu = gtk.Menu()
772                 about_menu = gtk.MenuItem("About")
773                 menu.append(about_menu)
774                 about_menu.connect("activate", self.about_menu_response, "about")
775                 about_menu.show()
776                 help_menu.set_submenu(menu)
777                 help_menu.show()
778                 menu_bar.append(help_menu)
779                 menu_bar.show()
780                 vbox.pack_start(menu_bar, expand=False, fill=True)
781                 vbox.pack_start(paned, expand=True, fill=True)
782                 paned.show()
783                 vbox.show()
784                 return vbox
787         def construct_top(self):
788                 """Construct the top-half of the window."""
789                 vbox = gtk.VBox(spacing=6)
790                 vbox.set_border_width(12)
791                 vbox.show()
794                 scrollwin = gtk.ScrolledWindow()
795                 scrollwin.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
796                 scrollwin.set_shadow_type(gtk.SHADOW_IN)
797                 vbox.pack_start(scrollwin, expand=True, fill=True)
798                 scrollwin.show()
800                 self.treeview = gtk.TreeView()
801                 self.treeview.set_rules_hint(True)
802                 self.treeview.set_search_column(4)
803                 self.treeview.connect("cursor-changed", self._treeview_cursor_cb)
804                 scrollwin.add(self.treeview)
805                 self.treeview.show()
807                 cell = CellRendererGraph()
808                 column = gtk.TreeViewColumn()
809                 column.set_resizable(True)
810                 column.pack_start(cell, expand=True)
811                 column.add_attribute(cell, "node", 1)
812                 column.add_attribute(cell, "in-lines", 2)
813                 column.add_attribute(cell, "out-lines", 3)
814                 self.treeview.append_column(column)
816                 cell = gtk.CellRendererText()
817                 cell.set_property("width-chars", 65)
818                 cell.set_property("ellipsize", pango.ELLIPSIZE_END)
819                 column = gtk.TreeViewColumn("Message")
820                 column.set_resizable(True)
821                 column.pack_start(cell, expand=True)
822                 column.add_attribute(cell, "text", 4)
823                 self.treeview.append_column(column)
825                 cell = gtk.CellRendererText()
826                 cell.set_property("width-chars", 40)
827                 cell.set_property("ellipsize", pango.ELLIPSIZE_END)
828                 column = gtk.TreeViewColumn("Author")
829                 column.set_resizable(True)
830                 column.pack_start(cell, expand=True)
831                 column.add_attribute(cell, "text", 5)
832                 self.treeview.append_column(column)
834                 cell = gtk.CellRendererText()
835                 cell.set_property("ellipsize", pango.ELLIPSIZE_END)
836                 column = gtk.TreeViewColumn("Date")
837                 column.set_resizable(True)
838                 column.pack_start(cell, expand=True)
839                 column.add_attribute(cell, "text", 6)
840                 self.treeview.append_column(column)
842                 return vbox
844         def about_menu_response(self, widget, string):
845                 dialog = gtk.AboutDialog()
846                 dialog.set_name("Gitview")
847                 dialog.set_version(GitView.version)
848                 dialog.set_authors(["Aneesh Kumar K.V <aneesh.kumar@gmail.com>"])
849                 dialog.set_website("http://www.kernel.org/pub/software/scm/git/")
850                 dialog.set_copyright("Use and distribute under the terms of the GNU General Public License")
851                 dialog.set_wrap_license(True)
852                 dialog.run()
853                 dialog.destroy()
856         def construct_bottom(self):
857                 """Construct the bottom half of the window."""
858                 vbox = gtk.VBox(False, spacing=6)
859                 vbox.set_border_width(12)
860                 (width, height) = self.window.get_size()
861                 vbox.set_size_request(width, int(height / 2.5))
862                 vbox.show()
864                 self.table = gtk.Table(rows=4, columns=4)
865                 self.table.set_row_spacings(6)
866                 self.table.set_col_spacings(6)
867                 vbox.pack_start(self.table, expand=False, fill=True)
868                 self.table.show()
870                 align = gtk.Alignment(0.0, 0.5)
871                 label = gtk.Label()
872                 label.set_markup("<b>Revision:</b>")
873                 align.add(label)
874                 self.table.attach(align, 0, 1, 0, 1, gtk.FILL, gtk.FILL)
875                 label.show()
876                 align.show()
878                 align = gtk.Alignment(0.0, 0.5)
879                 self.revid_label = gtk.Label()
880                 self.revid_label.set_selectable(True)
881                 align.add(self.revid_label)
882                 self.table.attach(align, 1, 2, 0, 1, gtk.EXPAND | gtk.FILL, gtk.FILL)
883                 self.revid_label.show()
884                 align.show()
886                 align = gtk.Alignment(0.0, 0.5)
887                 label = gtk.Label()
888                 label.set_markup("<b>Committer:</b>")
889                 align.add(label)
890                 self.table.attach(align, 0, 1, 1, 2, gtk.FILL, gtk.FILL)
891                 label.show()
892                 align.show()
894                 align = gtk.Alignment(0.0, 0.5)
895                 self.committer_label = gtk.Label()
896                 self.committer_label.set_selectable(True)
897                 align.add(self.committer_label)
898                 self.table.attach(align, 1, 2, 1, 2, gtk.EXPAND | gtk.FILL, gtk.FILL)
899                 self.committer_label.show()
900                 align.show()
902                 align = gtk.Alignment(0.0, 0.5)
903                 label = gtk.Label()
904                 label.set_markup("<b>Timestamp:</b>")
905                 align.add(label)
906                 self.table.attach(align, 0, 1, 2, 3, gtk.FILL, gtk.FILL)
907                 label.show()
908                 align.show()
910                 align = gtk.Alignment(0.0, 0.5)
911                 self.timestamp_label = gtk.Label()
912                 self.timestamp_label.set_selectable(True)
913                 align.add(self.timestamp_label)
914                 self.table.attach(align, 1, 2, 2, 3, gtk.EXPAND | gtk.FILL, gtk.FILL)
915                 self.timestamp_label.show()
916                 align.show()
918                 align = gtk.Alignment(0.0, 0.5)
919                 label = gtk.Label()
920                 label.set_markup("<b>Parents:</b>")
921                 align.add(label)
922                 self.table.attach(align, 0, 1, 3, 4, gtk.FILL, gtk.FILL)
923                 label.show()
924                 align.show()
925                 self.parents_widgets = []
927                 align = gtk.Alignment(0.0, 0.5)
928                 label = gtk.Label()
929                 label.set_markup("<b>Children:</b>")
930                 align.add(label)
931                 self.table.attach(align, 2, 3, 3, 4, gtk.FILL, gtk.FILL)
932                 label.show()
933                 align.show()
934                 self.children_widgets = []
936                 scrollwin = gtk.ScrolledWindow()
937                 scrollwin.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
938                 scrollwin.set_shadow_type(gtk.SHADOW_IN)
939                 vbox.pack_start(scrollwin, expand=True, fill=True)
940                 scrollwin.show()
942                 if have_gtksourceview:
943                         self.message_buffer = gtksourceview.SourceBuffer()
944                         slm = gtksourceview.SourceLanguagesManager()
945                         gsl = slm.get_language_from_mime_type("text/x-patch")
946                         self.message_buffer.set_highlight(True)
947                         self.message_buffer.set_language(gsl)
948                         sourceview = gtksourceview.SourceView(self.message_buffer)
949                 else:
950                         self.message_buffer = gtk.TextBuffer()
951                         sourceview = gtk.TextView(self.message_buffer)
953                 sourceview.set_editable(False)
954                 sourceview.modify_font(pango.FontDescription("Monospace"))
955                 scrollwin.add(sourceview)
956                 sourceview.show()
958                 return vbox
960         def _treeview_cursor_cb(self, *args):
961                 """Callback for when the treeview cursor changes."""
962                 (path, col) = self.treeview.get_cursor()
963                 commit = self.model[path][0]
965                 if commit.committer is not None:
966                         committer = commit.committer
967                         timestamp = commit.commit_date
968                         message   =  commit.get_message(self.with_diff)
969                         revid_label = commit.commit_sha1
970                 else:
971                         committer = ""
972                         timestamp = ""
973                         message = ""
974                         revid_label = ""
976                 self.revid_label.set_text(revid_label)
977                 self.committer_label.set_text(committer)
978                 self.timestamp_label.set_text(timestamp)
979                 self.message_buffer.set_text(unicode(message, self.encoding).encode('utf-8'))
981                 for widget in self.parents_widgets:
982                         self.table.remove(widget)
984                 self.parents_widgets = []
985                 self.table.resize(4 + len(commit.parent_sha1) - 1, 4)
986                 for idx, parent_id in enumerate(commit.parent_sha1):
987                         self.table.set_row_spacing(idx + 3, 0)
989                         align = gtk.Alignment(0.0, 0.0)
990                         self.parents_widgets.append(align)
991                         self.table.attach(align, 1, 2, idx + 3, idx + 4,
992                                         gtk.EXPAND | gtk.FILL, gtk.FILL)
993                         align.show()
995                         hbox = gtk.HBox(False, 0)
996                         align.add(hbox)
997                         hbox.show()
999                         label = gtk.Label(parent_id)
1000                         label.set_selectable(True)
1001                         hbox.pack_start(label, expand=False, fill=True)
1002                         label.show()
1004                         image = gtk.Image()
1005                         image.set_from_stock(gtk.STOCK_JUMP_TO, gtk.ICON_SIZE_MENU)
1006                         image.show()
1008                         button = gtk.Button()
1009                         button.add(image)
1010                         button.set_relief(gtk.RELIEF_NONE)
1011                         button.connect("clicked", self._go_clicked_cb, parent_id)
1012                         hbox.pack_start(button, expand=False, fill=True)
1013                         button.show()
1015                         image = gtk.Image()
1016                         image.set_from_stock(gtk.STOCK_FIND, gtk.ICON_SIZE_MENU)
1017                         image.show()
1019                         button = gtk.Button()
1020                         button.add(image)
1021                         button.set_relief(gtk.RELIEF_NONE)
1022                         button.set_sensitive(True)
1023                         button.connect("clicked", self._show_clicked_cb,
1024                                         commit.commit_sha1, parent_id, self.encoding)
1025                         hbox.pack_start(button, expand=False, fill=True)
1026                         button.show()
1028                 # Populate with child details
1029                 for widget in self.children_widgets:
1030                         self.table.remove(widget)
1032                 self.children_widgets = []
1033                 try:
1034                         child_sha1 = Commit.children_sha1[commit.commit_sha1]
1035                 except KeyError:
1036                         # We don't have child
1037                         child_sha1 = [ 0 ]
1039                 if ( len(child_sha1) > len(commit.parent_sha1)):
1040                         self.table.resize(4 + len(child_sha1) - 1, 4)
1042                 for idx, child_id in enumerate(child_sha1):
1043                         self.table.set_row_spacing(idx + 3, 0)
1045                         align = gtk.Alignment(0.0, 0.0)
1046                         self.children_widgets.append(align)
1047                         self.table.attach(align, 3, 4, idx + 3, idx + 4,
1048                                         gtk.EXPAND | gtk.FILL, gtk.FILL)
1049                         align.show()
1051                         hbox = gtk.HBox(False, 0)
1052                         align.add(hbox)
1053                         hbox.show()
1055                         label = gtk.Label(child_id)
1056                         label.set_selectable(True)
1057                         hbox.pack_start(label, expand=False, fill=True)
1058                         label.show()
1060                         image = gtk.Image()
1061                         image.set_from_stock(gtk.STOCK_JUMP_TO, gtk.ICON_SIZE_MENU)
1062                         image.show()
1064                         button = gtk.Button()
1065                         button.add(image)
1066                         button.set_relief(gtk.RELIEF_NONE)
1067                         button.connect("clicked", self._go_clicked_cb, child_id)
1068                         hbox.pack_start(button, expand=False, fill=True)
1069                         button.show()
1071                         image = gtk.Image()
1072                         image.set_from_stock(gtk.STOCK_FIND, gtk.ICON_SIZE_MENU)
1073                         image.show()
1075                         button = gtk.Button()
1076                         button.add(image)
1077                         button.set_relief(gtk.RELIEF_NONE)
1078                         button.set_sensitive(True)
1079                         button.connect("clicked", self._show_clicked_cb,
1080                                         child_id, commit.commit_sha1, self.encoding)
1081                         hbox.pack_start(button, expand=False, fill=True)
1082                         button.show()
1084         def _destroy_cb(self, widget):
1085                 """Callback for when a window we manage is destroyed."""
1086                 self.quit()
1089         def quit(self):
1090                 """Stop the GTK+ main loop."""
1091                 gtk.main_quit()
1093         def run(self, args):
1094                 self.set_branch(args)
1095                 self.window.connect("destroy", self._destroy_cb)
1096                 self.window.show()
1097                 gtk.main()
1099         def set_branch(self, args):
1100                 """Fill in different windows with info from the reposiroty"""
1101                 fp = os.popen("git rev-parse --sq --default HEAD " + list_to_string(args, 1))
1102                 git_rev_list_cmd = fp.read()
1103                 fp.close()
1104                 fp = os.popen("git rev-list  --header --topo-order --parents " + git_rev_list_cmd)
1105                 self.update_window(fp)
1107         def update_window(self, fp):
1108                 commit_lines = []
1110                 self.model = gtk.ListStore(gobject.TYPE_PYOBJECT, gobject.TYPE_PYOBJECT,
1111                                 gobject.TYPE_PYOBJECT, gobject.TYPE_PYOBJECT, str, str, str)
1113                 # used for cursor positioning
1114                 self.index = {}
1116                 self.colours = {}
1117                 self.nodepos = {}
1118                 self.incomplete_line = {}
1119                 self.commits = []
1121                 index = 0
1122                 last_colour = 0
1123                 last_nodepos = -1
1124                 out_line = []
1125                 input_line = fp.readline()
1126                 while (input_line != ""):
1127                         # The commit header ends with '\0'
1128                         # This NULL is immediately followed by the sha1 of the
1129                         # next commit
1130                         if (input_line[0] != '\0'):
1131                                 commit_lines.append(input_line)
1132                                 input_line = fp.readline()
1133                                 continue;
1135                         commit = Commit(commit_lines)
1136                         if (commit != None ):
1137                                 self.commits.append(commit)
1139                         # Skip the '\0
1140                         commit_lines = []
1141                         commit_lines.append(input_line[1:])
1142                         input_line = fp.readline()
1144                 fp.close()
1146                 for commit in self.commits:
1147                         (out_line, last_colour, last_nodepos) = self.draw_graph(commit,
1148                                                                                 index, out_line,
1149                                                                                 last_colour,
1150                                                                                 last_nodepos)
1151                         self.index[commit.commit_sha1] = index
1152                         index += 1
1154                 self.treeview.set_model(self.model)
1155                 self.treeview.show()
1157         def draw_graph(self, commit, index, out_line, last_colour, last_nodepos):
1158                 in_line=[]
1160                 #   |   -> outline
1161                 #   X
1162                 #   |\  <- inline
1164                 # Reset nodepostion
1165                 if (last_nodepos > 5):
1166                         last_nodepos = -1
1168                 # Add the incomplete lines of the last cell in this
1169                 try:
1170                         colour = self.colours[commit.commit_sha1]
1171                 except KeyError:
1172                         self.colours[commit.commit_sha1] = last_colour+1
1173                         last_colour = self.colours[commit.commit_sha1]
1174                         colour =   self.colours[commit.commit_sha1]
1176                 try:
1177                         node_pos = self.nodepos[commit.commit_sha1]
1178                 except KeyError:
1179                         self.nodepos[commit.commit_sha1] = last_nodepos+1
1180                         last_nodepos = self.nodepos[commit.commit_sha1]
1181                         node_pos =  self.nodepos[commit.commit_sha1]
1183                 #The first parent always continue on the same line
1184                 try:
1185                         # check we alreay have the value
1186                         tmp_node_pos = self.nodepos[commit.parent_sha1[0]]
1187                 except KeyError:
1188                         self.colours[commit.parent_sha1[0]] = colour
1189                         self.nodepos[commit.parent_sha1[0]] = node_pos
1191                 for sha1 in self.incomplete_line.keys():
1192                         if (sha1 != commit.commit_sha1):
1193                                 self.draw_incomplete_line(sha1, node_pos,
1194                                                 out_line, in_line, index)
1195                         else:
1196                                 del self.incomplete_line[sha1]
1199                 for parent_id in commit.parent_sha1:
1200                         try:
1201                                 tmp_node_pos = self.nodepos[parent_id]
1202                         except KeyError:
1203                                 self.colours[parent_id] = last_colour+1
1204                                 last_colour = self.colours[parent_id]
1205                                 self.nodepos[parent_id] = last_nodepos+1
1206                                 last_nodepos = self.nodepos[parent_id]
1208                         in_line.append((node_pos, self.nodepos[parent_id],
1209                                                 self.colours[parent_id]))
1210                         self.add_incomplete_line(parent_id)
1212                 try:
1213                         branch_tag = self.bt_sha1[commit.commit_sha1]
1214                 except KeyError:
1215                         branch_tag = [ ]
1218                 node = (node_pos, colour, branch_tag)
1220                 self.model.append([commit, node, out_line, in_line,
1221                                 commit.message, commit.author, commit.date])
1223                 return (in_line, last_colour, last_nodepos)
1225         def add_incomplete_line(self, sha1):
1226                 try:
1227                         self.incomplete_line[sha1].append(self.nodepos[sha1])
1228                 except KeyError:
1229                         self.incomplete_line[sha1] = [self.nodepos[sha1]]
1231         def draw_incomplete_line(self, sha1, node_pos, out_line, in_line, index):
1232                 for idx, pos in enumerate(self.incomplete_line[sha1]):
1233                         if(pos == node_pos):
1234                                 #remove the straight line and add a slash
1235                                 if ((pos, pos, self.colours[sha1]) in out_line):
1236                                         out_line.remove((pos, pos, self.colours[sha1]))
1237                                 out_line.append((pos, pos+0.5, self.colours[sha1]))
1238                                 self.incomplete_line[sha1][idx] = pos = pos+0.5
1239                         try:
1240                                 next_commit = self.commits[index+1]
1241                                 if (next_commit.commit_sha1 == sha1 and pos != int(pos)):
1242                                 # join the line back to the node point
1243                                 # This need to be done only if we modified it
1244                                         in_line.append((pos, pos-0.5, self.colours[sha1]))
1245                                         continue;
1246                         except IndexError:
1247                                 pass
1248                         in_line.append((pos, pos, self.colours[sha1]))
1251         def _go_clicked_cb(self, widget, revid):
1252                 """Callback for when the go button for a parent is clicked."""
1253                 try:
1254                         self.treeview.set_cursor(self.index[revid])
1255                 except KeyError:
1256                         dialog = gtk.MessageDialog(parent=None, flags=0,
1257                                         type=gtk.MESSAGE_WARNING, buttons=gtk.BUTTONS_CLOSE,
1258                                         message_format=None)
1259                         dialog.set_markup("Revision <b>%s</b> not present in the list" % revid)
1260                         # revid == 0 is the parent of the first commit
1261                         if (revid != 0 ):
1262                                 dialog.format_secondary_text("Try running gitview without any options")
1263                         dialog.run()
1264                         dialog.destroy()
1266                 self.treeview.grab_focus()
1268         def _show_clicked_cb(self, widget,  commit_sha1, parent_sha1, encoding):
1269                 """Callback for when the show button for a parent is clicked."""
1270                 window = DiffWindow()
1271                 window.set_diff(commit_sha1, parent_sha1, encoding)
1272                 self.treeview.grab_focus()
1274 without_diff = 0
1275 if __name__ == "__main__":
1277         if (len(sys.argv) > 1 ):
1278                 if (sys.argv[1] == "--without-diff"):
1279                         without_diff = 1
1281         view = GitView( without_diff != 1)
1282         view.run(sys.argv[without_diff:])