cola: add more documentation strings to the cola modules
[git-cola.git] / cola / models.py
bloba077bd1d0779d4a4655193e3df8a24993eda292a
1 # Copyright (c) 2008 David Aguilar
2 """This module provides the cola model class.
3 """
5 import os
6 import sys
7 import re
8 import time
9 import subprocess
10 from cStringIO import StringIO
12 from cola import git
13 from cola import utils
14 from cola import model
15 from cola.core import encode, decode
17 #+-------------------------------------------------------------------------
18 #+ A regex for matching the output of git(log|rev-list) --pretty=oneline
19 REV_LIST_REGEX = re.compile('([0-9a-f]+)\W(.*)')
21 class GitCola(git.Git):
22 """GitPython throws exceptions by default.
23 We suppress exceptions in favor of return values.
24 """
25 def __init__(self):
26 git.Git.__init__(self)
27 self.load_worktree(os.getcwd())
29 def load_worktree(self, path):
30 self._git_dir = path
31 self._work_tree = None
32 self.get_work_tree()
34 def get_work_tree(self):
35 if self._work_tree:
36 return self._work_tree
37 self.get_git_dir()
38 if self._git_dir:
39 curdir = self._git_dir
40 else:
41 curdir = os.getcwd()
43 if self._is_git_dir(os.path.join(curdir, '.git')):
44 return curdir
46 # Handle bare repositories
47 if (len(os.path.basename(curdir)) > 4
48 and curdir.endswith('.git')):
49 return curdir
50 if 'GIT_WORK_TREE' in os.environ:
51 self._work_tree = os.getenv('GIT_WORK_TREE')
52 if not self._work_tree or not os.path.isdir(self._work_tree):
53 if self._git_dir:
54 gitparent = os.path.join(os.path.abspath(self._git_dir), '..')
55 self._work_tree = os.path.abspath(gitparent)
56 self.set_cwd(self._work_tree)
57 return self._work_tree
59 def is_valid(self):
60 return self._git_dir and self._is_git_dir(self._git_dir)
62 def get_git_dir(self):
63 if self.is_valid():
64 return self._git_dir
65 if 'GIT_DIR' in os.environ:
66 self._git_dir = os.getenv('GIT_DIR')
67 if self._git_dir:
68 curpath = os.path.abspath(self._git_dir)
69 else:
70 curpath = os.path.abspath(os.getcwd())
71 # Search for a .git directory
72 while curpath:
73 if self._is_git_dir(curpath):
74 self._git_dir = curpath
75 break
76 gitpath = os.path.join(curpath, '.git')
77 if self._is_git_dir(gitpath):
78 self._git_dir = gitpath
79 break
80 curpath, dummy = os.path.split(curpath)
81 if not dummy:
82 break
83 return self._git_dir
85 def _is_git_dir(self, d):
86 """ This is taken from the git setup.c:is_git_directory
87 function."""
88 if (os.path.isdir(d)
89 and os.path.isdir(os.path.join(d, 'objects'))
90 and os.path.isdir(os.path.join(d, 'refs'))):
91 headref = os.path.join(d, 'HEAD')
92 return (os.path.isfile(headref)
93 or (os.path.islink(headref)
94 and os.readlink(headref).startswith('refs')))
95 return False
97 def eval_path(path):
98 """handles quoted paths."""
99 if path.startswith('"') and path.endswith('"'):
100 return decode(eval(path))
101 else:
102 return path
104 class Model(model.Model):
105 """Provides a friendly wrapper for doing commit git operations."""
107 def clone(self):
108 worktree = self.git.get_work_tree()
109 clone = model.Model.clone(self)
110 clone.use_worktree(worktree)
111 return clone
113 def use_worktree(self, worktree):
114 self.git.load_worktree(worktree)
115 is_valid = self.git.is_valid()
116 if is_valid:
117 self.__init_config_data()
118 return is_valid
120 def init(self):
121 """Reads git repository settings and sets several methods
122 so that they refer to the git module. This object
123 encapsulates cola's interaction with git."""
125 # Initialize the git command object
126 self.git = GitCola()
127 self.partially_staged = set()
129 self.fetch_helper = self.gen_remote_helper(self.git.fetch)
130 self.push_helper = self.gen_remote_helper(self.git.push)
131 self.pull_helper = self.gen_remote_helper(self.git.pull)
133 self.create(
134 #####################################################
135 # Used in various places
136 currentbranch = '',
137 remotes = [],
138 remotename = '',
139 local_branch = '',
140 remote_branch = '',
141 search_text = '',
143 #####################################################
144 # Used primarily by the main UI
145 commitmsg = '',
146 modified = [],
147 staged = [],
148 unstaged = [],
149 untracked = [],
150 unmerged = [],
152 #####################################################
153 # Used by the create branch dialog
154 revision = '',
155 local_branches = [],
156 remote_branches = [],
157 tags = [],
159 #####################################################
160 # Used by the commit/repo browser
161 directory = '',
162 revisions = [],
163 summaries = [],
165 # These are parallel lists
166 types = [],
167 sha1s = [],
168 names = [],
170 # All items below here are re-calculated in
171 # init_browser_data()
172 directories = [],
173 directory_entries = {},
175 # These are also parallel lists
176 subtree_types = [],
177 subtree_sha1s = [],
178 subtree_names = [],
181 def __init_config_data(self):
182 """Reads git config --list and creates parameters
183 for each setting."""
184 # These parameters are saved in .gitconfig,
185 # so ideally these should be as short as possible.
187 # config items that are controllable globally
188 # and per-repository
189 self.__local_and_global_defaults = {
190 'user_name': '',
191 'user_email': '',
192 'merge_summary': False,
193 'merge_diffstat': True,
194 'merge_verbosity': 2,
195 'gui_diffcontext': 3,
196 'gui_pruneduringfetch': False,
198 # config items that are purely git config --global settings
199 self.__global_defaults = {
200 'cola_geometry':'',
201 'cola_fontui': '',
202 'cola_fontuisize': 12,
203 'cola_fontdiff': '',
204 'cola_fontdiffsize': 12,
205 'cola_savewindowsettings': False,
206 'merge_keepbackup': True,
207 'merge_tool': os.getenv('MERGETOOL', 'xxdiff'),
208 'gui_editor': os.getenv('EDITOR', 'gvim'),
209 'gui_historybrowser': 'gitk',
212 local_dict = self.config_dict(local=True)
213 global_dict = self.config_dict(local=False)
215 for k,v in local_dict.iteritems():
216 self.set_param('local_'+k, v)
217 for k,v in global_dict.iteritems():
218 self.set_param('global_'+k, v)
219 if k not in local_dict:
220 local_dict[k]=v
221 self.set_param('local_'+k, v)
223 # Bootstrap the internal font*size variables
224 for param in ('global_cola_fontui', 'global_cola_fontdiff'):
225 setdefault = True
226 if hasattr(self, param):
227 font = self.get_param(param)
228 if font:
229 setdefault = False
230 size = int(font.split(',')[1])
231 self.set_param(param+'size', size)
232 param = param[len('global_'):]
233 global_dict[param] = font
234 global_dict[param+'size'] = size
236 # Load defaults for all undefined items
237 local_and_global_defaults = self.__local_and_global_defaults
238 for k,v in local_and_global_defaults.iteritems():
239 if k not in local_dict:
240 self.set_param('local_'+k, v)
241 if k not in global_dict:
242 self.set_param('global_'+k, v)
244 global_defaults = self.__global_defaults
245 for k,v in global_defaults.iteritems():
246 if k not in global_dict:
247 self.set_param('global_'+k, v)
249 # Load the diff context
250 self.diff_context = self.local_gui_diffcontext
252 def get_global_config(self, key):
253 return getattr(self, 'global_'+key.replace('.', '_'))
255 def get_cola_config(self, key):
256 return getattr(self, 'global_cola_'+key)
258 def get_gui_config(self, key):
259 return getattr(self, 'global_gui_'+key)
261 def get_default_remote(self):
262 branch = self.get_currentbranch()
263 branchconfig = 'local_branch_%s_remote' % branch
264 if branchconfig in self.get_param_names():
265 remote = self.get_param(branchconfig)
266 else:
267 remote = 'origin'
268 return remote
270 def get_corresponding_remote_ref(self):
271 remote = self.get_default_remote()
272 branch = self.get_currentbranch()
273 best_match = '%s/%s' % (remote, branch)
274 remote_branches = self.get_remote_branches()
275 if not remote_branches:
276 return remote
277 for rb in remote_branches:
278 if rb == best_match:
279 return rb
280 return remote_branches[0]
282 def get_diff_filenames(self, arg):
283 diff_zstr = self.git.diff(arg, name_only=True, z=True).rstrip('\0')
284 return [ decode(f) for f in diff_zstr.split('\0') if f ]
286 def branch_list(self, remote=False):
287 branches = map(lambda x: x.lstrip('* '),
288 self.git.branch(r=remote).splitlines())
289 if remote:
290 remotes = []
291 for branch in branches:
292 if branch.endswith('/HEAD'):
293 continue
294 remotes.append(branch)
295 return remotes
296 return branches
298 def get_config_params(self):
299 params = []
300 params.extend(map(lambda x: 'local_' + x,
301 self.__local_and_global_defaults.keys()))
302 params.extend(map(lambda x: 'global_' + x,
303 self.__local_and_global_defaults.keys()))
304 params.extend(map(lambda x: 'global_' + x,
305 self.__global_defaults.keys()))
306 return [ p for p in params if not p.endswith('size') ]
308 def save_config_param(self, param):
309 if param not in self.get_config_params():
310 return
311 value = self.get_param(param)
312 if param == 'local_gui_diffcontext':
313 self.diff_context = value
314 if param.startswith('local_'):
315 param = param[len('local_'):]
316 is_local = True
317 elif param.startswith('global_'):
318 param = param[len('global_'):]
319 is_local = False
320 else:
321 raise Exception("Invalid param '%s' passed to " % param
322 +'save_config_param()')
323 param = param.replace('_', '.') # model -> git
324 return self.config_set(param, value, local=is_local)
326 def init_browser_data(self):
327 """This scans over self.(names, sha1s, types) to generate
328 directories, directory_entries, and subtree_*"""
330 # Collect data for the model
331 if not self.get_currentbranch(): return
333 self.subtree_types = []
334 self.subtree_sha1s = []
335 self.subtree_names = []
336 self.directories = []
337 self.directory_entries = {}
339 # Lookup the tree info
340 tree_info = self.parse_ls_tree(self.get_currentbranch())
342 self.set_types(map( lambda(x): x[1], tree_info ))
343 self.set_sha1s(map( lambda(x): x[2], tree_info ))
344 self.set_names(map( lambda(x): x[3], tree_info ))
346 if self.directory: self.directories.append('..')
348 dir_entries = self.directory_entries
349 dir_regex = re.compile('([^/]+)/')
350 dirs_seen = {}
351 subdirs_seen = {}
353 for idx, name in enumerate(self.names):
354 if not name.startswith(self.directory):
355 continue
356 name = name[ len(self.directory): ]
357 if name.count('/'):
358 # This is a directory...
359 match = dir_regex.match(name)
360 if not match:
361 continue
362 dirent = match.group(1) + '/'
363 if dirent not in self.directory_entries:
364 self.directory_entries[dirent] = []
366 if dirent not in dirs_seen:
367 dirs_seen[dirent] = True
368 self.directories.append(dirent)
370 entry = name.replace(dirent, '')
371 entry_match = dir_regex.match(entry)
372 if entry_match:
373 subdir = entry_match.group(1) + '/'
374 if subdir in subdirs_seen:
375 continue
376 subdirs_seen[subdir] = True
377 dir_entries[dirent].append(subdir)
378 else:
379 dir_entries[dirent].append(entry)
380 else:
381 self.subtree_types.append(self.types[idx])
382 self.subtree_sha1s.append(self.sha1s[idx])
383 self.subtree_names.append(name)
385 def add_or_remove(self, to_process):
386 """Invokes 'git add' to index the filenames in to_process that exist
387 and 'git rm' for those that do not exist."""
389 if not to_process:
390 return 'No files to add or remove.'
392 to_add = []
393 to_remove = []
395 for filename in to_process:
396 encfilename = encode(filename)
397 if os.path.exists(encfilename):
398 to_add.append(filename)
400 if to_add:
401 output = self.git.add(v=True, *to_add)
402 else:
403 output = ''
405 if len(to_add) == len(to_process):
406 # to_process only contained unremoved files --
407 # short-circuit the removal checks
408 return output
410 # Process files to remote
411 for filename in to_process:
412 if not os.path.exists(filename):
413 to_remove.append(filename)
414 output + '\n\n' + self.git.rm(*to_remove)
416 def get_editor(self):
417 return self.get_gui_config('editor')
419 def get_mergetool(self):
420 return self.get_global_config('merge.tool')
422 def get_history_browser(self):
423 return self.get_gui_config('historybrowser')
425 def remember_gui_settings(self):
426 return self.get_cola_config('savewindowsettings')
428 def get_tree_node(self, idx):
429 return (self.get_types()[idx],
430 self.get_sha1s()[idx],
431 self.get_names()[idx] )
433 def get_subtree_node(self, idx):
434 return (self.get_subtree_types()[idx],
435 self.get_subtree_sha1s()[idx],
436 self.get_subtree_names()[idx] )
438 def get_all_branches(self):
439 return (self.get_local_branches() + self.get_remote_branches())
441 def set_remote(self, remote):
442 if not remote:
443 return
444 self.set_param('remote', remote)
445 branches = utils.grep('%s/\S+$' % remote,
446 self.branch_list(remote=True),
447 squash=False)
448 self.set_remote_branches(branches)
450 def add_signoff(self,*rest):
451 """Adds a standard Signed-off by: tag to the end
452 of the current commit message."""
453 msg = self.get_commitmsg()
454 signoff =('\n\nSigned-off-by: %s <%s>\n'
455 % (self.get_local_user_name(), self.get_local_user_email()))
456 if signoff not in msg:
457 self.set_commitmsg(msg + signoff)
459 def apply_diff(self, filename):
460 return self.git.apply(filename, index=True, cached=True)
462 def apply_diff_to_worktree(self, filename):
463 return self.git.apply(filename)
465 def load_commitmsg(self, path):
466 file = open(path, 'r')
467 contents = decode(file.read())
468 file.close()
469 self.set_commitmsg(contents)
471 def get_prev_commitmsg(self,*rest):
472 """Queries git for the latest commit message and sets it in
473 self.commitmsg."""
474 commit_msg = []
475 commit_lines = decode(self.git.show('HEAD')).split('\n')
476 for idx, msg in enumerate(commit_lines):
477 if idx < 4:
478 continue
479 msg = msg.lstrip()
480 if msg.startswith('diff --git'):
481 commit_msg.pop()
482 break
483 commit_msg.append(msg)
484 self.set_commitmsg('\n'.join(commit_msg).rstrip())
486 def load_commitmsg_template(self):
487 try:
488 template = self.get_global_config('commit.template')
489 except AttributeError:
490 return
491 self.load_commitmsg(template)
493 def update_status(self, amend=False):
494 # This allows us to defer notification until the
495 # we finish processing data
496 notify_enabled = self.get_notify()
497 self.set_notify(False)
499 (self.staged,
500 self.modified,
501 self.unmerged,
502 self.untracked) = self.get_workdir_state(amend=amend)
503 # NOTE: the model's unstaged list holds an aggregate of the
504 # the modified, unmerged, and untracked file lists.
505 self.set_unstaged(self.modified + self.unmerged + self.untracked)
506 self.set_currentbranch(self.current_branch())
507 self.set_remotes(self.git.remote().splitlines())
508 self.set_remote_branches(self.branch_list(remote=True))
509 self.set_local_branches(self.branch_list(remote=False))
510 self.set_tags(self.git.tag().splitlines())
511 self.set_revision('')
512 self.set_local_branch('')
513 self.set_remote_branch('')
514 # Re-enable notifications and emit changes
515 self.set_notify(notify_enabled)
516 self.notify_observers('staged','unstaged')
518 def delete_branch(self, branch):
519 return self.git.branch(branch, D=True)
521 def get_revision_sha1(self, idx):
522 return self.get_revisions()[idx]
524 def apply_font_size(self, param, default):
525 old_font = self.get_param(param)
526 if not old_font:
527 old_font = default
528 size = self.get_param(param+'size')
529 props = old_font.split(',')
530 props[1] = str(size)
531 new_font = ','.join(props)
533 self.set_param(param, new_font)
535 def get_commit_diff(self, sha1):
536 commit = self.git.show(sha1)
537 first_newline = commit.index('\n')
538 if commit[first_newline+1:].startswith('Merge:'):
539 return (commit + '\n\n'
540 + self.diff_helper(commit=sha1,
541 cached=False,
542 suppress_header=False))
543 else:
544 return commit
546 def get_filename(self, idx, staged=True):
547 try:
548 if staged:
549 return self.get_staged()[idx]
550 else:
551 return self.get_unstaged()[idx]
552 except IndexError:
553 return None
555 def get_diff_details(self, idx, ref, staged=True):
556 filename = self.get_filename(idx, staged=staged)
557 if not filename:
558 return (None, None, None)
559 encfilename = encode(filename)
560 if staged:
561 if os.path.exists(encfilename):
562 status = 'Staged for commit'
563 else:
564 status = 'Staged for removal'
565 diff = self.diff_helper(filename=filename,
566 ref=ref,
567 cached=True)
568 else:
569 if os.path.isdir(encfilename):
570 status = 'Untracked directory'
571 diff = '\n'.join(os.listdir(filename))
573 elif filename in self.get_unmerged():
574 status = 'Unmerged'
575 diff = ('@@@+-+-+-+-+-+-+-+-+-+-+ UNMERGED +-+-+-+-+-+-+-+-+-+-+@@@\n\n'
576 '>>> %s is unmerged.\n' % filename +
577 'Right-click on the filename '
578 'to launch "git mergetool".\n\n\n')
579 diff += self.diff_helper(filename=filename,
580 cached=False,
581 patch_with_raw=False)
582 elif filename in self.get_modified():
583 status = 'Modified, not staged'
584 diff = self.diff_helper(filename=filename,
585 cached=False)
586 else:
587 status = 'Untracked, not staged'
588 diff = 'SHA1: ' + self.git.hash_object(filename)
589 return diff, status, filename
591 def stage_modified(self):
592 output = self.git.add(v=True, *self.get_modified())
593 self.update_status()
594 return output
596 def stage_untracked(self):
597 output = self.git.add(*self.get_untracked())
598 self.update_status()
599 return output
601 def reset(self, *items):
602 output = self.git.reset('--', *items)
603 self.update_status()
604 return output
606 def unstage_all(self):
607 output = self.git.reset()
608 self.update_status()
609 return output
611 def stage_all(self):
612 output = self.git.add(v=True,u=True)
613 self.update_status()
614 return output
616 def save_gui_settings(self):
617 self.config_set('cola.geometry', utils.get_geom(), local=False)
619 def config_set(self, key=None, value=None, local=True):
620 if key and value is not None:
621 # git config category.key value
622 strval = unicode(value)
623 if type(value) is bool:
624 # git uses "true" and "false"
625 strval = strval.lower()
626 if local:
627 argv = [ key, strval ]
628 else:
629 argv = [ '--global', key, strval ]
630 return self.git.config(*argv)
631 else:
632 msg = "oops in config_set(key=%s,value=%s,local=%s"
633 raise Exception(msg % (key, value, local))
635 def config_dict(self, local=True):
636 """parses the lines from git config --list into a dictionary"""
638 kwargs = {
639 'list': True,
640 'global': not local, # global is a python keyword
642 config_lines = self.git.config(**kwargs).splitlines()
643 newdict = {}
644 for line in config_lines:
645 k, v = line.split('=', 1)
646 v = decode(v)
647 k = k.replace('.','_') # git -> model
648 if v == 'true' or v == 'false':
649 v = bool(eval(v.title()))
650 try:
651 v = int(eval(v))
652 except:
653 pass
654 newdict[k]=v
655 return newdict
657 def commit_with_msg(self, msg, amend=False):
658 """Creates a git commit."""
660 if not msg.endswith('\n'):
661 msg += '\n'
662 # Sure, this is a potential "security risk," but if someone
663 # is trying to intercept/re-write commit messages on your system,
664 # then you probably have bigger problems to worry about.
665 tmpfile = self.get_tmp_filename()
667 # Create the commit message file
668 fh = open(tmpfile, 'w')
669 fh.write(msg)
670 fh.close()
672 # Run 'git commit'
673 (status, stdout, stderr) = self.git.commit(F=tmpfile,
674 v=True,
675 amend=amend,
676 with_extended_output=True)
677 os.unlink(tmpfile)
679 return (status, stdout+stderr)
682 def diffindex(self):
683 return self.git.diff(unified=self.diff_context,
684 stat=True,
685 cached=True)
687 def get_tmp_dir(self):
688 # Allow TMPDIR/TMP with a fallback to /tmp
689 return os.environ.get('TMP', os.environ.get('TMPDIR', '/tmp'))
691 def get_tmp_file_pattern(self):
692 return os.path.join(self.get_tmp_dir(), '*.git-cola.%s.*' % os.getpid())
694 def get_tmp_filename(self, prefix=''):
695 basename = ((prefix+'.git-cola.%s.%s'
696 % (os.getpid(), time.time())))
697 basename = basename.replace('/', '-')
698 basename = basename.replace('\\', '-')
699 tmpdir = self.get_tmp_dir()
700 return os.path.join(tmpdir, basename)
702 def log_helper(self, all=False):
704 Returns a pair of parallel arrays listing the revision sha1's
705 and commit summaries.
707 revs = []
708 summaries = []
709 regex = REV_LIST_REGEX
710 output = self.git.log(pretty='oneline', all=all)
711 for line in output.splitlines():
712 match = regex.match(line)
713 if match:
714 revs.append(match.group(1))
715 summaries.append(match.group(2))
716 return (revs, summaries)
718 def parse_rev_list(self, raw_revs):
719 revs = []
720 for line in raw_revs.splitlines():
721 match = REV_LIST_REGEX.match(line)
722 if match:
723 rev_id = match.group(1)
724 summary = match.group(2)
725 revs.append((rev_id, summary,))
726 return revs
728 def rev_list_range(self, start, end):
729 range = '%s..%s' % (start, end)
730 raw_revs = self.git.rev_list(range, pretty='oneline')
731 return self.parse_rev_list(raw_revs)
733 def diff_helper(self,
734 commit=None,
735 branch=None,
736 ref = None,
737 endref = None,
738 filename=None,
739 cached=True,
740 with_diff_header=False,
741 suppress_header=True,
742 reverse=False,
743 patch_with_raw=True):
744 "Invokes git diff on a filepath."
745 if commit:
746 ref, endref = commit+'^', commit
747 argv = []
748 if ref and endref:
749 argv.append('%s..%s' % (ref, endref))
750 elif ref:
751 argv.append(ref)
752 elif branch:
753 argv.append(branch)
755 if filename:
756 argv.append('--')
757 if type(filename) is list:
758 argv.extend(filename)
759 else:
760 argv.append(filename)
762 output = StringIO()
763 start = False
764 del_tag = 'deleted file mode '
766 headers = []
767 deleted = cached and not os.path.exists(encode(filename))
769 diffoutput = self.git.diff(R=reverse,
770 cached=cached,
771 patch_with_raw=patch_with_raw,
772 unified=self.diff_context,
773 with_raw_output=True,
774 *argv)
775 diff = diffoutput.splitlines()
776 for line in map(decode, diff):
777 if not start and '@@' == line[:2] and '@@' in line[2:]:
778 start = True
779 if start or(deleted and del_tag in line):
780 output.write(encode(line) + '\n')
781 else:
782 if with_diff_header:
783 headers.append(line)
784 elif not suppress_header:
785 output.write(encode(line) + '\n')
787 result = decode(output.getvalue())
788 output.close()
790 if with_diff_header:
791 return('\n'.join(headers), result)
792 else:
793 return result
795 def git_repo_path(self, *subpaths):
796 paths = [ self.git.get_git_dir() ]
797 paths.extend(subpaths)
798 return os.path.realpath(os.path.join(*paths))
800 def get_merge_message_path(self):
801 for file in ('MERGE_MSG', 'SQUASH_MSG'):
802 path = self.git_repo_path(file)
803 if os.path.exists(path):
804 return path
805 return None
807 def get_merge_message(self):
808 return self.git.fmt_merge_msg('--file',
809 self.git_repo_path('FETCH_HEAD'))
811 def abort_merge(self):
812 # Reset the worktree
813 output = self.git.read_tree('HEAD', reset=True, u=True, v=True)
814 # remove MERGE_HEAD
815 merge_head = self.git_repo_path('MERGE_HEAD')
816 if os.path.exists(merge_head):
817 os.unlink(merge_head)
818 # remove MERGE_MESSAGE, etc.
819 merge_msg_path = self.get_merge_message_path()
820 while merge_msg_path:
821 os.unlink(merge_msg_path)
822 merge_msg_path = self.get_merge_message_path()
824 def get_workdir_state(self, amend=False):
825 """RETURNS: A tuple of staged, unstaged untracked, and unmerged
826 file lists.
828 self.partially_staged = set()
829 head = 'HEAD'
830 if amend:
831 head = 'HEAD^'
832 (staged, modified, unmerged, untracked) = ([], [], [], [])
833 try:
834 for name in self.git.diff_index(head).splitlines():
835 rest, name = name.split('\t')
836 status = rest[-1]
837 name = eval_path(name)
838 if status == 'M' or status == 'D':
839 modified.append(name)
840 except:
841 # handle git init
842 for name in (self.git.ls_files(modified=True, z=True)
843 .split('\0')):
844 if name:
845 modified.append(decode(name))
847 try:
848 for name in (self.git.diff_index(head, cached=True)
849 .splitlines()):
850 rest, name = name.split('\t')
851 status = rest[-1]
852 name = eval_path(name)
853 if status == 'M':
854 staged.append(name)
855 # is this file partially staged?
856 diff = self.git.diff('--', name, name_only=True, z=True)
857 if not diff.strip():
858 modified.remove(name)
859 else:
860 self.partially_staged.add(name)
861 elif status == 'A':
862 staged.append(name)
863 elif status == 'D':
864 staged.append(name)
865 modified.remove(name)
866 elif status == 'U':
867 unmerged.append(name)
868 except:
869 # handle git init
870 for name in self.git.ls_files(z=True).strip('\0').split('\0'):
871 if name:
872 staged.append(decode(name))
874 for name in self.git.ls_files(others=True, exclude_standard=True,
875 z=True).split('\0'):
876 if name:
877 untracked.append(decode(name))
879 # remove duplicate merged and modified entries
880 for u in unmerged:
881 if u in modified:
882 modified.remove(u)
884 return (staged, modified, unmerged, untracked)
886 def reset_helper(self, args):
887 """Removes files from the index.
888 This handles the git init case, which is why it's not
889 just git.reset(name).
890 For the git init case this fall back to git rm --cached.
892 output = self.git.reset('--', *args)
893 # handle git init -- we have to rm --cached them
894 state = self.get_workdir_state()
895 staged = state[0]
896 newargs = []
897 for arg in args:
898 if arg in staged:
899 newargs.append(arg)
900 if newargs:
901 output = self.git.rm('--', cached=True, *newargs)
902 return output
904 def remote_url(self, name):
905 return self.git.config('remote.%s.url' % name, get=True)
907 def get_remote_args(self, remote,
908 local_branch='', remote_branch='',
909 ffwd=True, tags=False):
910 if ffwd:
911 branch_arg = '%s:%s' % ( remote_branch, local_branch )
912 else:
913 branch_arg = '+%s:%s' % ( remote_branch, local_branch )
914 args = [remote]
915 if local_branch and remote_branch:
916 args.append(branch_arg)
917 kwargs = {
918 'verbose': True,
919 'tags': tags,
921 return (args, kwargs)
923 def gen_remote_helper(self, gitaction):
924 """Generates a closure that calls git fetch, push or pull
926 def remote_helper(remote, **kwargs):
927 args, kwargs = self.get_remote_args(remote, **kwargs)
928 return gitaction(*args, **kwargs)
929 return remote_helper
931 def parse_ls_tree(self, rev):
932 """Returns a list of(mode, type, sha1, path) tuples."""
933 lines = self.git.ls_tree(rev, r=True).splitlines()
934 output = []
935 regex = re.compile('^(\d+)\W(\w+)\W(\w+)[ \t]+(.*)$')
936 for line in lines:
937 match = regex.match(line)
938 if match:
939 mode = match.group(1)
940 objtype = match.group(2)
941 sha1 = match.group(3)
942 filename = match.group(4)
943 output.append((mode, objtype, sha1, filename,) )
944 return output
946 def format_patch_helper(self, to_export, revs, output='patches'):
947 """writes patches named by to_export to the output directory."""
949 outlines = []
951 cur_rev = to_export[0]
952 cur_master_idx = revs.index(cur_rev)
954 patches_to_export = [ [cur_rev] ]
955 patchset_idx = 0
957 # Group the patches into continuous sets
958 for idx, rev in enumerate(to_export[1:]):
959 # Limit the search to the current neighborhood for efficiency
960 master_idx = revs[ cur_master_idx: ].index(rev)
961 master_idx += cur_master_idx
962 if master_idx == cur_master_idx + 1:
963 patches_to_export[ patchset_idx ].append(rev)
964 cur_master_idx += 1
965 continue
966 else:
967 patches_to_export.append([ rev ])
968 cur_master_idx = master_idx
969 patchset_idx += 1
971 # Export each patchsets
972 for patchset in patches_to_export:
973 cmdoutput = self.export_patchset(patchset[0],
974 patchset[-1],
975 output="patches",
976 n=len(patchset) > 1,
977 thread=True,
978 patch_with_stat=True)
979 outlines.append(cmdoutput)
980 return '\n'.join(outlines)
982 def export_patchset(self, start, end, output="patches", **kwargs):
983 revarg = '%s^..%s' % (start, end)
984 return self.git.format_patch("-o", output, revarg, **kwargs)
986 def current_branch(self):
987 """Parses 'git symbolic-ref' to find the current branch."""
988 headref = self.git.symbolic_ref('HEAD')
989 if headref.startswith('refs/heads/'):
990 return headref[11:]
991 elif headref.startswith('fatal: '):
992 return 'Not currently on any branch'
993 return headref
995 def create_branch(self, name, base, track=False):
996 """Creates a branch starting from base. Pass track=True
997 to create a remote tracking branch."""
998 return self.git.branch(name, base, track=track)
1000 def cherry_pick_list(self, revs, **kwargs):
1001 """Cherry-picks each revision into the current branch.
1002 Returns a list of command output strings (1 per cherry pick)"""
1003 if not revs:
1004 return []
1005 cherries = []
1006 for rev in revs:
1007 cherries.append(self.git.cherry_pick(rev, **kwargs))
1008 return '\n'.join(cherries)
1010 def parse_stash_list(self, revids=False):
1011 """Parses "git stash list" and returns a list of stashes."""
1012 stashes = self.git.stash("list").splitlines()
1013 if revids:
1014 return [ s[:s.index(':')] for s in stashes ]
1015 else:
1016 return [ s[s.index(':')+1:] for s in stashes ]
1018 def diffstat(self):
1019 return self.git.diff(
1020 'HEAD^',
1021 unified=self.diff_context,
1022 stat=True)
1024 def pad(self, pstr, num=22):
1025 topad = num-len(pstr)
1026 if topad > 0:
1027 return pstr + ' '*topad
1028 else:
1029 return pstr
1031 def describe(self, revid, descr):
1032 version = self.git.describe(revid, tags=True, always=True,
1033 abbrev=4)
1034 return version + ' - ' + descr
1036 def update_revision_lists(self, filename=None, show_versions=False):
1037 num_results = self.get_num_results()
1038 if filename:
1039 rev_list = self.git.log('--', filename,
1040 max_count=num_results,
1041 pretty='oneline')
1042 else:
1043 rev_list = self.git.log(max_count=num_results,
1044 pretty='oneline', all=True)
1046 commit_list = self.parse_rev_list(rev_list)
1047 commit_list.reverse()
1048 commits = map(lambda x: x[0], commit_list)
1049 descriptions = map(lambda x: decode(x[1]), commit_list)
1050 if show_versions:
1051 fancy_descr_list = map(lambda x: self.describe(*x), commit_list)
1052 self.set_descriptions_start(fancy_descr_list)
1053 self.set_descriptions_end(fancy_descr_list)
1054 else:
1055 self.set_descriptions_start(descriptions)
1056 self.set_descriptions_end(descriptions)
1058 self.set_revisions_start(commits)
1059 self.set_revisions_end(commits)
1061 return commits
1063 def get_changed_files(self, start, end):
1064 zfiles_str = self.git.diff('%s..%s' % (start, end),
1065 name_only=True, z=True).strip('\0')
1066 return [ decode(enc) for enc in zfiles_str.split('\0') if enc ]
1068 def get_renamed_files(self, start, end):
1069 files = []
1070 difflines = self.git.diff('%s..%s' % (start, end), M=True).splitlines()
1071 return [ eval_path(r[12:].rstrip())
1072 for r in difflines if r.startswith('rename from ') ]