3 from PyQt4
import QtGui
4 from PyQt4
.QtGui
import QDialog
5 from PyQt4
.QtGui
import QMessageBox
6 from PyQt4
.QtGui
import QMenu
7 from qobserver
import QObserver
11 from views
import GitCommitBrowser
12 from views
import GitBranchDialog
13 from views
import GitCreateBranchDialog
14 from repobrowsercontroller
import GitRepoBrowserController
15 from createbranchcontroller
import GitCreateBranchController
17 class GitController (QObserver
):
18 '''The controller is a mediator between the model and view.
19 It allows for a clean decoupling between view and model classes.'''
21 def __init__ (self
, model
, view
):
22 QObserver
.__init
__ (self
, model
, view
)
24 # chdir to the root of the git tree. This is critical
25 # to being able to properly use the git porcelain.
26 cdup
= cmds
.git_show_cdup()
27 if cdup
: os
.chdir (cdup
)
29 # The diff-display context menu
31 self
.__staged
_diff
_in
_view
= True
33 # Diff display context menu
34 view
.displayText
.controller
= self
35 view
.displayText
.contextMenuEvent
= self
.__menu
_event
37 # Default to creating a new commit (i.e. not an amend commit)
38 view
.newCommitRadio
.setChecked (True)
40 # Binds a specific model attribute to a view widget,
42 self
.model_to_view (model
, 'commitmsg', 'commitText')
44 # When a model attribute changes, this runs a specific action
45 self
.add_actions (model
, 'staged', self
.action_staged
)
46 self
.add_actions (model
, 'unstaged', self
.action_unstaged
)
47 self
.add_actions (model
, 'untracked', self
.action_unstaged
)
49 # Routes signals for multiple widgets to our callbacks
51 self
.add_signals ('textChanged()', view
.commitText
)
52 self
.add_signals ('stateChanged(int)', view
.untrackedCheckBox
)
54 self
.add_signals ('released()',
60 self
.add_signals ('triggered()',
68 view
.setCommitMessage
,
76 view
.browseOtherBranch
,
78 view
.visualizeCurrent
,
82 self
.add_signals ('itemClicked (QListWidgetItem *)',
83 view
.stagedList
, view
.unstagedList
,)
85 self
.add_signals ('itemSelectionChanged()',
86 view
.stagedList
, view
.unstagedList
,)
89 self
.connect ( qtutils
.qapp(),
91 self
.cb_last_window_closed
)
93 # Handle double-clicks in the staged/unstaged lists.
94 # These are vanilla signal/slots since the qobserver
95 # signal routing is already handling these lists' signals.
96 self
.connect ( view
.unstagedList
,
97 'itemDoubleClicked(QListWidgetItem*)',
98 lambda (x
): self
.cb_stage_selected (model
) )
100 self
.connect ( view
.stagedList
,
101 'itemDoubleClicked(QListWidgetItem*)',
102 lambda (x
): self
.cb_unstage_selected (model
) )
104 # These callbacks are called in response to the signals
105 # defined above. One property of the QObserver callback
106 # mechanism is that the model is passed in as the first
107 # argument to the callback. This allows for a single
108 # controller to manage multiple models, though this
109 # isn't used at the moment.
110 self
.add_callbacks (model
, {
112 'stageButton': self
.cb_stage_selected
,
113 'signOffButton': lambda(m
): m
.add_signoff(),
114 'commitButton': self
.cb_commit
,
116 'untrackedCheckBox': self
.cb_rescan
,
118 'stagedList': self
.cb_diff_staged
,
119 'unstagedList': self
.cb_diff_unstaged
,
121 'rescan': self
.cb_rescan
,
122 'createBranch': self
.cb_branch_create
,
123 'deleteBranch': self
.cb_branch_delete
,
124 'checkoutBranch': self
.cb_checkout_branch
,
125 'rebaseBranch': self
.cb_rebase
,
126 'commitAll': self
.cb_commit_all
,
127 'commitSelected': self
.cb_commit_selected
,
129 lambda(m
): m
.set_latest_commitmsg(),
130 'stageChanged': self
.cb_stage_changed
,
131 'stageUntracked': self
.cb_stage_untracked
,
132 'stageSelected': self
.cb_stage_selected
,
133 'unstageAll': self
.cb_unstage_all
,
134 'unstageSelected': self
.cb_unstage_selected
,
135 'showDiffstat': self
.cb_show_diffstat
,
136 'browseBranch': self
.cb_browse_current
,
137 'browseOtherBranch': self
.cb_browse_other
,
138 'visualizeCurrent': self
.cb_viz_current
,
139 'visualizeAll': self
.cb_viz_all
,
140 'exportPatches': self
.cb_export_patches
,
141 'cherryPick': self
.cb_cherry_pick
,
145 self
.cb_rescan (model
)
147 # Setup the inotify server
148 self
.__start
_inotify
_thread
(model
)
150 #####################################################################
152 #####################################################################
154 def action_staged (self
, model
):
155 '''This action is called when the model's staged list
156 changes. This is a thin wrapper around update_list_widget.'''
157 list_widget
= self
.view
.stagedList
158 staged
= model
.get_staged()
159 self
.__update
_list
_widget
(list_widget
, staged
, True)
161 def action_unstaged (self
, model
):
162 '''This action is called when the model's unstaged list
163 changes. This is a thin wrapper around update_list_widget.'''
164 list_widget
= self
.view
.unstagedList
165 unstaged
= model
.get_unstaged()
166 self
.__update
_list
_widget
(list_widget
, unstaged
, False)
167 if self
.view
.untrackedCheckBox
.isChecked():
168 untracked
= model
.get_untracked()
169 self
.__update
_list
_widget
(list_widget
, untracked
,
174 #####################################################################
176 #####################################################################
178 def cb_branch_create (self
, model
):
179 view
= GitCreateBranchDialog (self
.view
)
180 controller
= GitCreateBranchController (model
, view
)
182 result
= view
.exec_()
183 if result
== QDialog
.Accepted
:
184 self
.cb_rescan (model
)
186 def cb_branch_delete (self
, model
):
187 dlg
= GitBranchDialog(self
.view
, branches
=cmds
.git_branch())
188 branch
= dlg
.getSelectedBranch()
189 if not branch
: return
190 qtutils
.show_command (self
.view
,
191 cmds
.git_branch(name
=branch
, delete
=True))
194 def cb_browse_current (self
, model
):
195 self
.__browse
_branch
(cmds
.git_current_branch())
197 def cb_browse_other (self
, model
):
198 # Prompt for a branch to browse
199 branches
= (cmds
.git_branch (remote
=False)
200 + cmds
.git_branch (remote
=True))
202 dialog
= GitBranchDialog (self
.view
, branches
=branches
)
204 # Launch the repobrowser
205 self
.__browse
_branch
(dialog
.getSelectedBranch())
207 def cb_checkout_branch (self
, model
):
208 dlg
= GitBranchDialog (self
.view
, cmds
.git_branch())
209 branch
= dlg
.getSelectedBranch()
210 if not branch
: return
211 qtutils
.show_command (self
.view
, cmds
.git_checkout(branch
))
212 self
.cb_rescan (model
)
214 def cb_cherry_pick (self
, model
):
215 '''Starts a cherry-picking session.'''
216 (revs
, summaries
) = cmds
.git_log (all
=True)
217 selection
, idxs
= self
.__select
_commits
(revs
, summaries
)
218 if not selection
: return
220 output
= cmds
.git_cherry_pick (selection
)
221 self
.__show
_command
(output
, model
)
223 def cb_commit (self
, model
):
224 '''Sets up data and calls cmds.commit.'''
226 msg
= model
.get_commitmsg()
228 error_msg
= 'ERROR: No commit message was provided.'
229 self
.__show
_command
(error_msg
)
232 amend
= self
.view
.amendRadio
.isChecked()
233 commit_all
= self
.view
.commitAllCheckBox
.isChecked()
237 files
= model
.get_staged()
239 wlist
= self
.view
.stagedList
240 mlist
= model
.get_staged()
241 files
= qtutils
.get_selection_from_list (wlist
, mlist
)
243 output
= cmds
.git_commit (msg
, amend
, files
)
246 self
.view
.newCommitRadio
.setChecked (True)
247 self
.view
.amendRadio
.setChecked (False)
248 model
.set_commitmsg ('')
249 self
.__show
_command
(output
, model
)
251 def cb_commit_all (self
, model
):
252 '''Sets the commit-all checkbox and runs cb_commit.'''
253 self
.view
.commitAllCheckBox
.setChecked (True)
254 self
.cb_commit (model
)
256 def cb_commit_selected (self
, model
):
257 '''Unsets the commit-all checkbox and runs cb_commit.'''
258 self
.view
.commitAllCheckBox
.setChecked (False)
259 self
.cb_commit (model
)
261 def cb_commit_sha1_selected (self
, browser
, revs
):
262 '''This callback is called when a commit browser's
263 item is selected. This callback puts the current
264 revision sha1 into the commitText field.
265 This callback also puts shows the commit in the
266 browser's commit textedit and copies it into
267 the global clipboard/selection.'''
268 current
= browser
.commitList
.currentRow()
269 item
= browser
.commitList
.item (current
)
270 if not item
.isSelected():
271 browser
.commitText
.setText ('')
272 browser
.revisionLine
.setText ('')
275 # Get the commit's sha1 and put it in the revision line
277 browser
.revisionLine
.setText (sha1
)
278 browser
.revisionLine
.selectAll()
280 # Lookup the info for that sha1 and display it
281 commit_diff
= cmds
.git_show (sha1
)
282 browser
.commitText
.setText (commit_diff
)
284 # Copy the sha1 into the clipboard
285 qtutils
.set_clipboard (sha1
)
288 cursor
= self
.view
.displayText
.textCursor()
289 selection
= cursor
.selection().toPlainText()
290 qtutils
.set_clipboard (selection
)
292 # use *args to handle being called from different signals
293 def cb_diff_staged (self
, model
, *args
):
294 self
.__staged
_diff
_in
_view
= True
295 list_widget
= self
.view
.stagedList
296 row
, selected
= qtutils
.get_selected_row (list_widget
)
299 self
.view
.displayText
.setText ('')
302 filename
= model
.get_staged()[row
]
303 diff
= cmds
.git_diff (filename
, staged
=True)
305 if os
.path
.exists (filename
):
306 pre
= utils
.header ('Staged for commit')
308 pre
= utils
.header ('Staged for removal')
310 self
.view
.displayText
.setText (pre
+ diff
)
312 # use *args to handle being called from different signals
313 def cb_diff_unstaged (self
, model
, *args
):
314 self
.__staged
_diff
_in
_view
= False
315 list_widget
= self
.view
.unstagedList
316 row
, selected
= qtutils
.get_selected_row (list_widget
)
318 self
.view
.displayText
.setText ('')
320 filename
= (model
.get_unstaged() + model
.get_untracked())[row
]
321 if os
.path
.isdir (filename
):
322 pre
= utils
.header ('Untracked directory')
323 cmd
= 'ls -la %s' % utils
.shell_quote (filename
)
324 output
= commands
.getoutput (cmd
)
325 self
.view
.displayText
.setText ( pre
+ output
)
328 if filename
in model
.get_unstaged():
329 diff
= cmds
.git_diff (filename
, staged
=False)
330 msg
= utils
.header ('Modified, unstaged') + diff
333 cmd
= 'file -b %s' % utils
.shell_quote (filename
)
334 file_type
= commands
.getoutput (cmd
)
336 if 'binary' in file_type
or 'data' in file_type
:
337 sq_filename
= utils
.shell_quote (filename
)
338 cmd
= 'hexdump -C %s' % sq_filename
339 contents
= commands
.getoutput (cmd
)
341 file = open (filename
, 'r')
342 contents
= file.read()
345 msg
= (utils
.header ('Untracked file: ' + file_type
)
348 self
.view
.displayText
.setText (msg
)
350 def cb_export_patches (self
, model
):
351 '''Launches the commit browser and exports the selected
354 (revs
, summaries
) = cmds
.git_log ()
355 selection
, idxs
= self
.__select
_commits
(revs
, summaries
)
356 if not selection
: return
358 # now get the selected indices to determine whether
359 # a range of consecutive commits were selected
360 selected_range
= range (idxs
[0], idxs
[-1] + 1)
361 export_range
= len (idxs
) > 1 and idxs
== selected_range
363 output
= cmds
.git_format_patch (selection
, export_range
)
364 self
.__show
_command
(output
)
366 def cb_get_commit_msg (self
, model
):
367 model
.retrieve_latest_commitmsg()
369 def cb_last_window_closed (self
):
370 '''Cleanup the inotify thread if it exists.'''
371 if not self
.inotify_thread
: return
372 if not self
.inotify_thread
.isRunning(): return
373 self
.inotify_thread
.abort
= True
374 self
.inotify_thread
.quit()
375 self
.inotify_thread
.wait()
377 def cb_rebase (self
, model
):
378 dlg
= GitBranchDialog(self
.view
, cmds
.git_branch())
379 dlg
.setWindowTitle ("Select the current branch's new root")
380 branch
= dlg
.getSelectedBranch()
381 if not branch
: return
382 qtutils
.show_command (self
.view
, cmds
.git_rebase (branch
))
384 def cb_rescan (self
, model
, *args
):
385 '''Populates view widgets with results from "git status."'''
387 # Scan for branch changes
388 self
.__set
_branch
_ui
_items
()
390 # Rescan for repo updates
391 model
.update_status()
393 if not model
.has_squash_msg(): return
395 if model
.get_commitmsg():
396 if not qtutils
.question (self
.view
,
397 'Import Commit Message?',
398 ('A commit message from a '
399 + 'merge-in-progress was found.\n'
400 + 'Do you want to import it?')):
403 # Set the new commit message
404 model
.set_commitmsg (model
.get_squash_msg())
406 def cb_show_diffstat (self
, model
):
407 '''Show the diffstat from the latest commit.'''
408 self
.__show
_command
(cmds
.git_diff_stat(), rescan
=False)
410 def cb_stage_changed (self
, model
):
411 '''Stage all changed files for commit.'''
412 output
= cmds
.git_add (model
.get_unstaged())
413 self
.__show
_command
(output
, model
)
415 def cb_stage_hunk (self
):
417 list_widget
= self
.view
.unstagedList
418 row
, selected
= qtutils
.get_selected_row (list_widget
)
419 if not selected
: return
422 filename
= model
.get_uncommitted_item (row
)
424 if not os
.path
.exists (filename
): return
425 if os
.path
.isdir (filename
): return
427 cursor
= self
.view
.displayText
.textCursor()
428 offset
= cursor
.position()
429 offset
-= utils
.HEADER_LENGTH
+ 1
430 if offset
< 0: return
432 selection
= cursor
.selection().toPlainText()
434 num_selected_lines
= selection
.count (os
.linesep
)
435 has_selection
= selection
and num_selected_lines
> 0
437 header
, diff
= cmds
.git_diff (filename
,
438 with_diff_header
=True,
441 parser
= utils
.DiffParser (diff
)
444 start
= diff
.index (selection
)
445 end
= start
+ len (selection
)
446 diffs
= parser
.get_diffs_for_range (start
, end
)
448 diffs
= [ parser
.get_diff_for_offset (offset
) ]
453 tmpfile
= utils
.get_tmp_filename()
454 file = open (tmpfile
, 'w')
455 file.write (header
+ os
.linesep
+ diff
+ os
.linesep
)
457 model
.apply_diff (tmpfile
)
460 self
.cb_rescan (model
)
462 def cb_stage_selected (self
, model
):
463 '''Use "git add" to add items to the git index.
464 This is a thin wrapper around __apply_to_list.'''
465 command
= cmds
.git_add_or_remove
466 widget
= self
.view
.unstagedList
467 items
= model
.get_unstaged() + model
.get_untracked()
468 self
.__apply
_to
_list
(command
, model
, widget
, items
)
470 def cb_stage_untracked (self
, model
):
471 '''Stage all untracked files for commit.'''
472 output
= cmds
.git_add (model
.get_untracked())
473 self
.__show
_command
(output
, model
)
475 def cb_unstage_all (self
, model
):
476 '''Use "git reset" to remove all items from the git index.'''
477 output
= cmds
.git_reset (model
.get_staged())
478 self
.__show
_command
(output
, model
)
480 def cb_unstage_selected (self
, model
):
481 '''Use "git reset" to remove items from the git index.
482 This is a thin wrapper around __apply_to_list.'''
484 command
= cmds
.git_reset
485 widget
= self
.view
.stagedList
486 items
= model
.get_staged()
487 self
.__apply
_to
_list
(command
, model
, widget
, items
)
489 def cb_viz_all (self
, model
):
490 '''Visualizes the entire git history using gitk.'''
491 os
.system ('gitk --all &')
493 def cb_viz_current (self
, model
):
494 '''Visualizes the current branch's history using gitk.'''
495 branch
= cmds
.git_current_branch()
496 os
.system ('gitk %s &' % utils
.shell_quote (branch
))
498 #####################################################################
499 # PRIVATE HELPER METHODS
500 #####################################################################
502 def __apply_to_list (self
, command
, model
, widget
, items
):
503 '''This is a helper method that retrieves the current
504 selection list, applies a command to that list,
505 displays a dialog showing the output of that command,
506 and calls cb_rescan to pickup changes.'''
507 apply_items
= qtutils
.get_selection_from_list (widget
, items
)
508 output
= command (apply_items
)
509 self
.__show
_command
(output
, model
)
511 def __browse_branch (self
, branch
):
512 if not branch
: return
513 # Clone the model to allow opening multiple browsers
514 # with different sets of data
515 model
= self
.model
.clone()
516 model
.set_branch (branch
)
517 view
= GitCommitBrowser()
518 controller
= GitRepoBrowserController(model
, view
)
522 def __menu_about_to_show (self
):
523 cursor
= self
.view
.displayText
.textCursor()
524 allow_hunk_staging
= ( not self
.__staged
_diff
_in
_view
525 and cursor
.position() > utils
.HEADER_LENGTH
)
527 self
.__stage
_hunk
_action
.setEnabled (allow_hunk_staging
)
529 def __menu_event (self
, event
):
531 textedit
= self
.view
.displayText
532 self
.__menu
.exec_ (textedit
.mapToGlobal (event
.pos()))
534 def __menu_setup (self
):
535 if self
.__menu
: return
537 menu
= QMenu (self
.view
)
538 stage
= menu
.addAction ('Stage Hunk(s)', self
.cb_stage_hunk
)
539 copy
= menu
.addAction ('Copy', self
.cb_copy
)
541 self
.connect (menu
, 'aboutToShow()', self
.__menu
_about
_to
_show
)
543 self
.__stage
_hunk
_action
= stage
544 self
.__copy
_action
= copy
548 def __file_to_widget_item (self
, filename
, staged
, untracked
=False):
549 '''Given a filename, return a QListWidgetItem suitable
550 for adding to a QListWidget. "staged" controls whether
551 to use icons for the staged or unstaged list widget.'''
554 icon_file
= utils
.get_staged_icon (filename
)
556 icon_file
= utils
.get_untracked_icon()
558 icon_file
= utils
.get_icon (filename
)
560 return qtutils
.create_listwidget_item (filename
, icon_file
)
562 def __select_commits (self
, revs
, summaries
):
563 '''Use the GitCommitBrowser to select commits from a list.'''
565 msg
= 'ERROR: No commits exist in this branch.'''
566 self.__show_command (output=msg)
569 browser = GitCommitBrowser (self.view)
570 self.connect ( browser.commitList,
571 'itemSelectionChanged()',
572 lambda: self.cb_commit_sha1_selected(
575 for summary in summaries:
576 browser.commitList.addItem (summary)
579 result = browser.exec_()
580 if result != QDialog.Accepted:
583 list_widget = browser.commitList
584 selection = qtutils.get_selection_from_list (list_widget, revs)
585 if not selection: return ([],[])
587 # also return the selected index numbers
588 index_nums = range (len (revs))
589 idxs = qtutils.get_selection_from_list (list_widget, index_nums)
591 return (selection, idxs)
593 def __set_branch_ui_items (self):
594 '''Sets up items that mention the current branch name.'''
595 current_branch = cmds.git_current_branch()
596 menu_text = 'Browse
' + current_branch + ' branch
'
597 self.view.browseBranch.setText (menu_text)
599 status_text = 'Current branch
: ' + current_branch
600 self.view.statusBar().showMessage (status_text)
602 def __start_inotify_thread (self, model):
603 # Do we have inotify? If not, return.
604 # Recommend installing inotify if we're on Linux
.
605 self
.inotify_thread
= None
607 from inotify
import GitNotifier
610 if platform
.system() == 'Linux':
611 msg
= ('ugit could not find python-inotify.'
612 + '\nSupport for inotify is disabled.')
614 plat
= platform
.platform().lower()
615 if 'debian' in plat
or 'ubuntu' in plat
:
616 msg
+= '\n\nHint: sudo apt-get install python-pyinotify'
618 qtutils
.information (self
.view
,
619 'inotify support disabled',
623 self
.inotify_thread
= GitNotifier (os
.getcwd())
624 self
.connect ( self
.inotify_thread
, 'timeForRescan()',
625 lambda: self
.cb_rescan (model
) )
627 # Start the notification thread
628 self
.inotify_thread
.start()
630 def __show_command (self
, output
, model
=None, rescan
=True):
631 '''Shows output and optionally rescans for changes.'''
632 qtutils
.show_command (self
.view
, output
)
633 if rescan
and model
: self
.cb_rescan (model
)
635 def __update_list_widget (self
, list_widget
, items
,
636 staged
, untracked
=False, append
=False):
637 '''A helper method to populate a QListWidget with the
638 contents of modelitems.'''
642 qitem
= self
.__file
_to
_widget
_item
(item
,
644 list_widget
.addItem( qitem
)