1#! /usr/bin/env python
2
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.
7
8""" 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>"
15
16
17import sys
18import os
19import gtk
20import pygtk
21import pango
22import re
23import time
24import gobject
25import cairo
26import math
27import string
28import fcntl
29
30have_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"
41
42re_ident = re.compile('(author|committer) (?P<ident>.*) (?P<epoch>\d+) (?P<tz>[+-]\d{4})')
43
44def 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
53 return str_arg
54
55def 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
64 return time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(secs))
65
66def 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)
85
86
87class CellRendererGraph(gtk.GenericCellRenderer):
88 """Cell renderer for directed graph.
89
90 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
94 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
98 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
104 __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
119 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
130 def box_size(self, widget):
131 """Calculate box size based on widget's font.
132
133 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
143 ascent = pango.PIXELS(metrics.get_ascent())
144 descent = pango.PIXELS(metrics.get_descent())
145
146 self._box_size = ascent + descent + 6
147 return self._box_size
148
149 def set_colour(self, ctx, colour, bg, fg):
150 """Set the context source colour.
151
152 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
166 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
171 ctx.set_source_rgb(red, green, blue)
172
173 def on_get_size(self, widget, cell_area):
174 """Return the size we need for this cell.
175
176 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
182 cols = self.node[0]
183 for start, end, colour in self.in_lines + self.out_lines:
184 cols = int(max(cols, start, end))
185
186 (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
192 width = box_size * (cols + 1 ) + names_len
193 height = box_size
194
195 # FIXME I have no idea how to use cell_area properly
196 return (0, 0, width, height)
197
198 def on_render(self, window, widget, bg_area, cell_area, exp_area, flags):
199 """Render an individual cell.
200
201 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
207 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
216 box_size = self.box_size(widget)
217
218 ctx.set_line_width(box_size / 8)
219 ctx.set_line_cap(cairo.LINE_CAP_SQUARE)
220
221 # 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
226 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
234 ctx.line_to(cell_area.x + box_size * end + box_size / 2,
235 bg_area.y + bg_area.height / 2)
236
237 self.set_colour(ctx, colour, 0.0, 0.65)
238 ctx.stroke()
239
240 # 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
245 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
256 ctx.line_to(cell_area.x + box_size * end + box_size / 2,
257 bg_area.y + bg_area.height / 2 + bg_area.height)
258
259 self.set_colour(ctx, colour, 0.0, 0.65)
260 ctx.stroke()
261
262 # 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
268
269 self.set_colour(ctx, colour, 0.0, 0.5)
270 ctx.stroke_preserve()
271
272 self.set_colour(ctx, colour, 0.5, 1.0)
273 ctx.fill_preserve()
274
275 if (len(names) != 0):
276 name = " "
277 for item in names:
278 name = name + item + " "
279
280 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)
286
287class Commit(object):
288 """ This represent a commit object obtained after parsing the git-rev-list
289 output """
290
291 __slots__ = ['children_sha1', 'message', 'author', 'date', 'committer',
292 'commit_date', 'commit_sha1', 'parent_sha1']
293
294 children_sha1 = {}
295
296 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
306
307 def parse_commit(self, commit_lines):
308
309 # 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
315 #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
322 # IF we don't have parent
323 if (len(self.parent_sha1) == 0):
324 self.parent_sha1 = [0]
325
326 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
334 m = re.match("tree", line)
335 if (m != None):
336 continue
337
338 m = re.match("parent", line)
339 if (m != None):
340 continue
341
342 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
352 continue
353
354 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
362 return message
363
364 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
369
370class AnnotateWindow(object):
371 """Annotate window.
372 This object represents and manages a single window containing the
373 annotate information of the file
374 """
375
376 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
382 # 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
389 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
398 # now set the cursor position
399 self.treeview.set_cursor(line_num-1)
400 self.treeview.grab_focus()
401
402 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
412 self.commit_buffer.set_text(commit_msg)
413
414 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
421 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
427 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
434 except:
435 # resource temporary not available
436 return True
437
438 if (len(buffer) == 0):
439 gobject.source_remove(self.io_watch_tag)
440 source.close()
441 return False
442
443 if (self.prev_read != ""):
444 buffer = self.prev_read + buffer
445 self.prev_read = ""
446
447 if (buffer[len(buffer) -1] != '\n'):
448 try:
449 newline_index = buffer.rindex("\n")
450 except ValueError:
451 newline_index = 0
452
453 self.prev_read = buffer[newline_index:(len(buffer))]
454 buffer = buffer[0:newline_index]
455
456 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
473 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
479
480 def annotate(self, filename, commit_sha1, line_num):
481 # verify the commit_sha1 specified has this filename
482
483 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
496 fp.close()
497
498 vpan = gtk.VPaned();
499 self.window.add(vpan);
500 vpan.show()
501
502 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
508 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
517 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
526 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
535 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
544 # 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
551 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
557 self.window.show()
558
559 self.add_file_data(filename, commit_sha1, line_num)
560
561 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)
565
566
567class 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
573 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
578 # 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
585
586 self.construct()
587
588 def construct(self):
589 """Construct the window contents."""
590 vbox = gtk.VBox()
591 self.window.add(vbox)
592 vbox.show()
593
594 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
602 hpan = gtk.HPaned()
603
604 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
610 (self.buffer, sourceview) = get_source_buffer_and_view()
611
612 sourceview.set_editable(False)
613 sourceview.modify_font(pango.FontDescription("Monospace"))
614 scrollwin.add(sourceview)
615 sourceview.show()
616
617 # 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
625 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
632 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
639 vbox.pack_start(hpan, expand=True, fill=True)
640 hpan.show()
641
642 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
652 window = AnnotateWindow();
653 window.annotate(specific_file, commit_sha1, 1)
654
655
656 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
671 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
681 self.treeview.expand_all()
682
683 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
692 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
698 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()
711
712class GitView(object):
713 """ This is the main class
714 """
715 version = "0.9"
716
717 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
723 self.get_encoding()
724 self.get_bt_sha1()
725
726 # 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
733 # FIXME AndyFitz!
734 icon = self.window.render_icon(gtk.STOCK_INDEX, gtk.ICON_SIZE_BUTTON)
735 self.window.set_icon(icon)
736
737 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
744 self.window.add(self.construct())
745
746 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
754 def maximize(self, widget, event=None, *arguments, **keywords):
755 self.window.maximize()
756 return True
757
758 def fullscreen(self, widget, event=None, *arguments, **keywords):
759 self.window.fullscreen()
760 return True
761
762 def unfullscreen(self, widget, event=None, *arguments, **keywords):
763 self.window.unfullscreen()
764 return True
765
766 def get_bt_sha1(self):
767 """ Update the bt_sha1 dictionary with the
768 respective sha1 details """
769
770 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
786 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
793
794 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
818
819 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
825
826 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
832 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
839 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
848 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
857 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
866 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
874 return vbox
875
876 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
887
888 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
896 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
902 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
910 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
918 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
926 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
934 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
942 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
950 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
959 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
968 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
974 (self.message_buffer, sourceview) = get_source_buffer_and_view()
975
976 sourceview.set_editable(False)
977 sourceview.modify_font(pango.FontDescription("Monospace"))
978 scrollwin.add(sourceview)
979 sourceview.show()
980
981 return vbox
982
983 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
988 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
999 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
1004 for widget in self.parents_widgets:
1005 self.table.remove(widget)
1006
1007 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
1012 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
1018 hbox = gtk.HBox(False, 0)
1019 align.add(hbox)
1020 hbox.show()
1021
1022 label = gtk.Label(parent_id)
1023 label.set_selectable(True)
1024 hbox.pack_start(label, expand=False, fill=True)
1025 label.show()
1026
1027 image = gtk.Image()
1028 image.set_from_stock(gtk.STOCK_JUMP_TO, gtk.ICON_SIZE_MENU)
1029 image.show()
1030
1031 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
1038 image = gtk.Image()
1039 image.set_from_stock(gtk.STOCK_FIND, gtk.ICON_SIZE_MENU)
1040 image.show()
1041
1042 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
1051 # Populate with child details
1052 for widget in self.children_widgets:
1053 self.table.remove(widget)
1054
1055 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
1062 if ( len(child_sha1) > len(commit.parent_sha1)):
1063 self.table.resize(4 + len(child_sha1) - 1, 4)
1064
1065 for idx, child_id in enumerate(child_sha1):
1066 self.table.set_row_spacing(idx + 3, 0)
1067
1068 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
1074 hbox = gtk.HBox(False, 0)
1075 align.add(hbox)
1076 hbox.show()
1077
1078 label = gtk.Label(child_id)
1079 label.set_selectable(True)
1080 hbox.pack_start(label, expand=False, fill=True)
1081 label.show()
1082
1083 image = gtk.Image()
1084 image.set_from_stock(gtk.STOCK_JUMP_TO, gtk.ICON_SIZE_MENU)
1085 image.show()
1086
1087 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
1094 image = gtk.Image()
1095 image.set_from_stock(gtk.STOCK_FIND, gtk.ICON_SIZE_MENU)
1096 image.show()
1097
1098 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
1107 def _destroy_cb(self, widget):
1108 """Callback for when a window we manage is destroyed."""
1109 self.quit()
1110
1111
1112 def quit(self):
1113 """Stop the GTK+ main loop."""
1114 gtk.main_quit()
1115
1116 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
1122 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
1130 def update_window(self, fp):
1131 commit_lines = []
1132
1133 self.model = gtk.ListStore(gobject.TYPE_PYOBJECT, gobject.TYPE_PYOBJECT,
1134 gobject.TYPE_PYOBJECT, gobject.TYPE_PYOBJECT, str, str, str)
1135
1136 # used for cursor positioning
1137 self.index = {}
1138
1139 self.colours = {}
1140 self.nodepos = {}
1141 self.incomplete_line = {}
1142 self.commits = []
1143
1144 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
1158 commit = Commit(commit_lines)
1159 if (commit != None ):
1160 self.commits.append(commit)
1161
1162 # Skip the '\0
1163 commit_lines = []
1164 commit_lines.append(input_line[1:])
1165 input_line = fp.readline()
1166
1167 fp.close()
1168
1169 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
1177 self.treeview.set_model(self.model)
1178 self.treeview.show()
1179
1180 def draw_graph(self, commit, index, out_line, last_colour, last_nodepos):
1181 in_line=[]
1182
1183 # | -> outline
1184 # X
1185 # |\ <- inline
1186
1187 # Reset nodepostion
1188 if (last_nodepos > 5):
1189 last_nodepos = -1
1190
1191 # 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
1199 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
1206 #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
1214 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
1221
1222 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
1231 in_line.append((node_pos, self.nodepos[parent_id],
1232 self.colours[parent_id]))
1233 self.add_incomplete_line(parent_id)
1234
1235 try:
1236 branch_tag = self.bt_sha1[commit.commit_sha1]
1237 except KeyError:
1238 branch_tag = [ ]
1239
1240
1241 node = (node_pos, colour, branch_tag)
1242
1243 self.model.append([commit, node, out_line, in_line,
1244 commit.message, commit.author, commit.date])
1245
1246 return (in_line, last_colour, last_nodepos)
1247
1248 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
1254 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
1273
1274 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
1289 self.treeview.grab_focus()
1290
1291 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()
1296
1297without_diff = 0
1298if __name__ == "__main__":
1299
1300 if (len(sys.argv) > 1 ):
1301 if (sys.argv[1] == "--without-diff"):
1302 without_diff = 1
1303
1304 view = GitView( without_diff != 1)
1305 view.run(sys.argv[without_diff:])