From 9044c8c267c2e812053497ad0109cc842dddfa3c Mon Sep 17 00:00:00 2001 From: David Aguilar Date: Sun, 1 Jun 2008 23:15:10 -0700 Subject: [PATCH] models: update to use GitPython Signed-off-by: David Aguilar --- ugit/models.py | 478 ++++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 439 insertions(+), 39 deletions(-) diff --git a/ugit/models.py b/ugit/models.py index 552eeb2a..4504837d 100644 --- a/ugit/models.py +++ b/ugit/models.py @@ -1,13 +1,40 @@ import os import sys import re +import time +import subprocess +from cStringIO import StringIO -#The git module itself knows nothing about ugit whatsoever +# GitPython http://gitorious.org/projects/git-python import git from ugit import utils from ugit import model +#+------------------------------------------------------------------------- +#+ A regex for matching the output of git(log|rev-list) --pretty=oneline +REV_LIST_REGEX = re.compile('([0-9a-f]+)\W(.*)') + +#+------------------------------------------------------------------------- +# List of functions available directly through model.command_name() +GIT_COMMANDS = """ + am annotate apply archive archive_recursive + bisect blame branch bundle + checkout checkout_index cherry cherry_pick citool + clean commit config count_objects + describe diff + fast_export fetch filter_branch format_patch fsck + gc get_tar_commit_id grep gui + hard_repack imap_send init instaweb + log lost_found ls_files ls_remote ls_tree + merge mergetool mv name_rev pull push + read_tree rebase relink remote repack + request_pull reset revert rev_list rm + send_email shortlog show show_branch + show_ref stash status submodule svn + tag var verify_pack whatchanged +""".split() + class Model(model.Model): """Provides a friendly wrapper for doing commit git operations.""" @@ -18,22 +45,15 @@ class Model(model.Model): # chdir to the root of the git tree. # This keeps paths relative. - cdup = git.rev_parse(show_cdup=True) - if cdup: - if cdup.startswith('fatal:'): - # this is not a git repo - sys.stderr.write(cdup+"\n") - sys.exit(-1) - os.chdir(cdup) + self.git = git.Git() + os.chdir( self.git.git_dir ) # Read git config - self.init_config_data() + self.__init_config_data() # Import all git commands from git.py - for name, cmd in git.commands.iteritems(): - # We still want clone from the core Model - if name == 'clone': continue - setattr(self, name, cmd) + for cmd in GIT_COMMANDS: + setattr(self, cmd, getattr(self.git, cmd)) self.create( ##################################################### @@ -44,7 +64,7 @@ class Model(model.Model): local_branch = '', remote_branch = '', search_text = '', - git_version = git.version(), + git_version = self.git.version(), ##################################################### # Used primarily by the main UI @@ -86,8 +106,7 @@ class Model(model.Model): subtree_names = [], ) - - def init_config_data(self): + def __init_config_data(self): """Reads git config --list and creates parameters for each setting.""" # These parameters are saved in .gitconfig, @@ -116,8 +135,8 @@ class Model(model.Model): 'ugit_saveatexit': False, } - local_dict = git.config_dict(local=True) - global_dict = git.config_dict(local=False) + local_dict = self.config_dict(local=True) + global_dict = self.config_dict(local=False) for k,v in local_dict.iteritems(): self.set_param('local_'+k, v) @@ -152,7 +171,19 @@ class Model(model.Model): self.set_param('global_'+k, v) # Load the diff context - git.set_diff_context( self.local_gui_diffcontext ) + self.diff_context = self.local_gui_diffcontext + + def branch_list(self, remote=False): + branches = map(lambda x: x.lstrip('* '), + self.git.branch(r=remote).splitlines()) + if remote: + remotes = [] + for branch in branches: + if branch.endswith('/HEAD'): + continue + remotes.append(branch) + return remotes + return branches def get_config_params(self): params = [] @@ -169,7 +200,7 @@ class Model(model.Model): return value = self.get_param(param) if param == 'local_gui_diffcontext': - git.DIFF_CONTEXT = value + self.diff_context = value if param.startswith('local_'): param = param[len('local_'):] is_local = True @@ -180,7 +211,7 @@ class Model(model.Model): raise Exception("Invalid param '%s' passed to " % param + "save_config_param()") param = param.replace('_','.') # model -> git - return git.config_set(param, value, local=is_local) + return self.config_set(param, value, local=is_local) def init_browser_data(self): '''This scans over self.(names, sha1s, types) to generate @@ -196,7 +227,7 @@ class Model(model.Model): self.directory_entries = {} # Lookup the tree info - tree_info = git.parse_ls_tree(self.get_currentbranch()) + tree_info = self.parse_ls_tree(self.get_currentbranch()) self.set_types(map( lambda(x): x[1], tree_info )) self.set_sha1s(map( lambda(x): x[2], tree_info )) @@ -241,6 +272,33 @@ class Model(model.Model): self.subtree_sha1s.append(self.sha1s[idx]) self.subtree_names.append(name) + def add_or_remove(self, *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 'No files to add or remove.' + + to_add = [] + to_remove = [] + + for filename in to_process: + if os.path.exists(filename): + to_add.append(filename) + + output = self.git.add(verbose=True, *to_add) + + if len(to_add) == len(to_process): + # to_process only contained unremoved files -- + # short-circuit the removal checks + return output + + # Process files to remote + for filename in to_process: + if not os.path.exists(filename): + to_remove.append(filename) + output + '\n\n' + self.git.rm(*to_remove) + def get_history_browser(self): return self.global_ugit_historybrowser @@ -267,7 +325,7 @@ class Model(model.Model): if not remote: return self.set_param('remote', remote) branches = utils.grep( '%s/\S+$' % remote, - git.branch_list(remote=True), squash=False) + self.branch_list(remote=True), squash=False) self.set_remote_branches(branches) def add_signoff(self,*rest): @@ -283,7 +341,7 @@ class Model(model.Model): self.set_commitmsg(msg + signoff) def apply_diff(self, filename): - return git.apply(filename, index=True, cached=True) + return self.git.apply(filename, index=True, cached=True) def load_commitmsg(self, path): file = open(path, 'r') @@ -295,7 +353,7 @@ class Model(model.Model): '''Queries git for the latest commit message and sets it in self.commitmsg.''' commit_msg = [] - commit_lines = git.show('HEAD').split('\n') + commit_lines = self.git.show('HEAD').split('\n') for idx, msg in enumerate(commit_lines): if idx < 4: continue msg = msg.lstrip() @@ -321,7 +379,7 @@ class Model(model.Model): # Read git status items ( staged_items, modified_items, - untracked_items ) = git.parse_status() + untracked_items ) = self.parse_status() # Gather items to be committed for staged in staged_items: @@ -338,12 +396,12 @@ class Model(model.Model): if untracked not in self.get_untracked(): self.add_untracked(untracked) - self.set_currentbranch(git.current_branch()) + self.set_currentbranch(self.current_branch()) self.set_unstaged(self.get_modified() + self.get_untracked()) - self.set_remotes(git.remote().splitlines()) - self.set_remote_branches(git.branch_list(remote=True)) - self.set_local_branches(git.branch_list(remote=False)) - self.set_tags(git.tag().splitlines()) + self.set_remotes(self.git.remote().splitlines()) + self.set_remote_branches(self.branch_list(remote=True)) + self.set_local_branches(self.branch_list(remote=False)) + self.set_tags(self.git.tag().splitlines()) self.set_revision('') self.set_local_branch('') self.set_remote_branch('') @@ -352,7 +410,7 @@ class Model(model.Model): self.notify_observers('staged','unstaged') def delete_branch(self, branch): - return git.branch(branch, D=True) + return self.git.branch(branch, D=True) def get_revision_sha1(self, idx): return self.get_revisions()[idx] @@ -374,7 +432,7 @@ class Model(model.Model): self.set_param(param, new_size) def get_commit_diff(self, sha1): - commit = git.show(sha1) + commit = self.git.show(sha1) first_newline = commit.index('\n') if commit[first_newline+1:].startswith('Merge:'): return (commit @@ -415,7 +473,7 @@ class Model(model.Model): file_type = utils.run_cmd('file',filename, b=True) if 'binary' in file_type or 'data' in file_type: - diff = utils.run_cmd('hexdump', filename, C=True) + diff = utils.run_cmd('hexdump', '-C', filename) else: if os.path.exists(filename): file = open(filename, 'r') @@ -426,23 +484,365 @@ class Model(model.Model): return diff, status def stage_modified(self): - output = git.add(self.get_modified()) + output = self.git.add(self.get_modified()) self.update_status() return output def stage_untracked(self): - output = git.add(self.get_untracked()) + output = self.git.add(self.get_untracked()) self.update_status() return output def reset(self, *items): - output = git.reset('--', *items) + output = self.git.reset('--', *items) self.update_status() return output def unstage_all(self): - git.reset('--', *self.get_staged()) + self.git.reset('--', *self.get_staged()) self.update_status() def save_gui_settings(self): - git.config_set('ugit.geometry', utils.get_geom(), local=False) + self.config_set('ugit.geometry', utils.get_geom(), local=False) + + def config_set(self, key=None, value=None, local=True): + if key and value is not None: + # git config category.key value + strval = str(value) + if type(value) is bool: + # git uses "true" and "false" + strval = strval.lower() + if local: + argv = [ key, strval ] + else: + argv = [ '--global', key, strval ] + return self.git.config(*argv) + else: + msg = "oops in config_set(key=%s,value=%s,local=%s" + raise Exception(msg % (key, value, local)) + + def config_dict(self, local=True): + """parses the lines from git config --list into a dictionary""" + + kwargs = { + 'list': True, + 'global': not local, + } + config_lines = self.git.config(**kwargs).splitlines() + newdict = {} + for line in config_lines: + k, v = line.split('=', 1) + k = k.replace('.','_') # git -> model + if v == 'true' or v == 'false': + v = bool(eval(v.title())) + try: + v = int(eval(v)) + except: + pass + newdict[k]=v + return newdict + + def commit_with_msg(self, msg, amend=False): + """Creates a git commit.""" + + if not msg.endswith('\n'): + msg += '\n' + # 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 = self.get_tmp_filename() + + # Create the commit message file + file = open(tmpfile, 'w') + file.write(msg) + file.close() + + # Run 'git commit' + output = self.git.commit(F=tmpfile, amend=amend) + os.unlink(tmpfile) + + return ('git commit -F %s --amend %s\n\n%s' + % ( tmpfile, amend, output )) + + + def diffindex(self): + return self.git.diff( + unified=self.diff_context, + stat=True, + cached=True + ) + + def get_tmp_filename(self): + # Allow TMPDIR/TMP with a fallback to /tmp + env = os.environ + basename = '.git.%s.%s' % ( os.getpid(), time.time() ) + tmpdir = env.get('TMP', env.get('TMPDIR', '/tmp')) + return os.path.join( tmpdir, basename ) + + def log_helper(self, all=False): + """Returns a pair of parallel arrays listing the revision sha1's + and commit summaries.""" + revs = [] + summaries = [] + regex = REV_LIST_REGEX + output = self.git.log(pretty='oneline', all=all) + 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 parse_rev_list(self, raw_revs): + revs = [] + for line in raw_revs.splitlines(): + match = REV_LIST_REGEX.match(line) + if match: + rev_id = match.group(1) + summary = match.group(2) + revs.append((rev_id, summary,) ) + return revs + + def rev_list_range(self, start, end): + range = '%s..%s' % ( start, end ) + raw_revs = self.git.rev_list(range, pretty='oneline') + return self.parse_rev_list(raw_revs) + + def diff_helper(self, + commit=None, + filename=None, + color=False, + cached=True, + with_diff_header=False, + suppress_header=True, + reverse=False): + "Invokes git diff on a filepath." + + argv = [] + if commit: + argv.append('%s^..%s' % (commit, commit)) + + if filename: + argv.append('--') + if type(filename) is list: + argv.extend(filename) + else: + argv.append(filename) + + diff = self.git.diff( + R=reverse, + color=color, + cached=cached, + patch_with_raw=True, + unified=self.diff_context, + *argv + ).splitlines() + + output = StringIO() + start = False + del_tag = 'deleted file mode ' + + headers = [] + deleted = cached and not os.path.exists(filename) + for line in diff: + 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: + if with_diff_header: + headers.append(line) + elif not suppress_header: + output.write(line + '\n') + result = output.getvalue() + output.close() + if with_diff_header: + return('\n'.join(headers), result) + else: + return result + + def git_repo_path(self, *subpaths): + paths = [ self.git.rev_parse(git_dir=True) ] + paths.extend(subpaths) + return os.path.realpath(os.path.join(*paths)) + + def get_merge_message_path(self): + for file in ('MERGE_MSG', 'SQUASH_MSG'): + path = self.git_repo_path(file) + if os.path.exists(path): + return path + return None + + def get_merge_message(self): + return self.git.fmt_merge_msg( + '--file', self.git_repo_path('FETCH_HEAD') + ) + + def abort_merge(self): + # Reset the worktree + output = self.git.read_tree("HEAD", reset=True, u=True, v=True) + # remove MERGE_HEAD + merge_head = self.git_repo_path('MERGE_HEAD') + if os.path.exists(merge_head): + os.unlink(merge_head) + # remove MERGE_MESSAGE, etc. + merge_msg_path = self.get_merge_message_path() + while merge_msg_path: + os.unlink(merge_msg_path) + merge_msg_path = self.get_merge_message_path() + + + def parse_status(self): + """RETURNS: A tuple of staged, unstaged and untracked file lists. + """ + def eval_path(path): + """handles quoted paths.""" + if path.startswith('"') and path.endswith('"'): + return eval(path) + else: + return path + + MODIFIED_TAG = '# Changed but not updated:' + UNTRACKED_TAG = '# Untracked files:' + RGX_RENAMED = re.compile( + '(#\trenamed:\s+)' + '(.*?)\s->\s(.*)' + ) + RGX_MODIFIED = re.compile( + '(#\tmodified:\s+' + '|#\tnew file:\s+' + '|#\tdeleted:\s+)' + ) + staged = [] + unstaged = [] + untracked = [] + + STAGED_MODE = 0 + UNSTAGED_MODE = 1 + UNTRACKED_MODE = 2 + + current_dest = staged + mode = STAGED_MODE + + for status_line in self.git.status().splitlines(): + if status_line == MODIFIED_TAG: + mode = UNSTAGED_MODE + current_dest = unstaged + continue + elif status_line == UNTRACKED_TAG: + mode = UNTRACKED_MODE + current_dest = untracked + continue + # Staged/unstaged modified/renamed/deleted files + if mode is STAGED_MODE or mode is UNSTAGED_MODE: + match = RGX_MODIFIED.match(status_line) + if match: + tag = match.group(0) + filename = status_line.replace(tag, '') + current_dest.append(eval_path(filename)) + continue + match = RGX_RENAMED.match(status_line) + if match: + oldname = match.group(2) + newname = match.group(3) + current_dest.append(eval_path(oldname)) + current_dest.append(eval_path(newname)) + continue + # Untracked files + elif mode is UNTRACKED_MODE: + if status_line.startswith('#\t'): + current_dest.append(eval_path(status_line[2:])) + + return( staged, unstaged, untracked ) + + def reset_helper(self, *args, **kwargs): + return self.git.reset('--', *args, **kwargs) + + def remote_url(self, name): + return self.git.config('remote.%s.url' % name, get=True) + + def push_helper(self, remote, local_branch, remote_branch, + ffwd=True, tags=False): + if ffwd: + branch_arg = '%s:%s' % ( local_branch, remote_branch ) + else: + branch_arg = '+%s:%s' % ( local_branch, remote_branch ) + return self.git.push(remote, branch_arg, + with_status=True, tags=tags + ) + + def parse_ls_tree(self, rev): + """Returns a list of(mode, type, sha1, path) tuples.""" + lines = self.git.ls_tree(rev, r=True).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 format_patch_helper(self, to_export, revs, output='patches'): + """writes patches named by to_export to the output directory.""" + + outlines = [] + + cur_rev = to_export[0] + cur_master_idx = revs.index(cur_rev) + + patches_to_export = [ [cur_rev] ] + patchset_idx = 0 + + for idx, rev in enumerate(to_export[1:]): + # Limit the search to the current neighborhood for efficiency + master_idx = revs[ cur_master_idx: ].index(rev) + master_idx += cur_master_idx + if master_idx == cur_master_idx + 1: + patches_to_export[ patchset_idx ].append(rev) + cur_master_idx += 1 + continue + else: + patches_to_export.append([ rev ]) + cur_master_idx = master_idx + patchset_idx += 1 + + for patchset in patches_to_export: + revarg = '%s^..%s' % (patchset[0], patchset[-1]) + outlines.append( + self.git.format_patch( + revarg, + o=output, + n=len(patchset) > 1, + thread=True, + patch_with_stat=True + ) + ) + + return '\n'.join(outlines) + + def current_branch(self): + """Parses 'git branch' to find the current branch.""" + branches = self.git.branch().splitlines() + for branch in branches: + if branch.startswith('* '): + return branch.lstrip('* ') + return 'Detached HEAD' + + def create_branch(self, name, base, track=False): + """Creates a branch starting from base. Pass track=True + to create a remote tracking branch.""" + return self.git.branch(name, base, track=track) + + def cherry_pick_list(self, revs, **kwargs): + """Cherry-picks each revision into the current branch. + Returns a list of command output strings (1 per cherry pick)""" + if not revs: + return [] + cherries = [] + for rev in revs: + cherries.append(self.git.cherry_pick(rev, **kwargs)) + return '\n'.join(cherries) -- 2.11.4.GIT