4 from PyQt4
import QtGui
5 from PyQt4
import QtCore
6 from PyQt4
.QtGui
import QDialog
7 from PyQt4
.QtGui
import QMessageBox
8 from PyQt4
.QtGui
import QMenu
9 from qobserver
import QObserver
14 from views
import GitPushDialog
15 from views
import GitBranchDialog
16 from views
import GitCreateBranchDialog
17 from views
import GitCommitBrowser
18 from repobrowsercontroller
import GitRepoBrowserController
19 from createbranchcontroller
import GitCreateBranchController
20 from pushcontroller
import GitPushController
22 class GitController(QObserver
):
23 '''The controller is a mediator between the model and view.
24 It allows for a clean decoupling between view and model classes.'''
26 def __init__(self
, model
, view
):
27 QObserver
.__init
__(self
, model
, view
)
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('commitmsg', 'commitText')
43 self
.model_to_view('staged', 'stagedList')
44 self
.model_to_view('all_unstaged', 'unstagedList')
46 # When a model attribute changes, this runs a specific action
47 self
.add_actions('staged', self
.action_staged
)
48 self
.add_actions('all_unstaged', self
.action_all_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()',
56 view
.stageButton
, view
.commitButton
,
57 view
.pushButton
, view
.signOffButton
,)
59 self
.add_signals('triggered()',
61 view
.createBranch
, view
.checkoutBranch
,
62 view
.rebaseBranch
, view
.deleteBranch
,
63 view
.commitAll
, view
.commitSelected
,
64 view
.setCommitMessage
,
65 view
.stageChanged
, view
.stageUntracked
,
66 view
.stageSelected
, view
.unstageAll
,
69 view
.browseBranch
, view
.browseOtherBranch
,
70 view
.visualizeAll
, view
.visualizeCurrent
,
71 view
.exportPatches
, view
.cherryPick
,
73 view
.cut
, view
.copy
, view
.paste
, view
.delete
,
74 view
.selectAll
, view
.undo
, view
.redo
,)
76 self
.add_signals('itemClicked(QListWidgetItem *)',
77 view
.stagedList
, view
.unstagedList
,)
79 self
.add_signals('itemSelectionChanged()',
80 view
.stagedList
, view
.unstagedList
,)
82 self
.add_signals('splitterMoved(int,int)',
83 view
.splitter_top
, view
.splitter_bottom
)
86 self
.connect(QtGui
.qApp
, 'lastWindowClosed()',
87 self
.last_window_closed
)
89 # These callbacks are called in response to the signals
90 # defined above. One property of the QObserver callback
91 # mechanism is that the model is passed in as the first
92 # argument to the callback. This allows for a single
93 # controller to manage multiple models, though this
94 # isn't used at the moment.
96 # Actions that delegate directly to the model
97 'signOffButton': model
.add_signoff
,
98 'setCommitMessage': model
.get_prev_commitmsg
,
100 'stageButton': self
.stage_selected
,
101 'commitButton': self
.commit
,
102 'pushButton': self
.push
,
104 'stagedList': self
.diff_staged
,
105 'unstagedList': self
.diff_unstaged
,
107 'untrackedCheckBox': self
.rescan
,
109 'rescan': self
.rescan
,
110 'createBranch': self
.branch_create
,
111 'deleteBranch': self
.branch_delete
,
112 'checkoutBranch': self
.checkout_branch
,
113 'rebaseBranch': self
.rebase
,
114 'commitAll': self
.commit_all
,
115 'commitSelected': self
.commit_selected
,
116 'stageChanged': self
.stage_changed
,
117 'stageUntracked': self
.stage_untracked
,
118 'stageSelected': self
.stage_selected
,
119 'unstageAll': self
.unstage_all
,
120 'unstageSelected': self
.unstage_selected
,
121 'showDiffstat': self
.show_diffstat
,
122 'browseBranch': self
.browse_current
,
123 'browseOtherBranch': self
.browse_other
,
124 'visualizeCurrent': self
.viz_current
,
125 'visualizeAll': self
.viz_all
,
126 'exportPatches': self
.export_patches
,
127 'cherryPick': self
.cherry_pick
,
128 'loadCommitMsg': self
.load_commitmsg
,
132 'delete': self
.delete
,
133 'selectAll': self
.select_all
,
134 'undo': self
.view
.commitText
.undo
,
137 'splitter_top': self
.splitter_top_moved
,
138 'splitter_bottom': self
.splitter_bottom_moved
,
141 # Handle double-clicks in the staged/unstaged lists.
142 # These are vanilla signal/slots since the qobserver
143 # signal routing is already handling these lists' signals.
144 self
.connect(view
.unstagedList
,
145 'itemDoubleClicked(QListWidgetItem*)',
148 self
.connect(view
.stagedList
,
149 'itemDoubleClicked(QListWidgetItem*)',
150 self
.unstage_selected
)
152 # Delegate window move events here
153 self
.view
.moveEvent
= self
.move_event
154 self
.view
.resizeEvent
= self
.resize_event
157 self
.__read
_config
_settings
()
160 # Setup the inotify watchdog
161 self
.__start
_inotify
_thread
()
163 #####################################################################
165 # Notify callbacks from the model
167 def action_staged(self
, widget
):
168 self
.__update
_listwidget
(widget
,
169 self
.model
.get_staged(), staged
=True)
171 def action_all_unstaged(self
, widget
):
172 self
.__update
_listwidget
(widget
,
173 self
.model
.get_unstaged(), staged
=False)
175 if self
.view
.untrackedCheckBox
.isChecked():
176 self
.__update
_listwidget
(widget
,
177 self
.model
.get_untracked(),
182 #####################################################################
185 def branch_create(self
):
186 view
= GitCreateBranchDialog(self
.view
)
187 controller
= GitCreateBranchController(self
.model
, view
)
189 result
= view
.exec_()
190 if result
== QDialog
.Accepted
:
193 def branch_delete(self
):
194 dlg
= GitBranchDialog(self
.view
, branches
=cmds
.git_branch())
195 branch
= dlg
.getSelectedBranch()
196 if not branch
: return
197 qtutils
.show_command(self
.view
,
198 cmds
.git_branch(name
=branch
, delete
=True))
200 def browse_current(self
):
201 self
.__browse
_branch
(cmds
.git_current_branch())
203 def browse_other(self
):
204 # Prompt for a branch to browse
205 branches
= self
.model
.all_branches()
206 dialog
= GitBranchDialog(self
.view
, branches
=branches
)
208 # Launch the repobrowser
209 self
.__browse
_branch
(dialog
.getSelectedBranch())
211 def checkout_branch(self
):
212 dlg
= GitBranchDialog(self
.view
, cmds
.git_branch())
213 branch
= dlg
.getSelectedBranch()
214 if not branch
: return
215 qtutils
.show_command(self
.view
, cmds
.git_checkout(branch
))
218 def cherry_pick(self
):
219 '''Starts a cherry-picking session.'''
220 (revs
, summaries
) = cmds
.git_log(all
=True)
221 selection
, idxs
= self
.__select
_commits
(revs
, summaries
)
222 output
= cmds
.git_cherry_pick(selection
)
223 self
.__show
_command
(self
.tr(output
))
226 '''Sets up data and calls cmds.commit.'''
227 msg
= self
.model
.get_commitmsg()
229 error_msg
= self
.tr(""
230 + "Please supply a commit message.\n"
232 + "A good commit message has the following format:\n"
234 + "- First line: Describe in one sentence what you did.\n"
235 + "- Second line: Blank\n"
236 + "- Remaining lines: Describe why this change is good.\n")
238 self
.__show
_command
(error_msg
)
241 amend
= self
.view
.amendRadio
.isChecked()
242 commit_all
= self
.view
.commitAllCheckBox
.isChecked()
246 files
= self
.model
.get_staged()
249 + "No changes to commit.\n"
251 + "You must stage at least 1 file before you can commit.\n")
252 self
.__show
_command
(errmsg
)
255 wlist
= self
.view
.stagedList
256 mlist
= self
.model
.get_staged()
257 files
= qtutils
.get_selection_list(wlist
, mlist
)
259 errmsg
= self
.tr('No files selected.')
260 self
.__show
_command
(errmsg
)
263 output
= cmds
.git_commit(msg
, amend
, files
)
266 self
.view
.newCommitRadio
.setChecked(True)
267 self
.view
.amendRadio
.setChecked(False)
268 self
.model
.set_commitmsg('')
269 self
.__show
_command
(output
)
271 def commit_all(self
):
272 '''Sets the commit-all checkbox and runs commit.'''
273 self
.view
.commitAllCheckBox
.setChecked(True)
276 def commit_selected(self
):
277 '''Unsets the commit-all checkbox and runs commit.'''
278 self
.view
.commitAllCheckBox
.setChecked(False)
281 def commit_sha1_selected(self
, browser
, revs
):
282 '''This callback is called when a commit browser's
283 item is selected. This callback puts the current
284 revision sha1 into the commitText field.
285 This callback also puts shows the commit in the
286 browser's commit textedit and copies it into
287 the global clipboard/selection.'''
288 current
= browser
.commitList
.currentRow()
289 item
= browser
.commitList
.item(current
)
290 if not item
.isSelected():
291 browser
.commitText
.setText('')
292 browser
.revisionLine
.setText('')
295 # Get the commit's sha1 and put it in the revision line
297 browser
.revisionLine
.setText(sha1
)
298 browser
.revisionLine
.selectAll()
300 # Lookup the info for that sha1 and display it
301 commit_diff
= cmds
.git_show(sha1
)
302 browser
.commitText
.setText(commit_diff
)
304 # Copy the sha1 into the clipboard
305 qtutils
.set_clipboard(sha1
)
307 # use *rest to handle being called from different signals
308 def diff_staged(self
, *rest
):
309 self
.__staged
_diff
_in
_view
= True
310 widget
= self
.view
.stagedList
311 row
, selected
= qtutils
.get_selected_row(widget
)
314 self
.__reset
_display
()
317 filename
= self
.model
.get_staged()[row
]
318 diff
= cmds
.git_diff(filename
, staged
=True)
320 if os
.path
.exists(filename
):
321 self
.__set
_info
(self
.tr('Staged for commit'))
323 self
.__set
_info
(self
.tr('Staged for removal'))
325 self
.view
.displayText
.setText(diff
)
327 # use *rest to handle being called from different signals
328 def diff_unstaged(self
,*rest
):
329 self
.__staged
_diff
_in
_view
= False
330 widget
= self
.view
.unstagedList
332 row
, selected
= qtutils
.get_selected_row(widget
)
334 self
.__reset
_display
()
337 filename
=(self
.model
.get_unstaged()
338 + self
.model
.get_untracked())[row
]
339 if os
.path
.isdir(filename
):
340 self
.__set
_info
(self
.tr('Untracked directory'))
341 cmd
= 'ls -la %s' % utils
.shell_quote(filename
)
342 output
= commands
.getoutput(cmd
)
343 self
.view
.displayText
.setText(output
)
346 if filename
in self
.model
.get_unstaged():
347 diff
= cmds
.git_diff(filename
, staged
=False)
349 self
.__set
_info
(self
.tr('Modified, not staged'))
352 cmd
= 'file -b %s' % utils
.shell_quote(filename
)
353 file_type
= commands
.getoutput(cmd
)
355 if 'binary' in file_type
or 'data' in file_type
:
356 sq_filename
= utils
.shell_quote(filename
)
357 cmd
= 'hexdump -C %s' % sq_filename
358 contents
= commands
.getoutput(cmd
)
360 if os
.path
.exists(filename
):
361 file = open(filename
, 'r')
362 contents
= file.read()
367 self
.__set
_info
(self
.tr('Untracked, not staged')
371 self
.view
.displayText
.setText(msg
)
373 def display_copy(self
):
374 cursor
= self
.view
.displayText
.textCursor()
375 selection
= cursor
.selection().toPlainText()
376 qtutils
.set_clipboard(selection
)
378 def export_patches(self
):
379 '''Launches the commit browser and exports the selected
382 (revs
, summaries
) = cmds
.git_log()
383 selection
, idxs
= self
.__select
_commits
(revs
, summaries
)
384 if not selection
: return
386 # now get the selected indices to determine whether
387 # a range of consecutive commits were selected
388 selected_range
= range(idxs
[0], idxs
[-1] + 1)
389 export_range
= len(idxs
) > 1 and idxs
== selected_range
391 output
= cmds
.git_format_patch(selection
, export_range
)
392 self
.__show
_command
(output
)
394 def get_commit_msg(self
):
395 self
.model
.retrieve_latest_commitmsg()
397 def last_window_closed(self
):
398 '''Save config settings and cleanup the any inotify threads.'''
400 self
.__save
_config
_settings
()
402 if not self
.inotify_thread
: return
403 if not self
.inotify_thread
.isRunning(): return
405 self
.inotify_thread
.abort
= True
406 self
.inotify_thread
.quit()
407 self
.inotify_thread
.wait()
409 def load_commitmsg(self
):
410 file = qtutils
.open_dialog(self
.view
,
411 self
.tr('Load Commit Message...'),
415 defaults
.DIRECTORY
= os
.path
.dirname(file)
416 slushy
= utils
.slurp(file)
417 self
.model
.set_commitmsg(slushy
)
421 dlg
= GitBranchDialog(self
.view
, cmds
.git_branch())
422 dlg
.setWindowTitle("Select the current branch's new root")
423 branch
= dlg
.getSelectedBranch()
424 if not branch
: return
425 qtutils
.show_command(self
.view
, cmds
.git_rebase(branch
))
427 # use *rest to handle being called from the checkbox signal
428 def rescan(self
, *rest
):
429 '''Populates view widgets with results from "git status."'''
431 self
.view
.statusBar().showMessage(
432 self
.tr('Scanning for modified files ...'))
434 # Rescan for repo updates
435 self
.model
.update_status()
437 # Scan for branch changes
438 self
.__set
_branch
_ui
_items
()
440 if not self
.model
.has_squash_msg(): return
442 if self
.model
.get_commitmsg():
443 answer
= qtutils
.question(self
.view
,
444 self
.tr('Import Commit Message?'),
445 self
.tr('A commit message from an in-progress'
446 + ' merge was found.\nImport it?'))
448 if not answer
: return
450 # Set the new commit message
451 self
.model
.set_commitmsg(self
.model
.get_squash_msg())
454 model
= self
.model
.clone()
455 view
= GitPushDialog(self
.view
)
456 controller
= GitPushController(model
,view
)
465 cursor
= self
.view
.commitText
.textCursor()
466 selection
= cursor
.selection().toPlainText()
467 qtutils
.set_clipboard(selection
)
469 def paste(self
): self
.view
.commitText
.paste()
470 def undo(self
): self
.view
.commitText
.undo()
471 def redo(self
): self
.view
.commitText
.redo()
472 def select_all(self
): self
.view
.commitText
.selectAll()
474 self
.view
.commitText
.textCursor().removeSelectedText()
476 def show_diffstat(self
):
477 '''Show the diffstat from the latest commit.'''
478 self
.__show
_command
(cmds
.git_diff_stat(), rescan
=False)
480 def stage_changed(self
):
481 '''Stage all changed files for commit.'''
482 output
= cmds
.git_add(self
.model
.get_unstaged())
483 self
.__show
_command
(output
)
485 def stage_hunk(self
):
487 list_widget
= self
.view
.unstagedList
488 row
, selected
= qtutils
.get_selected_row(list_widget
)
489 if not selected
: return
491 filename
= self
.model
.get_uncommitted_item(row
)
493 if not os
.path
.exists(filename
): return
494 if os
.path
.isdir(filename
): return
496 cursor
= self
.view
.displayText
.textCursor()
497 offset
= cursor
.position()
499 selection
= cursor
.selection().toPlainText()
500 header
, diff
= cmds
.git_diff(filename
,
501 with_diff_header
=True,
503 parser
= utils
.DiffParser(diff
)
505 num_selected_lines
= selection
.count(os
.linesep
)
506 has_selection
=(selection
507 and selection
.count(os
.linesep
) > 0)
510 start
= diff
.index(selection
)
511 end
= start
+ len(selection
)
512 diffs
= parser
.get_diffs_for_range(start
, end
)
514 diffs
= [ parser
.get_diff_for_offset(offset
) ]
519 tmpfile
= utils
.get_tmp_filename()
520 file = open(tmpfile
, 'w')
521 file.write(header
+ os
.linesep
+ diff
+ os
.linesep
)
523 self
.model
.apply_diff(tmpfile
)
528 def stage_untracked(self
):
529 '''Stage all untracked files for commit.'''
530 output
= cmds
.git_add(self
.model
.get_untracked())
531 self
.__show
_command
(output
)
533 # use *rest to handle being called from different signals
534 def stage_selected(self
,*rest
):
535 '''Use "git add" to add items to the git index.
536 This is a thin wrapper around __apply_to_list.'''
537 command
= cmds
.git_add_or_remove
538 widget
= self
.view
.unstagedList
539 items
= self
.model
.get_unstaged() + self
.model
.get_untracked()
541 self
.__apply
_to
_list
(command
, widget
, items
))
543 # use *rest to handle being called from different signals
544 def unstage_selected(self
, *rest
):
545 '''Use "git reset" to remove items from the git index.
546 This is a thin wrapper around __apply_to_list.'''
547 command
= cmds
.git_reset
548 widget
= self
.view
.stagedList
549 items
= self
.model
.get_staged()
550 self
.__show
_command
(self
.__apply
_to
_list
(command
, widget
, items
))
552 def unstage_all(self
):
553 '''Use "git reset" to remove all items from the git index.'''
554 output
= cmds
.git_reset(self
.model
.get_staged())
555 self
.__show
_command
(output
)
558 '''Visualizes the entire git history using gitk.'''
559 os
.system('gitk --all &')
561 def viz_current(self
):
562 '''Visualizes the current branch's history using gitk.'''
563 branch
= cmds
.git_current_branch()
564 os
.system('gitk %s &' % utils
.shell_quote(branch
))
566 # These actions monitor window resizes, splitter changes, etc.
567 def move_event(self
, event
):
568 defaults
.X
= event
.pos().x()
569 defaults
.Y
= event
.pos().y()
571 def resize_event(self
, event
):
572 defaults
.WIDTH
= event
.size().width()
573 defaults
.HEIGHT
= event
.size().height()
575 def splitter_top_moved(self
,*rest
):
576 sizes
= self
.view
.splitter_top
.sizes()
577 defaults
.SPLITTER_TOP_0
= sizes
[0]
578 defaults
.SPLITTER_TOP_1
= sizes
[1]
580 def splitter_bottom_moved(self
,*rest
):
581 sizes
= self
.view
.splitter_bottom
.sizes()
582 defaults
.SPLITTER_BOTTOM_0
= sizes
[0]
583 defaults
.SPLITTER_BOTTOM_1
= sizes
[1]
585 #####################################################################
588 def __apply_to_list(self
, command
, widget
, items
):
589 '''This is a helper method that retrieves the current
590 selection list, applies a command to that list,
591 displays a dialog showing the output of that command,
592 and calls rescan to pickup changes.'''
593 apply_items
= qtutils
.get_selection_list(widget
, items
)
594 output
= command(apply_items
)
598 def __browse_branch(self
, branch
):
599 if not branch
: return
600 # Clone the model to allow opening multiple browsers
601 # with different sets of data
602 model
= self
.model
.clone()
603 model
.set_branch(branch
)
604 view
= GitCommitBrowser()
605 controller
= GitRepoBrowserController(model
, view
)
609 def __menu_about_to_show(self
):
610 cursor
= self
.view
.displayText
.textCursor()
611 allow_hunk_staging
= not self
.__staged
_diff
_in
_view
612 self
.__stage
_hunk
_action
.setEnabled(allow_hunk_staging
)
614 def __menu_event(self
, event
):
616 textedit
= self
.view
.displayText
617 self
.__menu
.exec_(textedit
.mapToGlobal(event
.pos()))
619 def __menu_setup(self
):
620 if self
.__menu
: return
622 menu
= QMenu(self
.view
)
623 stage
= menu
.addAction(self
.tr('Stage Hunk For Commit'),
625 copy
= menu
.addAction(self
.tr('Copy'), self
.display_copy
)
627 self
.connect(menu
, 'aboutToShow()', self
.__menu
_about
_to
_show
)
629 self
.__stage
_hunk
_action
= stage
630 self
.__copy
_action
= copy
634 def __file_to_widget_item(self
, filename
, staged
, untracked
=False):
635 '''Given a filename, return a QListWidgetItem suitable
636 for adding to a QListWidget. "staged" controls whether
637 to use icons for the staged or unstaged list widget.'''
640 icon_file
= utils
.get_staged_icon(filename
)
642 icon_file
= utils
.get_untracked_icon()
644 icon_file
= utils
.get_icon(filename
)
646 return qtutils
.create_listwidget_item(filename
, icon_file
)
648 def __read_config_settings(self
):
651 sb0
,sb1
) = utils
.parse_geom(cmds
.git_config('ugit.geometry'))
652 self
.view
.resize(w
,h
)
654 self
.view
.splitter_top
.setSizes([st0
,st1
])
655 self
.view
.splitter_bottom
.setSizes([sb0
,sb1
])
657 def __save_config_settings(self
):
658 cmds
.git_config('ugit.geometry', utils
.get_geom())
660 def __select_commits(self
, revs
, summaries
):
661 '''Use the GitCommitBrowser to select commits from a list.'''
663 msg
= self
.tr('ERROR: No commits exist in this branch.')
664 self
.__show
_command
(msg
)
667 browser
= GitCommitBrowser(self
.view
)
668 self
.connect(browser
.commitList
,
669 'itemSelectionChanged()',
670 lambda: self
.commit_sha1_selected(
673 for summary
in summaries
:
674 browser
.commitList
.addItem(summary
)
677 result
= browser
.exec_()
678 if result
!= QDialog
.Accepted
:
681 list_widget
= browser
.commitList
682 selection
= qtutils
.get_selection_list(list_widget
, revs
)
683 if not selection
: return([],[])
685 # also return the selected index numbers
686 index_nums
= range(len(revs
))
687 idxs
= qtutils
.get_selection_list(list_widget
, index_nums
)
689 return(selection
, idxs
)
691 def __set_branch_ui_items(self
):
692 '''Sets up items that mention the current branch name.'''
693 branch
= cmds
.git_current_branch()
695 status_text
= self
.tr('Current Branch:') + ' ' + branch
696 self
.view
.statusBar().showMessage(status_text
)
698 project
= self
.model
.get_project()
699 title
= '%s [%s]' % ( project
, branch
)
701 self
.view
.setWindowTitle(title
)
703 def __reset_display(self
):
704 self
.view
.displayText
.setText('')
707 def __set_info(self
,text
):
708 self
.view
.displayLabel
.setText(text
)
710 def __start_inotify_thread(self
):
711 # Do we have inotify? If not, return.
712 # Recommend installing inotify if we're on Linux.
713 self
.inotify_thread
= None
715 from inotify
import GitNotifier
718 if platform
.system() == 'Linux':
719 msg
=(self
.tr('Unable import pyinotify.\n'
720 + 'inotify support has been'
724 plat
= platform
.platform().lower()
725 if 'debian' in plat
or 'ubuntu' in plat
:
726 msg
+= (self
.tr('Hint:')
727 + 'sudo apt-get install'
728 + ' python-pyinotify')
730 qtutils
.information(self
.view
,
731 self
.tr('inotify disabled'), msg
)
734 self
.inotify_thread
= GitNotifier(os
.getcwd())
735 self
.connect(self
.inotify_thread
,
736 'timeForRescan()', self
.rescan
)
738 # Start the notification thread
739 self
.inotify_thread
.start()
741 def __show_command(self
, output
, rescan
=True):
742 '''Shows output and optionally rescans for changes.'''
743 qtutils
.show_command(self
.view
, output
)
744 if rescan
: self
.rescan()
746 def __update_listwidget(self
, widget
, items
,
747 staged
, untracked
=False, append
=False):
748 '''A helper method to populate a QListWidget with the
749 contents of modelitems.'''
753 qitem
= self
.__file
_to
_widget
_item
(item
,
755 widget
.addItem(qitem
)