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
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 # chdir to the root of the git tree. This is critical
26 # to being able to properly use the git porcelain.
27 cdup
= cmds
.git_show_cdup()
28 if cdup
: os
.chdir(cdup
)
30 # The diff-display context menu
32 self
.__staged
_diff
_in
_view
= True
34 # Diff display context menu
35 view
.displayText
.controller
= self
36 view
.displayText
.contextMenuEvent
= self
.__menu
_event
38 # Default to creating a new commit(i.e. not an amend commit)
39 view
.newCommitRadio
.setChecked(True)
41 # Binds a specific model attribute to a view widget,
43 self
.model_to_view(model
, 'commitmsg', 'commitText')
45 # When a model attribute changes, this runs a specific action
46 self
.add_actions(model
, 'staged', self
.action_staged
)
47 self
.add_actions(model
, 'unstaged', self
.action_unstaged
)
48 self
.add_actions(model
, 'untracked', self
.action_unstaged
)
50 # Routes signals for multiple widgets to our callbacks
52 self
.add_signals('textChanged()', view
.commitText
)
53 self
.add_signals('stateChanged(int)', view
.untrackedCheckBox
)
55 self
.add_signals('released()',
61 self
.add_signals('triggered()',
69 view
.setCommitMessage
,
77 view
.browseOtherBranch
,
79 view
.visualizeCurrent
,
84 self
.add_signals('itemClicked(QListWidgetItem *)',
85 view
.stagedList
, view
.unstagedList
,)
87 self
.add_signals('itemSelectionChanged()',
88 view
.stagedList
, view
.unstagedList
,)
91 self
.connect(qtutils
.qapp(),
93 self
.last_window_closed
)
95 # These callbacks are called in response to the signals
96 # defined above. One property of the QObserver callback
97 # mechanism is that the model is passed in as the first
98 # argument to the callback. This allows for a single
99 # controller to manage multiple models, though this
100 # isn't used at the moment.
101 self
.add_callbacks(model
, {
102 # Actions that delegate directly to the model
103 'signOffButton': model
.add_signoff
,
104 'setCommitMessage': model
.get_prev_commitmsg
,
106 'stageButton': self
.stage_selected
,
107 'commitButton': self
.commit
,
109 'stagedList': self
.diff_staged
,
110 'unstagedList': self
.diff_unstaged
,
112 'rescan': self
.rescan
,
113 'untrackedCheckBox': self
.rescan
,
114 'createBranch': self
.branch_create
,
115 'deleteBranch': self
.branch_delete
,
116 'checkoutBranch': self
.checkout_branch
,
117 'rebaseBranch': self
.rebase
,
118 'commitAll': self
.commit_all
,
119 'commitSelected': self
.commit_selected
,
120 'stageChanged': self
.stage_changed
,
121 'stageUntracked': self
.stage_untracked
,
122 'stageSelected': self
.stage_selected
,
123 'unstageAll': self
.unstage_all
,
124 'unstageSelected': self
.unstage_selected
,
125 'showDiffstat': self
.show_diffstat
,
126 'browseBranch': self
.browse_current
,
127 'browseOtherBranch': self
.browse_other
,
128 'visualizeCurrent': self
.viz_current
,
129 'visualizeAll': self
.viz_all
,
130 'exportPatches': self
.export_patches
,
131 'cherryPick': self
.cherry_pick
,
132 'loadCommitMsg': self
.load_commitmsg
,
135 # Handle double-clicks in the staged/unstaged lists.
136 # These are vanilla signal/slots since the qobserver
137 # signal routing is already handling these lists' signals.
138 self
.connect(view
.unstagedList
,
139 'itemDoubleClicked(QListWidgetItem*)',
142 self
.connect(view
.stagedList
,
143 'itemDoubleClicked(QListWidgetItem*)',
144 self
.unstage_selected
)
149 # Setup the inotify server
150 self
.__start
_inotify
_thread
()
152 #####################################################################
155 def action_staged(self
,*rest
):
156 '''This action is called when the model's staged list
157 changes. This is a thin wrapper around update_list_widget.'''
158 list_widget
= self
.view
.stagedList
159 staged
= self
.model
.get_staged()
160 self
.__update
_list
_widget
(list_widget
, staged
, True)
162 def action_unstaged(self
,*rest
):
163 '''This action is called when the model's unstaged list
164 changes. This is a thin wrapper around update_list_widget.'''
165 list_widget
= self
.view
.unstagedList
166 unstaged
= self
.model
.get_unstaged()
167 self
.__update
_list
_widget
(list_widget
, unstaged
, False)
169 if self
.view
.untrackedCheckBox
.isChecked():
170 untracked
= self
.model
.get_untracked()
171 self
.__update
_list
_widget
(list_widget
, untracked
,
176 #####################################################################
179 def branch_create(self
,*rest
):
180 view
= GitCreateBranchDialog(self
.view
)
181 controller
= GitCreateBranchController(self
.model
, view
)
183 result
= view
.exec_()
184 if result
== QDialog
.Accepted
:
187 def branch_delete(self
,*rest
):
188 dlg
= GitBranchDialog(self
.view
, branches
=cmds
.git_branch())
189 branch
= dlg
.getSelectedBranch()
190 if not branch
: return
191 qtutils
.show_command(self
.view
,
192 cmds
.git_branch(name
=branch
, delete
=True))
194 def browse_current(self
,*rest
):
195 self
.__browse
_branch
(cmds
.git_current_branch())
197 def browse_other(self
,*rest
):
198 # Prompt for a branch to browse
199 branches
= self
.model
.all_branches()
200 dialog
= GitBranchDialog(self
.view
, branches
=branches
)
202 # Launch the repobrowser
203 self
.__browse
_branch
(dialog
.getSelectedBranch())
205 def checkout_branch(self
,*rest
):
206 dlg
= GitBranchDialog(self
.view
, cmds
.git_branch())
207 branch
= dlg
.getSelectedBranch()
208 if not branch
: return
209 qtutils
.show_command(self
.view
, cmds
.git_checkout(branch
))
212 def cherry_pick(self
,*rest
):
213 '''Starts a cherry-picking session.'''
214 (revs
, summaries
) = cmds
.git_log(all
=True)
215 selection
, idxs
= self
.__select
_commits
(revs
, summaries
)
216 if not selection
: return
218 output
= cmds
.git_cherry_pick(selection
)
219 self
.__show
_command
(output
)
221 def commit(self
, *rest
):
222 '''Sets up data and calls cmds.commit.'''
223 msg
= self
.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
= self
.model
.get_staged()
236 wlist
= self
.view
.stagedList
237 mlist
= self
.model
.get_staged()
238 files
= qtutils
.get_selection_from_list(wlist
, mlist
)
240 output
= cmds
.git_commit(msg
, amend
, files
)
243 self
.view
.newCommitRadio
.setChecked(True)
244 self
.view
.amendRadio
.setChecked(False)
245 self
.model
.set_commitmsg('')
246 self
.__show
_command
(output
)
248 def commit_all(self
,*rest
):
249 '''Sets the commit-all checkbox and runs commit.'''
250 self
.view
.commitAllCheckBox
.setChecked(True)
253 def commit_selected(self
,*rest
):
254 '''Unsets the commit-all checkbox and runs commit.'''
255 self
.view
.commitAllCheckBox
.setChecked(False)
258 def commit_sha1_selected(self
, browser
, revs
):
259 '''This callback is called when a commit browser's
260 item is selected. This callback puts the current
261 revision sha1 into the commitText field.
262 This callback also puts shows the commit in the
263 browser's commit textedit and copies it into
264 the global clipboard/selection.'''
265 current
= browser
.commitList
.currentRow()
266 item
= browser
.commitList
.item(current
)
267 if not item
.isSelected():
268 browser
.commitText
.setText('')
269 browser
.revisionLine
.setText('')
272 # Get the commit's sha1 and put it in the revision line
274 browser
.revisionLine
.setText(sha1
)
275 browser
.revisionLine
.selectAll()
277 # Lookup the info for that sha1 and display it
278 commit_diff
= cmds
.git_show(sha1
)
279 browser
.commitText
.setText(commit_diff
)
281 # Copy the sha1 into the clipboard
282 qtutils
.set_clipboard(sha1
)
285 cursor
= self
.view
.displayText
.textCursor()
286 selection
= cursor
.selection().toPlainText()
287 qtutils
.set_clipboard(selection
)
289 # use *args to handle being called from different signals
290 def diff_staged(self
, *rest
):
291 self
.__staged
_diff
_in
_view
= True
292 list_widget
= self
.view
.stagedList
293 row
, selected
= qtutils
.get_selected_row(list_widget
)
296 self
.view
.displayText
.setText('')
299 filename
= self
.model
.get_staged()[row
]
300 diff
= cmds
.git_diff(filename
, staged
=True)
302 if os
.path
.exists(filename
):
303 pre
= utils
.header('Staged for commit')
305 pre
= utils
.header('Staged for removal')
307 self
.view
.displayText
.setText(pre
+ diff
)
309 # use *args to handle being called from different signals
310 def diff_unstaged(self
,*rest
):
311 self
.__staged
_diff
_in
_view
= False
312 list_widget
= self
.view
.unstagedList
313 row
, selected
= qtutils
.get_selected_row(list_widget
)
315 self
.view
.displayText
.setText('')
317 filename
=(self
.model
.get_unstaged()
318 + self
.model
.get_untracked())[row
]
319 if os
.path
.isdir(filename
):
320 pre
= utils
.header('Untracked directory')
321 cmd
= 'ls -la %s' % utils
.shell_quote(filename
)
322 output
= commands
.getoutput(cmd
)
323 self
.view
.displayText
.setText(pre
+ output
)
326 if filename
in self
.model
.get_unstaged():
327 diff
= cmds
.git_diff(filename
, staged
=False)
328 msg
= utils
.header('Modified, unstaged') + diff
331 cmd
= 'file -b %s' % utils
.shell_quote(filename
)
332 file_type
= commands
.getoutput(cmd
)
334 if 'binary' in file_type
or 'data' in file_type
:
335 sq_filename
= utils
.shell_quote(filename
)
336 cmd
= 'hexdump -C %s' % sq_filename
337 contents
= commands
.getoutput(cmd
)
339 file = open(filename
, 'r')
340 contents
= file.read()
343 msg
=(utils
.header('Untracked file: ' + file_type
)
346 self
.view
.displayText
.setText(msg
)
348 def export_patches(self
,*rest
):
349 '''Launches the commit browser and exports the selected
352 (revs
, summaries
) = cmds
.git_log()
353 selection
, idxs
= self
.__select
_commits
(revs
, summaries
)
354 if not selection
: return
356 # now get the selected indices to determine whether
357 # a range of consecutive commits were selected
358 selected_range
= range(idxs
[0], idxs
[-1] + 1)
359 export_range
= len(idxs
) > 1 and idxs
== selected_range
361 output
= cmds
.git_format_patch(selection
, export_range
)
362 self
.__show
_command
(output
)
364 def get_commit_msg(self
,*rest
):
365 self
.model
.retrieve_latest_commitmsg()
367 def last_window_closed(self
):
368 '''Cleanup the inotify thread if it exists.'''
369 if not self
.inotify_thread
: return
370 if not self
.inotify_thread
.isRunning(): return
371 self
.inotify_thread
.abort
= True
372 self
.inotify_thread
.quit()
373 self
.inotify_thread
.wait()
375 def load_commitmsg(self
,*args
):
376 file = qtutils
.open_dialog(self
.view
,
377 'Load Commit Message...',
381 defaults
.DIRECTORY
= os
.path
.dirname(file)
382 slushy
= utils
.slurp(file)
383 self
.model
.set_commitmsg(slushy
)
386 def rebase(self
,*rest
):
387 dlg
= GitBranchDialog(self
.view
, cmds
.git_branch())
388 dlg
.setWindowTitle("Select the current branch's new root")
389 branch
= dlg
.getSelectedBranch()
390 if not branch
: return
391 qtutils
.show_command(self
.view
, cmds
.git_rebase(branch
))
393 def rescan(self
, *args
):
394 '''Populates view widgets with results from "git status."'''
395 # Scan for branch changes
396 self
.__set
_branch
_ui
_items
()
398 # Rescan for repo updates
399 self
.model
.update_status()
401 if not self
.model
.has_squash_msg(): return
403 if self
.model
.get_commitmsg():
404 if not qtutils
.question(self
.view
,
405 'Import Commit Message?',
406 ('A commit message from a '
407 + 'merge-in-progress was found.\n'
408 + 'Do you want to import it?')):
411 # Set the new commit message
412 self
.model
.set_commitmsg(self
.model
.get_squash_msg())
414 def show_diffstat(self
,*rest
):
415 '''Show the diffstat from the latest commit.'''
416 self
.__show
_command
(cmds
.git_diff_stat(), rescan
=False)
418 def stage_changed(self
,*rest
):
419 '''Stage all changed files for commit.'''
420 output
= cmds
.git_add(self
.model
.get_unstaged())
421 self
.__show
_command
(output
)
423 def stage_hunk(self
):
425 list_widget
= self
.view
.unstagedList
426 row
, selected
= qtutils
.get_selected_row(list_widget
)
427 if not selected
: return
429 filename
= self
.model
.get_uncommitted_item(row
)
431 if not os
.path
.exists(filename
): return
432 if os
.path
.isdir(filename
): return
434 cursor
= self
.view
.displayText
.textCursor()
435 offset
= cursor
.position()
436 offset
-= utils
.HEADER_LENGTH
+ 1
437 if offset
< 0: return
439 selection
= cursor
.selection().toPlainText()
440 header
, diff
= cmds
.git_diff(filename
,
441 with_diff_header
=True,
443 parser
= utils
.DiffParser(diff
)
445 num_selected_lines
= selection
.count(os
.linesep
)
446 has_selection
=(selection
447 and selection
.count(os
.linesep
) > 0)
450 start
= diff
.index(selection
)
451 end
= start
+ len(selection
)
452 diffs
= parser
.get_diffs_for_range(start
, end
)
454 diffs
= [ parser
.get_diff_for_offset(offset
) ]
459 tmpfile
= utils
.get_tmp_filename()
460 file = open(tmpfile
, 'w')
461 file.write(header
+ os
.linesep
+ diff
+ os
.linesep
)
463 self
.model
.apply_diff(tmpfile
)
468 def stage_untracked(self
,*rest
):
469 '''Stage all untracked files for commit.'''
470 output
= cmds
.git_add(self
.model
.get_untracked())
471 self
.__show
_command
(output
)
473 def stage_selected(self
,*rest
):
474 '''Use "git add" to add items to the git index.
475 This is a thin wrapper around __apply_to_list.'''
476 command
= cmds
.git_add_or_remove
477 widget
= self
.view
.unstagedList
478 items
= self
.model
.get_unstaged() + self
.model
.get_untracked()
479 self
.__apply
_to
_list
(command
, widget
, items
)
481 def unstage_selected(self
, *args
):
482 '''Use "git reset" to remove items from the git index.
483 This is a thin wrapper around __apply_to_list.'''
484 command
= cmds
.git_reset
485 widget
= self
.view
.stagedList
486 items
= self
.model
.get_staged()
487 self
.__apply
_to
_list
(command
, widget
, items
)
489 def unstage_all(self
,*rest
):
490 '''Use "git reset" to remove all items from the git index.'''
491 output
= cmds
.git_reset(self
.model
.get_staged())
492 self
.__show
_command
(output
)
494 def viz_all(self
,*rest
):
495 '''Visualizes the entire git history using gitk.'''
496 os
.system('gitk --all &')
498 def viz_current(self
,*rest
):
499 '''Visualizes the current branch's history using gitk.'''
500 branch
= cmds
.git_current_branch()
501 os
.system('gitk %s &' % utils
.shell_quote(branch
))
503 #####################################################################
506 def __apply_to_list(self
, command
, widget
, items
):
507 '''This is a helper method that retrieves the current
508 selection list, applies a command to that list,
509 displays a dialog showing the output of that command,
510 and calls rescan to pickup changes.'''
511 apply_items
= qtutils
.get_selection_from_list(widget
, items
)
515 def __browse_branch(self
, branch
):
516 if not branch
: return
517 # Clone the model to allow opening multiple browsers
518 # with different sets of data
519 model
= self
.model
.clone()
520 model
.set_branch(branch
)
521 view
= GitCommitBrowser()
522 controller
= GitRepoBrowserController(model
, view
)
526 def __menu_about_to_show(self
):
527 cursor
= self
.view
.displayText
.textCursor()
528 allow_hunk_staging
=(not self
.__staged
_diff
_in
_view
529 and cursor
.position() > utils
.HEADER_LENGTH
)
531 self
.__stage
_hunk
_action
.setEnabled(allow_hunk_staging
)
533 def __menu_event(self
, event
):
535 textedit
= self
.view
.displayText
536 self
.__menu
.exec_(textedit
.mapToGlobal(event
.pos()))
538 def __menu_setup(self
):
539 if self
.__menu
: return
541 menu
= QMenu(self
.view
)
542 stage
= menu
.addAction('Stage Hunk(s)', self
.stage_hunk
)
543 copy
= menu
.addAction('Copy', self
.copy
)
545 self
.connect(menu
, 'aboutToShow()', self
.__menu
_about
_to
_show
)
547 self
.__stage
_hunk
_action
= stage
548 self
.__copy
_action
= copy
552 def __file_to_widget_item(self
, filename
, staged
, untracked
=False):
553 '''Given a filename, return a QListWidgetItem suitable
554 for adding to a QListWidget. "staged" controls whether
555 to use icons for the staged or unstaged list widget.'''
558 icon_file
= utils
.get_staged_icon(filename
)
560 icon_file
= utils
.get_untracked_icon()
562 icon_file
= utils
.get_icon(filename
)
564 return qtutils
.create_listwidget_item(filename
, icon_file
)
566 def __select_commits(self
, revs
, summaries
):
567 '''Use the GitCommitBrowser to select commits from a list.'''
569 msg
= 'ERROR: No commits exist in this branch.'''
570 self.__show_command(output=msg)
573 browser = GitCommitBrowser(self.view)
574 self.connect(browser.commitList,
575 'itemSelectionChanged()',
576 lambda: self.commit_sha1_selected(
579 for summary in summaries:
580 browser.commitList.addItem(summary)
583 result = browser.exec_()
584 if result != QDialog.Accepted:
587 list_widget = browser.commitList
588 selection = qtutils.get_selection_from_list(list_widget, revs)
589 if not selection: return([],[])
591 # also return the selected index numbers
592 index_nums = range(len(revs))
593 idxs = qtutils.get_selection_from_list(list_widget, index_nums)
595 return(selection, idxs)
597 def __set_branch_ui_items(self):
598 '''Sets up items that mention the current branch name.'''
599 current_branch = cmds.git_current_branch()
600 menu_text = 'Browse
' + current_branch + ' branch
'
601 self.view.browseBranch.setText(menu_text)
603 status_text = 'Current branch
: ' + current_branch
604 self.view.statusBar().showMessage(status_text)
606 def __start_inotify_thread(self):
607 # Do we have inotify? If not, return.
608 # Recommend installing inotify if we're on Linux
.
609 self
.inotify_thread
= None
611 from inotify
import GitNotifier
614 if platform
.system() == 'Linux':
615 msg
=('ugit could not find python-inotify.'
616 + '\nSupport for inotify is disabled.')
618 plat
= platform
.platform().lower()
619 if 'debian' in plat
or 'ubuntu' in plat
:
620 msg
+= '\n\nHint: sudo apt-get install python-pyinotify'
622 qtutils
.information(self
.view
,
623 'inotify support disabled', msg
)
626 self
.inotify_thread
= GitNotifier(os
.getcwd())
627 self
.connect(self
.inotify_thread
,
628 'timeForRescan()', self
.rescan
)
630 # Start the notification thread
631 self
.inotify_thread
.start()
633 def __show_command(self
, output
, rescan
=True):
634 '''Shows output and optionally rescans for changes.'''
635 qtutils
.show_command(self
.view
, output
)
636 if rescan
: self
.rescan()
638 def __update_list_widget(self
, list_widget
, items
,
639 staged
, untracked
=False, append
=False):
640 '''A helper method to populate a QListWidget with the
641 contents of modelitems.'''
645 qitem
= self
.__file
_to
_widget
_item
(item
,
647 list_widget
.addItem(qitem
)