3 from PyQt4
.QtGui
import QDialog
4 from PyQt4
.QtGui
import QMessageBox
5 from PyQt4
.QtGui
import QMenu
6 from qobserver
import QObserver
10 from views
import GitCommitBrowser
11 from views
import GitBranchDialog
12 from views
import GitCreateBranchDialog
13 from repobrowsercontroller
import GitRepoBrowserController
14 from createbranchcontroller
import GitCreateBranchController
16 class GitController (QObserver
):
17 '''The controller is a mediator between the model and view.
18 It allows for a clean decoupling between view and model classes.'''
20 def __init__ (self
, model
, view
):
21 QObserver
.__init
__ (self
, model
, view
)
23 # chdir to the root of the git tree. This is critical
24 # to being able to properly use the git porcelain.
25 cdup
= cmds
.git_show_cdup()
26 if cdup
: os
.chdir (cdup
)
28 # The diff-display context menu
30 self
.__staged
_diff
_in
_view
= True
32 # Diff display context menu
33 view
.displayText
.controller
= self
34 view
.displayText
.contextMenuEvent
= self
.__menu
_event
36 # Default to creating a new commit (i.e. not an amend commit)
37 view
.newCommitRadio
.setChecked (True)
39 # Binds a specific model attribute to a view widget,
41 self
.model_to_view (model
, 'commitmsg', 'commitText')
43 # When a model attribute changes, this runs a specific action
44 self
.add_actions (model
, 'staged', self
.action_staged
)
45 self
.add_actions (model
, 'unstaged', self
.action_unstaged
)
46 self
.add_actions (model
, 'untracked', self
.action_unstaged
)
48 # Routes signals for multiple widgets to our callbacks
50 self
.add_signals ('textChanged()', view
.commitText
)
51 self
.add_signals ('stateChanged(int)', view
.untrackedCheckBox
)
53 self
.add_signals ('released()',
59 self
.add_signals ('triggered()',
67 view
.setCommitMessage
,
75 view
.browseOtherBranch
,
77 view
.visualizeCurrent
,
81 self
.add_signals ('itemClicked (QListWidgetItem *)',
82 view
.stagedList
, view
.unstagedList
,)
84 self
.add_signals ('itemSelectionChanged()',
85 view
.stagedList
, view
.unstagedList
,)
88 self
.connect ( qtutils
.qapp(),
90 self
.cb_last_window_closed
)
92 # Handle double-clicks in the staged/unstaged lists.
93 # These are vanilla signal/slots since the qobserver
94 # signal routing is already handling these lists' signals.
95 self
.connect ( view
.unstagedList
,
96 'itemDoubleClicked(QListWidgetItem*)',
97 lambda (x
): self
.cb_stage_selected (model
) )
99 self
.connect ( view
.stagedList
,
100 'itemDoubleClicked(QListWidgetItem*)',
101 lambda (x
): self
.cb_unstage_selected (model
) )
103 # These callbacks are called in response to the signals
104 # defined above. One property of the QObserver callback
105 # mechanism is that the model is passed in as the first
106 # argument to the callback. This allows for a single
107 # controller to manage multiple models, though this
108 # isn't used at the moment.
109 self
.add_callbacks (model
, {
111 'stageButton': self
.cb_stage_selected
,
112 'signOffButton': lambda(m
): m
.add_signoff(),
113 'commitButton': self
.cb_commit
,
115 'untrackedCheckBox': self
.cb_rescan
,
117 'stagedList': self
.cb_diff_staged
,
118 'unstagedList': self
.cb_diff_unstaged
,
120 'rescan': self
.cb_rescan
,
121 'createBranch': self
.cb_branch_create
,
122 'deleteBranch': self
.cb_branch_delete
,
123 'checkoutBranch': self
.cb_checkout_branch
,
124 'rebaseBranch': self
.cb_rebase
,
125 'commitAll': self
.cb_commit_all
,
126 'commitSelected': self
.cb_commit_selected
,
128 lambda(m
): m
.set_latest_commitmsg(),
129 'stageChanged': self
.cb_stage_changed
,
130 'stageUntracked': self
.cb_stage_untracked
,
131 'stageSelected': self
.cb_stage_selected
,
132 'unstageAll': self
.cb_unstage_all
,
133 'unstageSelected': self
.cb_unstage_selected
,
134 'showDiffstat': self
.cb_show_diffstat
,
135 'browseBranch': self
.cb_browse_current
,
136 'browseOtherBranch': self
.cb_browse_other
,
137 'visualizeCurrent': self
.cb_viz_current
,
138 'visualizeAll': self
.cb_viz_all
,
139 'exportPatches': self
.cb_export_patches
,
140 'cherryPick': self
.cb_cherry_pick
,
144 self
.cb_rescan (model
)
146 # Setup the inotify server
147 self
.__start
_inotify
_thread
(model
)
149 #####################################################################
151 #####################################################################
153 def action_staged (self
, model
):
154 '''This action is called when the model's staged list
155 changes. This is a thin wrapper around update_list_widget.'''
156 list_widget
= self
.view
.stagedList
157 staged
= model
.get_staged()
158 self
.__update
_list
_widget
(list_widget
, staged
, True)
160 def action_unstaged (self
, model
):
161 '''This action is called when the model's unstaged list
162 changes. This is a thin wrapper around update_list_widget.'''
163 list_widget
= self
.view
.unstagedList
164 unstaged
= model
.get_unstaged()
165 self
.__update
_list
_widget
(list_widget
, unstaged
, False)
166 if self
.view
.untrackedCheckBox
.isChecked():
167 untracked
= model
.get_untracked()
168 self
.__update
_list
_widget
(list_widget
, untracked
,
173 #####################################################################
175 #####################################################################
177 def cb_branch_create (self
, model
):
178 view
= GitCreateBranchDialog (self
.view
)
179 controller
= GitCreateBranchController (model
, view
)
181 result
= view
.exec_()
182 if result
== QDialog
.Accepted
:
183 self
.cb_rescan (model
)
185 def cb_branch_delete (self
, model
):
186 dlg
= GitBranchDialog(self
.view
, branches
=cmds
.git_branch())
187 branch
= dlg
.getSelectedBranch()
188 if not branch
: return
189 qtutils
.show_command (self
.view
,
190 cmds
.git_branch(name
=branch
, delete
=True))
193 def cb_browse_current (self
, model
):
194 self
.__browse
_branch
(cmds
.git_current_branch())
196 def cb_browse_other (self
, model
):
197 # Prompt for a branch to browse
198 branches
= (cmds
.git_branch (remote
=False)
199 + cmds
.git_branch (remote
=True))
201 dialog
= GitBranchDialog (self
.view
, branches
=branches
)
203 # Launch the repobrowser
204 self
.__browse
_branch
(dialog
.getSelectedBranch())
206 def cb_checkout_branch (self
, model
):
207 dlg
= GitBranchDialog (self
.view
, cmds
.git_branch())
208 branch
= dlg
.getSelectedBranch()
209 if not branch
: return
210 qtutils
.show_command (self
.view
, cmds
.git_checkout(branch
))
211 self
.cb_rescan (model
)
213 def cb_cherry_pick (self
, model
):
214 '''Starts a cherry-picking session.'''
215 (revs
, summaries
) = cmds
.git_log (all
=True)
216 selection
, idxs
= self
.__select
_commits
(revs
, summaries
)
217 if not selection
: return
219 output
= cmds
.git_cherry_pick (selection
)
220 self
.__show
_command
(output
, model
)
222 def cb_commit (self
, model
):
223 '''Sets up data and calls cmds.commit.'''
225 msg
= model
.get_commitmsg()
227 error_msg
= 'ERROR: No commit message was provided.'
228 self
.__show
_command
(error_msg
)
231 amend
= self
.view
.amendRadio
.isChecked()
232 commit_all
= self
.view
.commitAllCheckBox
.isChecked()
236 files
= model
.get_staged()
238 wlist
= self
.view
.stagedList
239 mlist
= model
.get_staged()
240 files
= qtutils
.get_selection_from_list (wlist
, mlist
)
242 output
= cmds
.git_commit (msg
, amend
, files
)
245 self
.view
.newCommitRadio
.setChecked (True)
246 self
.view
.amendRadio
.setChecked (False)
247 model
.set_commitmsg ('')
248 self
.__show
_command
(output
, model
)
250 def cb_commit_all (self
, model
):
251 '''Sets the commit-all checkbox and runs cb_commit.'''
252 self
.view
.commitAllCheckBox
.setChecked (True)
253 self
.cb_commit (model
)
255 def cb_commit_selected (self
, model
):
256 '''Unsets the commit-all checkbox and runs cb_commit.'''
257 self
.view
.commitAllCheckBox
.setChecked (False)
258 self
.cb_commit (model
)
260 def cb_commit_sha1_selected (self
, browser
, revs
):
261 '''This callback is called when a commit browser's
262 item is selected. This callback puts the current
263 revision sha1 into the commitText field.
264 This callback also puts shows the commit in the
265 browser's commit textedit and copies it into
266 the global clipboard/selection.'''
267 current
= browser
.commitList
.currentRow()
268 item
= browser
.commitList
.item (current
)
269 if not item
.isSelected():
270 browser
.commitText
.setText ('')
271 browser
.revisionLine
.setText ('')
274 # Get the commit's sha1 and put it in the revision line
276 browser
.revisionLine
.setText (sha1
)
277 browser
.revisionLine
.selectAll()
279 # Lookup the info for that sha1 and display it
280 commit_diff
= cmds
.git_show (sha1
)
281 browser
.commitText
.setText (commit_diff
)
283 # Copy the sha1 into the clipboard
284 qtutils
.set_clipboard (sha1
)
287 cursor
= self
.view
.displayText
.textCursor()
288 selection
= cursor
.selection().toPlainText()
289 qtutils
.set_clipboard (selection
)
291 # use *args to handle being called from different signals
292 def cb_diff_staged (self
, model
, *args
):
293 self
.__staged
_diff
_in
_view
= True
294 list_widget
= self
.view
.stagedList
295 row
, selected
= qtutils
.get_selected_row (list_widget
)
298 self
.view
.displayText
.setText ('')
301 filename
= model
.get_staged()[row
]
302 diff
= cmds
.git_diff (filename
, staged
=True)
304 if os
.path
.exists (filename
):
305 pre
= utils
.header ('Staged for commit')
307 pre
= utils
.header ('Staged for removal')
309 self
.view
.displayText
.setText (pre
+ diff
)
311 # use *args to handle being called from different signals
312 def cb_diff_unstaged (self
, model
, *args
):
313 self
.__staged
_diff
_in
_view
= False
314 list_widget
= self
.view
.unstagedList
315 row
, selected
= qtutils
.get_selected_row (list_widget
)
317 self
.view
.displayText
.setText ('')
319 filename
= (model
.get_unstaged() + model
.get_untracked())[row
]
320 if os
.path
.isdir (filename
):
321 pre
= utils
.header ('Untracked directory')
322 cmd
= 'ls -la %s' % utils
.shell_quote (filename
)
323 output
= commands
.getoutput (cmd
)
324 self
.view
.displayText
.setText ( pre
+ output
)
327 if filename
in model
.get_unstaged():
328 diff
= cmds
.git_diff (filename
, staged
=False)
329 msg
= utils
.header ('Modified, unstaged') + diff
332 cmd
= 'file -b %s' % utils
.shell_quote (filename
)
333 file_type
= commands
.getoutput (cmd
)
335 if 'binary' in file_type
or 'data' in file_type
:
336 sq_filename
= utils
.shell_quote (filename
)
337 cmd
= 'hexdump -C %s' % sq_filename
338 contents
= commands
.getoutput (cmd
)
340 file = open (filename
, 'r')
341 contents
= file.read()
344 msg
= (utils
.header ('Untracked file: ' + file_type
)
347 self
.view
.displayText
.setText (msg
)
349 def cb_export_patches (self
, model
):
350 '''Launches the commit browser and exports the selected
353 (revs
, summaries
) = cmds
.git_log ()
354 selection
, idxs
= self
.__select
_commits
(revs
, summaries
)
355 if not selection
: return
357 # now get the selected indices to determine whether
358 # a range of consecutive commits were selected
359 selected_range
= range (idxs
[0], idxs
[-1] + 1)
360 export_range
= len (idxs
) > 1 and idxs
== selected_range
362 output
= cmds
.git_format_patch (selection
, export_range
)
363 self
.__show
_command
(output
)
365 def cb_get_commit_msg (self
, model
):
366 model
.retrieve_latest_commitmsg()
368 def cb_last_window_closed (self
):
369 '''Cleanup the inotify thread if it exists.'''
370 if not self
.inotify_thread
: return
371 if not self
.inotify_thread
.isRunning(): return
372 self
.inotify_thread
.abort
= True
373 self
.inotify_thread
.quit()
374 self
.inotify_thread
.wait()
376 def cb_rebase (self
, model
):
377 dlg
= GitBranchDialog(self
.view
, cmds
.git_branch())
378 dlg
.setWindowTitle ("Select the current branch's new root")
379 branch
= dlg
.getSelectedBranch()
380 if not branch
: return
381 qtutils
.show_command (self
.view
, cmds
.git_rebase (branch
))
383 def cb_rescan (self
, model
, *args
):
384 '''Populates view widgets with results from "git status."'''
386 # Scan for branch changes
387 self
.__set
_branch
_ui
_items
()
389 # This allows us to defer notification until the
390 # we finish processing data
391 model
.set_notify(False)
393 # Reset the staged and unstaged model lists
394 # NOTE: the model's unstaged list is used to
395 # hold both unstaged and untracked files.
400 # Read git status items
403 untracked_items
) = cmds
.git_status()
405 # Gather items to be committed
406 for staged
in staged_items
:
407 if staged
not in model
.get_staged():
408 model
.add_staged (staged
)
410 # Gather unindexed items
411 for unstaged
in unstaged_items
:
412 if unstaged
not in model
.get_unstaged():
413 model
.add_unstaged (unstaged
)
415 # Gather untracked items
416 for untracked
in untracked_items
:
417 if untracked
not in model
.get_untracked():
418 model
.add_untracked (untracked
)
420 # Re-enable notifications and emit changes
421 model
.set_notify(True)
422 model
.notify_observers ('staged', 'unstaged')
424 squash_msg
= os
.path
.join (os
.getcwd(), '.git', 'SQUASH_MSG')
425 if not os
.path
.exists (squash_msg
): return
427 msg
= model
.get_commitmsg()
430 result
= qtutils
.question (self
.view
,
431 'Import Commit Message?',
432 ('A commit message from a '
433 + 'merge-in-progress was found.\n'
434 + 'Do you want to import it?'))
435 if not result
: return
437 file = open (squash_msg
)
441 # Set the new commit message
442 model
.set_commitmsg (msg
)
444 def cb_show_diffstat (self
, model
):
445 '''Show the diffstat from the latest commit.'''
446 self
.__show
_command
(cmds
.git_diff_stat(), rescan
=False)
448 def cb_stage_changed (self
, model
):
449 '''Stage all changed files for commit.'''
450 output
= cmds
.git_add (model
.get_unstaged())
451 self
.__show
_command
(output
, model
)
453 def cb_stage_hunk (self
):
455 list_widget
= self
.view
.unstagedList
456 row
, selected
= qtutils
.get_selected_row (list_widget
)
457 if not selected
: return
459 filename
= model
.get_uncommitted_item (row
)
461 cursor
= self
.view
.displayText
.textCursor()
462 offset
= cursor
.position()
463 offset
-= utils
.HEADER_LENGTH
+ 1
464 if offset
< 0: return
466 selection
= cursor
.selection().toPlainText()
468 num_selected_lines
= selection
.count (os
.linesep
)
469 has_selection
= selection
and nb_selected_lines
> 0
473 print "\nNUM_LINES", num_selected_lines
474 print "SELECTION:\n", selection
476 print "SELECTION:\n", seleciton
478 print 'POSITION:', cursor
.position()
482 def cb_stage_selected (self
, model
):
483 '''Use "git add" to add items to the git index.
484 This is a thin wrapper around __apply_to_list.'''
485 command
= cmds
.git_add_or_remove
486 widget
= self
.view
.unstagedList
487 items
= model
.get_unstaged() + model
.get_untracked()
488 self
.__apply
_to
_list
(command
, model
, widget
, items
)
490 def cb_stage_untracked (self
, model
):
491 '''Stage all untracked files for commit.'''
492 output
= cmds
.git_add (model
.get_untracked())
493 self
.__show
_command
(output
, model
)
495 def cb_unstage_all (self
, model
):
496 '''Use "git reset" to remove all items from the git index.'''
497 output
= cmds
.git_reset (model
.get_staged())
498 self
.__show
_command
(output
, model
)
500 def cb_unstage_selected (self
, model
):
501 '''Use "git reset" to remove items from the git index.
502 This is a thin wrapper around __apply_to_list.'''
504 command
= cmds
.git_reset
505 widget
= self
.view
.stagedList
506 items
= model
.get_staged()
507 self
.__apply
_to
_list
(command
, model
, widget
, items
)
509 def cb_viz_all (self
, model
):
510 '''Visualizes the entire git history using gitk.'''
511 os
.system ('gitk --all &')
513 def cb_viz_current (self
, model
):
514 '''Visualizes the current branch's history using gitk.'''
515 branch
= cmds
.git_current_branch()
516 os
.system ('gitk %s &' % utils
.shell_quote (branch
))
518 #####################################################################
519 # PRIVATE HELPER METHODS
520 #####################################################################
522 def __apply_to_list (self
, command
, model
, widget
, items
):
523 '''This is a helper method that retrieves the current
524 selection list, applies a command to that list,
525 displays a dialog showing the output of that command,
526 and calls cb_rescan to pickup changes.'''
527 apply_items
= qtutils
.get_selection_from_list (widget
, items
)
528 output
= command (apply_items
)
529 self
.__show
_command
(output
, model
)
531 def __browse_branch (self
, branch
):
532 if not branch
: return
533 # Clone the model to allow opening multiple browsers
534 # with different sets of data
535 model
= self
.model
.clone()
536 model
.set_branch (branch
)
537 view
= GitCommitBrowser()
538 controller
= GitRepoBrowserController(model
, view
)
542 def __menu_about_to_show (self
):
543 self
.__stage
_hunk
_action
.setEnabled (not self
.__staged
_diff
_in
_view
)
545 def __menu_event (self
, event
):
547 textedit
= self
.view
.displayText
548 self
.__menu
.exec_ (textedit
.mapToGlobal (event
.pos()))
550 def __menu_setup (self
):
551 if self
.__menu
: return
553 menu
= QMenu (self
.view
)
554 stage
= menu
.addAction ('Stage Hunk', self
.cb_stage_hunk
)
555 copy
= menu
.addAction ('Copy', self
.cb_copy
)
557 self
.connect (menu
, 'aboutToShow()', self
.__menu
_about
_to
_show
)
559 self
.__stage
_hunk
_action
= stage
560 self
.__copy
_action
= copy
564 def __file_to_widget_item (self
, filename
, staged
, untracked
=False):
565 '''Given a filename, return a QListWidgetItem suitable
566 for adding to a QListWidget. "staged" controls whether
567 to use icons for the staged or unstaged list widget.'''
570 icon_file
= utils
.get_staged_icon (filename
)
572 icon_file
= utils
.get_untracked_icon()
574 icon_file
= utils
.get_icon (filename
)
576 return qtutils
.create_listwidget_item (filename
, icon_file
)
578 def __select_commits (self
, revs
, summaries
):
579 '''Use the GitCommitBrowser to select commits from a list.'''
581 msg
= 'ERROR: No commits exist in this branch.'''
582 self.__show_command (output=msg)
585 browser = GitCommitBrowser (self.view)
586 self.connect ( browser.commitList,
587 'itemSelectionChanged()',
588 lambda: self.cb_commit_sha1_selected(
591 for summary in summaries:
592 browser.commitList.addItem (summary)
595 result = browser.exec_()
596 if result != QDialog.Accepted:
599 list_widget = browser.commitList
600 selection = qtutils.get_selection_from_list (list_widget, revs)
601 if not selection: return ([],[])
603 # also return the selected index numbers
604 index_nums = range (len (revs))
605 idxs = qtutils.get_selection_from_list (list_widget, index_nums)
607 return (selection, idxs)
609 def __set_branch_ui_items (self):
610 '''Sets up items that mention the current branch name.'''
611 current_branch = cmds.git_current_branch()
612 menu_text = 'Browse
' + current_branch + ' branch
'
613 self.view.browseBranch.setText (menu_text)
615 status_text = 'Current branch
: ' + current_branch
616 self.view.statusBar().showMessage (status_text)
618 def __start_inotify_thread (self, model):
619 # Do we have inotify? If not, return.
620 # Recommend installing inotify if we're on Linux
.
621 self
.inotify_thread
= None
623 from inotify
import GitNotifier
626 if platform
.system() == 'Linux':
627 msg
= ('ugit could not find python-inotify.'
628 + '\nSupport for inotify is disabled.')
630 plat
= platform
.platform().lower()
631 if 'debian' in plat
or 'ubuntu' in plat
:
632 msg
+= '\n\nHint: sudo apt-get install python-pyinotify'
634 qtutils
.information (self
.view
,
635 'inotify support disabled',
639 self
.inotify_thread
= GitNotifier (os
.getcwd())
640 self
.connect ( self
.inotify_thread
, 'timeForRescan()',
641 lambda: self
.cb_rescan (model
) )
643 # Start the notification thread
644 self
.inotify_thread
.start()
646 def __show_command (self
, output
, model
=None, rescan
=True):
647 '''Shows output and optionally rescans for changes.'''
648 qtutils
.show_command (self
.view
, output
)
649 if rescan
and model
: self
.cb_rescan (model
)
651 def __update_list_widget (self
, list_widget
, items
,
652 staged
, untracked
=False, append
=False):
653 '''A helper method to populate a QListWidget with the
654 contents of modelitems.'''
658 qitem
= self
.__file
_to
_widget
_item
(item
,
660 list_widget
.addItem( qitem
)