Moved the inotify import after the try/except import of pyinotify
[ugit.git] / py / controllers.py
blobf389a982ec0235b3e447e30a7547a1beafb9ed5b
1 import os
2 import commands
3 from PyQt4.QtGui import QDialog
4 from PyQt4.QtGui import QMessageBox
5 from PyQt4.QtGui import QMenu
6 from qobserver import QObserver
7 import cmds
8 import utils
9 import qtutils
10 from models import GitRepoBrowserModel
11 from models import GitCreateBranchModel
12 from views import GitCommitBrowser
13 from views import GitBranchDialog
14 from views import GitCreateBranchDialog
15 from repobrowsercontroller import GitRepoBrowserController
16 from createbranchcontroller import GitCreateBranchController
18 class GitController (QObserver):
19 '''The controller is a mediator between the model and view.
20 It allows for a clean decoupling between view and model classes.'''
22 def __init__ (self, model, view):
23 QObserver.__init__ (self, model, view)
25 # Binds a specific model attribute to a view widget,
26 # and vice versa.
27 self.model_to_view (model, 'commitmsg', 'commitText')
29 # When a model attribute changes, this runs a specific action
30 self.add_actions (model, 'staged', self.action_staged)
31 self.add_actions (model, 'unstaged', self.action_unstaged)
32 self.add_actions (model, 'untracked', self.action_unstaged)
34 # Routes signals for multiple widgets to our callbacks
35 # defined below.
36 self.add_signals ('textChanged()', view.commitText)
37 self.add_signals ('stateChanged(int)', view.untrackedCheckBox)
39 self.add_signals ('released()',
40 view.stageButton,
41 view.commitButton,
42 view.pushButton,
43 view.signOffButton,)
45 self.add_signals ('triggered()',
46 view.rescan,
47 view.createBranch,
48 view.checkoutBranch,
49 view.rebaseBranch,
50 view.deleteBranch,
51 view.commitAll,
52 view.commitSelected,
53 view.setCommitMessage,
54 view.stageChanged,
55 view.stageUntracked,
56 view.stageSelected,
57 view.unstageAll,
58 view.unstageSelected,
59 view.showDiffstat,
60 view.browseBranch,
61 view.browseOtherBranch,
62 view.visualizeAll,
63 view.visualizeCurrent,
64 view.exportPatches,
65 view.cherryPick,)
67 self.add_signals ('itemSelectionChanged()',
68 view.stagedList,
69 view.unstagedList,)
71 # App cleanup
72 self.connect ( qtutils.qapp(),
73 'lastWindowClosed()',
74 self.cb_last_window_closed )
76 # Handle double-clicks in the staged/unstaged lists.
77 # These are vanilla signal/slots since the qobserver
78 # signal routing is already handling these lists' signals.
79 self.connect ( view.unstagedList,
80 'itemDoubleClicked(QListWidgetItem*)',
81 lambda (x): self.cb_stage_selected (model) )
83 self.connect ( view.stagedList,
84 'itemDoubleClicked(QListWidgetItem*)',
85 lambda (x): self.cb_unstage_selected (model) )
87 # These callbacks are called in response to the signals
88 # defined above. One property of the QObserver callback
89 # mechanism is that the model is passed in as the first
90 # argument to the callback. This allows for a single
91 # controller to manage multiple models, though this
92 # isn't used at the moment.
93 self.add_callbacks (model, {
94 # Push Buttons
95 'stageButton': self.cb_stage_selected,
96 'signOffButton': lambda(m): m.add_signoff(),
97 'commitButton': self.cb_commit,
98 # Checkboxes
99 'untrackedCheckBox': self.cb_rescan,
100 # List Widgets
101 'stagedList': self.cb_diff_staged,
102 'unstagedList': self.cb_diff_unstaged,
103 # Menu Actions
104 'rescan': self.cb_rescan,
105 'createBranch': self.cb_branch_create,
106 'deleteBranch': self.cb_branch_delete,
107 'checkoutBranch': self.cb_checkout_branch,
108 'rebaseBranch': self.cb_rebase,
109 'commitAll': self.cb_commit_all,
110 'commitSelected': self.cb_commit_selected,
111 'setCommitMessage':
112 lambda(m): m.set_latest_commitmsg(),
113 'stageChanged': self.cb_stage_changed,
114 'stageUntracked': self.cb_stage_untracked,
115 'stageSelected': self.cb_stage_selected,
116 'unstageAll': self.cb_unstage_all,
117 'unstageSelected': self.cb_unstage_selected,
118 'showDiffstat': self.cb_show_diffstat,
119 'browseBranch': self.cb_browse_current,
120 'browseOtherBranch': self.cb_browse_other,
121 'visualizeCurrent': self.cb_viz_current,
122 'visualizeAll': self.cb_viz_all,
123 'exportPatches': self.cb_export_patches,
124 'cherryPick': self.cb_cherry_pick,
127 # chdir to the root of the git tree. This is critical
128 # to being able to properly use the git porcelain.
129 cdup = cmds.git_show_cdup()
130 if cdup: os.chdir (cdup)
132 # The diff-display context menu
133 self.__menu = None
134 view.displayText.controller = self
135 view.displayText.contextMenuEvent = self.__menu_event
137 # Default to creating a new commit (i.e. not an amend commit)
138 view.newCommitRadio.setChecked (True)
140 # Initialize the GUI
141 self.cb_rescan (model)
143 # Setup the inotify server
144 self.__start_inotify_thread (model)
146 #####################################################################
147 # MODEL ACTIONS
148 #####################################################################
150 def action_staged (self, model):
151 '''This action is called when the model's staged list
152 changes. This is a thin wrapper around update_list_widget.'''
153 list_widget = self.view.stagedList
154 staged = model.get_staged()
155 self.__update_list_widget (list_widget, staged, True)
157 def action_unstaged (self, model):
158 '''This action is called when the model's unstaged list
159 changes. This is a thin wrapper around update_list_widget.'''
160 list_widget = self.view.unstagedList
161 unstaged = model.get_unstaged()
162 self.__update_list_widget (list_widget, unstaged, False)
163 if self.view.untrackedCheckBox.isChecked():
164 untracked = model.get_untracked()
165 self.__update_list_widget (list_widget, untracked,
166 append=True,
167 staged=False,
168 untracked=True)
170 #####################################################################
171 # CALLBACKS
172 #####################################################################
174 def cb_branch_create (self, ugit_model):
175 model = GitCreateBranchModel()
176 view = GitCreateBranchDialog (self.view)
177 controller = GitCreateBranchController (model, view)
178 view.show()
179 result = view.exec_()
180 if result == QDialog.Accepted:
181 self.cb_rescan (ugit_model)
183 def cb_branch_delete (self, model):
184 dlg = GitBranchDialog(self.view, branches=cmds.git_branch())
185 branch = dlg.getSelectedBranch()
186 if not branch: return
187 qtutils.show_command (self.view,
188 cmds.git_branch(name=branch, delete=True))
191 def cb_browse_current (self, model):
192 self.__browse_branch (cmds.git_current_branch())
194 def cb_browse_other (self, model):
195 # Prompt for a branch to browse
196 branches = (cmds.git_branch (remote=False)
197 + cmds.git_branch (remote=True))
199 dialog = GitBranchDialog (self.view, branches=branches)
201 # Launch the repobrowser
202 self.__browse_branch (dialog.getSelectedBranch())
204 def cb_checkout_branch (self, model):
205 dlg = GitBranchDialog (self.view, cmds.git_branch())
206 branch = dlg.getSelectedBranch()
207 if not branch: return
208 qtutils.show_command (self.view, cmds.git_checkout(branch))
209 self.cb_rescan (model)
211 def cb_cherry_pick (self, model):
212 '''Starts a cherry-picking session.'''
213 (revs, summaries) = cmds.git_log (all=True)
214 selection, idxs = self.__select_commits (revs, summaries)
215 if not selection: return
217 output = cmds.git_cherry_pick (selection)
218 self.__show_command (output, model)
220 def cb_commit (self, model):
221 '''Sets up data and calls cmds.commit.'''
223 msg = model.get_commitmsg()
224 if not msg:
225 error_msg = 'ERROR: No commit message was provided.'
226 self.__show_command (error_msg)
227 return
229 amend = self.view.amendRadio.isChecked()
230 commit_all = self.view.commitAllCheckBox.isChecked()
232 files = []
233 if commit_all:
234 files = model.get_staged()
235 else:
236 wlist = self.view.stagedList
237 mlist = model.get_staged()
238 files = qtutils.get_selection_from_list (wlist, mlist)
239 # Perform the commit
240 output = cmds.git_commit (msg, amend, files)
242 # Reset commitmsg and rescan
243 model.set_commitmsg ('')
244 self.__show_command (output, model)
246 def cb_commit_all (self, model):
247 '''Sets the commit-all checkbox and runs cb_commit.'''
248 self.view.commitAllCheckBox.setChecked (True)
249 self.cb_commit (model)
251 def cb_commit_selected (self, model):
252 '''Unsets the commit-all checkbox and runs cb_commit.'''
253 self.view.commitAllCheckBox.setChecked (False)
254 self.cb_commit (model)
256 def cb_commit_sha1_selected (self, browser, revs):
257 '''This callback is called when a commit browser's
258 item is selected. This callback puts the current
259 revision sha1 into the commitText field.
260 This callback also puts shows the commit in the
261 browser's commit textedit and copies it into
262 the global clipboard/selection.'''
263 current = browser.commitList.currentRow()
264 item = browser.commitList.item (current)
265 if not item.isSelected():
266 browser.commitText.setText ('')
267 browser.revisionLine.setText ('')
268 return
270 # Get the commit's sha1 and put it in the revision line
271 sha1 = revs[current]
272 browser.revisionLine.setText (sha1)
273 browser.revisionLine.selectAll()
275 # Lookup the info for that sha1 and display it
276 commit_diff = cmds.git_show (sha1)
277 browser.commitText.setText (commit_diff)
279 # Copy the sha1 into the clipboard
280 qtutils.set_clipboard (sha1)
282 def cb_copy (self):
283 self.view.displayText.copy()
285 def cb_diff_staged (self, model):
286 list_widget = self.view.stagedList
287 row, selected = qtutils.get_selected_row (list_widget)
289 if not selected:
290 self.view.displayText.setText ('')
291 return
293 filename = model.get_staged()[row]
294 diff = cmds.git_diff (filename, staged=True)
296 if os.path.exists (filename):
297 pre = utils.header ('Staged for commit')
298 else:
299 pre = utils.header ('Staged for removal')
301 self.view.displayText.setText (pre + diff)
303 def cb_diff_unstaged (self, model):
304 list_widget = self.view.unstagedList
305 row, selected = qtutils.get_selected_row (list_widget)
306 if not selected:
307 self.view.displayText.setText ('')
308 return
309 filename = (model.get_unstaged() + model.get_untracked())[row]
310 if os.path.isdir (filename):
311 pre = utils.header ('Untracked directory')
312 cmd = 'ls -la %s' % utils.shell_quote (filename)
313 output = commands.getoutput (cmd)
314 self.view.displayText.setText ( pre + output )
315 return
317 if filename in model.get_unstaged():
318 diff = cmds.git_diff (filename, staged=False)
319 msg = utils.header ('Modified, unstaged') + diff
320 else:
321 # untracked file
322 cmd = 'file -b %s' % utils.shell_quote (filename)
323 file_type = commands.getoutput (cmd)
325 if 'binary' in file_type or 'data' in file_type:
326 sq_filename = utils.shell_quote (filename)
327 cmd = 'hexdump -C %s' % sq_filename
328 contents = commands.getoutput (cmd)
329 else:
330 file = open (filename, 'r')
331 contents = file.read()
332 file.close()
334 msg = (utils.header ('Untracked file: ' + file_type)
335 + contents)
337 self.view.displayText.setText (msg)
339 def cb_export_patches (self, model):
340 '''Launches the commit browser and exports the selected
341 patches.'''
343 (revs, summaries) = cmds.git_log ()
344 selection, idxs = self.__select_commits (revs, summaries)
345 if not selection: return
347 # now get the selected indices to determine whether
348 # a range of consecutive commits were selected
349 selected_range = range (idxs[0], idxs[-1] + 1)
350 export_range = len (idxs) > 1 and idxs == selected_range
352 output = cmds.git_format_patch (selection, export_range)
353 self.__show_command (output)
355 def cb_get_commit_msg (self, model):
356 model.retrieve_latest_commitmsg()
358 def cb_last_window_closed (self):
359 '''Cleanup the inotify thread if it exists.'''
360 if not self.inotify_thread: return
361 if not self.inotify_thread.isRunning(): return
362 self.inotify_thread.abort = True
363 self.inotify_thread.quit()
364 self.inotify_thread.wait()
366 def cb_rebase (self, model):
367 dlg = GitBranchDialog(self.view, cmds.git_branch())
368 dlg.setWindowTitle ("Select the current branch's new root")
369 branch = dlg.getSelectedBranch()
370 if not branch: return
371 qtutils.show_command (self.view, cmds.git_rebase (branch))
373 def cb_rescan (self, model, *args):
374 '''Populates view widgets with results from "git status."'''
376 # Scan for branch changes
377 self.__set_branch_ui_items()
379 # This allows us to defer notification until the
380 # we finish processing data
381 model.set_notify(False)
383 # Reset the staged and unstaged model lists
384 # NOTE: the model's unstaged list is used to
385 # hold both unstaged and untracked files.
386 model.staged = []
387 model.unstaged = []
388 model.untracked = []
390 # Read git status items
391 ( staged_items,
392 unstaged_items,
393 untracked_items ) = cmds.git_status()
395 # Gather items to be committed
396 for staged in staged_items:
397 if staged not in model.get_staged():
398 model.add_staged (staged)
400 # Gather unindexed items
401 for unstaged in unstaged_items:
402 if unstaged not in model.get_unstaged():
403 model.add_unstaged (unstaged)
405 # Gather untracked items
406 for untracked in untracked_items:
407 if untracked not in model.get_untracked():
408 model.add_untracked (untracked)
410 # Re-enable notifications and emit changes
411 model.set_notify(True)
412 model.notify_observers ('staged', 'unstaged')
414 squash_msg = os.path.join (os.getcwd(), '.git', 'SQUASH_MSG')
415 if not os.path.exists (squash_msg): return
417 msg = model.get_commitmsg()
419 if msg:
420 result = qtutils.question (self.view,
421 'Import Commit Message?',
422 ('A commit message from a '
423 + 'merge-in-progress was found.\n'
424 + 'Do you want to import it?'))
425 if not result: return
427 file = open (squash_msg)
428 msg = file.read()
429 file.close()
431 # Set the new commit message
432 model.set_commitmsg (msg)
434 def cb_show_diffstat (self, model):
435 '''Show the diffstat from the latest commit.'''
436 self.__show_command (cmds.git_diff_stat(), rescan=False)
438 def cb_stage_changed (self, model):
439 '''Stage all changed files for commit.'''
440 output = cmds.git_add (model.get_unstaged())
441 self.__show_command (output, model)
443 def cb_stage_hunk (self):
444 print "STAGING HUNK"
446 def cb_stage_selected (self, model):
447 '''Use "git add" to add items to the git index.
448 This is a thin wrapper around __apply_to_list.'''
449 command = cmds.git_add_or_remove
450 widget = self.view.unstagedList
451 items = model.get_unstaged() + model.get_untracked()
452 self.__apply_to_list (command, model, widget, items)
454 def cb_stage_untracked (self, model):
455 '''Stage all untracked files for commit.'''
456 output = cmds.git_add (model.get_untracked())
457 self.__show_command (output, model)
459 def cb_unstage_all (self, model):
460 '''Use "git reset" to remove all items from the git index.'''
461 output = cmds.git_reset (model.get_staged())
462 self.__show_command (output, model)
464 def cb_unstage_selected (self, model):
465 '''Use "git reset" to remove items from the git index.
466 This is a thin wrapper around __apply_to_list.'''
468 command = cmds.git_reset
469 widget = self.view.stagedList
470 items = model.get_staged()
471 self.__apply_to_list (command, model, widget, items)
473 def cb_viz_all (self, model):
474 '''Visualizes the entire git history using gitk.'''
475 os.system ('gitk --all &')
477 def cb_viz_current (self, model):
478 '''Visualizes the current branch's history using gitk.'''
479 branch = cmds.git_current_branch()
480 os.system ('gitk %s &' % utils.shell_quote (branch))
482 #####################################################################
483 # PRIVATE HELPER METHODS
484 #####################################################################
486 def __apply_to_list (self, command, model, widget, items):
487 '''This is a helper method that retrieves the current
488 selection list, applies a command to that list,
489 displays a dialog showing the output of that command,
490 and calls cb_rescan to pickup changes.'''
491 apply_items = qtutils.get_selection_from_list (widget, items)
492 output = command (apply_items)
493 self.__show_command (output, model)
495 def __browse_branch (self, branch):
496 if not branch: return
497 model = GitRepoBrowserModel (branch)
498 view = GitCommitBrowser()
499 controller = GitRepoBrowserController(model, view)
500 view.show()
501 view.exec_()
503 def __menu_about_to_show (self):
504 self.__stage_hunk_action.setEnabled (True)
506 def __menu_event (self, event):
507 self.__menu_setup()
508 textedit = self.view.displayText
509 self.__menu.exec_ (textedit.mapToGlobal (event.pos()))
511 def __menu_setup (self):
512 if self.__menu: return
514 menu = QMenu (self.view)
515 stage = menu.addAction ('Stage Hunk', self.cb_stage_hunk)
516 copy = menu.addAction ('Copy', self.cb_copy)
518 self.connect (menu, 'aboutToShow()', self.__menu_about_to_show)
520 self.__stage_hunk_action = stage
521 self.__copy_action = copy
522 self.__menu = menu
525 def __file_to_widget_item (self, filename, staged, untracked=False):
526 '''Given a filename, return a QListWidgetItem suitable
527 for adding to a QListWidget. "staged" controls whether
528 to use icons for the staged or unstaged list widget.'''
530 if staged:
531 icon_file = utils.get_staged_icon (filename)
532 elif untracked:
533 icon_file = utils.get_untracked_icon()
534 else:
535 icon_file = utils.get_icon (filename)
537 return qtutils.create_listwidget_item (filename, icon_file)
539 def __select_commits (self, revs, summaries):
540 '''Use the GitCommitBrowser to select commits from a list.'''
541 if not summaries:
542 msg = 'ERROR: No commits exist in this branch.'''
543 self.__show_command (output=msg)
544 return ([],[])
546 browser = GitCommitBrowser (self.view)
547 self.connect ( browser.commitList,
548 'itemSelectionChanged()',
549 lambda: self.cb_commit_sha1_selected(
550 browser, revs) )
552 for summary in summaries:
553 browser.commitList.addItem (summary)
555 browser.show()
556 result = browser.exec_()
557 if result != QDialog.Accepted:
558 return ([],[])
560 list_widget = browser.commitList
561 selection = qtutils.get_selection_from_list (list_widget, revs)
562 if not selection: return ([],[])
564 # also return the selected index numbers
565 index_nums = range (len (revs))
566 idxs = qtutils.get_selection_from_list (list_widget, index_nums)
568 return (selection, idxs)
570 def __set_branch_ui_items (self):
571 '''Sets up items that mention the current branch name.'''
572 current_branch = cmds.git_current_branch()
573 menu_text = 'Browse ' + current_branch + ' branch'
574 self.view.browseBranch.setText (menu_text)
576 status_text = 'Current branch: ' + current_branch
577 self.view.statusBar().showMessage (status_text)
579 def __start_inotify_thread (self, model):
580 # Do we have inotify?
581 # If not, return peacefully
582 self.inotify_thread = None
583 try:
584 import pyinotify
585 except:
586 return
588 from inotify import GitNotifier
589 self.inotify_thread = GitNotifier (os.getcwd())
590 self.connect ( self.inotify_thread, 'timeForRescan()',
591 lambda: self.cb_rescan (model) )
593 # Start the notification thread
594 self.inotify_thread.start()
596 def __show_command (self, output, model=None, rescan=True):
597 '''Shows output and optionally rescans for changes.'''
598 qtutils.show_command (self.view, output)
599 if rescan and model: self.cb_rescan (model)
601 def __update_list_widget (self, list_widget, items,
602 staged, untracked=False, append=False):
603 '''A helper method to populate a QListWidget with the
604 contents of modelitems.'''
605 if not append:
606 list_widget.clear()
607 for item in items:
608 qitem = self.__file_to_widget_item (item,
609 staged, untracked)
610 list_widget.addItem( qitem )