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 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,
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
36 self
.add_signals ('textChanged()', view
.commitText
)
37 self
.add_signals ('stateChanged(int)', view
.untrackedCheckBox
)
39 self
.add_signals ('released()',
45 self
.add_signals ('triggered()',
53 view
.setCommitMessage
,
61 view
.browseOtherBranch
,
63 view
.visualizeCurrent
,
67 self
.add_signals ('itemSelectionChanged()',
72 self
.connect ( qtutils
.qapp(),
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
, {
95 'stageButton': self
.cb_stage_selected
,
96 'signOffButton': lambda(m
): m
.add_signoff(),
97 'commitButton': self
.cb_commit
,
99 'untrackedCheckBox': self
.cb_rescan
,
101 'stagedList': self
.cb_diff_staged
,
102 'unstagedList': self
.cb_diff_unstaged
,
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
,
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
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)
141 self
.cb_rescan (model
)
143 # Setup the inotify server
144 self
.__start
_inotify
_thread
(model
)
146 #####################################################################
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
,
170 #####################################################################
172 #####################################################################
174 def cb_branch_create (self
, ugit_model
):
175 model
= GitCreateBranchModel()
176 view
= GitCreateBranchDialog (self
.view
)
177 controller
= GitCreateBranchController (model
, view
)
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()
225 error_msg
= 'ERROR: No commit message was provided.'
226 self
.__show
_command
(error_msg
)
229 amend
= self
.view
.amendRadio
.isChecked()
230 commit_all
= self
.view
.commitAllCheckBox
.isChecked()
234 files
= model
.get_staged()
236 wlist
= self
.view
.stagedList
237 mlist
= model
.get_staged()
238 files
= qtutils
.get_selection_from_list (wlist
, mlist
)
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 ('')
270 # Get the commit's sha1 and put it in the revision line
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
)
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
)
290 self
.view
.displayText
.setText ('')
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')
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
)
307 self
.view
.displayText
.setText ('')
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
)
317 if filename
in model
.get_unstaged():
318 diff
= cmds
.git_diff (filename
, staged
=False)
319 msg
= utils
.header ('Modified, unstaged') + diff
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
)
330 file = open (filename
, 'r')
331 contents
= file.read()
334 msg
= (utils
.header ('Untracked file: ' + file_type
)
337 self
.view
.displayText
.setText (msg
)
339 def cb_export_patches (self
, model
):
340 '''Launches the commit browser and exports the selected
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.
390 # Read git status 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()
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
)
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
):
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
)
503 def __menu_about_to_show (self
):
504 self
.__stage
_hunk
_action
.setEnabled (True)
506 def __menu_event (self
, event
):
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
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.'''
531 icon_file
= utils
.get_staged_icon (filename
)
533 icon_file
= utils
.get_untracked_icon()
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.'''
542 msg
= 'ERROR: No commits exist in this branch.'''
543 self.__show_command (output=msg)
546 browser = GitCommitBrowser (self.view)
547 self.connect ( browser.commitList,
548 'itemSelectionChanged()',
549 lambda: self.cb_commit_sha1_selected(
552 for summary in summaries:
553 browser.commitList.addItem (summary)
556 result = browser.exec_()
557 if result != QDialog.Accepted:
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
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.'''
608 qitem = self.__file_to_widget_item (item,
610 list_widget.addItem( qitem )