controllers: do not require modified files when amending
[git-cola.git] / ugit / controllers / __init__.py
blob484b8133b488d9d8dc0ee54f7419838e419afe6b
1 #!/usr/bin/env python
2 import os
3 import time
5 from PyQt4 import QtCore
6 from PyQt4 import QtGui
7 from PyQt4.QtGui import QDialog
8 from PyQt4.QtGui import QMessageBox
9 from PyQt4.QtGui import QMenu
10 from PyQt4.QtGui import QFont
12 from ugit import utils
13 from ugit import qtutils
14 from ugit import defaults
15 from ugit.qobserver import QObserver
17 # controllers namespace
18 import search
19 from util import logger
20 from push import push_branches
21 from util import choose_branch
22 from util import select_commits
23 from util import update_options
24 from repobrowser import browse_git_branch
25 from createbranch import create_new_branch
26 from search import search_commits
27 from merge import local_merge
28 from merge import abort_merge
30 class Controller(QObserver):
31 """Manages the interaction between models and views."""
33 def init(self, model, view):
34 # parent-less log window
35 qtutils.LOGGER = logger()
37 # Avoids inotify floods from e.g. make
38 self.__last_inotify_event = time.time()
40 # The unstaged list context menu
41 self.__unstaged_menu = None
43 # The diff-display context menu
44 self.__diff_menu = None
45 self.__staged_diff_in_view = True
46 self.__diffgui_enabled = True
48 # Unstaged changes context menu
49 view.unstaged.contextMenuEvent = self.unstaged_context_menu_event
51 # Diff display context menu
52 view.display_text.contextMenuEvent = self.diff_context_menu_event
54 # Binds model params to their equivalent view widget
55 self.add_observables('commitmsg', 'staged', 'unstaged')
57 # When a model attribute changes, this runs a specific action
58 self.add_actions(staged = self.action_staged)
59 self.add_actions(unstaged = self.action_unstaged)
60 self.add_actions(global_ugit_fontdiff = self.update_diff_font)
61 self.add_actions(global_ugit_fontui = self.update_ui_font)
63 self.add_callbacks(
64 # Push Buttons
65 signoff_button = self.model.add_signoff,
66 stage_button = self.stage_selected,
67 commit_button = self.commit,
68 push_button = self.push,
69 # List Widgets
70 staged = self.diff_staged,
71 unstaged = self.diff_unstaged,
72 # Checkboxes
73 untracked_checkbox = self.rescan,
75 # File Menu
76 menu_quit = self.quit_app,
77 # menu_load_bookmark = self.load_bookmark,
78 # menu_save_bookmark = self.save_bookmark,
79 # menu_manage_bookmarks = self.manage_bookmarks,
81 # Edit Menu
82 menu_options = self.options,
83 menu_cut = self.view.action_cut,
84 menu_copy = self.view.action_copy,
85 menu_paste = self.view.action_paste,
86 menu_delete = self.view.action_delete,
87 menu_select_all = self.view.action_select_all,
88 menu_undo = self.view.action_undo,
89 menu_redo = self.view.action_redo,
91 # Search Menu
92 menu_search_grep = self.grep,
93 menu_search_revision =
94 self.gen_search( search.REVISION_ID ),
95 menu_search_revision_range =
96 self.gen_search( search.REVISION_RANGE ),
97 menu_search_message =
98 self.gen_search( search.MESSAGE ),
99 menu_search_path =
100 self.gen_search( search.PATH, True ),
101 menu_search_date_range =
102 self.gen_search( search.DATE_RANGE ),
103 menu_search_diff =
104 self.gen_search( search.DIFF ),
105 menu_search_author =
106 self.gen_search( search.AUTHOR ),
107 menu_search_committer =
108 self.gen_search( search.COMMITTER ),
110 # Merge Menu
111 menu_merge_local =
112 lambda: local_merge( self.model, self.view ),
113 menu_merge_abort =
114 lambda: abort_merge( self.model, self.view ),
116 # Repository Menu
117 menu_visualize_current = self.viz_current,
118 menu_visualize_all = self.viz_all,
119 menu_browse_commits = self.browse_commits,
120 menu_browse_branch = self.browse_current,
121 menu_browse_other_branch = self.browse_other,
123 # Commit Menu
124 menu_rescan = self.rescan,
125 menu_create_branch = self.branch_create,
126 menu_delete_branch = self.branch_delete,
127 menu_checkout_branch = self.checkout_branch,
128 menu_rebase_branch = self.rebase,
129 menu_commit = self.commit,
130 menu_stage_selected = self.stage_selected,
131 menu_unstage_selected = self.unstage_selected,
132 menu_show_diffstat = self.show_diffstat,
133 menu_show_index = self.show_index,
134 menu_export_patches = self.export_patches,
135 menu_load_commitmsg = self.load_commitmsg,
136 menu_cherry_pick = self.cherry_pick,
137 menu_get_prev_commitmsg = model.get_prev_commitmsg,
138 menu_stage_modified =
139 lambda: self.log(self.model.stage_modified()),
140 menu_stage_untracked =
141 lambda: self.log(self.model.stage_untracked()),
142 menu_unstage_all =
143 lambda: self.log(self.model.unstage_all()),
146 # Delegate window events here
147 view.moveEvent = self.move_event
148 view.resizeEvent = self.resize_event
149 view.closeEvent = self.quit_app
150 view.staged.mousePressEvent = self.click_staged
151 view.unstaged.mousePressEvent = self.click_unstaged
153 # These are vanilla signal/slots since QObserver
154 # is already handling these signals.
155 self.connect(view.unstaged,
156 'itemDoubleClicked(QListWidgetItem*)',
157 self.stage_selected)
158 self.connect(view.staged,
159 'itemDoubleClicked(QListWidgetItem*)',
160 self.unstage_selected)
162 # Toolbar log button
163 self.connect(view.toolbar_show_log,
164 'triggered()', self.show_log)
166 self.connect(view.diff_dock,
167 'topLevelChanged(bool)',
168 lambda(b): self.setwindow(view.diff_dock, b))
170 self.connect(view.editor_dock,
171 'topLevelChanged(bool)',
172 lambda(b): self.setwindow(view.editor_dock, b))
174 self.connect(view.status_dock,
175 'topLevelChanged(bool)',
176 lambda(b): self.setwindow(view.status_dock, b))
178 self.init_log_window()
179 self.load_gui_settings()
180 self.rescan()
181 self.refresh_view(
182 'global_ugit_fontdiff',
183 'global_ugit_fontui',
185 self.start_inotify_thread()
187 def setwindow(self, dock, isfloating):
188 if isfloating:
189 flags = ( QtCore.Qt.Window
190 | QtCore.Qt.FramelessWindowHint )
191 dock.setWindowFlags( flags )
192 dock.show()
194 #####################################################################
195 # handle when the listitem icons are clicked
196 def click_event(self, widget, action_callback, event):
197 result = QtGui.QListWidget.mousePressEvent(widget, event)
198 xpos = event.pos().x()
199 if xpos > 5 and xpos < 20:
200 action_callback()
201 return result
203 def click_staged(self, event):
204 return self.click_event(
205 self.view.staged,
206 self.unstage_selected,
207 event)
209 def click_unstaged(self, event):
210 return self.click_event(
211 self.view.unstaged,
212 self.stage_selected,
213 event)
216 #####################################################################
217 # event() is called in response to messages from the inotify thread
218 def event(self, msg):
219 if msg.type() == defaults.INOTIFY_EVENT:
220 self.rescan()
221 return True
222 else:
223 return False
225 #####################################################################
226 # Actions triggered during model updates
228 def action_staged(self, widget):
229 qtutils.update_listwidget(widget,
230 self.model.get_staged(), staged=True)
231 self.view.editor_dock.raise_()
233 def action_unstaged(self, widget):
234 qtutils.update_listwidget(widget,
235 self.model.get_modified(), staged=False)
237 if self.view.untracked_checkbox.isChecked():
238 qtutils.update_listwidget(widget,
239 self.model.get_untracked(),
240 staged=False,
241 append=True,
242 untracked=True)
244 #####################################################################
245 # Qt callbacks
246 def gen_search(self, searchtype, browse=False):
247 def search_handler():
248 search_commits(self.model, searchtype, browse)
249 return search_handler
251 def grep(self):
252 txt, ok = qtutils.input("grep")
253 if not ok: return
254 stuff = self.model.grep(txt)
255 self.view.display_text.setText(stuff)
256 self.view.diff_dock.raise_()
258 def show_log(self, *rest):
259 qtutils.toggle_log_window()
261 def options(self):
262 update_options(self.model, self.view)
264 def branch_create(self):
265 if create_new_branch(self.model, self.view):
266 self.rescan()
268 def branch_delete(self):
269 branch = choose_branch('Delete Branch',
270 self.view, self.model.get_local_branches())
271 if not branch: return
272 self.log(self.model.delete_branch(branch))
274 def browse_current(self):
275 branch = self.model.get_currentbranch()
276 browse_git_branch(self.model, self.view, branch)
278 def browse_other(self):
279 # Prompt for a branch to browse
280 branch = choose_branch('Browse Branch Files',
281 self.view, self.model.get_all_branches())
282 if not branch: return
283 # Launch the repobrowser
284 browse_git_branch(self.model, self.view, branch)
286 def checkout_branch(self):
287 branch = choose_branch('Checkout Branch',
288 self.view, self.model.get_local_branches())
289 if not branch: return
290 self.log(self.model.checkout(branch))
292 def browse_commits(self):
293 self.select_commits_gui(self.tr('Browse Commits'),
294 *self.model.log_helper(all=True))
296 def cherry_pick(self):
297 commits = self.select_commits_gui(self.tr('Cherry-Pick Commits'),
298 *self.model.log_helper(all=True))
299 if not commits: return
300 self.log(self.model.cherry_pick_list(commits))
302 def commit(self):
303 msg = self.model.get_commitmsg()
304 if not msg:
305 error_msg = self.tr(""
306 + "Please supply a commit message.\n"
307 + "\n"
308 + "A good commit message has the following format:\n"
309 + "\n"
310 + "- First line: Describe in one sentence what you did.\n"
311 + "- Second line: Blank\n"
312 + "- Remaining lines: Describe why this change is good.\n")
313 self.log(error_msg)
314 return
316 files = self.model.get_staged()
317 if not files and not self.view.amend_radio.isChecked():
318 error_msg = self.tr(""
319 + "No changes to commit.\n"
320 + "\n"
321 + "You must stage at least 1 file before you can commit.\n")
322 self.log(error_msg)
323 return
325 # Perform the commit
326 output = self.model.commit_with_msg(
327 msg, amend=self.view.amend_radio.isChecked())
329 # Reset state
330 self.view.new_commit_radio.setChecked(True)
331 self.view.amend_radio.setChecked(False)
332 self.model.set_commitmsg('')
333 self.log(output)
335 def view_diff(self, staged=True):
336 self.__staged_diff_in_view = staged
337 if self.__staged_diff_in_view:
338 widget = self.view.staged
339 else:
340 widget = self.view.unstaged
341 row, selected = qtutils.get_selected_row(widget)
342 if not selected:
343 self.view.reset_display()
344 self.__diffgui_enabled = False
345 return
346 (diff,
347 status) = self.model.get_diff_and_status(row, staged=staged)
349 self.view.set_display(diff)
350 self.view.set_info(self.tr(status))
351 self.view.diff_dock.raise_()
352 self.__diffgui_enabled = True
354 # use *rest to handle being called from different signals
355 def diff_staged(self, *rest):
356 self.view_diff(staged=True)
358 # use *rest to handle being called from different signals
359 def diff_unstaged(self, *rest):
360 self.view_diff(staged=False)
362 def export_patches(self):
363 (revs, summaries) = self.model.log_helper()
364 commits = self.select_commits_gui(self.tr('Export Patches'),
365 revs, summaries)
366 if not commits: return
367 self.log(self.model.format_patch_helper(*commits))
369 def quit_app(self,*rest):
370 """Save config settings and cleanup any inotify threads."""
372 if self.model.save_at_exit():
373 self.model.save_gui_settings()
374 qtutils.close_log_window()
375 self.view.hide()
377 if not self.inotify_thread: return
378 if not self.inotify_thread.isRunning(): return
380 self.inotify_thread.abort = True
381 self.inotify_thread.terminate()
382 self.inotify_thread.wait()
384 def load_commitmsg(self):
385 file = qtutils.open_dialog(self.view,
386 'Load Commit Message...', defaults.DIRECTORY)
388 if file:
389 defaults.DIRECTORY = os.path.dirname(file)
390 slushy = utils.slurp(file)
391 if slushy: self.model.set_commitmsg(slushy)
393 def rebase(self):
394 branch = choose_branch('Rebase Branch',
395 self.view, self.model.get_local_branches())
396 if not branch: return
397 self.log(self.model.rebase(branch))
399 # use *rest to handle being called from the checkbox signal
400 def rescan(self, *rest):
401 '''Populates view widgets with results from "git status."'''
403 # save entire selection
404 unstaged = qtutils.get_selection_list(
405 self.view.unstaged,
406 self.model.get_unstaged())
407 staged = qtutils.get_selection_list(
408 self.view.staged,
409 self.model.get_staged())
411 scrollbar = self.view.display_text.verticalScrollBar()
412 scrollvalue = scrollbar.value()
414 # save selected item
415 unstageditem = qtutils.get_selected_item(
416 self.view.unstaged,
417 self.model.get_unstaged())
419 stageditem = qtutils.get_selected_item(
420 self.view.staged,
421 self.model.get_staged())
423 # get new values
424 self.model.update_status()
426 # restore selection
427 update_staged = False
428 update_unstaged = False
429 updated_unstaged = self.model.get_unstaged()
430 updated_staged = self.model.get_staged()
432 for item in unstaged:
433 if item in updated_unstaged:
434 idx = updated_unstaged.index(item)
435 listitem = self.view.unstaged.item(idx)
436 if listitem:
437 listitem.setSelected(True)
438 self.view.unstaged\
439 .setItemSelected(listitem, True)
440 update_unstaged = True
441 self.view.unstaged.update()
442 for item in staged:
443 if item in updated_staged:
444 idx = updated_staged.index(item)
445 listitem = self.view.staged.item(idx)
446 if listitem:
447 listitem.setSelected(True)
448 self.view.staged\
449 .setItemSelected(listitem, True)
450 update_staged = True
452 # restore selected item
453 if update_staged and stageditem:
454 idx = updated_staged.index(stageditem)
455 item = self.view.staged.item(idx)
456 self.view.staged.setCurrentItem(item)
457 self.view_diff(True)
458 scrollbar.setValue(scrollvalue)
460 elif update_unstaged and unstageditem:
461 idx = updated_unstaged.index(unstageditem)
462 item = self.view.unstaged.item(idx)
463 self.view.unstaged.setCurrentItem(item)
464 self.view_diff(False)
465 scrollbar.setValue(scrollvalue)
467 # Update the title with the current branch
468 self.view.setWindowTitle('%s [%s]' % (
469 self.model.get_project(),
470 self.model.get_currentbranch()))
472 # Check if there's a message file in .git/
473 merge_msg_path = self.model.get_merge_message_path()
474 if merge_msg_path is None:
475 return
477 # A merge message file exists.
478 set_msg = False
479 if self.model.get_commitmsg():
480 # The commit message editor contains data.
481 # Prompt before overwriting the commit message
482 # with the contents of the merge message.
483 answer = qtutils.question(self.view,
484 self.tr('Import Commit Message?'),
485 self.tr('A commit message from an in-progress'
486 + ' merge was found.\nImport it?'))
488 if answer:
489 set_msg = True
490 else:
491 set_msg = True
492 # Set the new commit message
493 if set_msg:
494 self.model.load_commitmsg(merge_msg_path)
495 self.view.editor_dock.raise_()
497 def push(self):
498 push_branches(self.model, self.view)
500 def show_diffstat(self):
501 """Show the diffstat from the latest commit."""
502 self.__diffgui_enabled = False
503 self.view.set_info(self.tr('Diffstat'))
504 self.view.set_display(self.model.diffstat())
506 def show_index(self):
507 self.__diffgui_enabled = False
508 self.view.set_info(self.tr('Index'))
509 self.view.set_display(self.model.diffindex())
511 #####################################################################
512 # diff gui
513 def process_diff_selection(self, items, widget,
514 cached=True, selected=False, reverse=True, noop=False):
516 filename = qtutils.get_selected_item(widget, items)
517 if not filename: return
518 parser = utils.DiffParser(self.model, filename=filename,
519 cached=cached)
520 offset, selection = self.view.diff_selection()
521 parser.process_diff_selection(selected, offset, selection)
522 self.rescan()
524 def stage_hunk(self):
525 self.process_diff_selection(
526 self.model.get_unstaged(),
527 self.view.unstaged,
528 cached=False)
530 def stage_hunk_selection(self):
531 self.process_diff_selection(
532 self.model.get_unstaged(),
533 self.view.unstaged,
534 cached=False,
535 selected=True)
537 def unstage_hunk(self, cached=True):
538 self.process_diff_selection(
539 self.model.get_staged(),
540 self.view.staged,
541 cached=True)
543 def unstage_hunk_selection(self):
544 self.process_diff_selection(
545 self.model.get_staged(),
546 self.view.staged,
547 cached=True,
548 selected=True)
550 # #######################################################################
551 # end diff gui
553 # *rest handles being called from different signals
554 def stage_selected(self,*rest):
555 """Use "git add" to add items to the git index.
556 This is a thin wrapper around map_to_listwidget."""
557 command = self.model.add_or_remove
558 widget = self.view.unstaged
559 items = self.model.get_unstaged()
560 self.map_to_listwidget(command, widget, items)
562 # *rest handles being called from different signals
563 def unstage_selected(self, *rest):
564 """Use "git reset" to remove items from the git index.
565 This is a thin wrapper around map_to_listwidget."""
566 command = self.model.reset_helper
567 widget = self.view.staged
568 items = self.model.get_staged()
569 self.map_to_listwidget(command, widget, items)
571 def undo_changes(self):
572 """Reverts local changes back to whatever's in HEAD."""
573 widget = self.view.unstaged
574 items = self.model.get_unstaged()
575 potential_items = qtutils.get_selection_list(widget, items)
576 items_to_undo = []
577 untracked = self.model.get_untracked()
578 for item in potential_items:
579 if item not in untracked:
580 items_to_undo.append(item)
581 if items_to_undo:
582 answer = qtutils.question(self.view,
583 self.tr('Destroy Local Changes?'),
584 self.tr('This operation will drop all '
585 + ' uncommitted changes. Continue?'),
586 default=False)
588 if not answer: return
590 output = self.model.checkout('HEAD', '--',
591 *items_to_undo)
592 self.log('git checkout HEAD -- '
593 + ' '.join(items_to_undo)
594 + '\n' + output)
595 else:
596 msg = 'No files selected for checkout from HEAD.'
597 self.log(self.tr(msg))
599 def viz_all(self):
600 """Visualizes the entire git history using gitk."""
601 browser = self.model.get_global_ugit_historybrowser()
602 utils.fork(browser,'--all')
604 def viz_current(self):
605 """Visualizes the current branch's history using gitk."""
606 browser = self.model.get_global_ugit_historybrowser()
607 utils.fork(browser, self.model.get_currentbranch())
609 def move_event(self, event):
610 defaults.X = event.pos().x()
611 defaults.Y = event.pos().y()
613 def resize_event(self, event):
614 defaults.WIDTH = event.size().width()
615 defaults.HEIGHT = event.size().height()
617 def load_gui_settings(self):
618 if not self.model.remember_gui_settings():
619 return
620 (w,h,x,y,
621 st0,st1,
622 sb0,sb1) = self.model.get_window_geom()
623 self.view.resize(w,h)
624 self.view.move(x,y)
626 def log(self, output, rescan=True, quiet=False):
627 """Logs output and optionally rescans for changes."""
628 qtutils.log(output, quiet=quiet, doraise=False)
629 if rescan: self.rescan()
631 def map_to_listwidget(self, command, widget, items):
632 """This is a helper method that retrieves the current
633 selection list, applies a command to that list,
634 displays a dialog showing the output of that command,
635 and calls rescan to pickup changes."""
636 apply_items = qtutils.get_selection_list(widget, items)
637 output = command(*apply_items)
638 self.log(output, quiet=True)
640 def unstaged_context_menu_event(self, event):
641 self.unstaged_context_menu_setup()
642 unstaged = self.view.unstaged
643 self.__unstaged_menu.exec_(unstaged.mapToGlobal(event.pos()))
645 def unstaged_context_menu_setup(self):
646 if self.__unstaged_menu: return
648 menu = self.__unstaged_menu = QMenu(self.view)
649 self.__stage_selected_action = menu.addAction(
650 self.tr('Stage Selected'), self.stage_selected)
651 self.__undo_changes_action = menu.addAction(
652 self.tr('Undo Local Changes'), self.undo_changes)
653 self.connect(self.__unstaged_menu, 'aboutToShow()',
654 self.unstaged_context_menu_about_to_show)
656 def unstaged_context_menu_about_to_show(self):
657 unstaged_item = qtutils.get_selected_item(
658 self.view.unstaged,
659 self.model.get_unstaged())
661 is_tracked = unstaged_item not in self.model.get_untracked()
663 enable_staging = bool(self.__diffgui_enabled
664 and unstaged_item)
665 enable_undo = enable_staging and is_tracked
667 self.__stage_selected_action.setEnabled(enable_staging)
668 self.__undo_changes_action.setEnabled(enable_undo)
670 def diff_context_menu_about_to_show(self):
671 unstaged_item = qtutils.get_selected_item(
672 self.view.unstaged,
673 self.model.get_unstaged())
675 is_tracked= unstaged_item not in self.model.get_untracked()
677 enable_staged= (
678 self.__diffgui_enabled
679 and unstaged_item
680 and not self.__staged_diff_in_view
681 and is_tracked)
683 enable_unstaged= (
684 self.__diffgui_enabled
685 and self.__staged_diff_in_view
686 and qtutils.get_selected_item(
687 self.view.staged,
688 self.model.get_staged()))
690 self.__stage_hunk_action.setEnabled(bool(enable_staged))
691 self.__stage_hunk_selection_action.setEnabled(bool(enable_staged))
693 self.__unstage_hunk_action.setEnabled(bool(enable_unstaged))
694 self.__unstage_hunk_selection_action.setEnabled(bool(enable_unstaged))
696 def diff_context_menu_event(self, event):
697 self.diff_context_menu_setup()
698 textedit = self.view.display_text
699 self.__diff_menu.exec_(textedit.mapToGlobal(event.pos()))
701 def diff_context_menu_setup(self):
702 if self.__diff_menu: return
704 menu = self.__diff_menu = QMenu(self.view)
705 self.__stage_hunk_action = menu.addAction(
706 self.tr('Stage Hunk For Commit'), self.stage_hunk)
708 self.__stage_hunk_selection_action = menu.addAction(
709 self.tr('Stage Selected Lines'),
710 self.stage_hunk_selection)
712 self.__unstage_hunk_action = menu.addAction(
713 self.tr('Unstage Hunk From Commit'),
714 self.unstage_hunk)
716 self.__unstage_hunk_selection_action = menu.addAction(
717 self.tr('Unstage Selected Lines'),
718 self.unstage_hunk_selection)
720 self.__copy_action = menu.addAction(
721 self.tr('Copy'), self.view.copy_display)
723 self.connect(self.__diff_menu, 'aboutToShow()',
724 self.diff_context_menu_about_to_show)
726 def select_commits_gui(self, title, revs, summaries):
727 return select_commits(self.model, self.view, title, revs, summaries)
729 def update_diff_font(self):
730 font = self.model.get_global_ugit_fontdiff()
731 if not font: return
732 qfont = QFont()
733 qfont.fromString(font)
734 self.view.display_text.setFont(qfont)
735 self.view.commitmsg.setFont(qfont)
737 def update_ui_font(self):
738 font = self.model.get_global_ugit_fontui()
739 if not font: return
740 qfont = QFont()
741 qfont.fromString(font)
742 QtGui.qApp.setFont(qfont)
744 def init_log_window(self):
745 branch = self.model.get_currentbranch()
746 version = defaults.VERSION
747 qtutils.log(self.model.get_git_version()
748 + '\nugit version '+ version
749 + '\nCurrent Branch: '+ branch)
751 def start_inotify_thread(self):
752 # Do we have inotify? If not, return.
753 # Recommend installing inotify if we're on Linux.
754 self.inotify_thread = None
755 try:
756 from ugit.inotify import GitNotifier
757 qtutils.log(self.tr('inotify support: enabled'))
758 except ImportError:
759 import platform
760 if platform.system() == 'Linux':
762 msg = self.tr(
763 'inotify: disabled\n'
764 'Note: To enable inotify, '
765 'install python-pyinotify.\n')
767 plat = platform.platform().lower()
768 if 'debian' in plat or 'ubuntu' in plat:
769 msg += self.tr(
770 'On Debian or Ubuntu systems, '
771 'try: sudo apt-get install '
772 'python-pyinotify')
773 qtutils.log(msg)
775 return
777 # Start the notification thread
778 self.inotify_thread = GitNotifier(self, os.getcwd())
779 self.inotify_thread.start()