From 1a7ad1b99bd071ed85229263c60b3df06474402a Mon Sep 17 00:00:00 2001 From: David Aguilar Date: Fri, 21 Dec 2007 23:23:30 -0800 Subject: [PATCH] Added commit message menu, other fixes commit 4e80caa2b8d06571640b1351956e60118194460b Author: David Aguilar Date: Fri Dec 21 23:20:17 2007 -0800 Load commit message menu commit 7aed0d0e110f699bfd02316c641eb5baaca39b15 Author: David Aguilar Date: Fri Dec 21 22:53:23 2007 -0800 Cleanup model names commit bd123a0dcb8a63a72c683b4dc70a010156cb64fb Author: David Aguilar Date: Fri Dec 21 22:53:06 2007 -0800 Dir dialog commit b4fe4ff48f66660a3952c06c4fce144d39e3a6e6 Author: David Aguilar Date: Fri Dec 21 22:13:52 2007 -0800 fix for mktar script commit 1c85af2ab70f76a7523e5c4ac7a670e34f1df3e6 Author: David Aguilar Date: Fri Dec 21 22:04:41 2007 -0800 Build dir shuffle commit ea12a220cb33124eb2c2ed57353a5e2582046705 Author: David Aguilar Date: Fri Dec 21 21:51:33 2007 -0800 style cleanup commit 6b71609035b35f0a23a9d6c492def3757e2ebb1e Author: David Aguilar Date: Fri Dec 21 21:15:01 2007 -0800 style cleanup commit bb3b90d53ee2d166356c11a87f63d2ea8adcf219 Author: David Aguilar Date: Fri Dec 21 20:53:42 2007 -0800 Removed a few lambda's commit c883104cda9ade3644c5ff5162c2996ab338feeb Author: David Aguilar Date: Fri Dec 21 20:47:46 2007 -0800 Less output for common tasks, simplified callbacks commit 9f75684588fcf92e33a97dd8182e957114bc3b07 Author: David Aguilar Date: Fri Dec 21 20:12:02 2007 -0800 script for creating tarballs Signed-off by: David Aguilar --- LICENSE => COPYING | 0 bin/ugit.py | 16 +- buildutils/__init__.py | 10 +- buildutils/pyuic4.py | 52 +-- py/cmds.py | 380 ----------------- py/controllers.py | 644 ---------------------------- py/models.py | 207 --------- py/qobserver.py | 113 ----- py/qtutils.py | 64 --- py/repobrowsercontroller.py | 178 -------- py/syntax.py | 94 ----- py/views.py | 73 ---- py/wscript | 5 - scripts/build-win32.sh | 2 +- scripts/mktar.sh | 22 + {py => ugitlibs}/__init__.py | 0 ugitlibs/cmds.py | 380 +++++++++++++++++ ugitlibs/controllers.py | 647 +++++++++++++++++++++++++++++ {py => ugitlibs}/createbranchcontroller.py | 72 ++-- {py => ugitlibs}/defaults.py | 0 {py => ugitlibs}/inotify.py | 34 +- {py => ugitlibs}/model.py | 30 +- ugitlibs/models.py | 216 ++++++++++ {py => ugitlibs}/observer.py | 26 +- ugitlibs/qobserver.py | 113 +++++ ugitlibs/qtutils.py | 80 ++++ ugitlibs/repobrowsercontroller.py | 162 ++++++++ ugitlibs/syntax.py | 94 +++++ {py => ugitlibs}/utils.py | 130 +++--- ugitlibs/views.py | 73 ++++ ugitlibs/wscript | 5 + ui/Window.ui | 7 +- ui/wscript | 6 +- wscript | 86 ++-- 34 files changed, 2032 insertions(+), 1989 deletions(-) rename LICENSE => COPYING (100%) delete mode 100644 py/cmds.py delete mode 100644 py/controllers.py delete mode 100644 py/models.py delete mode 100644 py/qobserver.py delete mode 100644 py/qtutils.py delete mode 100644 py/repobrowsercontroller.py delete mode 100755 py/syntax.py delete mode 100644 py/views.py delete mode 100644 py/wscript create mode 100755 scripts/mktar.sh rename {py => ugitlibs}/__init__.py (100%) create mode 100644 ugitlibs/cmds.py create mode 100644 ugitlibs/controllers.py rename {py => ugitlibs}/createbranchcontroller.py (66%) rename {py => ugitlibs}/defaults.py (100%) rename {py => ugitlibs}/inotify.py (60%) rename {py => ugitlibs}/model.py (92%) create mode 100644 ugitlibs/models.py rename {py => ugitlibs}/observer.py (72%) create mode 100644 ugitlibs/qobserver.py create mode 100644 ugitlibs/qtutils.py create mode 100644 ugitlibs/repobrowsercontroller.py create mode 100755 ugitlibs/syntax.py rename {py => ugitlibs}/utils.py (53%) create mode 100644 ugitlibs/views.py create mode 100644 ugitlibs/wscript rewrite wscript (72%) diff --git a/LICENSE b/COPYING similarity index 100% rename from LICENSE rename to COPYING diff --git a/bin/ugit.py b/bin/ugit.py index ad707bd..a396279 100755 --- a/bin/ugit.py +++ b/bin/ugit.py @@ -1,25 +1,25 @@ #!/usr/bin/env python -# Copyright (C) 2007, David Aguilar +# Copyright(C) 2007, David Aguilar # License: GPL v2 or later import os import sys import platform version = platform.python_version() -ugit = os.path.realpath (__file__) -sys.path.insert (0, os.path.join( - os.path.dirname (os.path.dirname(ugit)), +ugit = os.path.realpath(__file__) +sys.path.insert(0, os.path.join( + os.path.dirname(os.path.dirname(ugit)), 'lib', 'python' + version[:3], 'site-packages')) -sys.path.insert (0, os.path.dirname(ugit)) +sys.path.insert(0, os.path.dirname(ugit)) from PyQt4 import QtCore, QtGui from ugitlibs.models import GitModel from ugitlibs.views import GitView from ugitlibs.controllers import GitController if __name__ == "__main__": - app = QtGui.QApplication (sys.argv) + app = QtGui.QApplication(sys.argv) model = GitModel() - view = GitView (app.activeWindow()) - ctl = GitController (model, view) + view = GitView(app.activeWindow()) + ctl = GitController(model, view) view.show() sys.exit(app.exec_()) diff --git a/buildutils/__init__.py b/buildutils/__init__.py index 1490e9b..c069d78 100644 --- a/buildutils/__init__.py +++ b/buildutils/__init__.py @@ -7,15 +7,15 @@ def pymod(prefix): '''Returns a lib/python2.x/site-packages path relative to prefix''' python_ver = platform.python_version_tuple() python_ver_str = 'python' + '.'.join(python_ver[:2]) - return os.path.join (prefix, 'lib', python_ver_str, 'site-packages') + return os.path.join(prefix, 'lib', python_ver_str, 'site-packages') def configure_python(conf): - if not conf.check_tool ('python'): - Params.fatal ('Error: could not find a Python installation.') + if not conf.check_tool('python'): + Params.fatal('Error: could not find a Python installation.') def configure_pyqt(conf): # pyuic4 is a custom build object, hence the 2nd parameter - if not conf.check_tool ('pyuic4', os.path.dirname (__file__)): - Params.fatal ('Error: missing PyQt4 development tools.\n' + if not conf.check_tool('pyuic4', os.path.dirname(__file__)): + Params.fatal('Error: missing PyQt4 development tools.\n' + 'Hint: on Debian systems try:\n' + '\tapt-get install pyqt4-dev-tools python-qt4-dev') diff --git a/buildutils/pyuic4.py b/buildutils/pyuic4.py index f1dbefd..30c94ba 100644 --- a/buildutils/pyuic4.py +++ b/buildutils/pyuic4.py @@ -6,7 +6,7 @@ pyuic4: support for generating .py Python scripts from Qt Designer4 .ui files. NOTES: -- If PYQT4_ROOT is given (absolute path), the configuration will look +- If PYQT4_ROOT is given(absolute path), the configuration will look in PYQT4_ROOT/bin first. - This module hooks adds a python hook that runs @@ -22,42 +22,42 @@ import Params import python python.pyobj.s_default_ext.append('.ui') -def set_options (opt): +def set_options(opt): '''Adds the --pyuic4 build option.''' - opt.add_option ( '--pyuic4', type='string', dest='pyuic4', + opt.add_option( '--pyuic4', type='string', dest='pyuic4', default='', help='path to the pyuic4 binary') -def create_pyuic4_tasks (self, node): +def create_pyuic4_tasks(self, node): '''Creates the tasks to generate python files. The 'pyuic4' action is called for this.''' # Create a pyuic4 task to generate the python .py file - pyuic4task = self.create_task ('pyuic4') - pyuic4task.set_inputs (node) - pyuic4task.set_outputs (node.change_ext ('.py')) + pyuic4task = self.create_task('pyuic4') + pyuic4task.set_inputs(node) + pyuic4task.set_outputs(node.change_ext('.py')) # Add the python compilation tasks if self.pyc: - task = self.create_task ('pyc', self.env, 50) - task.set_inputs (node.change_ext ('.py')) - task.set_outputs (node.change_ext ('.pyc')) + task = self.create_task('pyc', self.env, 50) + task.set_inputs(node.change_ext('.py')) + task.set_outputs(node.change_ext('.pyc')) if self.pyo: - task = self.create_task ('pyo', self.env, 50) - task.set_inputs (node.change_ext ('.py')) - task.set_outputs (node.change_ext ('.pyo')) + task = self.create_task('pyo', self.env, 50) + task.set_inputs(node.change_ext('.py')) + task.set_outputs(node.change_ext('.pyo')) -def setup (env): +def setup(env): '''Creates a python hook and registers it with the environment.''' # create the hook action cmd_template = '${PYUIC4} ${PYUIC4_FLAGS} ${SRC} -o ${TGT}' cmd_color = 'BLUE' - Action.simple_action ('pyuic4', cmd_template, cmd_color) + Action.simple_action('pyuic4', cmd_template, cmd_color) # register .ui for use with python - env.hook ('py', 'PYUIC4_EXT', create_pyuic4_tasks) + env.hook('py', 'PYUIC4_EXT', create_pyuic4_tasks) -def detect (conf): +def detect(conf): env = conf.env opt = Params.g_options @@ -68,19 +68,19 @@ def detect (conf): pass if not pyuic4: - qtdir = os.environ.get ('PYQT4_ROOT', '') + qtdir = os.environ.get('PYQT4_ROOT', '') if qtdir: - binpath = [qtdir] + os.environ['PATH'].split (':') + binpath = [qtdir] + os.environ['PATH'].split(':') else: - binpath = os.environ['PATH'].split (':') + binpath = os.environ['PATH'].split(':') for f in ['pyuic4', 'pyuic-qt4', 'pyuic']: - pyuic4 = conf.find_program (f, path_list=binpath) + pyuic4 = conf.find_program(f, path_list=binpath) if pyuic4: break if not pyuic4: - conf.check_message ('pyuic4 binary', '(not found)', 0) + conf.check_message('pyuic4 binary', '(not found)', 0) return False # Set the path to pyuic4 @@ -89,14 +89,14 @@ def detect (conf): env['PYUIC4_FLAGS'] = '-x' vercmd = env['PYUIC4'] + ' --version 2>&1' - version = os.popen (vercmd).read().strip().split (' ')[-1] + version = os.popen(vercmd).read().strip().split(' ')[-1] version = version.split('.')[0] - if int(version) < 4: - conf.check_message ('pyuic version', '(too old)', 0, + if not version.isdigit() or int(version) < 4: + conf.check_message('pyuic4 version', '(not found or too old)', 0, option= '(%s)' % version) return False - conf.check_message ('pyuic4 version', '', 1, option='(%s)' % version) + conf.check_message('pyuic4 version', '', 1, option='(%s)' % version) # all tests passed return True diff --git a/py/cmds.py b/py/cmds.py deleted file mode 100644 index 9721080..0000000 --- a/py/cmds.py +++ /dev/null @@ -1,380 +0,0 @@ -import os -import re -import commands -import utils -from cStringIO import StringIO - -from PyQt4.QtCore import QProcess - -# A regex for matching the output of git (log|rev-list) --pretty=oneline -REV_LIST_REGEX = re.compile ('([0-9a-f]+)\W(.*)') - -def quote (argv): - return ' '.join ([ utils.shell_quote (arg) for arg in argv ]) - -def run_cmd (cmd, *args, **kwargs): - # Handle cmd as either a string or an argv list - if type (cmd) is str: - cmd = cmd.split (' ') - cmd += list (args) - else: - cmd = list (cmd + list (args)) - - child = QProcess() - child.setProcessChannelMode(QProcess.MergedChannels); - child.start (cmd[0], cmd[1:]) - - if (not child.waitForStarted()): - raise Exception, "failed to start child" - - if (not child.waitForFinished()): - raise Exception, "failed to start child" - - output = str (child.readAll()) - - # Allow run_cmd (argv, raw=True) for when we - # want the full, raw output (e.g. git cat-file) - if 'raw' in kwargs and kwargs['raw']: - return output - else: - return output.rstrip() - -def git_add (to_add): - '''Invokes 'git add' to index the filenames in to_add.''' - if not to_add: return 'ERROR: No files to add.' - argv = [ 'git', 'add' ] - argv.extend (to_add) - return 'Running:\t' + quote (argv) + '\n' + run_cmd (argv) - -def git_add_or_remove (to_process): - '''Invokes 'git add' to index the filenames in to_process that exist - and 'git rm' for those that do not exist.''' - - if not to_process: - return 'ERROR: No files to add or remove.' - - to_add = [] - output = '' - - for filename in to_process: - if os.path.exists (filename): - to_add.append (filename) - - if to_add: - output += git_add (to_add) + '\n\n' - - if len(to_add) == len(to_process): - # to_process only contained unremoved files -- - # short-circuit the removal checks - return output - - # Process files to add - argv = [ 'git', 'rm' ] - for filename in to_process: - if not os.path.exists (filename): - argv.append (filename) - - return '%sRunning:\t%s\n%s' % ( output, quote (argv), run_cmd (argv) ) - -def git_apply (filename, indexonly=True): - argv = ['git', 'apply'] - if indexonly: - argv.extend (['--index', '--cached']) - argv.append (filename) - return run_cmd (argv) - -def git_branch (name=None, remote=False, delete=False): - argv = ['git', 'branch'] - if delete and name: - return run_cmd (argv, '-D', name) - else: - if remote: argv.append ('-r') - - branches = run_cmd (argv).splitlines() - return map (lambda (x): x.lstrip ('* '), branches) - -def git_cat_file (objtype, sha1): - cmd = 'git cat-file %s %s' % ( objtype, sha1 ) - return run_cmd (cmd, raw=True) - -def git_cherry_pick (revs, commit=False): - '''Cherry-picks each revision into the current branch.''' - if not revs: - return 'ERROR: No revisions selected for cherry-picking.' - - argv = [ 'git', 'cherry-pick' ] - if not commit: argv.append ('-n') - - output = [] - for rev in revs: - output.append ('Cherry-picking: ' + rev) - output.append (run_cmd (argv, rev)) - output.append ('') - return '\n'.join (output) - -def git_checkout(rev): - return run_cmd('git','checkout', rev) - -def git_commit (msg, amend, files): - '''Creates a git commit. 'commit_all' triggers the -a - flag to 'git commit.' 'amend' triggers --amend. - 'files' is a list of files to use for commits without -a.''' - - # Sure, this is a potential "security risk," but if someone - # is trying to intercept/re-write commit messages on your system, - # then you probably have bigger problems to worry about. - tmpfile = utils.get_tmp_filename() - argv = [ 'git', 'commit', '-F', tmpfile ] - - if amend: argv.append ('--amend') - - if not files: - return 'ERROR: No files selected for commit.' - - argv.append ('--') - argv.extend (files) - - # Create the commit message file - file = open (tmpfile, 'w') - file.write (msg) - file.close() - - # Run 'git commit' - output = run_cmd (argv) - os.unlink (tmpfile) - - return 'Running:\t' + quote (argv) + '\n\n' + output - -def git_create_branch (name, base, track=False): - '''Creates a branch starting from base. Pass track=True - to create a remote tracking branch.''' - argv = ['git','branch'] - if track: argv.append ('--track') - return run_cmd (argv, name, base) - - -def git_current_branch(): - '''Parses 'git branch' to find the current branch.''' - branches = run_cmd ('git branch').splitlines() - for branch in branches: - if branch.startswith ('* '): - return branch.lstrip ('* ') - raise Exception, 'No current branch. Detached HEAD?' - -def git_diff (filename, staged=True, color=False, with_diff_header=False): - '''Invokes git_diff on filename. Passing staged=True adds - diffs the index against HEAD (i.e. --cached).''' - - deleted = False - argv = [ 'git', 'diff'] - if color: - argv.append ('--color') - - if staged: - deleted = not os.path.exists (filename) - argv.append ('--cached') - - argv.append ('--') - argv.append (filename) - - diff = run_cmd (argv) - diff_lines = diff.splitlines() - - output = StringIO() - start = False - del_tag = 'deleted file mode ' - - headers = [] - for line in diff_lines: - if not start and '@@ ' in line and ' @@' in line: - start = True - if start or (deleted and del_tag in line): - output.write (line + '\n') - else: - headers.append (line) - - result = output.getvalue() - output.close() - - if with_diff_header: - return (os.linesep.join (headers), result) - else: - return result - -def git_diff_stat (): - '''Returns the latest diffstat.''' - return run_cmd ('git diff --stat HEAD^') - -def git_format_patch (revs, use_range): - '''Exports patches revs in the 'ugit-patches' subdirectory. - If use_range is True, a commit range is passed to git format-patch.''' - - argv = ['git','format-patch','--thread','--patch-with-stat', - '-o','ugit-patches'] - if len (revs) > 1: - argv.append ('-n') - - header = 'Generated Patches:' - if use_range: - rev_range = '%s^..%s' % ( revs[-1], revs[0] ) - return (header + '\n' - + run_cmd (argv, rev_range)) - - output = [ header ] - num_patches = 1 - for idx, rev in enumerate (revs): - real_idx = str (idx + num_patches) - output.append ( - run_cmd (argv, '-1', '--start-number', real_idx, rev)) - - num_patches += output[-1].count ('\n') - - return '\n'.join (output) - -def git_config(key, value=None): - '''Gets or sets git config values. If value is not None, then - the config key will be set. Otherwise, the config value of the - config key is returned.''' - if value is not None: - return run_cmd ('git', 'config', key, value) - else: - return run_cmd ('git', 'config', '--get', key) - -def git_log (oneline=True, all=False): - '''Returns a pair of parallel arrays listing the revision sha1's - and commit summaries.''' - argv = [ 'git', 'log' ] - if oneline: - argv.append ('--pretty=oneline') - if all: - argv.append ('--all') - revs = [] - summaries = [] - regex = REV_LIST_REGEX - output = run_cmd (argv) - for line in output.splitlines(): - match = regex.match (line) - if match: - revs.append (match.group (1)) - summaries.append (match.group (2)) - return ( revs, summaries ) - -def git_ls_files (): - return run_cmd ('git ls-files').splitlines() - -def git_ls_tree (rev): - '''Returns a list of (mode, type, sha1, path) tuples.''' - - lines = run_cmd ('git', 'ls-tree', '-r', rev).splitlines() - output = [] - regex = re.compile ('^(\d+)\W(\w+)\W(\w+)[ \t]+(.*)$') - for line in lines: - match = regex.match (line) - if match: - mode = match.group (1) - objtype = match.group (2) - sha1 = match.group (3) - filename = match.group (4) - output.append ( (mode, objtype, sha1, filename,) ) - return output - -def git_rebase (newbase): - if not newbase: return - return run_cmd ('git','rebase', newbase) - -def git_reset (to_unstage): - '''Use 'git reset' to unstage files from the index.''' - - if not to_unstage: return 'ERROR: No files to reset.' - - argv = [ 'git', 'reset', '--' ] - argv.extend (to_unstage) - - return 'Running:\t' + quote (argv) + '\n' + run_cmd (argv) - -def git_rev_list_range (start, end): - - argv = [ 'git', 'rev-list', '--pretty=oneline', start, end ] - - raw_revs = run_cmd (argv).splitlines() - revs = [] - regex = REV_LIST_REGEX - for line in raw_revs: - match = regex.match (line) - if match: - rev_id = match.group (1) - summary = match.group (2) - revs.append ( (rev_id, summary,) ) - - return revs - -def git_show (sha1, color=False): - cmd = 'git show ' - if color: cmd += '--color ' - return run_cmd (cmd + sha1) - -def git_show_cdup(): - '''Returns a relative path to the git project root.''' - return run_cmd ('git rev-parse --show-cdup') - -def git_status(): - '''RETURNS: A tuple of staged, unstaged and untracked files. - ( array(staged), array(unstaged), array(untracked) )''' - - status_lines = run_cmd ('git status').splitlines() - - unstaged_header_seen = False - untracked_header_seen = False - - modified_header = '# Changed but not updated:' - modified_regex = re.compile ('(#\tmodified:\W{3}' - + '|#\tnew file:\W{3}' - + '|#\tdeleted:\W{4})') - - renamed_regex = re.compile ('(#\trenamed:\W{4})(.*?)\W->\W(.*)') - - untracked_header = '# Untracked files:' - untracked_regex = re.compile ('#\t(.+)') - - staged = [] - unstaged = [] - untracked = [] - - # Untracked files - for status_line in status_lines: - if untracked_header in status_line: - untracked_header_seen = True - continue - if not untracked_header_seen: - continue - match = untracked_regex.match (status_line) - if match: - filename = match.group (1) - untracked.append (filename) - - # Staged, unstaged, and renamed files - for status_line in status_lines: - if modified_header in status_line: - unstaged_header_seen = True - continue - match = modified_regex.match (status_line) - if match: - tag = match.group (0) - filename = status_line.replace (tag, '') - if unstaged_header_seen: - unstaged.append (filename) - else: - staged.append (filename) - continue - # Renamed files - match = renamed_regex.match (status_line) - if match: - oldname = match.group (2) - newname = match.group (3) - staged.append (oldname) - staged.append (newname) - - return ( staged, unstaged, untracked ) - -def git_tag (): - return run_cmd ('git tag').splitlines() diff --git a/py/controllers.py b/py/controllers.py deleted file mode 100644 index 05db82a..0000000 --- a/py/controllers.py +++ /dev/null @@ -1,644 +0,0 @@ -import os -import commands -from PyQt4 import QtGui -from PyQt4.QtGui import QDialog -from PyQt4.QtGui import QMessageBox -from PyQt4.QtGui import QMenu -from qobserver import QObserver -import cmds -import utils -import qtutils -from views import GitCommitBrowser -from views import GitBranchDialog -from views import GitCreateBranchDialog -from repobrowsercontroller import GitRepoBrowserController -from createbranchcontroller import GitCreateBranchController - -class GitController (QObserver): - '''The controller is a mediator between the model and view. - It allows for a clean decoupling between view and model classes.''' - - def __init__ (self, model, view): - QObserver.__init__ (self, model, view) - - # chdir to the root of the git tree. This is critical - # to being able to properly use the git porcelain. - cdup = cmds.git_show_cdup() - if cdup: os.chdir (cdup) - - # The diff-display context menu - self.__menu = None - self.__staged_diff_in_view = True - - # Diff display context menu - view.displayText.controller = self - view.displayText.contextMenuEvent = self.__menu_event - - # Default to creating a new commit (i.e. not an amend commit) - view.newCommitRadio.setChecked (True) - - # Binds a specific model attribute to a view widget, - # and vice versa. - self.model_to_view (model, 'commitmsg', 'commitText') - - # When a model attribute changes, this runs a specific action - self.add_actions (model, 'staged', self.action_staged) - self.add_actions (model, 'unstaged', self.action_unstaged) - self.add_actions (model, 'untracked', self.action_unstaged) - - # Routes signals for multiple widgets to our callbacks - # defined below. - self.add_signals ('textChanged()', view.commitText) - self.add_signals ('stateChanged(int)', view.untrackedCheckBox) - - self.add_signals ('released()', - view.stageButton, - view.commitButton, - view.pushButton, - view.signOffButton,) - - self.add_signals ('triggered()', - view.rescan, - view.createBranch, - view.checkoutBranch, - view.rebaseBranch, - view.deleteBranch, - view.commitAll, - view.commitSelected, - view.setCommitMessage, - view.stageChanged, - view.stageUntracked, - view.stageSelected, - view.unstageAll, - view.unstageSelected, - view.showDiffstat, - view.browseBranch, - view.browseOtherBranch, - view.visualizeAll, - view.visualizeCurrent, - view.exportPatches, - view.cherryPick,) - - self.add_signals ('itemClicked (QListWidgetItem *)', - view.stagedList, view.unstagedList,) - - self.add_signals ('itemSelectionChanged()', - view.stagedList, view.unstagedList,) - - # App cleanup - self.connect ( qtutils.qapp(), - 'lastWindowClosed()', - self.cb_last_window_closed ) - - # Handle double-clicks in the staged/unstaged lists. - # These are vanilla signal/slots since the qobserver - # signal routing is already handling these lists' signals. - self.connect ( view.unstagedList, - 'itemDoubleClicked(QListWidgetItem*)', - lambda (x): self.cb_stage_selected (model) ) - - self.connect ( view.stagedList, - 'itemDoubleClicked(QListWidgetItem*)', - lambda (x): self.cb_unstage_selected (model) ) - - # These callbacks are called in response to the signals - # defined above. One property of the QObserver callback - # mechanism is that the model is passed in as the first - # argument to the callback. This allows for a single - # controller to manage multiple models, though this - # isn't used at the moment. - self.add_callbacks (model, { - # Push Buttons - 'stageButton': self.cb_stage_selected, - 'signOffButton': lambda(m): m.add_signoff(), - 'commitButton': self.cb_commit, - # Checkboxes - 'untrackedCheckBox': self.cb_rescan, - # List Widgets - 'stagedList': self.cb_diff_staged, - 'unstagedList': self.cb_diff_unstaged, - # Menu Actions - 'rescan': self.cb_rescan, - 'createBranch': self.cb_branch_create, - 'deleteBranch': self.cb_branch_delete, - 'checkoutBranch': self.cb_checkout_branch, - 'rebaseBranch': self.cb_rebase, - 'commitAll': self.cb_commit_all, - 'commitSelected': self.cb_commit_selected, - 'setCommitMessage': - lambda(m): m.set_latest_commitmsg(), - 'stageChanged': self.cb_stage_changed, - 'stageUntracked': self.cb_stage_untracked, - 'stageSelected': self.cb_stage_selected, - 'unstageAll': self.cb_unstage_all, - 'unstageSelected': self.cb_unstage_selected, - 'showDiffstat': self.cb_show_diffstat, - 'browseBranch': self.cb_browse_current, - 'browseOtherBranch': self.cb_browse_other, - 'visualizeCurrent': self.cb_viz_current, - 'visualizeAll': self.cb_viz_all, - 'exportPatches': self.cb_export_patches, - 'cherryPick': self.cb_cherry_pick, - }) - - # Initialize the GUI - self.cb_rescan (model) - - # Setup the inotify server - self.__start_inotify_thread (model) - - ##################################################################### - # MODEL ACTIONS - ##################################################################### - - def action_staged (self, model): - '''This action is called when the model's staged list - changes. This is a thin wrapper around update_list_widget.''' - list_widget = self.view.stagedList - staged = model.get_staged() - self.__update_list_widget (list_widget, staged, True) - - def action_unstaged (self, model): - '''This action is called when the model's unstaged list - changes. This is a thin wrapper around update_list_widget.''' - list_widget = self.view.unstagedList - unstaged = model.get_unstaged() - self.__update_list_widget (list_widget, unstaged, False) - if self.view.untrackedCheckBox.isChecked(): - untracked = model.get_untracked() - self.__update_list_widget (list_widget, untracked, - append=True, - staged=False, - untracked=True) - - ##################################################################### - # CALLBACKS - ##################################################################### - - def cb_branch_create (self, model): - view = GitCreateBranchDialog (self.view) - controller = GitCreateBranchController (model, view) - view.show() - result = view.exec_() - if result == QDialog.Accepted: - self.cb_rescan (model) - - def cb_branch_delete (self, model): - dlg = GitBranchDialog(self.view, branches=cmds.git_branch()) - branch = dlg.getSelectedBranch() - if not branch: return - qtutils.show_command (self.view, - cmds.git_branch(name=branch, delete=True)) - - - def cb_browse_current (self, model): - self.__browse_branch (cmds.git_current_branch()) - - def cb_browse_other (self, model): - # Prompt for a branch to browse - branches = (cmds.git_branch (remote=False) - + cmds.git_branch (remote=True)) - - dialog = GitBranchDialog (self.view, branches=branches) - - # Launch the repobrowser - self.__browse_branch (dialog.getSelectedBranch()) - - def cb_checkout_branch (self, model): - dlg = GitBranchDialog (self.view, cmds.git_branch()) - branch = dlg.getSelectedBranch() - if not branch: return - qtutils.show_command (self.view, cmds.git_checkout(branch)) - self.cb_rescan (model) - - def cb_cherry_pick (self, model): - '''Starts a cherry-picking session.''' - (revs, summaries) = cmds.git_log (all=True) - selection, idxs = self.__select_commits (revs, summaries) - if not selection: return - - output = cmds.git_cherry_pick (selection) - self.__show_command (output, model) - - def cb_commit (self, model): - '''Sets up data and calls cmds.commit.''' - - msg = model.get_commitmsg() - if not msg: - error_msg = 'ERROR: No commit message was provided.' - self.__show_command (error_msg) - return - - amend = self.view.amendRadio.isChecked() - commit_all = self.view.commitAllCheckBox.isChecked() - - files = [] - if commit_all: - files = model.get_staged() - else: - wlist = self.view.stagedList - mlist = model.get_staged() - files = qtutils.get_selection_from_list (wlist, mlist) - # Perform the commit - output = cmds.git_commit (msg, amend, files) - - # Reset state - self.view.newCommitRadio.setChecked (True) - self.view.amendRadio.setChecked (False) - model.set_commitmsg ('') - self.__show_command (output, model) - - def cb_commit_all (self, model): - '''Sets the commit-all checkbox and runs cb_commit.''' - self.view.commitAllCheckBox.setChecked (True) - self.cb_commit (model) - - def cb_commit_selected (self, model): - '''Unsets the commit-all checkbox and runs cb_commit.''' - self.view.commitAllCheckBox.setChecked (False) - self.cb_commit (model) - - def cb_commit_sha1_selected (self, browser, revs): - '''This callback is called when a commit browser's - item is selected. This callback puts the current - revision sha1 into the commitText field. - This callback also puts shows the commit in the - browser's commit textedit and copies it into - the global clipboard/selection.''' - current = browser.commitList.currentRow() - item = browser.commitList.item (current) - if not item.isSelected(): - browser.commitText.setText ('') - browser.revisionLine.setText ('') - return - - # Get the commit's sha1 and put it in the revision line - sha1 = revs[current] - browser.revisionLine.setText (sha1) - browser.revisionLine.selectAll() - - # Lookup the info for that sha1 and display it - commit_diff = cmds.git_show (sha1) - browser.commitText.setText (commit_diff) - - # Copy the sha1 into the clipboard - qtutils.set_clipboard (sha1) - - def cb_copy (self): - cursor = self.view.displayText.textCursor() - selection = cursor.selection().toPlainText() - qtutils.set_clipboard (selection) - - # use *args to handle being called from different signals - def cb_diff_staged (self, model, *args): - self.__staged_diff_in_view = True - list_widget = self.view.stagedList - row, selected = qtutils.get_selected_row (list_widget) - - if not selected: - self.view.displayText.setText ('') - return - - filename = model.get_staged()[row] - diff = cmds.git_diff (filename, staged=True) - - if os.path.exists (filename): - pre = utils.header ('Staged for commit') - else: - pre = utils.header ('Staged for removal') - - self.view.displayText.setText (pre + diff) - - # use *args to handle being called from different signals - def cb_diff_unstaged (self, model, *args): - self.__staged_diff_in_view = False - list_widget = self.view.unstagedList - row, selected = qtutils.get_selected_row (list_widget) - if not selected: - self.view.displayText.setText ('') - return - filename = (model.get_unstaged() + model.get_untracked())[row] - if os.path.isdir (filename): - pre = utils.header ('Untracked directory') - cmd = 'ls -la %s' % utils.shell_quote (filename) - output = commands.getoutput (cmd) - self.view.displayText.setText ( pre + output ) - return - - if filename in model.get_unstaged(): - diff = cmds.git_diff (filename, staged=False) - msg = utils.header ('Modified, unstaged') + diff - else: - # untracked file - cmd = 'file -b %s' % utils.shell_quote (filename) - file_type = commands.getoutput (cmd) - - if 'binary' in file_type or 'data' in file_type: - sq_filename = utils.shell_quote (filename) - cmd = 'hexdump -C %s' % sq_filename - contents = commands.getoutput (cmd) - else: - file = open (filename, 'r') - contents = file.read() - file.close() - - msg = (utils.header ('Untracked file: ' + file_type) - + contents) - - self.view.displayText.setText (msg) - - def cb_export_patches (self, model): - '''Launches the commit browser and exports the selected - patches.''' - - (revs, summaries) = cmds.git_log () - selection, idxs = self.__select_commits (revs, summaries) - if not selection: return - - # now get the selected indices to determine whether - # a range of consecutive commits were selected - selected_range = range (idxs[0], idxs[-1] + 1) - export_range = len (idxs) > 1 and idxs == selected_range - - output = cmds.git_format_patch (selection, export_range) - self.__show_command (output) - - def cb_get_commit_msg (self, model): - model.retrieve_latest_commitmsg() - - def cb_last_window_closed (self): - '''Cleanup the inotify thread if it exists.''' - if not self.inotify_thread: return - if not self.inotify_thread.isRunning(): return - self.inotify_thread.abort = True - self.inotify_thread.quit() - self.inotify_thread.wait() - - def cb_rebase (self, model): - dlg = GitBranchDialog(self.view, cmds.git_branch()) - dlg.setWindowTitle ("Select the current branch's new root") - branch = dlg.getSelectedBranch() - if not branch: return - qtutils.show_command (self.view, cmds.git_rebase (branch)) - - def cb_rescan (self, model, *args): - '''Populates view widgets with results from "git status."''' - - # Scan for branch changes - self.__set_branch_ui_items() - - # Rescan for repo updates - model.update_status() - - if not model.has_squash_msg(): return - - if model.get_commitmsg(): - if not qtutils.question (self.view, - 'Import Commit Message?', - ('A commit message from a ' - + 'merge-in-progress was found.\n' - + 'Do you want to import it?')): - return - - # Set the new commit message - model.set_commitmsg (model.get_squash_msg()) - - def cb_show_diffstat (self, model): - '''Show the diffstat from the latest commit.''' - self.__show_command (cmds.git_diff_stat(), rescan=False) - - def cb_stage_changed (self, model): - '''Stage all changed files for commit.''' - output = cmds.git_add (model.get_unstaged()) - self.__show_command (output, model) - - def cb_stage_hunk (self): - - list_widget = self.view.unstagedList - row, selected = qtutils.get_selected_row (list_widget) - if not selected: return - - model = self.model - filename = model.get_uncommitted_item (row) - - if not os.path.exists (filename): return - if os.path.isdir (filename): return - - cursor = self.view.displayText.textCursor() - offset = cursor.position() - offset -= utils.HEADER_LENGTH + 1 - if offset < 0: return - - selection = cursor.selection().toPlainText() - - num_selected_lines = selection.count (os.linesep) - has_selection = selection and num_selected_lines > 0 - - header, diff = cmds.git_diff (filename, - with_diff_header=True, - staged=False) - - parser = utils.DiffParser (diff) - - if has_selection: - start = diff.index (selection) - end = start + len (selection) - diffs = parser.get_diffs_for_range (start, end) - else: - diffs = [ parser.get_diff_for_offset (offset) ] - - if not diffs: return - - for diff in diffs: - tmpfile = utils.get_tmp_filename() - file = open (tmpfile, 'w') - file.write (header + os.linesep + diff + os.linesep) - file.close() - model.apply_diff (tmpfile) - os.unlink (tmpfile) - - self.cb_rescan (model) - - def cb_stage_selected (self, model): - '''Use "git add" to add items to the git index. - This is a thin wrapper around __apply_to_list.''' - command = cmds.git_add_or_remove - widget = self.view.unstagedList - items = model.get_unstaged() + model.get_untracked() - self.__apply_to_list (command, model, widget, items) - - def cb_stage_untracked (self, model): - '''Stage all untracked files for commit.''' - output = cmds.git_add (model.get_untracked()) - self.__show_command (output, model) - - def cb_unstage_all (self, model): - '''Use "git reset" to remove all items from the git index.''' - output = cmds.git_reset (model.get_staged()) - self.__show_command (output, model) - - def cb_unstage_selected (self, model): - '''Use "git reset" to remove items from the git index. - This is a thin wrapper around __apply_to_list.''' - - command = cmds.git_reset - widget = self.view.stagedList - items = model.get_staged() - self.__apply_to_list (command, model, widget, items) - - def cb_viz_all (self, model): - '''Visualizes the entire git history using gitk.''' - os.system ('gitk --all &') - - def cb_viz_current (self, model): - '''Visualizes the current branch's history using gitk.''' - branch = cmds.git_current_branch() - os.system ('gitk %s &' % utils.shell_quote (branch)) - - ##################################################################### - # PRIVATE HELPER METHODS - ##################################################################### - - def __apply_to_list (self, command, model, widget, items): - '''This is a helper method that retrieves the current - selection list, applies a command to that list, - displays a dialog showing the output of that command, - and calls cb_rescan to pickup changes.''' - apply_items = qtutils.get_selection_from_list (widget, items) - output = command (apply_items) - self.__show_command (output, model) - - def __browse_branch (self, branch): - if not branch: return - # Clone the model to allow opening multiple browsers - # with different sets of data - model = self.model.clone() - model.set_branch (branch) - view = GitCommitBrowser() - controller = GitRepoBrowserController(model, view) - view.show() - view.exec_() - - def __menu_about_to_show (self): - cursor = self.view.displayText.textCursor() - allow_hunk_staging = ( not self.__staged_diff_in_view - and cursor.position() > utils.HEADER_LENGTH ) - - self.__stage_hunk_action.setEnabled (allow_hunk_staging) - - def __menu_event (self, event): - self.__menu_setup() - textedit = self.view.displayText - self.__menu.exec_ (textedit.mapToGlobal (event.pos())) - - def __menu_setup (self): - if self.__menu: return - - menu = QMenu (self.view) - stage = menu.addAction ('Stage Hunk(s)', self.cb_stage_hunk) - copy = menu.addAction ('Copy', self.cb_copy) - - self.connect (menu, 'aboutToShow()', self.__menu_about_to_show) - - self.__stage_hunk_action = stage - self.__copy_action = copy - self.__menu = menu - - - def __file_to_widget_item (self, filename, staged, untracked=False): - '''Given a filename, return a QListWidgetItem suitable - for adding to a QListWidget. "staged" controls whether - to use icons for the staged or unstaged list widget.''' - - if staged: - icon_file = utils.get_staged_icon (filename) - elif untracked: - icon_file = utils.get_untracked_icon() - else: - icon_file = utils.get_icon (filename) - - return qtutils.create_listwidget_item (filename, icon_file) - - def __select_commits (self, revs, summaries): - '''Use the GitCommitBrowser to select commits from a list.''' - if not summaries: - msg = 'ERROR: No commits exist in this branch.''' - self.__show_command (output=msg) - return ([],[]) - - browser = GitCommitBrowser (self.view) - self.connect ( browser.commitList, - 'itemSelectionChanged()', - lambda: self.cb_commit_sha1_selected( - browser, revs) ) - - for summary in summaries: - browser.commitList.addItem (summary) - - browser.show() - result = browser.exec_() - if result != QDialog.Accepted: - return ([],[]) - - list_widget = browser.commitList - selection = qtutils.get_selection_from_list (list_widget, revs) - if not selection: return ([],[]) - - # also return the selected index numbers - index_nums = range (len (revs)) - idxs = qtutils.get_selection_from_list (list_widget, index_nums) - - return (selection, idxs) - - def __set_branch_ui_items (self): - '''Sets up items that mention the current branch name.''' - current_branch = cmds.git_current_branch() - menu_text = 'Browse ' + current_branch + ' branch' - self.view.browseBranch.setText (menu_text) - - status_text = 'Current branch: ' + current_branch - self.view.statusBar().showMessage (status_text) - - def __start_inotify_thread (self, model): - # Do we have inotify? If not, return. - # Recommend installing inotify if we're on Linux. - self.inotify_thread = None - try: - from inotify import GitNotifier - except ImportError: - import platform - if platform.system() == 'Linux': - msg = ('ugit could not find python-inotify.' - + '\nSupport for inotify is disabled.') - - plat = platform.platform().lower() - if 'debian' in plat or 'ubuntu' in plat: - msg += '\n\nHint: sudo apt-get install python-pyinotify' - - qtutils.information (self.view, - 'inotify support disabled', - msg) - return - - self.inotify_thread = GitNotifier (os.getcwd()) - self.connect ( self.inotify_thread, 'timeForRescan()', - lambda: self.cb_rescan (model) ) - - # Start the notification thread - self.inotify_thread.start() - - def __show_command (self, output, model=None, rescan=True): - '''Shows output and optionally rescans for changes.''' - qtutils.show_command (self.view, output) - if rescan and model: self.cb_rescan (model) - - def __update_list_widget (self, list_widget, items, - staged, untracked=False, append=False): - '''A helper method to populate a QListWidget with the - contents of modelitems.''' - if not append: - list_widget.clear() - for item in items: - qitem = self.__file_to_widget_item (item, - staged, untracked) - list_widget.addItem( qitem ) diff --git a/py/models.py b/py/models.py deleted file mode 100644 index f119652..0000000 --- a/py/models.py +++ /dev/null @@ -1,207 +0,0 @@ -import os -import re -import commands -import cmds -from model import Model - -class GitModel(Model): - def __init__ (self): - Model.__init__ (self, { - # =========================================== - # Used in various places - # =========================================== - 'branch': '', - - # =========================================== - # Used primarily by the main UI - # =========================================== - 'name': cmds.git_config('user.name'), - 'email': cmds.git_config('user.email'), - 'commitmsg': '', - 'staged': [], - 'unstaged': [], - 'untracked': [], - - # =========================================== - # Used by the create branch dialog - # =========================================== - 'revision': '', - 'local_branches': cmds.git_branch (remote=False), - 'remote_branches': cmds.git_branch (remote=True), - 'tags': cmds.git_tag(), - - # =========================================== - # Used by the repo browser - # =========================================== - 'directory': '', - - # These are parallel lists - 'files': [], - 'sha1s': [], - 'types': [], - - # All items below here are re-calculated in - # init_browser_data() - 'directories': [], - 'directory_entries': {}, - - # These are also parallel lists - 'item_names': [], - 'item_sha1s': [], - 'item_types': [], - }) - - - def init_branch_data (self): - remote_branches = cmds.git_branch (remote=True) - local_branches = cmds.git_branch (remote=False) - tags = cmds.git_tag() - - self.set_branch ('') - self.set_revision ('') - self.set_local_branches (local_branches) - self.set_remote_branches (remote_branches) - self.set_tags (tags) - - def init_browser_data (self): - '''This scans over self.(files, sha1s, types) to generate - directories, directory_entries, itmes, item_sha1s, - and item_types.''' - - # Collect data for the model - if not self.get_branch(): return - - self.item_names = [] - self.item_sha1s = [] - self.item_types = [] - self.directories = [] - self.directory_entries = {} - - # Lookup the tree info - tree_info = cmds.git_ls_tree (self.get_branch()) - - self.set_types (map ( lambda (x): x[1], tree_info )) - self.set_sha1s (map ( lambda (x): x[2], tree_info )) - self.set_files (map ( lambda (x): x[3], tree_info )) - - if self.directory: self.directories.append ('..') - - dir_entries = self.directory_entries - dir_regex = re.compile ('([^/]+)/') - dirs_seen = {} - subdirs_seen = {} - - for idx, file in enumerate (self.files): - - orig_file = str (file) - if not orig_file.startswith (self.directory): continue - file = file[ len (self.directory): ] - - if file.count ('/'): - # This is a directory... - match = dir_regex.match (file) - if not match: continue - - dirent = match.group (1) + '/' - if dirent not in self.directory_entries: - self.directory_entries[dirent] = [] - - if dirent not in dirs_seen: - dirs_seen[dirent] = True - self.directories.append (dirent) - - entry = file.replace (dirent, '') - entry_match = dir_regex.match (entry) - if entry_match: - subdir = entry_match.group (1) + '/' - if subdir in subdirs_seen: continue - subdirs_seen[subdir] = True - dir_entries[dirent].append (subdir) - else: - dir_entries[dirent].append (entry) - else: - self.item_names.append (file) - self.item_sha1s.append (self.sha1s[idx]) - self.item_types.append (self.types[idx]) - - def add_signoff (self): - '''Adds a standard Signed-off by: tag to the end - of the current commit message.''' - - msg = self.get_commitmsg() - signoff = ('Signed-off by: %s <%s>' - % (self.get_name(), self.get_email())) - - if signoff not in msg: - self.set_commitmsg (msg + '\n\n' + signoff) - - def apply_diff (self, filename): - return cmds.git_apply (filename) - - def get_uncommitted_item (self, row): - return (self.get_unstaged() + self.get_untracked())[row] - - def __get_squash_msg_path (self): - return os.path.join (os.getcwd(), '.git', 'SQUASH_MSG') - - def has_squash_msg (self): - squash_msg = self.__get_squash_msg_path() - return os.path.exists (squash_msg) - - def get_squash_msg (self): - squash_msg = self.__get_squash_msg_path() - file = open (squash_msg) - msg = file.read() - file.close() - return msg - - def set_latest_commitmsg (self): - '''Queries git for the latest commit message and sets it in - self.commitmsg.''' - commit_msg = [] - commit_lines = cmds.git_show ('HEAD').split ('\n') - for idx, msg in enumerate (commit_lines): - if idx < 4: continue - msg = msg.lstrip() - if msg.startswith ('diff --git'): - commit_msg.pop() - break - commit_msg.append (msg) - self.set_commitmsg ('\n'.join (commit_msg).rstrip()) - - def update_status (self): - # This allows us to defer notification until the - # we finish processing data - notify_enabled = self.get_notify() - self.set_notify(False) - - # Reset the staged and unstaged model lists - # NOTE: the model's unstaged list is used to - # hold both unstaged and untracked files. - self.staged = [] - self.unstaged = [] - self.untracked = [] - - # Read git status items - ( staged_items, - unstaged_items, - untracked_items ) = cmds.git_status() - - # Gather items to be committed - for staged in staged_items: - if staged not in self.get_staged(): - self.add_staged (staged) - - # Gather unindexed items - for unstaged in unstaged_items: - if unstaged not in self.get_unstaged(): - self.add_unstaged (unstaged) - - # Gather untracked items - for untracked in untracked_items: - if untracked not in self.get_untracked(): - self.add_untracked (untracked) - - # Re-enable notifications and emit changes - self.set_notify (notify_enabled) - self.notify_observers ('staged', 'unstaged') diff --git a/py/qobserver.py b/py/qobserver.py deleted file mode 100644 index afcc287..0000000 --- a/py/qobserver.py +++ /dev/null @@ -1,113 +0,0 @@ -#!/usr/bin/env python -from PyQt4.QtCore import QObject -from PyQt4.QtCore import SIGNAL -from PyQt4.QtGui import QSpinBox, QPixmap, QTextEdit, QLineEdit - -from observer import Observer - -class QObserver (Observer, QObject): - - def __init__ (self, model, view): - Observer.__init__ (self) - QObject.__init__ (self) - - self.model = model - self.view = view - model.add_observer (self) - - self.__actions= {} - self.__callbacks = {} - self.__model_to_view = {} - self.__view_to_model = {} - - def __del__ (self): - self.model.remove_observer (self) - self.model = None - self.view = None - - del self.__actions - del self.__model_to_view - del self.__view_to_model - - def connect (self, obj, signal_str, *args): - '''Convenience function so that subclasses do not have - to import QtCore.SIGNAL.''' - signal = signal_str - if type (signal) is str: - signal = SIGNAL (signal) - return QObject.connect ( obj, signal, *args) - - def SLOT (self, *args): - '''Default slot to handle all Qt callbacks. - This method delegates to callbacks from add_signals.''' - - widget = self.sender() - sender = str (widget.objectName()) - - if sender in self.__callbacks: - model = self.__callbacks[sender][0] - callback = self.__callbacks[sender][1] - callback (model, *args) - - elif sender in self.__view_to_model: - model = self.__view_to_model[sender][0] - model_attr = self.__view_to_model[sender][1] - if isinstance (widget, QTextEdit): - value = str (widget.toPlainText()) - model.set (model_attr, value) - elif isinstance (widget, QLineEdit): - value = str (widget.text()) - model.set (model_attr, value) - else: - print ("SLOT(): Unknown widget:", - sender, widget) - - def add_signals (self, signal_str, *objects): - '''Connects object's signal to the QObserver.''' - for obj in objects: - self.connect (obj, signal_str, self.SLOT) - - def add_callbacks (self, model, callbacks): - '''Registers callbacks that are called in response to GUI events.''' - for sender, callback in callbacks.iteritems(): - self.__callbacks[sender] = ( model, callback ) - - def model_to_view (self, model, model_attr, *widget_names): - '''Binds model attributes to qt widgets (model->view)''' - self.add_subject (model, model_attr) - self.__model_to_view[model_attr] = widget_names - for widget_name in widget_names: - self.__view_to_model[widget_name] = (model, model_attr) - - def add_actions (self, model, model_attr, callback): - '''Register view actions that are called in response to - view changes. (view->model)''' - self.add_subject (model, model_attr) - self.__actions[model_attr] = callback - - def subject_changed (self, model, attr, value): - '''Sends a model attribute to the view (model->view)''' - if attr in self.__model_to_view: - for widget_name in self.__model_to_view[attr]: - widget = getattr (self.view, widget_name) - if isinstance (widget, QSpinBox): - widget.setValue (value) - elif isinstance (widget, QPixmap): - widget.load (value) - elif isinstance (widget, QTextEdit): - widget.setText (value) - elif isinstance (widget, QLineEdit): - widget.setText (value) - else: - print ('subject_changed(): ' - + 'Unknown widget:', - widget_name, widget) - - if attr not in self.__actions: return - widgets = [] - if attr in self.__model_to_view: - for widget_name in self.__model_to_view[attr]: - widget = getattr (self.__view, widget_name) - widgets.append (widget) - # Call the model callback w/ the view's widgets as the args - self.__actions[attr] (model, *widgets) diff --git a/py/qtutils.py b/py/qtutils.py deleted file mode 100644 index f1fdd49..0000000 --- a/py/qtutils.py +++ /dev/null @@ -1,64 +0,0 @@ -from PyQt4 import QtGui -from PyQt4.QtGui import QClipboard -from PyQt4.QtGui import QIcon -from PyQt4.QtGui import QListWidgetItem -from PyQt4.QtGui import QMessageBox -from PyQt4.QtGui import QPixmap -from views import GitCommandDialog - -def create_listwidget_item (text, filename): - icon = QIcon (QPixmap (filename)) - item = QListWidgetItem() - item.setIcon (icon) - item.setText (text) - return item - -def information (parent, title, message): - '''Launches a QMessageBox information with the - provided title and message.''' - QMessageBox.information(parent, title, message) - -def get_selected_row (list_widget): - '''Returns a (row_number, is_selected) tuple for a QListWidget.''' - row = list_widget.currentRow() - item = list_widget.item (row) - selected = item is not None and item.isSelected() - return (row, selected) - -def get_selection_from_list (list_widget, items): - '''Returns an array of model items that correspond to - the selected QListWidget indices.''' - selected = [] - for idx in range (list_widget.count()): - item = list_widget.item (idx) - if item.isSelected(): - selected.append (items[idx]) - return selected - -def qapp (): return QtGui.qApp - -def question (parent, title, message, default=True): - '''Launches a QMessageBox question with the provided title and message. - Passing "default=False" will make "No" the default choice.''' - yes = QMessageBox.Yes - no = QMessageBox.No - buttons = yes | no - - if default: - default = yes - else: - default = no - - result = QMessageBox.question (parent, - title, message, buttons, default) - return result == QMessageBox.Yes - -def set_clipboard (text): - qapp().clipboard().setText (text, QClipboard.Clipboard) - qapp().clipboard().setText (text, QClipboard.Selection) - -def show_command (parent, output): - if not output: return - dialog = GitCommandDialog (parent, output=output) - dialog.show() - dialog.exec_() diff --git a/py/repobrowsercontroller.py b/py/repobrowsercontroller.py deleted file mode 100644 index 57cc29f..0000000 --- a/py/repobrowsercontroller.py +++ /dev/null @@ -1,178 +0,0 @@ -#!/usr/bin/env python -import os -from PyQt4.QtGui import QFileDialog -from qobserver import QObserver -import cmds -import utils -import qtutils -import defaults - -class GitRepoBrowserController (QObserver): - def __init__ (self, model, view): - QObserver.__init__ (self, model, view) - - view.setWindowTitle ('Git Repo Browser') - - self.add_signals ('itemSelectionChanged()', - view.commitList,) - - self.add_actions (model, 'directory', - self.action_directory_changed) - - self.add_callbacks (model, { - 'commitList': self.cb_item_changed, - }) - - self.connect (view.commitList, - 'itemDoubleClicked(QListWidgetItem*)', - lambda(x): self.cb_item_double_clicked (model)) - - # Start at the root of the tree - model.set_directory('') - - ###################################################################### - # ACTIONS - ###################################################################### - - def action_directory_changed (self, model): - '''This is called in response to a change in the the - model's directory.''' - model.init_browser_data() - self.__display_items (model) - - ###################################################################### - # CALLBACKS - ###################################################################### - - def cb_item_changed (self, model): - '''This is called when the current item changes in the - file/directory list (aka the commitList).''' - current = self.view.commitList.currentRow() - item = self.view.commitList.item (current) - if item is None or not item.isSelected(): - self.view.revisionLine.setText ('') - self.view.commitText.setText ('') - return - item_names = model.get_item_names() - item_sha1s = model.get_item_sha1s() - item_types = model.get_item_types() - directories = model.get_directories() - directory_entries = model.get_directory_entries() - - if current < len (directories): - # This is a directory... - dirent = directories[current] - if dirent != '..': - # This is a real directory for which - # we have child entries - msg = utils.header ('Directory:' + dirent) - entries = directory_entries[dirent] - else: - # This is '..' which is a special case - # since it doesn't really exist - msg = utils.header ('Parent Directory') - entries = [] - - contents = '\n'.join (entries) - - self.view.commitText.setText (msg + contents) - self.view.revisionLine.setText ('') - else: - # This is a file entry. The current row is absolute, - # so get a relative index by subtracting the number - # of directory entries - idx = current - len (directories) - - if idx >= len (item_sha1s): - # This can happen when changing directories - return - - sha1 = item_sha1s[idx] - objtype = item_types[idx] - filename = item_names[idx] - - guts = cmds.git_cat_file (objtype, sha1) - header = utils.header ('File: ' + filename) - contents = guts - - self.view.commitText.setText (header + contents) - - self.view.revisionLine.setText (sha1) - self.view.revisionLine.selectAll() - - # Copy the sha1 into the clipboard - qtutils.set_clipboard (sha1) - - def cb_item_double_clicked (self, model): - '''This is called when an entry is double-clicked. - This callback changes the model's directory when - invoked on a directory item. When invoked on a file - it allows the file to be saved.''' - - current = self.view.commitList.currentRow() - directories = model.get_directories() - - # A file item was double-clicked. - # Create a save-as dialog and export the file. - if current >= len (directories): - idx = current - len (directories) - - names = model.get_item_names() - sha1s = model.get_item_sha1s() - types = model.get_item_types() - - objtype = types[idx] - sha1 = sha1s[idx] - name = names[idx] - - file_to_save = os.path.join(defaults.DIRECTORY, name) - - qstr_filename = QFileDialog.getSaveFileName(self.view, - 'Git File Export', file_to_save) - if not qstr_filename: return - - filename = str (qstr_filename) - defaults.DIRECTORY = os.path.dirname (filename) - contents = cmds.git_cat_file (objtype, sha1) - - file = open (filename, 'w') - file.write (contents) - file.close() - return - - dirent = directories[current] - curdir = model.get_directory() - - # '..' is a special case--it doesn't really exist... - if dirent == '..': - newdir = os.path.dirname (os.path.dirname (curdir)) - if newdir == '': - model.set_directory (newdir) - else: - model.set_directory (newdir + os.sep) - else: - model.set_directory (curdir + dirent) - - ###################################################################### - # PRIVATE HELPER METHODS - ###################################################################### - - def __display_items (self, model): - '''This method populates the commitList (aka item list) - with the current directories and items. Directories are - always listed first.''' - - self.view.commitList.clear() - self.view.commitText.setText ('') - self.view.revisionLine.setText ('') - - dir_icon = utils.get_directory_icon() - file_icon = utils.get_file_icon() - - for entry in model.get_directories(): - item = qtutils.create_listwidget_item(entry, dir_icon) - self.view.commitList.addItem (item) - - for entry in model.get_item_names(): - item = qtutils.create_listwidget_item(entry, file_icon) - self.view.commitList.addItem (item) diff --git a/py/syntax.py b/py/syntax.py deleted file mode 100755 index a8c3207..0000000 --- a/py/syntax.py +++ /dev/null @@ -1,94 +0,0 @@ -#!/usr/bin/python -import re -from PyQt4.QtCore import Qt -from PyQt4.QtGui import QFont -from PyQt4.QtGui import QSyntaxHighlighter -from PyQt4.QtGui import QTextCharFormat - -BEGIN = 0 -ADD = 1 -REMOVE = 2 -TEXT = 3 - -class GitSyntaxHighlighter (QSyntaxHighlighter): - - def __init__ (self, doc): - QSyntaxHighlighter.__init__ (self, doc) - - begin = self.__mkformat (QFont.Bold, Qt.cyan) - addition = self.__mkformat (QFont.Bold, Qt.green) - removal = self.__mkformat (QFont.Bold, Qt.red) - message = self.__mkformat (QFont.Bold, Qt.yellow, Qt.black) - - # Catch trailing whitespace - bad_ws_format = self.__mkformat (QFont.Bold, Qt.black, Qt.red) - self._bad_ws_regex = re.compile ('(.*?)(\s+)$') - self._bad_ws_format = bad_ws_format - - self._rules = ( - ( re.compile ('^(@@|\+\+\+|---)'), begin ), - ( re.compile ('^\+'), addition ), - ( re.compile ('^-'), removal ), - ( re.compile ('^:'), message ), - ) - - def getFormat (self, line): - for regex, rule in self._rules: - if regex.match (line): - return rule - return None - - def highlightBlock (self, qstr): - ascii = qstr.toAscii().data() - if not ascii: return - fmt = self.getFormat (ascii) - if fmt: - match = self._bad_ws_regex.match (ascii) - if match and match.group (2): - start = len (match.group (1)) - self.setFormat (0, start, fmt) - self.setFormat (start, len (ascii), - self._bad_ws_format) - else: - self.setFormat (0, len (ascii), fmt) - - def __mkformat (self, weight, color, bgcolor=None): - format = QTextCharFormat() - format.setFontWeight (weight) - format.setForeground (color) - if bgcolor: format.setBackground (bgcolor) - return format - - -if __name__ == '__main__': - import sys - from PyQt4 import QtCore, QtGui - - class SyntaxTestDialog(QtGui.QDialog): - def __init__ (self, parent): - QtGui.QDialog.__init__ (self, parent) - self.setupUi (self) - - def setupUi(self, CommandDialog): - CommandDialog.resize(QtCore.QSize(QtCore.QRect(0,0,720,512).size()).expandedTo(CommandDialog.minimumSizeHint())) - - self.vboxlayout = QtGui.QVBoxLayout(CommandDialog) - self.vboxlayout.setObjectName("vboxlayout") - - self.commandText = QtGui.QTextEdit(CommandDialog) - - font = QtGui.QFont() - font.setFamily("Monospace") - font.setPointSize(13) - self.commandText.setFont(font) - self.commandText.setAcceptDrops(False) - #self.commandText.setReadOnly(True) - self.vboxlayout.addWidget(self.commandText) - - GitSyntaxHighlighter (self.commandText.document()) - - - app = QtGui.QApplication (sys.argv) - dialog = SyntaxTestDialog (app.activeWindow()) - dialog.show() - dialog.exec_() diff --git a/py/views.py b/py/views.py deleted file mode 100644 index e8fadf0..0000000 --- a/py/views.py +++ /dev/null @@ -1,73 +0,0 @@ -import os -from PyQt4 import QtCore, QtGui -from PyQt4.QtCore import SIGNAL -from PyQt4.QtGui import QDialog -from Window import Ui_Window -from CommandDialog import Ui_CommandDialog -from CommitBrowser import Ui_CommitBrowser -from BranchDialog import Ui_BranchDialog -from CreateBranchDialog import Ui_CreateBranchDialog - -from syntax import GitSyntaxHighlighter - -class GitView (Ui_Window, QtGui.QMainWindow): - '''The main ugit interface.''' - def __init__ (self, parent=None): - QtGui.QMainWindow.__init__ (self, parent) - Ui_Window.__init__ (self) - self.setupUi (self) - self.display_splitter.setSizes ([ 300, 400 ]) - GitSyntaxHighlighter (self.displayText.document()) - -class GitCommandDialog (Ui_CommandDialog, QtGui.QDialog): - '''A simple dialog to display command output.''' - def __init__ (self, parent=None, output=None): - QtGui.QDialog.__init__ (self, parent) - Ui_CommandDialog.__init__ (self) - self.setupUi (self) - if output: self.set_command (output) - - def set_command (self, output): - self.commandText.setText (output) - -class GitBranchDialog (Ui_BranchDialog, QtGui.QDialog): - '''A dialog to display available branches.''' - def __init__ (self, parent=None, branches=None): - QtGui.QDialog.__init__ (self, parent) - Ui_BranchDialog.__init__ (self) - self.setupUi (self) - self.reset() - if branches: self.addBranches (branches) - - def reset (self): - self.branches = [] - self.comboBox.clear() - - def addBranches (self, branches): - for branch in branches: - self.branches.append (branch) - self.comboBox.addItem (branch) - - def getSelectedBranch (self): - self.show() - if self.exec_() == QDialog.Accepted: - return self.branches [ self.comboBox.currentIndex() ] - else: - return None - -class GitCommitBrowser (Ui_CommitBrowser, QtGui.QDialog): - '''A dialog to display commits in for selection.''' - def __init__ (self, parent=None): - QtGui.QDialog.__init__ (self, parent) - Ui_CommitBrowser.__init__ (self) - self.setupUi (self) - # Make the list widget slighty larger - self.splitter.setSizes ([ 50, 200 ]) - GitSyntaxHighlighter (self.commitText.document()) - -class GitCreateBranchDialog (Ui_CreateBranchDialog, QtGui.QDialog): - '''A dialog for creating or updating branches.''' - def __init__ (self, parent=None): - QtGui.QDialog.__init__ (self, parent) - Ui_CreateBranchDialog.__init__ (self) - self.setupUi (self) diff --git a/py/wscript b/py/wscript deleted file mode 100644 index 6be73eb..0000000 --- a/py/wscript +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env python -def build (bld): - pyqt = bld.create_obj ('py') - pyqt.inst_var = 'PYMODS_UGIT' - pyqt.find_sources_in_dirs ('.') diff --git a/scripts/build-win32.sh b/scripts/build-win32.sh index d46cd3b..0756029 100755 --- a/scripts/build-win32.sh +++ b/scripts/build-win32.sh @@ -23,7 +23,7 @@ mkdir -p $UGITLIBS cp README $PREFIX/README.txt cp bin/ugit.py $PREFIX cp scripts/ugit-*.sh $PREFIX -cp py/* $UGITLIBS +cp ugitlibs/* $UGITLIBS if [ -x $PYUIC4 ] && [ ! -z $PYUIC4 ]; then diff --git a/scripts/mktar.sh b/scripts/mktar.sh new file mode 100755 index 0000000..ee82982 --- /dev/null +++ b/scripts/mktar.sh @@ -0,0 +1,22 @@ +#!/bin/sh +if [ $# -lt 1 ]; then + echo "usage: mktar [BASENAME]"; exit -1 +fi +FILE="$1".tar.gz +BLD="$1".bld +DIR=installroot +if [ -d $DIR ]; then + mv $DIR $DIR.old."$$" +fi +./configure --prefix=$DIR --blddir="$BLD" \ +&& make && make install \ +&& rsync -avr $DIR/ "$1/" \ +&& tar czf "$FILE" "$1/" \ +&& rm -rf $DIR "$1" "$BLD" + +if [ -d $DIR.old.$$ ]; then + mv -v $DIR.old.$$ $DIR +fi +if [ -e $HOME/htdocs/ugit ]; then + mv -v "$FILE" $HOME/htdocs/ugit +fi diff --git a/py/__init__.py b/ugitlibs/__init__.py similarity index 100% rename from py/__init__.py rename to ugitlibs/__init__.py diff --git a/ugitlibs/cmds.py b/ugitlibs/cmds.py new file mode 100644 index 0000000..ae24580 --- /dev/null +++ b/ugitlibs/cmds.py @@ -0,0 +1,380 @@ +import os +import re +import commands +import utils +from cStringIO import StringIO + +from PyQt4.QtCore import QProcess + +# A regex for matching the output of git(log|rev-list) --pretty=oneline +REV_LIST_REGEX = re.compile('([0-9a-f]+)\W(.*)') + +def quote(argv): + return ' '.join([ utils.shell_quote(arg) for arg in argv ]) + +def run_cmd(cmd, *args, **kwargs): + # Handle cmd as either a string or an argv list + if type(cmd) is str: + cmd = cmd.split(' ') + cmd += list(args) + else: + cmd = list(cmd + list(args)) + + child = QProcess() + child.setProcessChannelMode(QProcess.MergedChannels); + child.start(cmd[0], cmd[1:]) + + if(not child.waitForStarted()): + raise Exception, "failed to start child" + + if(not child.waitForFinished()): + raise Exception, "failed to start child" + + output = str(child.readAll()) + + # Allow run_cmd(argv, raw=True) for when we + # want the full, raw output(e.g. git cat-file) + if 'raw' in kwargs and kwargs['raw']: + return output + else: + return output.rstrip() + +def git_add(to_add): + '''Invokes 'git add' to index the filenames in to_add.''' + if not to_add: return 'ERROR: No files to add.' + argv = [ 'git', 'add' ] + argv.extend(to_add) + return 'Running:\t' + quote(argv) + '\n' + run_cmd(argv) + +def git_add_or_remove(to_process): + '''Invokes 'git add' to index the filenames in to_process that exist + and 'git rm' for those that do not exist.''' + + if not to_process: + return 'ERROR: No files to add or remove.' + + to_add = [] + output = '' + + for filename in to_process: + if os.path.exists(filename): + to_add.append(filename) + + if to_add: + output += git_add(to_add) + '\n\n' + + if len(to_add) == len(to_process): + # to_process only contained unremoved files -- + # short-circuit the removal checks + return output + + # Process files to add + argv = [ 'git', 'rm' ] + for filename in to_process: + if not os.path.exists(filename): + argv.append(filename) + + return '%sRunning:\t%s\n%s' %( output, quote(argv), run_cmd(argv) ) + +def git_apply(filename, indexonly=True): + argv = ['git', 'apply'] + if indexonly: + argv.extend(['--index', '--cached']) + argv.append(filename) + return run_cmd(argv) + +def git_branch(name=None, remote=False, delete=False): + argv = ['git', 'branch'] + if delete and name: + return run_cmd(argv, '-D', name) + else: + if remote: argv.append('-r') + + branches = run_cmd(argv).splitlines() + return map(lambda(x): x.lstrip('* '), branches) + +def git_cat_file(objtype, sha1): + cmd = 'git cat-file %s %s' %( objtype, sha1 ) + return run_cmd(cmd, raw=True) + +def git_cherry_pick(revs, commit=False): + '''Cherry-picks each revision into the current branch.''' + if not revs: + return 'ERROR: No revisions selected for cherry-picking.' + + argv = [ 'git', 'cherry-pick' ] + if not commit: argv.append('-n') + + output = [] + for rev in revs: + output.append('Cherry-picking: ' + rev) + output.append(run_cmd(argv, rev)) + output.append('') + return '\n'.join(output) + +def git_checkout(rev): + return run_cmd('git','checkout', rev) + +def git_commit(msg, amend, files): + '''Creates a git commit. 'commit_all' triggers the -a + flag to 'git commit.' 'amend' triggers --amend. + 'files' is a list of files to use for commits without -a.''' + + # Sure, this is a potential "security risk," but if someone + # is trying to intercept/re-write commit messages on your system, + # then you probably have bigger problems to worry about. + tmpfile = utils.get_tmp_filename() + argv = [ 'git', 'commit', '-F', tmpfile ] + + if amend: argv.append('--amend') + + if not files: + return 'ERROR: No files selected for commit.' + + argv.append('--') + argv.extend(files) + + # Create the commit message file + file = open(tmpfile, 'w') + file.write(msg) + file.close() + + # Run 'git commit' + output = run_cmd(argv) + os.unlink(tmpfile) + + return 'Running:\t' + quote(argv) + '\n\n' + output + +def git_create_branch(name, base, track=False): + '''Creates a branch starting from base. Pass track=True + to create a remote tracking branch.''' + argv = ['git','branch'] + if track: argv.append('--track') + return run_cmd(argv, name, base) + + +def git_current_branch(): + '''Parses 'git branch' to find the current branch.''' + branches = run_cmd('git branch').splitlines() + for branch in branches: + if branch.startswith('* '): + return branch.lstrip('* ') + raise Exception, 'No current branch. Detached HEAD?' + +def git_diff(filename, staged=True, color=False, with_diff_header=False): + '''Invokes git_diff on filename. Passing staged=True adds + diffs the index against HEAD(i.e. --cached).''' + + deleted = False + argv = [ 'git', 'diff'] + if color: + argv.append('--color') + + if staged: + deleted = not os.path.exists(filename) + argv.append('--cached') + + argv.append('--') + argv.append(filename) + + diff = run_cmd(argv) + diff_lines = diff.splitlines() + + output = StringIO() + start = False + del_tag = 'deleted file mode ' + + headers = [] + for line in diff_lines: + if not start and '@@ ' in line and ' @@' in line: + start = True + if start or(deleted and del_tag in line): + output.write(line + '\n') + else: + headers.append(line) + + result = output.getvalue() + output.close() + + if with_diff_header: + return(os.linesep.join(headers), result) + else: + return result + +def git_diff_stat(): + '''Returns the latest diffstat.''' + return run_cmd('git diff --stat HEAD^') + +def git_format_patch(revs, use_range): + '''Exports patches revs in the 'ugit-patches' subdirectory. + If use_range is True, a commit range is passed to git format-patch.''' + + argv = ['git','format-patch','--thread','--patch-with-stat', + '-o','ugit-patches'] + if len(revs) > 1: + argv.append('-n') + + header = 'Generated Patches:' + if use_range: + rev_range = '%s^..%s' %( revs[-1], revs[0] ) + return(header + '\n' + + run_cmd(argv, rev_range)) + + output = [ header ] + num_patches = 1 + for idx, rev in enumerate(revs): + real_idx = str(idx + num_patches) + output.append( + run_cmd(argv, '-1', '--start-number', real_idx, rev)) + + num_patches += output[-1].count('\n') + + return '\n'.join(output) + +def git_config(key, value=None): + '''Gets or sets git config values. If value is not None, then + the config key will be set. Otherwise, the config value of the + config key is returned.''' + if value is not None: + return run_cmd('git', 'config', key, value) + else: + return run_cmd('git', 'config', '--get', key) + +def git_log(oneline=True, all=False): + '''Returns a pair of parallel arrays listing the revision sha1's + and commit summaries.''' + argv = [ 'git', 'log' ] + if oneline: + argv.append('--pretty=oneline') + if all: + argv.append('--all') + revs = [] + summaries = [] + regex = REV_LIST_REGEX + output = run_cmd(argv) + for line in output.splitlines(): + match = regex.match(line) + if match: + revs.append(match.group(1)) + summaries.append(match.group(2)) + return( revs, summaries ) + +def git_ls_files(): + return run_cmd('git ls-files').splitlines() + +def git_ls_tree(rev): + '''Returns a list of(mode, type, sha1, path) tuples.''' + + lines = run_cmd('git', 'ls-tree', '-r', rev).splitlines() + output = [] + regex = re.compile('^(\d+)\W(\w+)\W(\w+)[ \t]+(.*)$') + for line in lines: + match = regex.match(line) + if match: + mode = match.group(1) + objtype = match.group(2) + sha1 = match.group(3) + filename = match.group(4) + output.append((mode, objtype, sha1, filename,) ) + return output + +def git_rebase(newbase): + if not newbase: return + return run_cmd('git','rebase', newbase) + +def git_reset(to_unstage): + '''Use 'git reset' to unstage files from the index.''' + + if not to_unstage: return 'ERROR: No files to reset.' + + argv = [ 'git', 'reset', '--' ] + argv.extend(to_unstage) + + return 'Running:\t' + quote(argv) + '\n' + run_cmd(argv) + +def git_rev_list_range(start, end): + + argv = [ 'git', 'rev-list', '--pretty=oneline', start, end ] + + raw_revs = run_cmd(argv).splitlines() + revs = [] + regex = REV_LIST_REGEX + for line in raw_revs: + match = regex.match(line) + if match: + rev_id = match.group(1) + summary = match.group(2) + revs.append((rev_id, summary,) ) + + return revs + +def git_show(sha1, color=False): + cmd = 'git show ' + if color: cmd += '--color ' + return run_cmd(cmd + sha1) + +def git_show_cdup(): + '''Returns a relative path to the git project root.''' + return run_cmd('git rev-parse --show-cdup') + +def git_status(): + '''RETURNS: A tuple of staged, unstaged and untracked files. + ( array(staged), array(unstaged), array(untracked) )''' + + status_lines = run_cmd('git status').splitlines() + + unstaged_header_seen = False + untracked_header_seen = False + + modified_header = '# Changed but not updated:' + modified_regex = re.compile('(#\tmodified:\W{3}' + + '|#\tnew file:\W{3}' + + '|#\tdeleted:\W{4})') + + renamed_regex = re.compile('(#\trenamed:\W{4})(.*?)\W->\W(.*)') + + untracked_header = '# Untracked files:' + untracked_regex = re.compile('#\t(.+)') + + staged = [] + unstaged = [] + untracked = [] + + # Untracked files + for status_line in status_lines: + if untracked_header in status_line: + untracked_header_seen = True + continue + if not untracked_header_seen: + continue + match = untracked_regex.match(status_line) + if match: + filename = match.group(1) + untracked.append(filename) + + # Staged, unstaged, and renamed files + for status_line in status_lines: + if modified_header in status_line: + unstaged_header_seen = True + continue + match = modified_regex.match(status_line) + if match: + tag = match.group(0) + filename = status_line.replace(tag, '') + if unstaged_header_seen: + unstaged.append(filename) + else: + staged.append(filename) + continue + # Renamed files + match = renamed_regex.match(status_line) + if match: + oldname = match.group(2) + newname = match.group(3) + staged.append(oldname) + staged.append(newname) + + return( staged, unstaged, untracked ) + +def git_tag(): + return run_cmd('git tag').splitlines() diff --git a/ugitlibs/controllers.py b/ugitlibs/controllers.py new file mode 100644 index 0000000..cb49912 --- /dev/null +++ b/ugitlibs/controllers.py @@ -0,0 +1,647 @@ +import os +import commands +from PyQt4 import QtGui +from PyQt4.QtGui import QDialog +from PyQt4.QtGui import QMessageBox +from PyQt4.QtGui import QMenu +from qobserver import QObserver +import cmds +import utils +import qtutils +import defaults +from views import GitCommitBrowser +from views import GitBranchDialog +from views import GitCreateBranchDialog +from repobrowsercontroller import GitRepoBrowserController +from createbranchcontroller import GitCreateBranchController + +class GitController(QObserver): + '''The controller is a mediator between the model and view. + It allows for a clean decoupling between view and model classes.''' + + def __init__(self, model, view): + QObserver.__init__(self, model, view) + + # chdir to the root of the git tree. This is critical + # to being able to properly use the git porcelain. + cdup = cmds.git_show_cdup() + if cdup: os.chdir(cdup) + + # The diff-display context menu + self.__menu = None + self.__staged_diff_in_view = True + + # Diff display context menu + view.displayText.controller = self + view.displayText.contextMenuEvent = self.__menu_event + + # Default to creating a new commit(i.e. not an amend commit) + view.newCommitRadio.setChecked(True) + + # Binds a specific model attribute to a view widget, + # and vice versa. + self.model_to_view(model, 'commitmsg', 'commitText') + + # When a model attribute changes, this runs a specific action + self.add_actions(model, 'staged', self.action_staged) + self.add_actions(model, 'unstaged', self.action_unstaged) + self.add_actions(model, 'untracked', self.action_unstaged) + + # Routes signals for multiple widgets to our callbacks + # defined below. + self.add_signals('textChanged()', view.commitText) + self.add_signals('stateChanged(int)', view.untrackedCheckBox) + + self.add_signals('released()', + view.stageButton, + view.commitButton, + view.pushButton, + view.signOffButton,) + + self.add_signals('triggered()', + view.rescan, + view.createBranch, + view.checkoutBranch, + view.rebaseBranch, + view.deleteBranch, + view.commitAll, + view.commitSelected, + view.setCommitMessage, + view.stageChanged, + view.stageUntracked, + view.stageSelected, + view.unstageAll, + view.unstageSelected, + view.showDiffstat, + view.browseBranch, + view.browseOtherBranch, + view.visualizeAll, + view.visualizeCurrent, + view.exportPatches, + view.cherryPick, + view.loadCommitMsg,) + + self.add_signals('itemClicked(QListWidgetItem *)', + view.stagedList, view.unstagedList,) + + self.add_signals('itemSelectionChanged()', + view.stagedList, view.unstagedList,) + + # App cleanup + self.connect(qtutils.qapp(), + 'lastWindowClosed()', + self.last_window_closed) + + # These callbacks are called in response to the signals + # defined above. One property of the QObserver callback + # mechanism is that the model is passed in as the first + # argument to the callback. This allows for a single + # controller to manage multiple models, though this + # isn't used at the moment. + self.add_callbacks(model, { + # Actions that delegate directly to the model + 'signOffButton': model.add_signoff, + 'setCommitMessage': model.get_prev_commitmsg, + # Push Buttons + 'stageButton': self.stage_selected, + 'commitButton': self.commit, + # List Widgets + 'stagedList': self.diff_staged, + 'unstagedList': self.diff_unstaged, + # Menu Actions + 'rescan': self.rescan, + 'untrackedCheckBox': self.rescan, + 'createBranch': self.branch_create, + 'deleteBranch': self.branch_delete, + 'checkoutBranch': self.checkout_branch, + 'rebaseBranch': self.rebase, + 'commitAll': self.commit_all, + 'commitSelected': self.commit_selected, + 'stageChanged': self.stage_changed, + 'stageUntracked': self.stage_untracked, + 'stageSelected': self.stage_selected, + 'unstageAll': self.unstage_all, + 'unstageSelected': self.unstage_selected, + 'showDiffstat': self.show_diffstat, + 'browseBranch': self.browse_current, + 'browseOtherBranch': self.browse_other, + 'visualizeCurrent': self.viz_current, + 'visualizeAll': self.viz_all, + 'exportPatches': self.export_patches, + 'cherryPick': self.cherry_pick, + 'loadCommitMsg': self.load_commitmsg, + }) + + # Handle double-clicks in the staged/unstaged lists. + # These are vanilla signal/slots since the qobserver + # signal routing is already handling these lists' signals. + self.connect(view.unstagedList, + 'itemDoubleClicked(QListWidgetItem*)', + self.stage_selected) + + self.connect(view.stagedList, + 'itemDoubleClicked(QListWidgetItem*)', + self.unstage_selected ) + + # Initialize the GUI + self.rescan() + + # Setup the inotify server + self.__start_inotify_thread() + + ##################################################################### + # Actions + + def action_staged(self,*rest): + '''This action is called when the model's staged list + changes. This is a thin wrapper around update_list_widget.''' + list_widget = self.view.stagedList + staged = self.model.get_staged() + self.__update_list_widget(list_widget, staged, True) + + def action_unstaged(self,*rest): + '''This action is called when the model's unstaged list + changes. This is a thin wrapper around update_list_widget.''' + list_widget = self.view.unstagedList + unstaged = self.model.get_unstaged() + self.__update_list_widget(list_widget, unstaged, False) + + if self.view.untrackedCheckBox.isChecked(): + untracked = self.model.get_untracked() + self.__update_list_widget(list_widget, untracked, + append=True, + staged=False, + untracked=True) + + ##################################################################### + # Qt callbacks + + def branch_create(self,*rest): + view = GitCreateBranchDialog(self.view) + controller = GitCreateBranchController(self.model, view) + view.show() + result = view.exec_() + if result == QDialog.Accepted: + self.rescan() + + def branch_delete(self,*rest): + dlg = GitBranchDialog(self.view, branches=cmds.git_branch()) + branch = dlg.getSelectedBranch() + if not branch: return + qtutils.show_command(self.view, + cmds.git_branch(name=branch, delete=True)) + + def browse_current(self,*rest): + self.__browse_branch(cmds.git_current_branch()) + + def browse_other(self,*rest): + # Prompt for a branch to browse + branches = self.model.all_branches() + dialog = GitBranchDialog(self.view, branches=branches) + + # Launch the repobrowser + self.__browse_branch(dialog.getSelectedBranch()) + + def checkout_branch(self,*rest): + dlg = GitBranchDialog(self.view, cmds.git_branch()) + branch = dlg.getSelectedBranch() + if not branch: return + qtutils.show_command(self.view, cmds.git_checkout(branch)) + self.rescan() + + def cherry_pick(self,*rest): + '''Starts a cherry-picking session.''' + (revs, summaries) = cmds.git_log(all=True) + selection, idxs = self.__select_commits(revs, summaries) + if not selection: return + + output = cmds.git_cherry_pick(selection) + self.__show_command(output) + + def commit(self, *rest): + '''Sets up data and calls cmds.commit.''' + msg = self.model.get_commitmsg() + if not msg: + error_msg = 'ERROR: No commit message was provided.' + self.__show_command(error_msg) + return + + amend = self.view.amendRadio.isChecked() + commit_all = self.view.commitAllCheckBox.isChecked() + + files = [] + if commit_all: + files = self.model.get_staged() + else: + wlist = self.view.stagedList + mlist = self.model.get_staged() + files = qtutils.get_selection_from_list(wlist, mlist) + # Perform the commit + output = cmds.git_commit(msg, amend, files) + + # Reset state + self.view.newCommitRadio.setChecked(True) + self.view.amendRadio.setChecked(False) + self.model.set_commitmsg('') + self.__show_command(output) + + def commit_all(self,*rest): + '''Sets the commit-all checkbox and runs commit.''' + self.view.commitAllCheckBox.setChecked(True) + self.commit() + + def commit_selected(self,*rest): + '''Unsets the commit-all checkbox and runs commit.''' + self.view.commitAllCheckBox.setChecked(False) + self.commit() + + def commit_sha1_selected(self, browser, revs): + '''This callback is called when a commit browser's + item is selected. This callback puts the current + revision sha1 into the commitText field. + This callback also puts shows the commit in the + browser's commit textedit and copies it into + the global clipboard/selection.''' + current = browser.commitList.currentRow() + item = browser.commitList.item(current) + if not item.isSelected(): + browser.commitText.setText('') + browser.revisionLine.setText('') + return + + # Get the commit's sha1 and put it in the revision line + sha1 = revs[current] + browser.revisionLine.setText(sha1) + browser.revisionLine.selectAll() + + # Lookup the info for that sha1 and display it + commit_diff = cmds.git_show(sha1) + browser.commitText.setText(commit_diff) + + # Copy the sha1 into the clipboard + qtutils.set_clipboard(sha1) + + def copy(self): + cursor = self.view.displayText.textCursor() + selection = cursor.selection().toPlainText() + qtutils.set_clipboard(selection) + + # use *args to handle being called from different signals + def diff_staged(self, *rest): + self.__staged_diff_in_view = True + list_widget = self.view.stagedList + row, selected = qtutils.get_selected_row(list_widget) + + if not selected: + self.view.displayText.setText('') + return + + filename = self.model.get_staged()[row] + diff = cmds.git_diff(filename, staged=True) + + if os.path.exists(filename): + pre = utils.header('Staged for commit') + else: + pre = utils.header('Staged for removal') + + self.view.displayText.setText(pre + diff) + + # use *args to handle being called from different signals + def diff_unstaged(self,*rest): + self.__staged_diff_in_view = False + list_widget = self.view.unstagedList + row, selected = qtutils.get_selected_row(list_widget) + if not selected: + self.view.displayText.setText('') + return + filename =(self.model.get_unstaged() + + self.model.get_untracked())[row] + if os.path.isdir(filename): + pre = utils.header('Untracked directory') + cmd = 'ls -la %s' % utils.shell_quote(filename) + output = commands.getoutput(cmd) + self.view.displayText.setText(pre + output ) + return + + if filename in self.model.get_unstaged(): + diff = cmds.git_diff(filename, staged=False) + msg = utils.header('Modified, unstaged') + diff + else: + # untracked file + cmd = 'file -b %s' % utils.shell_quote(filename) + file_type = commands.getoutput(cmd) + + if 'binary' in file_type or 'data' in file_type: + sq_filename = utils.shell_quote(filename) + cmd = 'hexdump -C %s' % sq_filename + contents = commands.getoutput(cmd) + else: + file = open(filename, 'r') + contents = file.read() + file.close() + + msg =(utils.header('Untracked file: ' + file_type) + + contents) + + self.view.displayText.setText(msg) + + def export_patches(self,*rest): + '''Launches the commit browser and exports the selected + patches.''' + + (revs, summaries) = cmds.git_log() + selection, idxs = self.__select_commits(revs, summaries) + if not selection: return + + # now get the selected indices to determine whether + # a range of consecutive commits were selected + selected_range = range(idxs[0], idxs[-1] + 1) + export_range = len(idxs) > 1 and idxs == selected_range + + output = cmds.git_format_patch(selection, export_range) + self.__show_command(output) + + def get_commit_msg(self,*rest): + self.model.retrieve_latest_commitmsg() + + def last_window_closed(self): + '''Cleanup the inotify thread if it exists.''' + if not self.inotify_thread: return + if not self.inotify_thread.isRunning(): return + self.inotify_thread.abort = True + self.inotify_thread.quit() + self.inotify_thread.wait() + + def load_commitmsg(self,*args): + file = qtutils.open_dialog(self.view, + 'Load Commit Message...', + defaults.DIRECTORY) + + if file: + defaults.DIRECTORY = os.path.dirname(file) + slushy = utils.slurp(file) + self.model.set_commitmsg(slushy) + + + def rebase(self,*rest): + dlg = GitBranchDialog(self.view, cmds.git_branch()) + dlg.setWindowTitle("Select the current branch's new root") + branch = dlg.getSelectedBranch() + if not branch: return + qtutils.show_command(self.view, cmds.git_rebase(branch)) + + def rescan(self, *args): + '''Populates view widgets with results from "git status."''' + # Scan for branch changes + self.__set_branch_ui_items() + + # Rescan for repo updates + self.model.update_status() + + if not self.model.has_squash_msg(): return + + if self.model.get_commitmsg(): + if not qtutils.question(self.view, + 'Import Commit Message?', + ('A commit message from a ' + + 'merge-in-progress was found.\n' + + 'Do you want to import it?')): + return + + # Set the new commit message + self.model.set_commitmsg(self.model.get_squash_msg()) + + def show_diffstat(self,*rest): + '''Show the diffstat from the latest commit.''' + self.__show_command(cmds.git_diff_stat(), rescan=False) + + def stage_changed(self,*rest): + '''Stage all changed files for commit.''' + output = cmds.git_add(self.model.get_unstaged()) + self.__show_command(output) + + def stage_hunk(self): + + list_widget = self.view.unstagedList + row, selected = qtutils.get_selected_row(list_widget) + if not selected: return + + filename = self.model.get_uncommitted_item(row) + + if not os.path.exists(filename): return + if os.path.isdir(filename): return + + cursor = self.view.displayText.textCursor() + offset = cursor.position() + offset -= utils.HEADER_LENGTH + 1 + if offset < 0: return + + selection = cursor.selection().toPlainText() + header, diff = cmds.git_diff(filename, + with_diff_header=True, + staged=False) + parser = utils.DiffParser(diff) + + num_selected_lines = selection.count(os.linesep) + has_selection =(selection + and selection.count(os.linesep) > 0) + + if has_selection: + start = diff.index(selection) + end = start + len(selection) + diffs = parser.get_diffs_for_range(start, end) + else: + diffs = [ parser.get_diff_for_offset(offset) ] + + if not diffs: return + + for diff in diffs: + tmpfile = utils.get_tmp_filename() + file = open(tmpfile, 'w') + file.write(header + os.linesep + diff + os.linesep) + file.close() + self.model.apply_diff(tmpfile) + os.unlink(tmpfile) + + self.rescan() + + def stage_untracked(self,*rest): + '''Stage all untracked files for commit.''' + output = cmds.git_add(self.model.get_untracked()) + self.__show_command(output) + + def stage_selected(self,*rest): + '''Use "git add" to add items to the git index. + This is a thin wrapper around __apply_to_list.''' + command = cmds.git_add_or_remove + widget = self.view.unstagedList + items = self.model.get_unstaged() + self.model.get_untracked() + self.__apply_to_list(command, widget, items) + + def unstage_selected(self, *args): + '''Use "git reset" to remove items from the git index. + This is a thin wrapper around __apply_to_list.''' + command = cmds.git_reset + widget = self.view.stagedList + items = self.model.get_staged() + self.__apply_to_list(command, widget, items) + + def unstage_all(self,*rest): + '''Use "git reset" to remove all items from the git index.''' + output = cmds.git_reset(self.model.get_staged()) + self.__show_command(output) + + def viz_all(self,*rest): + '''Visualizes the entire git history using gitk.''' + os.system('gitk --all &') + + def viz_current(self,*rest): + '''Visualizes the current branch's history using gitk.''' + branch = cmds.git_current_branch() + os.system('gitk %s &' % utils.shell_quote(branch)) + + ##################################################################### + # + + def __apply_to_list(self, command, widget, items): + '''This is a helper method that retrieves the current + selection list, applies a command to that list, + displays a dialog showing the output of that command, + and calls rescan to pickup changes.''' + apply_items = qtutils.get_selection_from_list(widget, items) + command(apply_items) + self.rescan() + + def __browse_branch(self, branch): + if not branch: return + # Clone the model to allow opening multiple browsers + # with different sets of data + model = self.model.clone() + model.set_branch(branch) + view = GitCommitBrowser() + controller = GitRepoBrowserController(model, view) + view.show() + view.exec_() + + def __menu_about_to_show(self): + cursor = self.view.displayText.textCursor() + allow_hunk_staging =(not self.__staged_diff_in_view + and cursor.position() > utils.HEADER_LENGTH ) + + self.__stage_hunk_action.setEnabled(allow_hunk_staging) + + def __menu_event(self, event): + self.__menu_setup() + textedit = self.view.displayText + self.__menu.exec_(textedit.mapToGlobal(event.pos())) + + def __menu_setup(self): + if self.__menu: return + + menu = QMenu(self.view) + stage = menu.addAction('Stage Hunk(s)', self.stage_hunk) + copy = menu.addAction('Copy', self.copy) + + self.connect(menu, 'aboutToShow()', self.__menu_about_to_show) + + self.__stage_hunk_action = stage + self.__copy_action = copy + self.__menu = menu + + + def __file_to_widget_item(self, filename, staged, untracked=False): + '''Given a filename, return a QListWidgetItem suitable + for adding to a QListWidget. "staged" controls whether + to use icons for the staged or unstaged list widget.''' + + if staged: + icon_file = utils.get_staged_icon(filename) + elif untracked: + icon_file = utils.get_untracked_icon() + else: + icon_file = utils.get_icon(filename) + + return qtutils.create_listwidget_item(filename, icon_file) + + def __select_commits(self, revs, summaries): + '''Use the GitCommitBrowser to select commits from a list.''' + if not summaries: + msg = 'ERROR: No commits exist in this branch.''' + self.__show_command(output=msg) + return([],[]) + + browser = GitCommitBrowser(self.view) + self.connect(browser.commitList, + 'itemSelectionChanged()', + lambda: self.commit_sha1_selected( + browser, revs) ) + + for summary in summaries: + browser.commitList.addItem(summary) + + browser.show() + result = browser.exec_() + if result != QDialog.Accepted: + return([],[]) + + list_widget = browser.commitList + selection = qtutils.get_selection_from_list(list_widget, revs) + if not selection: return([],[]) + + # also return the selected index numbers + index_nums = range(len(revs)) + idxs = qtutils.get_selection_from_list(list_widget, index_nums) + + return(selection, idxs) + + def __set_branch_ui_items(self): + '''Sets up items that mention the current branch name.''' + current_branch = cmds.git_current_branch() + menu_text = 'Browse ' + current_branch + ' branch' + self.view.browseBranch.setText(menu_text) + + status_text = 'Current branch: ' + current_branch + self.view.statusBar().showMessage(status_text) + + def __start_inotify_thread(self): + # Do we have inotify? If not, return. + # Recommend installing inotify if we're on Linux. + self.inotify_thread = None + try: + from inotify import GitNotifier + except ImportError: + import platform + if platform.system() == 'Linux': + msg =('ugit could not find python-inotify.' + + '\nSupport for inotify is disabled.') + + plat = platform.platform().lower() + if 'debian' in plat or 'ubuntu' in plat: + msg += '\n\nHint: sudo apt-get install python-pyinotify' + + qtutils.information(self.view, + 'inotify support disabled', msg) + return + + self.inotify_thread = GitNotifier(os.getcwd()) + self.connect(self.inotify_thread, + 'timeForRescan()', self.rescan) + + # Start the notification thread + self.inotify_thread.start() + + def __show_command(self, output, rescan=True): + '''Shows output and optionally rescans for changes.''' + qtutils.show_command(self.view, output) + if rescan: self.rescan() + + def __update_list_widget(self, list_widget, items, + staged, untracked=False, append=False): + '''A helper method to populate a QListWidget with the + contents of modelitems.''' + if not append: + list_widget.clear() + for item in items: + qitem = self.__file_to_widget_item(item, + staged, untracked) + list_widget.addItem(qitem) diff --git a/py/createbranchcontroller.py b/ugitlibs/createbranchcontroller.py similarity index 66% rename from py/createbranchcontroller.py rename to ugitlibs/createbranchcontroller.py index b9a3471..1e7982c 100644 --- a/py/createbranchcontroller.py +++ b/ugitlibs/createbranchcontroller.py @@ -5,45 +5,45 @@ import cmds import qtutils from qobserver import QObserver -class GitCreateBranchController (QObserver): - def __init__ (self, model, view): - QObserver.__init__ (self, model, view) +class GitCreateBranchController(QObserver): + def __init__(self, model, view): + QObserver.__init__(self, model, view) - self.model_to_view (model, 'revision', 'revisionLine') - self.model_to_view (model, 'branch', 'branchNameLine') + self.model_to_view(model, 'revision', 'revisionLine') + self.model_to_view(model, 'branch', 'branchNameLine') - self.add_signals ('textChanged (const QString&)', + self.add_signals('textChanged(const QString&)', view.revisionLine, view.branchNameLine) - self.add_signals ('itemSelectionChanged()', + self.add_signals('itemSelectionChanged()', view.branchRootList) - self.add_signals ('released()', + self.add_signals('released()', view.createBranchButton, view.localBranchRadio, view.remoteBranchRadio, view.tagRadio) - self.add_callbacks (model, { + self.add_callbacks(model, { 'branchRootList': self.cb_item_changed, 'createBranchButton': self.cb_create_branch, 'localBranchRadio': - lambda(m): self.__display_model (m), + lambda(m): self.__display_model(m), 'remoteBranchRadio': - lambda(m): self.__display_model (m), + lambda(m): self.__display_model(m), 'tagRadio': - lambda(m): self.__display_model (m), + lambda(m): self.__display_model(m), }) model.init_branch_data() - self.__display_model (model) + self.__display_model(model) ###################################################################### # CALLBACKS ###################################################################### - def cb_create_branch (self, model): + def cb_create_branch(self, model): '''This callback is called when the "Create Branch" button is called.''' @@ -52,7 +52,7 @@ class GitCreateBranchController (QObserver): existing_branches = cmds.git_branch() if not branch or not revision: - qtutils.information (self.view, + qtutils.information(self.view, 'Missing Data', ('Please provide both a branch name and ' + 'revision expression.' )) @@ -62,7 +62,7 @@ class GitCreateBranchController (QObserver): if branch in existing_branches: if self.view.noUpdateRadio.isChecked(): - qtutils.information (self.view, + qtutils.information(self.view, 'Warning: Branch Already Exists...', ('The "' + branch + '"' + ' branch already exists and ' @@ -70,17 +70,17 @@ class GitCreateBranchController (QObserver): return # Whether we should prompt the user for lost commits - commits = cmds.git_rev_list_range (revision, branch) - check_branch = bool (commits) + commits = cmds.git_rev_list_range(revision, branch) + check_branch = bool(commits) if check_branch: lines = [] for commit in commits: - lines.append (commit[0][:8] +'\t'+ commit[1]) + lines.append(commit[0][:8] +'\t'+ commit[1]) - lost_commits = '\n\t'.join (lines) + lost_commits = '\n\t'.join(lines) - result = qtutils.question (self.view, + result = qtutils.question(self.view, 'Warning: Commits Will Be Lost...', ('Updating the ' + branch + ' branch will lose the ' @@ -96,23 +96,23 @@ class GitCreateBranchController (QObserver): ffwd = self.view.fastForwardUpdateRadio.isChecked() reset = self.view.resetRadio.isChecked() - output = cmds.git_create_branch (branch, revision, track=track) - qtutils.show_command (self.view, output) + output = cmds.git_create_branch(branch, revision, track=track) + qtutils.show_command(self.view, output) self.view.accept() - def cb_item_changed (self, model): + def cb_item_changed(self, model): '''This callback is called when the item selection changes in the branchRootList.''' qlist = self.view.branchRootList - ( row, selected ) = qtutils.get_selected_row (qlist) + ( row, selected ) = qtutils.get_selected_row(qlist) if not selected: return - sources = self.__get_branch_sources (model) + sources = self.__get_branch_sources(model) rev = sources[row] # Update the model with the selection - model.set_revision (rev) + model.set_revision(rev) # Only set the branch name field if we're # branching from a remote branch. @@ -122,26 +122,26 @@ class GitCreateBranchController (QObserver): if not self.view.remoteBranchRadio.isChecked(): return - base_regex = re.compile ('(.*?/)?([^/]+)$') - match = base_regex.match (rev) + base_regex = re.compile('(.*?/)?([^/]+)$') + match = base_regex.match(rev) if match: - branch = match.group (2) - #branch = os.path.basename (rev) + branch = match.group(2) + #branch = os.path.basename(rev) if branch == 'HEAD': return - model.set_branch (branch) + model.set_branch(branch) ###################################################################### # PRIVATE HELPER METHODS ###################################################################### - def __display_model (self, model): + def __display_model(self, model): '''Visualize the current state of the model.''' - branch_sources = self.__get_branch_sources (model) + branch_sources = self.__get_branch_sources(model) self.view.branchRootList.clear() for branch_source in branch_sources: - self.view.branchRootList.addItem (branch_source) + self.view.branchRootList.addItem(branch_source) - def __get_branch_sources (self, model): + def __get_branch_sources(self, model): '''Get the list of items for populating the branch root list.''' if self.view.localBranchRadio.isChecked(): diff --git a/py/defaults.py b/ugitlibs/defaults.py similarity index 100% rename from py/defaults.py rename to ugitlibs/defaults.py diff --git a/py/inotify.py b/ugitlibs/inotify.py similarity index 60% rename from py/inotify.py rename to ugitlibs/inotify.py index e275089..2a3c56c 100644 --- a/py/inotify.py +++ b/ugitlibs/inotify.py @@ -5,31 +5,31 @@ from PyQt4.QtCore import QThread, SIGNAL from pyinotify import ProcessEvent from pyinotify import WatchManager, Notifier, EventsCodes -class FileSysEvent (ProcessEvent): - def __init__ (self, parent): - ProcessEvent.__init__ (self) +class FileSysEvent(ProcessEvent): + def __init__(self, parent): + ProcessEvent.__init__(self) self.parent = parent - def process_default (self, event): + def process_default(self, event): self.parent.notify() -class GitNotifier (QThread): - def __init__ (self, path): - QThread.__init__ (self) +class GitNotifier(QThread): + def __init__(self, path): + QThread.__init__(self) self.path = path self.abort = False - def notify (self): - self.emit ( SIGNAL ('timeForRescan()') ) + def notify(self): + self.emit( SIGNAL('timeForRescan()') ) - def run (self): + def run(self): # Only capture those events that git cares about - mask = ( EventsCodes.IN_CREATE + mask =( EventsCodes.IN_CREATE | EventsCodes.IN_DELETE | EventsCodes.IN_MOVED_TO | EventsCodes.IN_MODIFY ) wm = WatchManager() - notifier = Notifier (wm, FileSysEvent(self)) + notifier = Notifier(wm, FileSysEvent(self)) self.notifier = notifier dirs_seen = {} @@ -37,13 +37,13 @@ class GitNotifier (QThread): while not self.abort: if not added_flag: - wm.add_watch (self.path, mask) + wm.add_watch(self.path, mask) # Register files/directories known to git for file in cmds.git_ls_files(): - wm.add_watch (file, mask) - directory = os.path.dirname (file) + wm.add_watch(file, mask) + directory = os.path.dirname(file) if directory not in dirs_seen: - wm.add_watch (directory, mask) + wm.add_watch(directory, mask) dirs_seen[directory] = True added_flag = True notifier.process_events() @@ -51,6 +51,6 @@ class GitNotifier (QThread): if notifier.check_events(): notifier.read_events() - self.msleep (200) + self.msleep(200) notifier.stop() diff --git a/py/model.py b/ugitlibs/model.py similarity index 92% rename from py/model.py rename to ugitlibs/model.py index 50a13dd..35f9bfd 100644 --- a/py/model.py +++ b/ugitlibs/model.py @@ -16,7 +16,7 @@ class Observable(object): self.__observers.append(observer) def remove_observer(self, observer): if observer in self.__observers: - self.__observers.remove (observer) + self.__observers.remove(observer) def notify_observers(self, *attr): if not self.__notify: return for observer in self.__observers: @@ -48,8 +48,8 @@ class Model(Observable): self.__list_attrs = {} self.__object_attrs = {} - def clone (self): - return self.__class__().from_dict (self.to_dict()) + def clone(self): + return self.__class__().from_dict(self.to_dict()) def set_list_attrs(self, list_attrs): self.__list_attrs.update(list_attrs) @@ -93,7 +93,7 @@ class Model(Observable): return self.__append errmsg = "%s object has no attribute '%s'" \ - % ( self.__class__, attr ) + %( self.__class__, attr ) raise AttributeError, errmsg @@ -117,7 +117,7 @@ class Model(Observable): if array is None: errmsg = "%s object has no attribute '%s'" \ - % ( self.__class__, attr ) + %( self.__class__, attr ) raise AttributeError, errmsg for value in values: @@ -145,7 +145,7 @@ class Model(Observable): cls = module.__dict__[classname] else: cls = Model - warning = 'WARNING: %s not found in %s\n' % ( + warning = 'WARNING: %s not found in %s\n' %( modname, classname ) sys.stderr.write(warning) @@ -253,11 +253,11 @@ class Model(Observable): if type(value) == ListType: - indent = " " * (Model.__INDENT__ + 4) + indent = " " *(Model.__INDENT__ + 4) strings.append(inner + "[") for val in value: stringval = indent + str(val) - strings.append (stringval) + strings.append(stringval) indent = " " * Model.__INDENT__ strings.append(indent + "]") @@ -270,18 +270,18 @@ class Model(Observable): return "\n".join(strings) -def is_list(item): return type(item) is ListType or type(item) is TupleType -def is_dict(item): return type(item) is DictType +def is_dict(item): + return type(item) is DictType +def is_list(item): + return type(item) is ListType or type(item) is TupleType def is_atom(item): - return ( - type(item) in StringTypes + return(type(item) in StringTypes or type(item) is BooleanType or type(item) is IntType or type(item) is LongType or type(item) is FloatType - or type(item) is ComplexType - ) + or type(item) is ComplexType) def is_instance(item): - return ( issubclass(item.__class__, Model) + return( issubclass(item.__class__, Model) or type(item) is InstanceType ) diff --git a/ugitlibs/models.py b/ugitlibs/models.py new file mode 100644 index 0000000..804124b --- /dev/null +++ b/ugitlibs/models.py @@ -0,0 +1,216 @@ +import os +import re +import commands +import cmds +import utils +from model import Model + +class GitModel(Model): + def __init__(self): + Model.__init__(self, { + # =========================================== + # Used in various places + # =========================================== + 'branch': '', + + # =========================================== + # Used primarily by the main UI + # =========================================== + 'name': cmds.git_config('user.name'), + 'email': cmds.git_config('user.email'), + 'commitmsg': '', + 'staged': [], + 'unstaged': [], + 'untracked': [], + + # =========================================== + # Used by the create branch dialog + # =========================================== + 'revision': '', + 'local_branches': cmds.git_branch(remote=False), + 'remote_branches': cmds.git_branch(remote=True), + 'tags': cmds.git_tag(), + + # =========================================== + # Used by the repo browser + # =========================================== + 'directory': '', + + # These are parallel lists + 'types': [], + 'sha1s': [], + 'names': [], + + # All items below here are re-calculated in + # init_browser_data() + 'directories': [], + 'directory_entries': {}, + + # These are also parallel lists + 'subtree_types': [], + 'subtree_sha1s': [], + 'subtree_names': [], + }) + + + def all_branches(self): + return (self.get_local_branches() + + self.get_remote_branches()) + + def init_branch_data(self): + remote_branches = cmds.git_branch(remote=True) + local_branches = cmds.git_branch(remote=False) + tags = cmds.git_tag() + + self.set_branch('') + self.set_revision('') + self.set_local_branches(local_branches) + self.set_remote_branches(remote_branches) + self.set_tags(tags) + + def init_browser_data(self): + '''This scans over self.(names, sha1s, types) to generate + directories, directory_entries, and subtree_*''' + + # Collect data for the model + if not self.get_branch(): return + + self.subtree_types = [] + self.subtree_sha1s = [] + self.subtree_names = [] + self.directories = [] + self.directory_entries = {} + + # Lookup the tree info + tree_info = cmds.git_ls_tree(self.get_branch()) + + self.set_types(map( lambda(x): x[1], tree_info )) + self.set_sha1s(map( lambda(x): x[2], tree_info )) + self.set_names(map( lambda(x): x[3], tree_info )) + + if self.directory: self.directories.append('..') + + dir_entries = self.directory_entries + dir_regex = re.compile('([^/]+)/') + dirs_seen = {} + subdirs_seen = {} + + for idx, name in enumerate(self.names): + + if not name.startswith(self.directory): continue + name = name[ len(self.directory): ] + + if name.count('/'): + # This is a directory... + match = dir_regex.match(name) + if not match: continue + + dirent = match.group(1) + '/' + if dirent not in self.directory_entries: + self.directory_entries[dirent] = [] + + if dirent not in dirs_seen: + dirs_seen[dirent] = True + self.directories.append(dirent) + + entry = name.replace(dirent, '') + entry_match = dir_regex.match(entry) + if entry_match: + subdir = entry_match.group(1) + '/' + if subdir in subdirs_seen: continue + subdirs_seen[subdir] = True + dir_entries[dirent].append(subdir) + else: + dir_entries[dirent].append(entry) + else: + self.subtree_types.append(self.types[idx]) + self.subtree_sha1s.append(self.sha1s[idx]) + self.subtree_names.append(name) + + def get_tree_node(self, idx): + return (self.get_types()[idx], + self.get_sha1s()[idx], + self.get_names()[idx] ) + + def get_subtree_node(self, idx): + return (self.get_subtree_types()[idx], + self.get_subtree_sha1s()[idx], + self.get_subtree_names()[idx] ) + + def add_signoff(self,*rest): + '''Adds a standard Signed-off by: tag to the end + of the current commit message.''' + + msg = self.get_commitmsg() + signoff =('Signed-off by: %s <%s>' + %(self.get_name(), self.get_email())) + + if signoff not in msg: + self.set_commitmsg(msg + '\n\n' + signoff) + + def apply_diff(self, filename): + return cmds.git_apply(filename) + + def get_uncommitted_item(self, row): + return(self.get_unstaged() + self.get_untracked())[row] + + def __get_squash_msg_path(self): + return os.path.join(os.getcwd(), '.git', 'SQUASH_MSG') + + def has_squash_msg(self): + squash_msg = self.__get_squash_msg_path() + return os.path.exists(squash_msg) + + def get_squash_msg(self): + return utils.slurp(self.__get_squash_msg_path()) + + def get_prev_commitmsg(self,*rest): + '''Queries git for the latest commit message and sets it in + self.commitmsg.''' + commit_msg = [] + commit_lines = cmds.git_show('HEAD').split('\n') + for idx, msg in enumerate(commit_lines): + if idx < 4: continue + msg = msg.lstrip() + if msg.startswith('diff --git'): + commit_msg.pop() + break + commit_msg.append(msg) + self.set_commitmsg('\n'.join(commit_msg).rstrip()) + + def update_status(self): + # This allows us to defer notification until the + # we finish processing data + notify_enabled = self.get_notify() + self.set_notify(False) + + # Reset the staged and unstaged model lists + # NOTE: the model's unstaged list is used to + # hold both unstaged and untracked files. + self.staged = [] + self.unstaged = [] + self.untracked = [] + + # Read git status items + ( staged_items, + unstaged_items, + untracked_items ) = cmds.git_status() + + # Gather items to be committed + for staged in staged_items: + if staged not in self.get_staged(): + self.add_staged(staged) + + # Gather unindexed items + for unstaged in unstaged_items: + if unstaged not in self.get_unstaged(): + self.add_unstaged(unstaged) + + # Gather untracked items + for untracked in untracked_items: + if untracked not in self.get_untracked(): + self.add_untracked(untracked) + + # Re-enable notifications and emit changes + self.set_notify(notify_enabled) + self.notify_observers('staged', 'unstaged') diff --git a/py/observer.py b/ugitlibs/observer.py similarity index 72% rename from py/observer.py rename to ugitlibs/observer.py index 7048cbd..827abca 100644 --- a/py/observer.py +++ b/ugitlibs/observer.py @@ -1,20 +1,20 @@ #!/usr/bin/env python from pprint import pformat -class Observer (object): +class Observer(object): '''Observers receive notify(*attributes) messages from their subjects whenever new data arrives. This notify() message signifies that an observer should update its internal state/view.''' - def __init__ (self): + def __init__(self): self.__attribute_adapter = {} self.__subjects = {} self.__debug = False - def set_debug (self, enabled): + def set_debug(self, enabled): self.__debug = enabled - def notify (self, *attributes): + def notify(self, *attributes): '''Called by the model to notify Observers about changes.''' # We can be notified about multiple attribute changes at once for attr in attributes: @@ -25,7 +25,7 @@ class Observer (object): model = self.__subjects[attr] # The new value for updating - value = model.getattr (attr) + value = model.getattr(attr) # Allow mapping from model to observer attributes if attr in self.__attribute_adapter: @@ -33,28 +33,28 @@ class Observer (object): # Call the concrete observer's notification method notify = model.get_notify() - model.set_notify (False) + model.set_notify(False) - self.subject_changed (model, attr, value) + self.subject_changed(model, attr, value) - model.set_notify (notify) + model.set_notify(notify) if not self.__debug: continue - print ("Objserver::notify (" - + pformat (attributes) + "):") + print("Objserver::notify(" + + pformat(attributes) + "):") print model, "\n" - def subject_changed (self, model, attr, value): + def subject_changed(self, model, attr, value): '''This method handles updating of the observer/UI. This must be implemented in each concrete observer class.''' msg = 'Concrete Observers must override subject_changed().' raise NotImplementedError, msg - def add_subject (self, model, model_attr): + def add_subject(self, model, model_attr): self.__subjects[model_attr] = model - def add_attribute_adapter (self, model_attr, observer_attr): + def add_attribute_adapter(self, model_attr, observer_attr): self.__attribute_adapter[model_attr] = observer_attr diff --git a/ugitlibs/qobserver.py b/ugitlibs/qobserver.py new file mode 100644 index 0000000..f19805e --- /dev/null +++ b/ugitlibs/qobserver.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python +from PyQt4.QtCore import QObject +from PyQt4.QtCore import SIGNAL +from PyQt4.QtGui import QSpinBox, QPixmap, QTextEdit, QLineEdit + +from observer import Observer + +class QObserver(Observer, QObject): + + def __init__(self, model, view): + Observer.__init__(self) + QObject.__init__(self) + + self.model = model + self.view = view + model.add_observer(self) + + self.__actions= {} + self.__callbacks = {} + self.__model_to_view = {} + self.__view_to_model = {} + + def __del__(self): + self.model.remove_observer(self) + self.model = None + self.view = None + + del self.__actions + del self.__model_to_view + del self.__view_to_model + + def connect(self, obj, signal_str, *args): + '''Convenience function so that subclasses do not have + to import QtCore.SIGNAL.''' + signal = signal_str + if type(signal) is str: + signal = SIGNAL(signal) + return QObject.connect( obj, signal, *args) + + def SLOT(self, *args): + '''Default slot to handle all Qt callbacks. + This method delegates to callbacks from add_signals.''' + + widget = self.sender() + sender = str(widget.objectName()) + + if sender in self.__callbacks: + model = self.__callbacks[sender][0] + callback = self.__callbacks[sender][1] + callback(model, *args) + + elif sender in self.__view_to_model: + model = self.__view_to_model[sender][0] + model_attr = self.__view_to_model[sender][1] + if isinstance(widget, QTextEdit): + value = str(widget.toPlainText()) + model.set(model_attr, value) + elif isinstance(widget, QLineEdit): + value = str(widget.text()) + model.set(model_attr, value) + else: + print("SLOT(): Unknown widget:", + sender, widget) + + def add_signals(self, signal_str, *objects): + '''Connects object's signal to the QObserver.''' + for obj in objects: + self.connect(obj, signal_str, self.SLOT) + + def add_callbacks(self, model, callbacks): + '''Registers callbacks that are called in response to GUI events.''' + for sender, callback in callbacks.iteritems(): + self.__callbacks[sender] =( model, callback ) + + def model_to_view(self, model, model_attr, *widget_names): + '''Binds model attributes to qt widgets(model->view)''' + self.add_subject(model, model_attr) + self.__model_to_view[model_attr] = widget_names + for widget_name in widget_names: + self.__view_to_model[widget_name] =(model, model_attr) + + def add_actions(self, model, model_attr, callback): + '''Register view actions that are called in response to + view changes.(view->model)''' + self.add_subject(model, model_attr) + self.__actions[model_attr] = callback + + def subject_changed(self, model, attr, value): + '''Sends a model attribute to the view(model->view)''' + if attr in self.__model_to_view: + for widget_name in self.__model_to_view[attr]: + widget = getattr(self.view, widget_name) + if isinstance(widget, QSpinBox): + widget.setValue(value) + elif isinstance(widget, QPixmap): + widget.load(value) + elif isinstance(widget, QTextEdit): + widget.setText(value) + elif isinstance(widget, QLineEdit): + widget.setText(value) + else: + print('subject_changed(): ' + + 'Unknown widget:', + widget_name, widget) + + if attr not in self.__actions: return + widgets = [] + if attr in self.__model_to_view: + for widget_name in self.__model_to_view[attr]: + widget = getattr(self.__view, widget_name) + widgets.append(widget) + # Call the model callback w/ the view's widgets as the args + self.__actions[attr](model, *widgets) diff --git a/ugitlibs/qtutils.py b/ugitlibs/qtutils.py new file mode 100644 index 0000000..0cff2f7 --- /dev/null +++ b/ugitlibs/qtutils.py @@ -0,0 +1,80 @@ +from PyQt4 import QtGui +from PyQt4.QtGui import QClipboard +from PyQt4.QtGui import QFileDialog +from PyQt4.QtGui import QIcon +from PyQt4.QtGui import QListWidgetItem +from PyQt4.QtGui import QMessageBox +from PyQt4.QtGui import QPixmap +from views import GitCommandDialog + +def create_listwidget_item(text, filename): + icon = QIcon(QPixmap(filename)) + item = QListWidgetItem() + item.setIcon(icon) + item.setText(text) + return item + +def information(parent, title, message): + '''Launches a QMessageBox information with the + provided title and message.''' + QMessageBox.information(parent, title, message) + +def get_selected_row(list_widget): + '''Returns a(row_number, is_selected) tuple for a QListWidget.''' + row = list_widget.currentRow() + item = list_widget.item(row) + selected = item is not None and item.isSelected() + return(row, selected) + +def get_selection_from_list(list_widget, items): + '''Returns an array of model items that correspond to + the selected QListWidget indices.''' + selected = [] + for idx in range(list_widget.count()): + item = list_widget.item(idx) + if item.isSelected(): + selected.append(items[idx]) + return selected + +def open_dialog(parent, title, filename=None): + qstr = QFileDialog.getOpenFileName( + parent, title, filename) + return str(qstr) + +def save_dialog(parent, title, filename=None): + qstr = QFileDialog.getSaveFileName( + parent, title, filename) + return str(qstr) + +def dir_dialog(parent, title, directory): + directory = QFileDialog.getExistingDirectory( + parent, title, directory) + return str(directory) + +def qapp(): return QtGui.qApp + +def question(parent, title, message, default=True): + '''Launches a QMessageBox question with the provided title and message. + Passing "default=False" will make "No" the default choice.''' + yes = QMessageBox.Yes + no = QMessageBox.No + buttons = yes | no + + if default: + default = yes + else: + default = no + + result = QMessageBox.question(parent, + title, message, buttons, default) + return result == QMessageBox.Yes + +def set_clipboard(text): + qapp().clipboard().setText(text, QClipboard.Clipboard) + qapp().clipboard().setText(text, QClipboard.Selection) + +def show_command(parent, output): + if not output: return + dialog = GitCommandDialog(parent, output=output) + dialog.show() + dialog.exec_() diff --git a/ugitlibs/repobrowsercontroller.py b/ugitlibs/repobrowsercontroller.py new file mode 100644 index 0000000..c9ac445 --- /dev/null +++ b/ugitlibs/repobrowsercontroller.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python +import os +from qobserver import QObserver +import cmds +import utils +import qtutils +import defaults + +class GitRepoBrowserController(QObserver): + def __init__(self, model, view): + QObserver.__init__(self, model, view) + + view.setWindowTitle('Git Repo Browser') + + self.add_signals('itemSelectionChanged()', + view.commitList,) + + self.add_actions(model, 'directory', + self.action_directory_changed) + + self.add_callbacks(model, { + 'commitList': self.item_changed, + }) + + self.connect(view.commitList, + 'itemDoubleClicked(QListWidgetItem*)', + self.item_double_clicked) + + # Start at the root of the tree + model.set_directory('') + + ###################################################################### + # Actions + + def action_directory_changed(self,*rest): + '''This is called in response to a change in the the + model's directory.''' + self.model.init_browser_data() + self.__display_items() + + ###################################################################### + # Qt callbacks + + def item_changed(self,*rest): + '''This is called when the current item changes in the + file/directory list(aka the commitList).''' + current = self.view.commitList.currentRow() + item = self.view.commitList.item(current) + if item is None or not item.isSelected(): + self.view.revisionLine.setText('') + self.view.commitText.setText('') + return + + directories = self.model.get_directories() + directory_entries = self.model.get_directory_entries() + + if current < len(directories): + # This is a directory... + dirent = directories[current] + if dirent != '..': + # This is a real directory for which + # we have child entries + msg = utils.header('Directory:' + dirent) + entries = directory_entries[dirent] + else: + # This is '..' which is a special case + # since it doesn't really exist + msg = utils.header('Parent Directory') + entries = [] + + contents = '\n'.join(entries) + + self.view.commitText.setText(msg + contents) + self.view.revisionLine.setText('') + else: + # This is a file entry. The current row is absolute, + # so get a relative index by subtracting the number + # of directory entries + idx = current - len(directories) + + if idx >= len(self.model.get_subtree_sha1s()): + # This can happen when changing directories + return + + objtype, sha1, name = \ + self.model.get_subtree_node(idx) + + guts = cmds.git_cat_file(objtype, sha1) + header = utils.header('File: ' + name) + contents = guts + + self.view.commitText.setText(header + contents) + + self.view.revisionLine.setText(sha1) + self.view.revisionLine.selectAll() + + # Copy the sha1 into the clipboard + qtutils.set_clipboard(sha1) + + def item_double_clicked(self,*rest): + '''This is called when an entry is double-clicked. + This callback changes the model's directory when + invoked on a directory item. When invoked on a file + it allows the file to be saved.''' + + current = self.view.commitList.currentRow() + directories = self.model.get_directories() + + # A file item was double-clicked. + # Create a save-as dialog and export the file. + if current >= len(directories): + idx = current - len(directories) + + objtype, sha1, name = \ + self.model.get_subtree_node(idx) + + nameguess = os.path.join(defaults.DIRECTORY, name) + + filename = qtutils.save_dialog(self.view, + 'Git File Export', nameguess) + if not filename: return + + defaults.DIRECTORY = os.path.dirname(filename) + contents = cmds.git_cat_file(objtype, sha1) + + utils.write(filename, contents) + return + + dirent = directories[current] + curdir = self.model.get_directory() + + # '..' is a special case--it doesn't really exist... + if dirent == '..': + newdir = os.path.dirname(os.path.dirname(curdir)) + if newdir == '': + self.model.set_directory(newdir) + else: + self.model.set_directory(newdir + os.sep) + else: + self.model.set_directory(curdir + dirent) + + ###################################################################### + + def __display_items(self,*rest): + '''This method populates the commitList(aka item list) + with the current directories and items. Directories are + always listed first.''' + + self.view.commitList.clear() + self.view.commitText.setText('') + self.view.revisionLine.setText('') + + dir_icon = utils.get_directory_icon() + file_icon = utils.get_file_icon() + + for entry in self.model.get_directories(): + item = qtutils.create_listwidget_item(entry, dir_icon) + self.view.commitList.addItem(item) + + for entry in self.model.get_subtree_names(): + item = qtutils.create_listwidget_item(entry, file_icon) + self.view.commitList.addItem(item) diff --git a/ugitlibs/syntax.py b/ugitlibs/syntax.py new file mode 100755 index 0000000..d50c269 --- /dev/null +++ b/ugitlibs/syntax.py @@ -0,0 +1,94 @@ +#!/usr/bin/python +import re +from PyQt4.QtCore import Qt +from PyQt4.QtGui import QFont +from PyQt4.QtGui import QSyntaxHighlighter +from PyQt4.QtGui import QTextCharFormat + +BEGIN = 0 +ADD = 1 +REMOVE = 2 +TEXT = 3 + +class GitSyntaxHighlighter(QSyntaxHighlighter): + + def __init__(self, doc): + QSyntaxHighlighter.__init__(self, doc) + + begin = self.__mkformat(QFont.Bold, Qt.cyan) + addition = self.__mkformat(QFont.Bold, Qt.green) + removal = self.__mkformat(QFont.Bold, Qt.red) + message = self.__mkformat(QFont.Bold, Qt.yellow, Qt.black) + + # Catch trailing whitespace + bad_ws_format = self.__mkformat(QFont.Bold, Qt.black, Qt.red) + self._bad_ws_regex = re.compile('(.*?)(\s+)$') + self._bad_ws_format = bad_ws_format + + self._rules =( + ( re.compile('^(@@|\+\+\+|---)'), begin ), + ( re.compile('^\+'), addition ), + ( re.compile('^-'), removal ), + ( re.compile('^:'), message ), + ) + + def getFormat(self, line): + for regex, rule in self._rules: + if regex.match(line): + return rule + return None + + def highlightBlock(self, qstr): + ascii = qstr.toAscii().data() + if not ascii: return + fmt = self.getFormat(ascii) + if fmt: + match = self._bad_ws_regex.match(ascii) + if match and match.group(2): + start = len(match.group(1)) + self.setFormat(0, start, fmt) + self.setFormat(start, len(ascii), + self._bad_ws_format) + else: + self.setFormat(0, len(ascii), fmt) + + def __mkformat(self, weight, color, bgcolor=None): + format = QTextCharFormat() + format.setFontWeight(weight) + format.setForeground(color) + if bgcolor: format.setBackground(bgcolor) + return format + + +if __name__ == '__main__': + import sys + from PyQt4 import QtCore, QtGui + + class SyntaxTestDialog(QtGui.QDialog): + def __init__(self, parent): + QtGui.QDialog.__init__(self, parent) + self.setupUi(self) + + def setupUi(self, CommandDialog): + CommandDialog.resize(QtCore.QSize(QtCore.QRect(0,0,720,512).size()).expandedTo(CommandDialog.minimumSizeHint())) + + self.vboxlayout = QtGui.QVBoxLayout(CommandDialog) + self.vboxlayout.setObjectName("vboxlayout") + + self.commandText = QtGui.QTextEdit(CommandDialog) + + font = QtGui.QFont() + font.setFamily("Monospace") + font.setPointSize(13) + self.commandText.setFont(font) + self.commandText.setAcceptDrops(False) + #self.commandText.setReadOnly(True) + self.vboxlayout.addWidget(self.commandText) + + GitSyntaxHighlighter(self.commandText.document()) + + + app = QtGui.QApplication(sys.argv) + dialog = SyntaxTestDialog(app.activeWindow()) + dialog.show() + dialog.exec_() diff --git a/py/utils.py b/ugitlibs/utils.py similarity index 53% rename from py/utils.py rename to ugitlibs/utils.py index 8a2b228..1ee6df1 100644 --- a/py/utils.py +++ b/ugitlibs/utils.py @@ -18,12 +18,12 @@ KNOWN_FILE_TYPES = { 'image': 'image.png', } -ICONSDIR = os.path.join (os.path.dirname (__file__), 'icons') +ICONSDIR = os.path.join(os.path.dirname(__file__), 'icons') -def ident_file_type (filename): +def ident_file_type(filename): '''Returns an icon based on the contents of filename.''' - if os.path.exists (filename): - quoted_filename = shell_quote (filename) + if os.path.exists(filename): + quoted_filename = shell_quote(filename) fileinfo = commands.getoutput('file -b %s' % quoted_filename) for filetype, iconname in KNOWN_FILE_TYPES.iteritems(): if filetype in fileinfo.lower(): @@ -33,38 +33,38 @@ def ident_file_type (filename): # Fallback for modified files of an unknown type return 'generic.png' -def get_icon (filename): +def get_icon(filename): '''Returns the full path to an icon file corresponding to filename's contents.''' - icon_file = ident_file_type (filename) - return os.path.join (ICONSDIR, icon_file) + icon_file = ident_file_type(filename) + return os.path.join(ICONSDIR, icon_file) -def get_staged_icon (filename): +def get_staged_icon(filename): '''Special-case method for staged items. These are only ever 'staged' and 'removed' items in the staged list.''' - if os.path.exists (filename): - return os.path.join (ICONSDIR, 'staged.png') + if os.path.exists(filename): + return os.path.join(ICONSDIR, 'staged.png') else: - return os.path.join (ICONSDIR, 'removed.png') + return os.path.join(ICONSDIR, 'removed.png') def get_untracked_icon(): - return os.path.join (ICONSDIR, 'untracked.png') + return os.path.join(ICONSDIR, 'untracked.png') def get_directory_icon(): - return os.path.join (ICONSDIR, 'dir.png') + return os.path.join(ICONSDIR, 'dir.png') def get_file_icon(): - return os.path.join (ICONSDIR, 'generic.png') + return os.path.join(ICONSDIR, 'generic.png') -def shell_quote (*inputs): +def shell_quote(*inputs): '''Quote strings so that they can be suitably martialled off to the shell. This method supports POSIX sh syntax. This is crucial to properly handle command line arguments with spaces, quotes, double-quotes, etc.''' - regex = re.compile ('[^\w!%+,\-./:@^]') - quote_regex = re.compile ("((?:'\\''){2,})") + regex = re.compile('[^\w!%+,\-./:@^]') + quote_regex = re.compile("((?:'\\''){2,})") ret = [] for input in inputs: @@ -72,107 +72,119 @@ def shell_quote (*inputs): continue if '\x00' in input: - raise AssertionError, ('No way to quote strings ' - 'containing null (\\000) bytes') + raise AssertionError,('No way to quote strings ' + 'containing null(\\000) bytes') # = does need quoting else in command position it's a # program-local environment setting - match = regex.search (input) + match = regex.search(input) if match and '=' not in input: # ' -> '\'' - input = input.replace ("'", "'\\''") + input = input.replace("'", "'\\''") # make multiple ' in a row look simpler # '\'''\'''\'' -> '"'''"' - quote_match = quote_regex.match (input) + quote_match = quote_regex.match(input) if quote_match: - quotes = match.group (1) - input.replace (quotes, - ("'" * (len(quotes)/4)) + "\"'") + quotes = match.group(1) + input.replace(quotes, + ("'" *(len(quotes)/4)) + "\"'") input = "'%s'" % input - if input.startswith ("''"): + if input.startswith("''"): input = input[2:] - if input.endswith ("''"): + if input.endswith("''"): input = input[:-2] - ret.append (input) - return ' '.join (ret) + ret.append(input) + return ' '.join(ret) def get_tmp_filename(): # Allow TMPDIR/TMP with a fallback to /tmp - return '.ugit.%s.%s' % ( os.getpid(), time.time() ) + return '.ugit.%s.%s' %( os.getpid(), time.time() ) HEADER_LENGTH = 80 -def header (msg): - pad = HEADER_LENGTH - len (msg) - 4 # len (':+') + len ('+:') +def header(msg): + pad = HEADER_LENGTH - len(msg) - 4 # len(':+') + len('+:') extra = pad % 2 pad /= 2 - return (':+' - + (' ' * pad) + return(':+' + +(' ' * pad) + msg - + (' ' * (pad + extra)) + +(' ' *(pad + extra)) + '+:' + '\n') -class DiffParser (object): - def __init__ (self, diff): - self.__diff_header = re.compile ('^@@\s[^@]+\s@@.*') +def slurp(path): + file = open(path) + slushy = file.read() + file.close() + return slushy + +def write(path, contents): + file = open(path, 'w') + file.write(contents) + file.close() + + +class DiffParser(object): + def __init__(self, diff): + self.__diff_header = re.compile('^@@\s[^@]+\s@@.*') self.__idx = -1 self.__diffs = [] self.__diff_spans = [] self.__diff_offsets = [] - self.parse_diff (diff) + self.parse_diff(diff) - def get_diffs (self): + def get_diffs(self): return self.__diffs - def get_spans (self): + def get_spans(self): return self.__diff_spans - def get_offsets (self): + def get_offsets(self): return self.__diff_offsets - def get_diff_for_offset (self, offset): - for idx, diff_offset in enumerate (self.__diff_offsets): + def get_diff_for_offset(self, offset): + for idx, diff_offset in enumerate(self.__diff_offsets): if offset < diff_offset: - return os.linesep.join (self.__diffs[idx]) + return os.linesep.join(self.__diffs[idx]) return None - def get_diffs_for_range (self, start, end): + def get_diffs_for_range(self, start, end): diffs = [] - for idx, span in enumerate (self.__diff_spans): + for idx, span in enumerate(self.__diff_spans): has_end_of_diff = start >= span[0] and start < span[1] has_all_of_diff = start <= span[0] and end >= span[1] has_head_of_diff = end >= span[0] and end <= span[1] - selected_diff = (has_end_of_diff + selected_diff =(has_end_of_diff or has_all_of_diff or has_head_of_diff) if selected_diff: - diff = os.linesep.join (self.__diffs[idx]) - diffs.append (diff) + diff = os.linesep.join(self.__diffs[idx]) + diffs.append(diff) return diffs - def parse_diff (self, diff): + def parse_diff(self, diff): total_offset = 0 - for idx, line in enumerate (diff.splitlines()): + for idx, line in enumerate(diff.splitlines()): - if self.__diff_header.match (line): - self.__diffs.append ( [line] ) + if self.__diff_header.match(line): + self.__diffs.append( [line] ) - line_len = len (line) + 1 - self.__diff_spans.append ([total_offset, + line_len = len(line) + 1 + self.__diff_spans.append([total_offset, total_offset + line_len]) total_offset += line_len - self.__diff_offsets.append (total_offset) + self.__diff_offsets.append(total_offset) self.__idx += 1 else: @@ -180,10 +192,10 @@ class DiffParser (object): errmsg = 'Malformed diff?\n\n%s' % diff raise AssertionError, errmsg - line_len = len (line) + 1 + line_len = len(line) + 1 total_offset += line_len - self.__diffs[self.__idx].append (line) + self.__diffs[self.__idx].append(line) self.__diff_spans[-1][-1] += line_len self.__diff_offsets[self.__idx] += line_len diff --git a/ugitlibs/views.py b/ugitlibs/views.py new file mode 100644 index 0000000..71e67b7 --- /dev/null +++ b/ugitlibs/views.py @@ -0,0 +1,73 @@ +import os +from PyQt4 import QtCore, QtGui +from PyQt4.QtCore import SIGNAL +from PyQt4.QtGui import QDialog +from Window import Ui_Window +from CommandDialog import Ui_CommandDialog +from CommitBrowser import Ui_CommitBrowser +from BranchDialog import Ui_BranchDialog +from CreateBranchDialog import Ui_CreateBranchDialog + +from syntax import GitSyntaxHighlighter + +class GitView(Ui_Window, QtGui.QMainWindow): + '''The main ugit interface.''' + def __init__(self, parent=None): + QtGui.QMainWindow.__init__(self, parent) + Ui_Window.__init__(self) + self.setupUi(self) + self.display_splitter.setSizes([ 300, 400 ]) + GitSyntaxHighlighter(self.displayText.document()) + +class GitCommandDialog(Ui_CommandDialog, QtGui.QDialog): + '''A simple dialog to display command output.''' + def __init__(self, parent=None, output=None): + QtGui.QDialog.__init__(self, parent) + Ui_CommandDialog.__init__(self) + self.setupUi(self) + if output: self.set_command(output) + + def set_command(self, output): + self.commandText.setText(output) + +class GitBranchDialog(Ui_BranchDialog, QtGui.QDialog): + '''A dialog to display available branches.''' + def __init__(self, parent=None, branches=None): + QtGui.QDialog.__init__(self, parent) + Ui_BranchDialog.__init__(self) + self.setupUi(self) + self.reset() + if branches: self.addBranches(branches) + + def reset(self): + self.branches = [] + self.comboBox.clear() + + def addBranches(self, branches): + for branch in branches: + self.branches.append(branch) + self.comboBox.addItem(branch) + + def getSelectedBranch(self): + self.show() + if self.exec_() == QDialog.Accepted: + return self.branches [ self.comboBox.currentIndex() ] + else: + return None + +class GitCommitBrowser(Ui_CommitBrowser, QtGui.QDialog): + '''A dialog to display commits in for selection.''' + def __init__(self, parent=None): + QtGui.QDialog.__init__(self, parent) + Ui_CommitBrowser.__init__(self) + self.setupUi(self) + # Make the list widget slighty larger + self.splitter.setSizes([ 50, 200 ]) + GitSyntaxHighlighter(self.commitText.document()) + +class GitCreateBranchDialog(Ui_CreateBranchDialog, QtGui.QDialog): + '''A dialog for creating or updating branches.''' + def __init__(self, parent=None): + QtGui.QDialog.__init__(self, parent) + Ui_CreateBranchDialog.__init__(self) + self.setupUi(self) diff --git a/ugitlibs/wscript b/ugitlibs/wscript new file mode 100644 index 0000000..19c0893 --- /dev/null +++ b/ugitlibs/wscript @@ -0,0 +1,5 @@ +#!/usr/bin/env python +def build(bld): + pyqt = bld.create_obj('py') + pyqt.inst_var = 'PYMODS_UGIT' + pyqt.find_sources_in_dirs('.') diff --git a/ui/Window.ui b/ui/Window.ui index 8112f6f..782c921 100644 --- a/ui/Window.ui +++ b/ui/Window.ui @@ -412,7 +412,7 @@ retrieve the latest commit message prior to committing. File - + @@ -628,10 +628,7 @@ retrieve the latest commit message prior to committing. Ctrl+Q - - - false - + Load Commit Message... diff --git a/ui/wscript b/ui/wscript index b03af27..eb50da4 100644 --- a/ui/wscript +++ b/ui/wscript @@ -1,6 +1,6 @@ #!/usr/bin/env python -def build (bld): - pyqt = bld.create_obj ('py') +def build(bld): + pyqt = bld.create_obj('py') pyqt.inst_var = 'PYMODS_UGIT' - pyqt.find_sources_in_dirs ('.') + pyqt.find_sources_in_dirs('.') diff --git a/wscript b/wscript dissimilarity index 72% index 057295b..efe164d 100644 --- a/wscript +++ b/wscript @@ -1,43 +1,43 @@ -#!/usr/bin/env python -import os -import glob -import buildutils -import Common - -APPNAME = 'ugit' -VERSION = '0.5.0' - -# Mandatory variables -srcdir = '.' -blddir = 'build' - -# Options -def set_options (opt): - opt.tool_options ('python') - opt.tool_options ('pyuic4', 'buildutils') - pass - -# Configure -def configure (conf): - env = conf.env - env['PYMODS'] = buildutils.pymod (env['PREFIX']) - env['PYMODS_UGIT'] = os.path.join (env['PYMODS'], 'ugitlibs') - env['ICONS'] = os.path.join (env['PYMODS_UGIT'], 'icons') - env['BIN'] = os.path.join (env['PREFIX'], 'bin') - - buildutils.configure_python (conf) - buildutils.configure_pyqt (conf) - -# Build -def build (bld): - bld.add_subdirs ('py ui') - - bin = bld.create_obj ('py') - bin.inst_var = 'BIN' - bin.chmod = 0755 - bin.find_sources_in_dirs ('bin') - - for icon in glob.glob ('icons/*.png'): - Common.install_files ('ICONS', '', icon) - - Common.symlink_as ('BIN', 'ugit.py', 'ugit') +#!/usr/bin/env python +import os +import glob +import buildutils +import Common + +APPNAME = 'ugit' +VERSION = '0.5.0' + +# Mandatory variables +srcdir = '.' +blddir = 'build' + +# Options +def set_options(opt): + opt.tool_options('python') + opt.tool_options('pyuic4', 'buildutils') + pass + +# Configure +def configure(conf): + env = conf.env + env['PYMODS'] = buildutils.pymod(env['PREFIX']) + env['PYMODS_UGIT'] = os.path.join(env['PYMODS'], 'ugitlibs') + env['ICONS'] = os.path.join(env['PYMODS_UGIT'], 'icons') + env['BIN'] = os.path.join(env['PREFIX'], 'bin') + + buildutils.configure_python(conf) + buildutils.configure_pyqt(conf) + +# Build +def build(bld): + bld.add_subdirs('ui ugitlibs') + + bin = bld.create_obj('py') + bin.inst_var = 'BIN' + bin.chmod = 0755 + bin.find_sources_in_dirs('bin') + + for icon in glob.glob('icons/*.png'): + Common.install_files('ICONS', '', icon) + + Common.symlink_as('BIN', 'ugit.py', 'ugit') -- 2.11.4.GIT