models: slightly refactor the unicode handling
[git-cola.git] / cola / models.py
blob52f3ec38f072a4d06a045e3570e4e5cab3572ef7
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 git.Git.__init__(self)
23 self.load_worktree(os.getcwd())
25 def load_worktree(self, path):
26 self._git_dir = path
27 self._work_tree = None
28 self.get_work_tree()
30 def execute(*args, **kwargs):
31 kwargs['with_exceptions'] = False
32 return git.Git.execute(*args, **kwargs)
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 eval(path).decode('utf-8')
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 [ f.decode('utf-8') 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 = filename.encode('utf-8')
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 = file.read().decode('utf-8')
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 = self.git.show('HEAD').split('\n')
476 for idx, msg in enumerate(commit_lines):
477 if idx < 4: continue
478 msg = msg.lstrip()
479 if msg.startswith('diff --git'):
480 commit_msg.pop()
481 break
482 commit_msg.append(msg)
483 self.set_commitmsg('\n'.join(commit_msg).rstrip())
485 def load_commitmsg_template(self):
486 try:
487 template = self.get_global_config('commit.template')
488 except AttributeError:
489 return
490 self.load_commitmsg(template)
492 def update_status(self, amend=False):
493 # This allows us to defer notification until the
494 # we finish processing data
495 notify_enabled = self.get_notify()
496 self.set_notify(False)
498 # Reset the staged and unstaged model lists
499 # NOTE: the model's unstaged list is used to
500 # hold both modified and untracked files.
501 self.staged = []
502 self.modified = []
503 self.untracked = []
505 # Read git status items
506 (staged_items,
507 modified_items,
508 untracked_items,
509 unmerged_items) = self.get_workdir_state(amend=amend)
511 # Gather items to be committed
512 for staged in staged_items:
513 if staged not in self.get_staged():
514 self.add_staged(staged)
516 # Gather unindexed items
517 for modified in modified_items:
518 if modified not in self.get_modified():
519 self.add_modified(modified)
521 # Gather untracked items
522 for untracked in untracked_items:
523 if untracked not in self.get_untracked():
524 self.add_untracked(untracked)
526 # Gather unmerged items
527 for unmerged in unmerged_items:
528 if unmerged not in self.get_unmerged():
529 self.add_unmerged(unmerged)
531 self.set_currentbranch(self.current_branch())
532 self.set_unstaged(self.get_modified() + self.get_untracked() + self.get_unmerged())
533 self.set_remotes(self.git.remote().splitlines())
534 self.set_remote_branches(self.branch_list(remote=True))
535 self.set_local_branches(self.branch_list(remote=False))
536 self.set_tags(self.git.tag().splitlines())
537 self.set_revision('')
538 self.set_local_branch('')
539 self.set_remote_branch('')
540 # Re-enable notifications and emit changes
541 self.set_notify(notify_enabled)
542 self.notify_observers('staged','unstaged')
544 def delete_branch(self, branch):
545 return self.git.branch(branch, D=True)
547 def get_revision_sha1(self, idx):
548 return self.get_revisions()[idx]
550 def apply_font_size(self, param, default):
551 old_font = self.get_param(param)
552 if not old_font:
553 old_font = default
554 size = self.get_param(param+'size')
555 props = old_font.split(',')
556 props[1] = str(size)
557 new_font = ','.join(props)
559 self.set_param(param, new_font)
561 def get_commit_diff(self, sha1):
562 commit = self.git.show(sha1)
563 first_newline = commit.index('\n')
564 if commit[first_newline+1:].startswith('Merge:'):
565 return (commit + '\n\n'
566 + self.diff_helper(commit=sha1,
567 cached=False,
568 suppress_header=False))
569 else:
570 return commit
572 def get_filename(self, idx, staged=True):
573 try:
574 if staged:
575 return self.get_staged()[idx]
576 else:
577 return self.get_unstaged()[idx]
578 except IndexError:
579 return None
581 def get_diff_details(self, idx, ref, staged=True):
582 filename = self.get_filename(idx, staged=staged)
583 if not filename:
584 return (None, None, None)
585 encfilename = filename.encode('utf-8')
586 if staged:
587 if os.path.exists(encfilename):
588 status = 'Staged for commit'
589 else:
590 status = 'Staged for removal'
591 diff = self.diff_helper(filename=filename,
592 ref=ref,
593 cached=True)
594 else:
595 if os.path.isdir(encfilename):
596 status = 'Untracked directory'
597 diff = '\n'.join(os.listdir(filename))
599 elif filename in self.get_unmerged():
600 status = 'Unmerged'
601 diff = ('@@@+-+-+-+-+-+-+-+-+-+-+ UNMERGED +-+-+-+-+-+-+-+-+-+-+@@@\n\n'
602 '>>> %s is unmerged.\n' % filename +
603 'Right-click on the filename '
604 'to launch "git mergetool".\n\n\n')
605 diff += self.diff_helper(filename=filename,
606 cached=False,
607 patch_with_raw=False)
608 elif filename in self.get_modified():
609 status = 'Modified, not staged'
610 diff = self.diff_helper(filename=filename,
611 cached=False)
612 else:
613 status = 'Untracked, not staged'
614 diff = 'SHA1: ' + self.git.hash_object(filename)
615 return diff, status, filename
617 def stage_modified(self):
618 output = self.git.add(v=True, *self.get_modified())
619 self.update_status()
620 return output
622 def stage_untracked(self):
623 output = self.git.add(self.get_untracked())
624 self.update_status()
625 return output
627 def reset(self, *items):
628 output = self.git.reset('--', *items)
629 self.update_status()
630 return output
632 def unstage_all(self):
633 self.git.reset('--', *self.get_staged())
634 self.update_status()
636 def save_gui_settings(self):
637 self.config_set('cola.geometry', utils.get_geom(), local=False)
639 def config_set(self, key=None, value=None, local=True):
640 if key and value is not None:
641 # git config category.key value
642 strval = unicode(value).encode('utf-8')
643 if type(value) is bool:
644 # git uses "true" and "false"
645 strval = strval.lower()
646 if local:
647 argv = [ key, strval ]
648 else:
649 argv = [ '--global', key, strval ]
650 return self.git.config(*argv)
651 else:
652 msg = "oops in config_set(key=%s,value=%s,local=%s"
653 raise Exception(msg % (key, value, local))
655 def config_dict(self, local=True):
656 """parses the lines from git config --list into a dictionary"""
658 kwargs = {
659 'list': True,
660 'global': not local, # global is a python keyword
662 config_lines = self.git.config(**kwargs).splitlines()
663 newdict = {}
664 for line in config_lines:
665 k, v = line.split('=', 1)
666 k = k.replace('.','_') # git -> model
667 if v == 'true' or v == 'false':
668 v = bool(eval(v.title()))
669 try:
670 v = int(eval(v))
671 except:
672 pass
673 newdict[k]=v
674 return newdict
676 def commit_with_msg(self, msg, amend=False):
677 """Creates a git commit."""
679 if not msg.endswith('\n'):
680 msg += '\n'
681 # Sure, this is a potential "security risk," but if someone
682 # is trying to intercept/re-write commit messages on your system,
683 # then you probably have bigger problems to worry about.
684 tmpfile = self.get_tmp_filename()
686 # Create the commit message file
687 fh = open(tmpfile, 'w')
688 fh.write(msg)
689 fh.close()
691 # Run 'git commit'
692 (status, stdout, stderr) = self.git.commit(v=True,
693 F=tmpfile,
694 amend=amend,
695 with_extended_output=True)
696 os.unlink(tmpfile)
698 return (status, stdout+stderr)
701 def diffindex(self):
702 return self.git.diff(unified=self.diff_context,
703 stat=True,
704 cached=True)
706 def get_tmp_dir(self):
707 return os.environ.get('TMP', os.environ.get('TMPDIR', '/tmp'))
709 def get_tmp_file_pattern(self):
710 return os.path.join(self.get_tmp_dir(), '*.git.%s.*' % os.getpid())
712 def get_tmp_filename(self, prefix=''):
713 # Allow TMPDIR/TMP with a fallback to /tmp
714 basename = (prefix+'.git.%s.%s'
715 % (os.getpid(), time.time())).replace(os.sep, '-')
716 return os.path.join(self.get_tmp_dir(), basename)
718 def log_helper(self, all=False):
720 Returns a pair of parallel arrays listing the revision sha1's
721 and commit summaries.
723 revs = []
724 summaries = []
725 regex = REV_LIST_REGEX
726 output = self.git.log(pretty='oneline', all=all)
727 for line in output.splitlines():
728 match = regex.match(line)
729 if match:
730 revs.append(match.group(1))
731 summaries.append(match.group(2))
732 return (revs, summaries)
734 def parse_rev_list(self, raw_revs):
735 revs = []
736 for line in raw_revs.splitlines():
737 match = REV_LIST_REGEX.match(line)
738 if match:
739 rev_id = match.group(1)
740 summary = match.group(2)
741 revs.append((rev_id, summary,))
742 return revs
744 def rev_list_range(self, start, end):
745 range = '%s..%s' % (start, end)
746 raw_revs = self.git.rev_list(range, pretty='oneline')
747 return self.parse_rev_list(raw_revs)
749 def diff_helper(self,
750 commit=None,
751 branch=None,
752 ref = None,
753 endref = None,
754 filename=None,
755 cached=True,
756 with_diff_header=False,
757 suppress_header=True,
758 reverse=False,
759 patch_with_raw=True):
760 "Invokes git diff on a filepath."
761 if commit:
762 ref, endref = commit+'^', commit
763 argv = []
764 if ref and endref:
765 argv.append('%s..%s' % (ref, endref))
766 elif ref:
767 argv.append(ref)
768 elif branch:
769 argv.append(branch)
771 if filename:
772 argv.append('--')
773 if type(filename) is list:
774 argv.extend(filename)
775 else:
776 argv.append(filename)
778 output = StringIO()
779 start = False
780 del_tag = 'deleted file mode '
782 headers = []
783 deleted = cached and not os.path.exists(filename.encode('utf-8'))
785 diffoutput = self.git.diff(R=reverse,
786 cached=cached,
787 patch_with_raw=patch_with_raw,
788 unified=self.diff_context,
789 with_raw_output=True,
790 *argv)
791 diff = diffoutput.splitlines()
792 for line in diff:
793 line = unicode(line.decode('utf-8'))
794 if not start and '@@' == line[:2] and '@@' in line[2:]:
795 start = True
796 if start or(deleted and del_tag in line):
797 output.write(line.encode('utf-8') + '\n')
798 else:
799 if with_diff_header:
800 headers.append(line)
801 elif not suppress_header:
802 output.write(line.encode('utf-8') + '\n')
804 result = output.getvalue().decode('utf-8')
805 output.close()
807 if with_diff_header:
808 return('\n'.join(headers), result)
809 else:
810 return result
812 def git_repo_path(self, *subpaths):
813 paths = [ self.git.get_git_dir() ]
814 paths.extend(subpaths)
815 return os.path.realpath(os.path.join(*paths))
817 def get_merge_message_path(self):
818 for file in ('MERGE_MSG', 'SQUASH_MSG'):
819 path = self.git_repo_path(file)
820 if os.path.exists(path):
821 return path
822 return None
824 def get_merge_message(self):
825 return self.git.fmt_merge_msg('--file',
826 self.git_repo_path('FETCH_HEAD'))
828 def abort_merge(self):
829 # Reset the worktree
830 output = self.git.read_tree('HEAD', reset=True, u=True, v=True)
831 # remove MERGE_HEAD
832 merge_head = self.git_repo_path('MERGE_HEAD')
833 if os.path.exists(merge_head):
834 os.unlink(merge_head)
835 # remove MERGE_MESSAGE, etc.
836 merge_msg_path = self.get_merge_message_path()
837 while merge_msg_path:
838 os.unlink(merge_msg_path)
839 merge_msg_path = self.get_merge_message_path()
841 def get_workdir_state(self, amend=False):
842 """RETURNS: A tuple of staged, unstaged untracked, and unmerged
843 file lists.
845 self.partially_staged = set()
846 head = 'HEAD'
847 if amend:
848 head = 'HEAD^'
849 (staged, unstaged, unmerged, untracked) = ([], [], [], [])
851 for idx, line in enumerate(self.git.diff_index(head).splitlines()):
852 rest, name = line.split('\t')
853 status = rest[-1]
854 name = eval_path(name)
855 if status == 'M' or status == 'D':
856 unstaged.append(name)
858 for idx, line in enumerate(self.git.diff_index(head, cached=True)
859 .splitlines()):
860 rest, name = line.split('\t')
861 status = rest[-1]
862 name = eval_path(name)
863 if status == 'M':
864 staged.append(name)
865 # is this file partially staged?
866 diff = self.git.diff('--', name, name_only=True, z=True)
867 if not diff.strip():
868 unstaged.remove(name)
869 else:
870 self.partially_staged.add(name)
871 elif status == 'A':
872 staged.append(name)
873 elif status == 'D':
874 staged.append(name)
875 unstaged.remove(name)
876 elif status == 'U':
877 unmerged.append(name)
879 for line in self.git.ls_files(others=True, exclude_standard=True,
880 z=True).split('\0'):
881 if line:
882 untracked.append(line.decode('utf-8'))
884 return (staged, unstaged, untracked, unmerged)
886 def reset_helper(self, *args, **kwargs):
887 return self.git.reset('--', *args, **kwargs)
889 def remote_url(self, name):
890 return self.git.config('remote.%s.url' % name, get=True)
892 def get_remote_args(self, remote,
893 local_branch='', remote_branch='',
894 ffwd=True, tags=False):
895 if ffwd:
896 branch_arg = '%s:%s' % ( remote_branch, local_branch )
897 else:
898 branch_arg = '+%s:%s' % ( remote_branch, local_branch )
899 args = [remote]
900 if local_branch and remote_branch:
901 args.append(branch_arg)
902 kwargs = {
903 'verbose': True,
904 'tags': tags,
906 return (args, kwargs)
908 def gen_remote_helper(self, gitaction):
909 """Generates a closure that calls git fetch, push or pull
911 def remote_helper(remote, **kwargs):
912 args, kwargs = self.get_remote_args(remote, **kwargs)
913 return gitaction(*args, **kwargs)
914 return remote_helper
916 def parse_ls_tree(self, rev):
917 """Returns a list of(mode, type, sha1, path) tuples."""
918 lines = self.git.ls_tree(rev, r=True).splitlines()
919 output = []
920 regex = re.compile('^(\d+)\W(\w+)\W(\w+)[ \t]+(.*)$')
921 for line in lines:
922 match = regex.match(line)
923 if match:
924 mode = match.group(1)
925 objtype = match.group(2)
926 sha1 = match.group(3)
927 filename = match.group(4)
928 output.append((mode, objtype, sha1, filename,) )
929 return output
931 def format_patch_helper(self, to_export, revs, output='patches'):
932 """writes patches named by to_export to the output directory."""
934 outlines = []
936 cur_rev = to_export[0]
937 cur_master_idx = revs.index(cur_rev)
939 patches_to_export = [ [cur_rev] ]
940 patchset_idx = 0
942 # Group the patches into continuous sets
943 for idx, rev in enumerate(to_export[1:]):
944 # Limit the search to the current neighborhood for efficiency
945 master_idx = revs[ cur_master_idx: ].index(rev)
946 master_idx += cur_master_idx
947 if master_idx == cur_master_idx + 1:
948 patches_to_export[ patchset_idx ].append(rev)
949 cur_master_idx += 1
950 continue
951 else:
952 patches_to_export.append([ rev ])
953 cur_master_idx = master_idx
954 patchset_idx += 1
956 # Export each patchsets
957 for patchset in patches_to_export:
958 cmdoutput = self.export_patchset(patchset[0],
959 patchset[-1],
960 output="patches",
961 n=len(patchset) > 1,
962 thread=True,
963 patch_with_stat=True)
964 outlines.append(cmdoutput)
965 return '\n'.join(outlines)
967 def export_patchset(self, start, end, output="patches", **kwargs):
968 revarg = '%s^..%s' % (start, end)
969 return self.git.format_patch("-o", output, revarg, **kwargs)
971 def current_branch(self):
972 """Parses 'git branch' to find the current branch."""
973 branches = self.git.branch().splitlines()
974 for branch in branches:
975 if branch.startswith('* '):
976 return branch.lstrip('* ')
977 return 'Detached HEAD'
979 def create_branch(self, name, base, track=False):
980 """Creates a branch starting from base. Pass track=True
981 to create a remote tracking branch."""
982 return self.git.branch(name, base, track=track)
984 def cherry_pick_list(self, revs, **kwargs):
985 """Cherry-picks each revision into the current branch.
986 Returns a list of command output strings (1 per cherry pick)"""
987 if not revs:
988 return []
989 cherries = []
990 for rev in revs:
991 cherries.append(self.git.cherry_pick(rev, **kwargs))
992 return '\n'.join(cherries)
994 def parse_stash_list(self, revids=False):
995 """Parses "git stash list" and returns a list of stashes."""
996 stashes = self.git.stash("list").splitlines()
997 if revids:
998 return [ s[:s.index(':')] for s in stashes ]
999 else:
1000 return [ s[s.index(':')+1:] for s in stashes ]
1002 def diffstat(self):
1003 return self.git.diff(
1004 'HEAD^',
1005 unified=self.diff_context,
1006 stat=True)
1008 def pad(self, pstr, num=22):
1009 topad = num-len(pstr)
1010 if topad > 0:
1011 return pstr + ' '*topad
1012 else:
1013 return pstr
1015 def describe(self, revid, descr):
1016 version = self.git.describe(revid, tags=True, always=True,
1017 abbrev=4)
1018 return version + ' - ' + descr
1020 def update_revision_lists(self, filename=None, show_versions=False):
1021 num_results = self.get_num_results()
1022 if filename:
1023 rev_list = self.git.log('--', filename,
1024 max_count=num_results,
1025 pretty='oneline')
1026 else:
1027 rev_list = self.git.log(max_count=num_results,
1028 pretty='oneline', all=True)
1030 commit_list = self.parse_rev_list(rev_list)
1031 commit_list.reverse()
1032 commits = map(lambda x: x[0], commit_list)
1033 descriptions = map(lambda x: x[1], commit_list)
1034 if show_versions:
1035 fancy_descr_list = map(lambda x: self.describe(*x), commit_list)
1036 self.set_descriptions_start(fancy_descr_list)
1037 self.set_descriptions_end(fancy_descr_list)
1038 else:
1039 self.set_descriptions_start(descriptions)
1040 self.set_descriptions_end(descriptions)
1042 self.set_revisions_start(commits)
1043 self.set_revisions_end(commits)
1045 return commits
1047 def get_changed_files(self, start, end):
1048 zfiles_str = self.git.diff('%s..%s' % (start, end),
1049 name_only=True, z=True).strip('\0')
1050 return [ enc.decode('utf-8')
1051 for enc in zfiles_str.split('\0') if enc ]
1053 def get_renamed_files(self, start, end):
1054 files = []
1055 difflines = self.git.diff('%s..%s' % (start, end), M=True).splitlines()
1056 return [ eval_path(r[12:].rstrip())
1057 for r in difflines if r.startswith('rename from ') ]