models: avoid parsing the output of 'git status'
[git-cola.git] / cola / models.py
blobf3fe26066cc47a9e27ffd1fdd1d6497663260098
1 # Copyright (c) 2008 David Aguilar
2 import os
3 import sys
4 import re
5 import time
6 import subprocess
7 from cStringIO import StringIO
9 from cola import git
10 from cola import utils
11 from cola import model
13 #+-------------------------------------------------------------------------
14 #+ A regex for matching the output of git(log|rev-list) --pretty=oneline
15 REV_LIST_REGEX = re.compile('([0-9a-f]+)\W(.*)')
17 class GitCola(git.Git):
18 """GitPython throws exceptions by default.
19 We suppress exceptions in favor of return values.
20 """
21 def __init__(self):
22 self._git_dir = None
23 self._work_tree = None
24 self._has_worktree = True
25 git_dir = self.get_git_dir()
26 work_tree = self.get_work_tree()
27 if work_tree:
28 os.chdir(work_tree)
29 git.Git.__init__(self, work_tree)
30 def execute(*args, **kwargs):
31 kwargs['with_exceptions'] = False
32 return git.Git.execute(*args, **kwargs)
33 def get_work_tree(self):
34 if self._work_tree or not self._has_worktree:
35 return self._work_tree
36 if not self._git_dir:
37 self._git_dir = self.get_git_dir()
38 # Handle bare repositories
39 if (len(os.path.basename(self._git_dir)) > 4
40 and self._git_dir.endswith('.git')):
41 self._has_worktree = False
42 return self._work_tree
43 self._work_tree = os.getenv('GIT_WORK_TREE')
44 if not self._work_tree or not os.path.isdir(self._work_tree):
45 self._work_tree = os.path.abspath(
46 os.path.join(os.path.abspath(self._git_dir), '..'))
47 return self._work_tree
48 def get_git_dir(self):
49 if self._git_dir:
50 return self._git_dir
51 self._git_dir = os.getenv('GIT_DIR')
52 if self._git_dir and self._is_git_dir(self._git_dir):
53 return self._git_dir
54 curpath = os.path.abspath(os.getcwd())
55 # Search for a .git directory
56 while curpath:
57 if self._is_git_dir(curpath):
58 self._git_dir = curpath
59 break
60 gitpath = os.path.join(curpath, '.git')
61 if self._is_git_dir(gitpath):
62 self._git_dir = gitpath
63 break
64 curpath, dummy = os.path.split(curpath)
65 if not dummy:
66 break
67 if not self._git_dir:
68 sys.stderr.write("oops, %s is not a git project.\n"
69 % os.getcwd() )
70 sys.exit(-1)
71 return self._git_dir
73 def _is_git_dir(self, d):
74 """ This is taken from the git setup.c:is_git_directory
75 function."""
76 if (os.path.isdir(d)
77 and os.path.isdir(os.path.join(d, 'objects'))
78 and os.path.isdir(os.path.join(d, 'refs'))):
79 headref = os.path.join(d, 'HEAD')
80 return (os.path.isfile(headref)
81 or (os.path.islink(headref)
82 and os.readlink(headref).startswith('refs')))
83 return False
85 class Model(model.Model):
86 """Provides a friendly wrapper for doing commit git operations."""
88 def init(self):
89 """Reads git repository settings and sets several methods
90 so that they refer to the git module. This object
91 encapsulates cola's interaction with git."""
93 # Initialize the git command object
94 self.git = GitCola()
96 # Read git config
97 self.__init_config_data()
99 self.create(
100 #####################################################
101 # Used in various places
102 currentbranch = '',
103 remotes = [],
104 remotename = '',
105 local_branch = '',
106 remote_branch = '',
107 search_text = '',
108 git_version = self.git.version(),
110 #####################################################
111 # Used primarily by the main UI
112 project = os.path.basename(os.getcwd()),
113 commitmsg = '',
114 modified = [],
115 staged = [],
116 unstaged = [],
117 untracked = [],
118 unmerged = [],
119 window_geom = utils.parse_geom(self.get_cola_config('geometry')),
121 #####################################################
122 # Used by the create branch dialog
123 revision = '',
124 local_branches = [],
125 remote_branches = [],
126 tags = [],
128 #####################################################
129 # Used by the commit/repo browser
130 directory = '',
131 revisions = [],
132 summaries = [],
134 # These are parallel lists
135 types = [],
136 sha1s = [],
137 names = [],
139 # All items below here are re-calculated in
140 # init_browser_data()
141 directories = [],
142 directory_entries = {},
144 # These are also parallel lists
145 subtree_types = [],
146 subtree_sha1s = [],
147 subtree_names = [],
150 def __init_config_data(self):
151 """Reads git config --list and creates parameters
152 for each setting."""
153 # These parameters are saved in .gitconfig,
154 # so ideally these should be as short as possible.
156 # config items that are controllable globally
157 # and per-repository
158 self.__local_and_global_defaults = {
159 'user_name': '',
160 'user_email': '',
161 'merge_summary': False,
162 'merge_diffstat': True,
163 'merge_verbosity': 2,
164 'gui_diffcontext': 3,
165 'gui_pruneduringfetch': False,
167 # config items that are purely git config --global settings
168 self.__global_defaults = {
169 'cola_geometry':'',
170 'cola_fontui': '',
171 'cola_fontuisize': 12,
172 'cola_fontdiff': '',
173 'cola_fontdiffsize': 12,
174 'cola_savewindowsettings': False,
175 'merge_keepbackup': True,
176 'merge_tool': os.getenv('MERGETOOL', 'xxdiff'),
177 'gui_editor': os.getenv('EDITOR', 'gvim'),
178 'gui_historybrowser': 'gitk',
181 local_dict = self.config_dict(local=True)
182 global_dict = self.config_dict(local=False)
184 for k,v in local_dict.iteritems():
185 self.set_param('local_'+k, v)
186 for k,v in global_dict.iteritems():
187 self.set_param('global_'+k, v)
188 if k not in local_dict:
189 local_dict[k]=v
190 self.set_param('local_'+k, v)
192 # Bootstrap the internal font*size variables
193 for param in ('global_cola_fontui', 'global_cola_fontdiff'):
194 setdefault = True
195 if hasattr(self, param):
196 font = self.get_param(param)
197 if font:
198 setdefault = False
199 size = int(font.split(',')[1])
200 self.set_param(param+'size', size)
201 param = param[len('global_'):]
202 global_dict[param] = font
203 global_dict[param+'size'] = size
205 # Load defaults for all undefined items
206 local_and_global_defaults = self.__local_and_global_defaults
207 for k,v in local_and_global_defaults.iteritems():
208 if k not in local_dict:
209 self.set_param('local_'+k, v)
210 if k not in global_dict:
211 self.set_param('global_'+k, v)
213 global_defaults = self.__global_defaults
214 for k,v in global_defaults.iteritems():
215 if k not in global_dict:
216 self.set_param('global_'+k, v)
218 # Load the diff context
219 self.diff_context = self.local_gui_diffcontext
221 def get_global_config(self, key):
222 return getattr(self, 'global_'+key.replace('.', '_'))
224 def get_cola_config(self, key):
225 return getattr(self, 'global_cola_'+key)
227 def get_gui_config(self, key):
228 return getattr(self, 'global_gui_'+key)
230 def branch_list(self, remote=False):
231 branches = map(lambda x: x.lstrip('* '),
232 self.git.branch(r=remote).splitlines())
233 if remote:
234 remotes = []
235 for branch in branches:
236 if branch.endswith('/HEAD'):
237 continue
238 remotes.append(branch)
239 return remotes
240 return branches
242 def get_config_params(self):
243 params = []
244 params.extend(map(lambda x: 'local_' + x,
245 self.__local_and_global_defaults.keys()))
246 params.extend(map(lambda x: 'global_' + x,
247 self.__local_and_global_defaults.keys()))
248 params.extend(map(lambda x: 'global_' + x,
249 self.__global_defaults.keys()))
250 return [ p for p in params if not p.endswith('size') ]
252 def save_config_param(self, param):
253 if param not in self.get_config_params():
254 return
255 value = self.get_param(param)
256 if param == 'local_gui_diffcontext':
257 self.diff_context = value
258 if param.startswith('local_'):
259 param = param[len('local_'):]
260 is_local = True
261 elif param.startswith('global_'):
262 param = param[len('global_'):]
263 is_local = False
264 else:
265 raise Exception("Invalid param '%s' passed to " % param
266 +'save_config_param()')
267 param = param.replace('_', '.') # model -> git
268 return self.config_set(param, value, local=is_local)
270 def init_browser_data(self):
271 """This scans over self.(names, sha1s, types) to generate
272 directories, directory_entries, and subtree_*"""
274 # Collect data for the model
275 if not self.get_currentbranch(): return
277 self.subtree_types = []
278 self.subtree_sha1s = []
279 self.subtree_names = []
280 self.directories = []
281 self.directory_entries = {}
283 # Lookup the tree info
284 tree_info = self.parse_ls_tree(self.get_currentbranch())
286 self.set_types(map( lambda(x): x[1], tree_info ))
287 self.set_sha1s(map( lambda(x): x[2], tree_info ))
288 self.set_names(map( lambda(x): x[3], tree_info ))
290 if self.directory: self.directories.append('..')
292 dir_entries = self.directory_entries
293 dir_regex = re.compile('([^/]+)/')
294 dirs_seen = {}
295 subdirs_seen = {}
297 for idx, name in enumerate(self.names):
298 if not name.startswith(self.directory):
299 continue
300 name = name[ len(self.directory): ]
301 if name.count('/'):
302 # This is a directory...
303 match = dir_regex.match(name)
304 if not match:
305 continue
306 dirent = match.group(1) + '/'
307 if dirent not in self.directory_entries:
308 self.directory_entries[dirent] = []
310 if dirent not in dirs_seen:
311 dirs_seen[dirent] = True
312 self.directories.append(dirent)
314 entry = name.replace(dirent, '')
315 entry_match = dir_regex.match(entry)
316 if entry_match:
317 subdir = entry_match.group(1) + '/'
318 if subdir in subdirs_seen:
319 continue
320 subdirs_seen[subdir] = True
321 dir_entries[dirent].append(subdir)
322 else:
323 dir_entries[dirent].append(entry)
324 else:
325 self.subtree_types.append(self.types[idx])
326 self.subtree_sha1s.append(self.sha1s[idx])
327 self.subtree_names.append(name)
329 def add_or_remove(self, *to_process):
330 """Invokes 'git add' to index the filenames in to_process that exist
331 and 'git rm' for those that do not exist."""
333 if not to_process:
334 return 'No files to add or remove.'
336 to_add = []
337 to_remove = []
339 for filename in to_process:
340 encfilename = filename.encode('utf-8')
341 if os.path.exists(encfilename):
342 to_add.append(filename)
344 output = self.git.add(v=True, *to_add)
346 if len(to_add) == len(to_process):
347 # to_process only contained unremoved files --
348 # short-circuit the removal checks
349 return output
351 # Process files to remote
352 for filename in to_process:
353 if not os.path.exists(filename):
354 to_remove.append(filename)
355 output + '\n\n' + self.git.rm(*to_remove)
357 def get_editor(self):
358 return self.get_gui_config('editor')
360 def get_mergetool(self):
361 return self.get_global_config('merge.tool')
363 def get_history_browser(self):
364 return self.get_gui_config('historybrowser')
366 def remember_gui_settings(self):
367 return self.get_cola_config('savewindowsettings')
369 def get_tree_node(self, idx):
370 return (self.get_types()[idx],
371 self.get_sha1s()[idx],
372 self.get_names()[idx] )
374 def get_subtree_node(self, idx):
375 return (self.get_subtree_types()[idx],
376 self.get_subtree_sha1s()[idx],
377 self.get_subtree_names()[idx] )
379 def get_all_branches(self):
380 return (self.get_local_branches() + self.get_remote_branches())
382 def set_remote(self, remote):
383 if not remote:
384 return
385 self.set_param('remote', remote)
386 branches = utils.grep('%s/\S+$' % remote,
387 self.branch_list(remote=True),
388 squash=False)
389 self.set_remote_branches(branches)
391 def add_signoff(self,*rest):
392 """Adds a standard Signed-off by: tag to the end
393 of the current commit message."""
394 msg = self.get_commitmsg()
395 signoff =('\n\nSigned-off-by: %s <%s>\n'
396 % (self.get_local_user_name(), self.get_local_user_email()))
397 if signoff not in msg:
398 self.set_commitmsg(msg + signoff)
400 def apply_diff(self, filename):
401 return self.git.apply(filename, index=True, cached=True)
403 def apply_diff_to_worktree(self, filename):
404 return self.git.apply(filename)
406 def load_commitmsg(self, path):
407 file = open(path, 'r')
408 contents = file.read().decode('utf-8')
409 file.close()
410 self.set_commitmsg(contents)
412 def get_prev_commitmsg(self,*rest):
413 """Queries git for the latest commit message and sets it in
414 self.commitmsg."""
415 commit_msg = []
416 commit_lines = self.git.show('HEAD').split('\n')
417 for idx, msg in enumerate(commit_lines):
418 if idx < 4: continue
419 msg = msg.lstrip()
420 if msg.startswith('diff --git'):
421 commit_msg.pop()
422 break
423 commit_msg.append(msg)
424 self.set_commitmsg('\n'.join(commit_msg).rstrip())
426 def load_commitmsg_template(self):
427 try:
428 template = self.get_global_config('commit.template')
429 except AttributeError:
430 return
431 self.load_commitmsg(template)
433 def update_status(self, amend=False):
434 # This allows us to defer notification until the
435 # we finish processing data
436 notify_enabled = self.get_notify()
437 self.set_notify(False)
439 # Reset the staged and unstaged model lists
440 # NOTE: the model's unstaged list is used to
441 # hold both modified and untracked files.
442 self.staged = []
443 self.modified = []
444 self.untracked = []
446 # Read git status items
447 (staged_items,
448 modified_items,
449 untracked_items,
450 unmerged_items) = self.get_workdir_state(amend=amend)
452 # Gather items to be committed
453 for staged in staged_items:
454 if staged not in self.get_staged():
455 self.add_staged(staged)
457 # Gather unindexed items
458 for modified in modified_items:
459 if modified not in self.get_modified():
460 self.add_modified(modified)
462 # Gather untracked items
463 for untracked in untracked_items:
464 if untracked not in self.get_untracked():
465 self.add_untracked(untracked)
467 # Gather unmerged items
468 for unmerged in unmerged_items:
469 if unmerged not in self.get_unmerged():
470 self.add_unmerged(unmerged)
472 self.set_currentbranch(self.current_branch())
473 self.set_unstaged(self.get_modified() + self.get_untracked() + self.get_unmerged())
474 self.set_remotes(self.git.remote().splitlines())
475 self.set_remote_branches(self.branch_list(remote=True))
476 self.set_local_branches(self.branch_list(remote=False))
477 self.set_tags(self.git.tag().splitlines())
478 self.set_revision('')
479 self.set_local_branch('')
480 self.set_remote_branch('')
481 # Re-enable notifications and emit changes
482 self.set_notify(notify_enabled)
483 self.notify_observers('staged','unstaged')
485 def delete_branch(self, branch):
486 return self.git.branch(branch, D=True)
488 def get_revision_sha1(self, idx):
489 return self.get_revisions()[idx]
491 def apply_font_size(self, param, default):
492 old_font = self.get_param(param)
493 if not old_font:
494 old_font = default
495 size = self.get_param(param+'size')
496 props = old_font.split(',')
497 props[1] = str(size)
498 new_font = ','.join(props)
500 self.set_param(param, new_font)
502 def get_commit_diff(self, sha1):
503 commit = self.git.show(sha1)
504 first_newline = commit.index('\n')
505 if commit[first_newline+1:].startswith('Merge:'):
506 return (commit + '\n\n'
507 + self.diff_helper(commit=sha1,
508 cached=False,
509 suppress_header=False))
510 else:
511 return commit
513 def get_filename(self, idx, staged=True):
514 try:
515 if staged:
516 return self.get_staged()[idx]
517 else:
518 return self.get_unstaged()[idx]
519 except IndexError:
520 return None
522 def get_diff_details(self, idx, ref, staged=True):
523 filename = self.get_filename(idx, staged=staged)
524 if not filename:
525 return (None, None, None)
526 encfilename = filename.encode('utf-8')
527 if staged:
528 if os.path.exists(encfilename):
529 status = 'Staged for commit'
530 else:
531 status = 'Staged for removal'
532 diff = self.diff_helper(filename=filename,
533 ref=ref,
534 cached=True)
535 else:
536 if os.path.isdir(encfilename):
537 status = 'Untracked directory'
538 diff = '\n'.join(os.listdir(filename))
540 elif filename in self.get_unmerged():
541 status = 'Unmerged'
542 diff = ('@@@+-+-+-+-+-+-+-+-+-+-+ UNMERGED +-+-+-+-+-+-+-+-+-+-+@@@\n\n'
543 '>>> %s is unmerged.\n' % filename +
544 'Right-click on the filename '
545 'to launch "git mergetool".\n\n\n')
546 diff += self.diff_helper(filename=filename,
547 cached=False,
548 patch_with_raw=False)
549 elif filename in self.get_modified():
550 status = 'Modified, not staged'
551 diff = self.diff_helper(filename=filename,
552 cached=False)
553 else:
554 status = 'Untracked, not staged'
555 diff = 'SHA1: ' + self.git.hash_object(filename)
556 return diff, status, filename
558 def stage_modified(self):
559 output = self.git.add(v=True, *self.get_modified())
560 self.update_status()
561 return output
563 def stage_untracked(self):
564 output = self.git.add(self.get_untracked())
565 self.update_status()
566 return output
568 def reset(self, *items):
569 output = self.git.reset('--', *items)
570 self.update_status()
571 return output
573 def unstage_all(self):
574 self.git.reset('--', *self.get_staged())
575 self.update_status()
577 def save_gui_settings(self):
578 self.config_set('cola.geometry', utils.get_geom(), local=False)
580 def config_set(self, key=None, value=None, local=True):
581 if key and value is not None:
582 # git config category.key value
583 strval = unicode(value).encode('utf-8')
584 if type(value) is bool:
585 # git uses "true" and "false"
586 strval = strval.lower()
587 if local:
588 argv = [ key, strval ]
589 else:
590 argv = [ '--global', key, strval ]
591 return self.git.config(*argv)
592 else:
593 msg = "oops in config_set(key=%s,value=%s,local=%s"
594 raise Exception(msg % (key, value, local))
596 def config_dict(self, local=True):
597 """parses the lines from git config --list into a dictionary"""
599 kwargs = {
600 'list': True,
601 'global': not local,
603 config_lines = self.git.config(**kwargs).splitlines()
604 newdict = {}
605 for line in config_lines:
606 k, v = line.split('=', 1)
607 k = k.replace('.','_') # git -> model
608 if v == 'true' or v == 'false':
609 v = bool(eval(v.title()))
610 try:
611 v = int(eval(v))
612 except:
613 pass
614 newdict[k]=v
615 return newdict
617 def commit_with_msg(self, msg, amend=False):
618 """Creates a git commit."""
620 if not msg.endswith('\n'):
621 msg += '\n'
622 # Sure, this is a potential "security risk," but if someone
623 # is trying to intercept/re-write commit messages on your system,
624 # then you probably have bigger problems to worry about.
625 tmpfile = self.get_tmp_filename()
627 # Create the commit message file
628 fh = open(tmpfile, 'w')
629 fh.write(msg)
630 fh.close()
632 # Run 'git commit'
633 (status, stdout, stderr) = self.git.commit(v=True,
634 F=tmpfile,
635 amend=amend,
636 with_extended_output=True)
637 os.unlink(tmpfile)
639 return (status, stdout+stderr)
642 def diffindex(self):
643 return self.git.diff(unified=self.diff_context,
644 stat=True,
645 cached=True)
647 def get_tmp_dir(self):
648 return os.environ.get('TMP', os.environ.get('TMPDIR', '/tmp'))
650 def get_tmp_file_pattern(self):
651 return os.path.join(self.get_tmp_dir(), '*.git.%s.*' % os.getpid())
653 def get_tmp_filename(self, prefix=''):
654 # Allow TMPDIR/TMP with a fallback to /tmp
655 basename = (prefix+'.git.%s.%s'
656 % (os.getpid(), time.time())).replace(os.sep, '-')
657 return os.path.join(self.get_tmp_dir(), basename)
659 def log_helper(self, all=False):
661 Returns a pair of parallel arrays listing the revision sha1's
662 and commit summaries.
664 revs = []
665 summaries = []
666 regex = REV_LIST_REGEX
667 output = self.git.log(pretty='oneline', all=all)
668 for line in output.splitlines():
669 match = regex.match(line)
670 if match:
671 revs.append(match.group(1))
672 summaries.append(match.group(2))
673 return (revs, summaries)
675 def parse_rev_list(self, raw_revs):
676 revs = []
677 for line in raw_revs.splitlines():
678 match = REV_LIST_REGEX.match(line)
679 if match:
680 rev_id = match.group(1)
681 summary = match.group(2)
682 revs.append((rev_id, summary,))
683 return revs
685 def rev_list_range(self, start, end):
686 range = '%s..%s' % (start, end)
687 raw_revs = self.git.rev_list(range, pretty='oneline')
688 return self.parse_rev_list(raw_revs)
690 def diff_helper(self,
691 commit=None,
692 branch=None,
693 ref = None,
694 endref = None,
695 filename=None,
696 cached=True,
697 with_diff_header=False,
698 suppress_header=True,
699 reverse=False,
700 patch_with_raw=True):
701 "Invokes git diff on a filepath."
702 if commit:
703 ref, endref = commit+'^', commit
704 argv = []
705 if ref and endref:
706 argv.append('%s..%s' % (ref, endref))
707 elif ref:
708 argv.append(ref)
709 elif branch:
710 argv.append(branch)
712 if filename:
713 argv.append('--')
714 if type(filename) is list:
715 argv.extend(filename)
716 else:
717 argv.append(filename)
719 output = StringIO()
720 start = False
721 del_tag = 'deleted file mode '
723 headers = []
724 deleted = cached and not os.path.exists(filename.encode('utf-8'))
726 diffoutput = self.git.diff(R=reverse,
727 cached=cached,
728 patch_with_raw=patch_with_raw,
729 unified=self.diff_context,
730 with_raw_output=True,
731 *argv)
732 diff = diffoutput.splitlines()
733 for line in diff:
734 line = unicode(line).encode('utf-8')
735 if not start and '@@' == line[:2] and '@@' in line[2:]:
736 start = True
737 if start or(deleted and del_tag in line):
738 output.write(line + '\n')
739 else:
740 if with_diff_header:
741 headers.append(line)
742 elif not suppress_header:
743 output.write(line + '\n')
745 result = output.getvalue().decode('utf-8')
746 output.close()
748 if with_diff_header:
749 return('\n'.join(headers), result)
750 else:
751 return result
753 def git_repo_path(self, *subpaths):
754 paths = [ self.git.get_git_dir() ]
755 paths.extend(subpaths)
756 return os.path.realpath(os.path.join(*paths))
758 def get_merge_message_path(self):
759 for file in ('MERGE_MSG', 'SQUASH_MSG'):
760 path = self.git_repo_path(file)
761 if os.path.exists(path):
762 return path
763 return None
765 def get_merge_message(self):
766 return self.git.fmt_merge_msg('--file',
767 self.git_repo_path('FETCH_HEAD'))
769 def abort_merge(self):
770 # Reset the worktree
771 output = self.git.read_tree('HEAD', reset=True, u=True, v=True)
772 # remove MERGE_HEAD
773 merge_head = self.git_repo_path('MERGE_HEAD')
774 if os.path.exists(merge_head):
775 os.unlink(merge_head)
776 # remove MERGE_MESSAGE, etc.
777 merge_msg_path = self.get_merge_message_path()
778 while merge_msg_path:
779 os.unlink(merge_msg_path)
780 merge_msg_path = self.get_merge_message_path()
782 def get_workdir_state(self, amend=False):
783 """RETURNS: A tuple of staged, unstaged untracked, and unmerged
784 file lists.
786 def eval_path(path):
787 """handles quoted paths."""
788 if path.startswith('"') and path.endswith('"'):
789 return eval(path).decode('utf-8')
790 else:
791 return path
793 head = 'HEAD'
794 if amend:
795 head = 'HEAD^'
796 (staged, unstaged, unmerged, untracked) = ([], [], [], [])
798 for idx, line in enumerate(self.git.diff_index(head).splitlines()):
799 rest, name = line.split('\t')
800 status = rest[-1]
801 name = eval_path(name)
802 if status == 'M' or status == 'D':
803 unstaged.append(name)
805 for idx, line in enumerate(self.git.diff_index(head, cached=True)
806 .splitlines()):
807 rest, name = line.split('\t')
808 status = rest[-1]
809 name = eval_path(name)
810 if status == 'M':
811 staged.append(name)
812 # is this file partially staged?
813 diff = self.git.diff('--', name, name_only=True, z=True)
814 if not diff.strip():
815 unstaged.remove(name)
816 elif status == 'A':
817 staged.append(name)
818 elif status == 'D':
819 staged.append(name)
820 unstaged.remove(name)
821 elif status == 'U':
822 unmerged.append(name)
824 for line in self.git.ls_files(others=True, exclude_standard=True,
825 z=True).split('\0'):
826 if line:
827 untracked.append(line)
829 return (staged, unstaged, untracked, unmerged)
831 def reset_helper(self, *args, **kwargs):
832 return self.git.reset('--', *args, **kwargs)
834 def remote_url(self, name):
835 return self.git.config('remote.%s.url' % name, get=True)
837 def get_remote_args(self, remote,
838 local_branch='', remote_branch='',
839 ffwd=True, tags=False):
840 if ffwd:
841 branch_arg = '%s:%s' % ( remote_branch, local_branch )
842 else:
843 branch_arg = '+%s:%s' % ( remote_branch, local_branch )
844 args = [remote]
845 if local_branch and remote_branch:
846 args.append(branch_arg)
847 kwargs = {
848 "with_extended_output": True,
849 "tags": tags
851 return (args, kwargs)
853 def fetch_helper(self, *args, **kwargs):
855 Fetches remote_branch to local_branch only if
856 remote_branch and local_branch are both supplied.
857 If either is ommitted, "git fetch <remote>" is performed instead.
858 Returns (status,output)
860 args, kwargs = self.get_remote_args(*args, **kwargs)
861 (status, stdout, stderr) = self.git.fetch(v=True, *args, **kwargs)
862 return (status, stdout + stderr)
864 def push_helper(self, *args, **kwargs):
866 Pushes local_branch to remote's remote_branch only if
867 remote_branch and local_branch both are supplied.
868 If either is ommitted, "git push <remote>" is performed instead.
869 Returns (status,output)
871 args, kwargs = self.get_remote_args(*args, **kwargs)
872 (status, stdout, stderr) = self.git.push(*args, **kwargs)
873 return (status, stdout + stderr)
875 def pull_helper(self, *args, **kwargs):
877 Pushes branches. If local_branch or remote_branch is ommitted,
878 "git pull <remote>" is performed instead of
879 "git pull <remote> <remote_branch>:<local_branch>
880 Returns (status,output)
882 args, kwargs = self.get_remote_args(*args, **kwargs)
883 (status, stdout, stderr) = self.git.pull(v=True, *args, **kwargs)
884 return (status, stdout + stderr)
886 def parse_ls_tree(self, rev):
887 """Returns a list of(mode, type, sha1, path) tuples."""
888 lines = self.git.ls_tree(rev, r=True).splitlines()
889 output = []
890 regex = re.compile('^(\d+)\W(\w+)\W(\w+)[ \t]+(.*)$')
891 for line in lines:
892 match = regex.match(line)
893 if match:
894 mode = match.group(1)
895 objtype = match.group(2)
896 sha1 = match.group(3)
897 filename = match.group(4)
898 output.append((mode, objtype, sha1, filename,) )
899 return output
901 def format_patch_helper(self, to_export, revs, output='patches'):
902 """writes patches named by to_export to the output directory."""
904 outlines = []
906 cur_rev = to_export[0]
907 cur_master_idx = revs.index(cur_rev)
909 patches_to_export = [ [cur_rev] ]
910 patchset_idx = 0
912 # Group the patches into continuous sets
913 for idx, rev in enumerate(to_export[1:]):
914 # Limit the search to the current neighborhood for efficiency
915 master_idx = revs[ cur_master_idx: ].index(rev)
916 master_idx += cur_master_idx
917 if master_idx == cur_master_idx + 1:
918 patches_to_export[ patchset_idx ].append(rev)
919 cur_master_idx += 1
920 continue
921 else:
922 patches_to_export.append([ rev ])
923 cur_master_idx = master_idx
924 patchset_idx += 1
926 # Export each patchsets
927 for patchset in patches_to_export:
928 cmdoutput = self.export_patchset(patchset[0],
929 patchset[-1],
930 output="patches",
931 n=len(patchset) > 1,
932 thread=True,
933 patch_with_stat=True)
934 outlines.append(cmdoutput)
935 return '\n'.join(outlines)
937 def export_patchset(self, start, end, output="patches", **kwargs):
938 revarg = '%s^..%s' % (start, end)
939 return self.git.format_patch("-o", output, revarg, **kwargs)
941 def current_branch(self):
942 """Parses 'git branch' to find the current branch."""
943 branches = self.git.branch().splitlines()
944 for branch in branches:
945 if branch.startswith('* '):
946 return branch.lstrip('* ')
947 return 'Detached HEAD'
949 def create_branch(self, name, base, track=False):
950 """Creates a branch starting from base. Pass track=True
951 to create a remote tracking branch."""
952 return self.git.branch(name, base, track=track)
954 def cherry_pick_list(self, revs, **kwargs):
955 """Cherry-picks each revision into the current branch.
956 Returns a list of command output strings (1 per cherry pick)"""
957 if not revs:
958 return []
959 cherries = []
960 for rev in revs:
961 cherries.append(self.git.cherry_pick(rev, **kwargs))
962 return '\n'.join(cherries)
964 def parse_stash_list(self, revids=False):
965 """Parses "git stash list" and returns a list of stashes."""
966 stashes = self.git.stash("list").splitlines()
967 if revids:
968 return [ s[:s.index(':')] for s in stashes ]
969 else:
970 return [ s[s.index(':')+1:] for s in stashes ]
972 def diffstat(self):
973 return self.git.diff(
974 'HEAD^',
975 unified=self.diff_context,
976 stat=True)
978 def pad(self, pstr, num=22):
979 topad = num-len(pstr)
980 if topad > 0:
981 return pstr + ' '*topad
982 else:
983 return pstr
985 def describe(self, revid, descr):
986 version = self.git.describe(revid, tags=True, always=True,
987 abbrev=4)
988 return version + ' - ' + descr
990 def update_revision_lists(self, filename=None, show_versions=False):
991 num_results = self.get_num_results()
992 if filename:
993 rev_list = self.git.log('--', filename,
994 max_count=num_results,
995 pretty='oneline')
996 else:
997 rev_list = self.git.log(max_count=num_results,
998 pretty='oneline', all=True)
1000 commit_list = self.parse_rev_list(rev_list)
1001 commit_list.reverse()
1002 commits = map(lambda x: x[0], commit_list)
1003 descriptions = map(lambda x: x[1], commit_list)
1004 if show_versions:
1005 fancy_descr_list = map(lambda x: self.describe(*x), commit_list)
1006 self.set_descriptions_start(fancy_descr_list)
1007 self.set_descriptions_end(fancy_descr_list)
1008 else:
1009 self.set_descriptions_start(descriptions)
1010 self.set_descriptions_end(descriptions)
1012 self.set_revisions_start(commits)
1013 self.set_revisions_end(commits)
1015 return commits
1017 def get_changed_files(self, start, end):
1018 zfiles_str = self.git.diff('%s..%s' % (start, end),
1019 name_only=True, z=True)
1020 zfiles_str = zfiles_str.strip('\0')
1021 files = zfiles_str.split('\0')
1022 return files
1024 def get_renamed_files(self, start, end):
1025 files = []
1026 difflines = self.git.diff('%s..%s' % (start, end), M=True).splitlines()
1027 for line in difflines:
1028 if line.startswith('rename from '):
1029 files.append(line[12:].rstrip())
1030 return files