models: add unicode/i18n support for get_latest_commitmsg()
[git-cola.git] / cola / models.py
blob44b2da9ed767ad2a40c8d774c842f5eb8019427e
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 = [],
151 show_untracked = True,
153 #####################################################
154 # Used by the create branch dialog
155 revision = '',
156 local_branches = [],
157 remote_branches = [],
158 tags = [],
160 #####################################################
161 # Used by the commit/repo browser
162 directory = '',
163 revisions = [],
164 summaries = [],
166 # These are parallel lists
167 types = [],
168 sha1s = [],
169 names = [],
171 # All items below here are re-calculated in
172 # init_browser_data()
173 directories = [],
174 directory_entries = {},
176 # These are also parallel lists
177 subtree_types = [],
178 subtree_sha1s = [],
179 subtree_names = [],
182 def __init_config_data(self):
183 """Reads git config --list and creates parameters
184 for each setting."""
185 # These parameters are saved in .gitconfig,
186 # so ideally these should be as short as possible.
188 # config items that are controllable globally
189 # and per-repository
190 self.__local_and_global_defaults = {
191 'user_name': '',
192 'user_email': '',
193 'merge_summary': False,
194 'merge_diffstat': True,
195 'merge_verbosity': 2,
196 'gui_diffcontext': 3,
197 'gui_pruneduringfetch': False,
199 # config items that are purely git config --global settings
200 self.__global_defaults = {
201 'cola_geometry':'',
202 'cola_fontui': '',
203 'cola_fontuisize': 12,
204 'cola_fontdiff': '',
205 'cola_fontdiffsize': 12,
206 'cola_savewindowsettings': False,
207 'merge_keepbackup': True,
208 'merge_tool': os.getenv('MERGETOOL', 'xxdiff'),
209 'gui_editor': os.getenv('EDITOR', 'gvim'),
210 'gui_historybrowser': 'gitk',
213 local_dict = self.config_dict(local=True)
214 global_dict = self.config_dict(local=False)
216 for k,v in local_dict.iteritems():
217 self.set_param('local_'+k, v)
218 for k,v in global_dict.iteritems():
219 self.set_param('global_'+k, v)
220 if k not in local_dict:
221 local_dict[k]=v
222 self.set_param('local_'+k, v)
224 # Bootstrap the internal font*size variables
225 for param in ('global_cola_fontui', 'global_cola_fontdiff'):
226 setdefault = True
227 if hasattr(self, param):
228 font = self.get_param(param)
229 if font:
230 setdefault = False
231 size = int(font.split(',')[1])
232 self.set_param(param+'size', size)
233 param = param[len('global_'):]
234 global_dict[param] = font
235 global_dict[param+'size'] = size
237 # Load defaults for all undefined items
238 local_and_global_defaults = self.__local_and_global_defaults
239 for k,v in local_and_global_defaults.iteritems():
240 if k not in local_dict:
241 self.set_param('local_'+k, v)
242 if k not in global_dict:
243 self.set_param('global_'+k, v)
245 global_defaults = self.__global_defaults
246 for k,v in global_defaults.iteritems():
247 if k not in global_dict:
248 self.set_param('global_'+k, v)
250 # Load the diff context
251 self.diff_context = self.local_gui_diffcontext
253 def get_global_config(self, key):
254 return getattr(self, 'global_'+key.replace('.', '_'))
256 def get_cola_config(self, key):
257 return getattr(self, 'global_cola_'+key)
259 def get_gui_config(self, key):
260 return getattr(self, 'global_gui_'+key)
262 def get_default_remote(self):
263 branch = self.get_currentbranch()
264 branchconfig = 'local_branch_%s_remote' % branch
265 if branchconfig in self.get_param_names():
266 remote = self.get_param(branchconfig)
267 else:
268 remote = 'origin'
269 return remote
271 def get_corresponding_remote_ref(self):
272 remote = self.get_default_remote()
273 branch = self.get_currentbranch()
274 best_match = '%s/%s' % (remote, branch)
275 remote_branches = self.get_remote_branches()
276 if not remote_branches:
277 return remote
278 for rb in remote_branches:
279 if rb == best_match:
280 return rb
281 return remote_branches[0]
283 def get_diff_filenames(self, arg):
284 diff_zstr = self.git.diff(arg, name_only=True, z=True).rstrip('\0')
285 return [ f.decode('utf-8') for f in diff_zstr.split('\0') if f ]
287 def branch_list(self, remote=False):
288 branches = map(lambda x: x.lstrip('* '),
289 self.git.branch(r=remote).splitlines())
290 if remote:
291 remotes = []
292 for branch in branches:
293 if branch.endswith('/HEAD'):
294 continue
295 remotes.append(branch)
296 return remotes
297 return branches
299 def get_config_params(self):
300 params = []
301 params.extend(map(lambda x: 'local_' + x,
302 self.__local_and_global_defaults.keys()))
303 params.extend(map(lambda x: 'global_' + x,
304 self.__local_and_global_defaults.keys()))
305 params.extend(map(lambda x: 'global_' + x,
306 self.__global_defaults.keys()))
307 return [ p for p in params if not p.endswith('size') ]
309 def save_config_param(self, param):
310 if param not in self.get_config_params():
311 return
312 value = self.get_param(param)
313 if param == 'local_gui_diffcontext':
314 self.diff_context = value
315 if param.startswith('local_'):
316 param = param[len('local_'):]
317 is_local = True
318 elif param.startswith('global_'):
319 param = param[len('global_'):]
320 is_local = False
321 else:
322 raise Exception("Invalid param '%s' passed to " % param
323 +'save_config_param()')
324 param = param.replace('_', '.') # model -> git
325 return self.config_set(param, value, local=is_local)
327 def init_browser_data(self):
328 """This scans over self.(names, sha1s, types) to generate
329 directories, directory_entries, and subtree_*"""
331 # Collect data for the model
332 if not self.get_currentbranch(): return
334 self.subtree_types = []
335 self.subtree_sha1s = []
336 self.subtree_names = []
337 self.directories = []
338 self.directory_entries = {}
340 # Lookup the tree info
341 tree_info = self.parse_ls_tree(self.get_currentbranch())
343 self.set_types(map( lambda(x): x[1], tree_info ))
344 self.set_sha1s(map( lambda(x): x[2], tree_info ))
345 self.set_names(map( lambda(x): x[3], tree_info ))
347 if self.directory: self.directories.append('..')
349 dir_entries = self.directory_entries
350 dir_regex = re.compile('([^/]+)/')
351 dirs_seen = {}
352 subdirs_seen = {}
354 for idx, name in enumerate(self.names):
355 if not name.startswith(self.directory):
356 continue
357 name = name[ len(self.directory): ]
358 if name.count('/'):
359 # This is a directory...
360 match = dir_regex.match(name)
361 if not match:
362 continue
363 dirent = match.group(1) + '/'
364 if dirent not in self.directory_entries:
365 self.directory_entries[dirent] = []
367 if dirent not in dirs_seen:
368 dirs_seen[dirent] = True
369 self.directories.append(dirent)
371 entry = name.replace(dirent, '')
372 entry_match = dir_regex.match(entry)
373 if entry_match:
374 subdir = entry_match.group(1) + '/'
375 if subdir in subdirs_seen:
376 continue
377 subdirs_seen[subdir] = True
378 dir_entries[dirent].append(subdir)
379 else:
380 dir_entries[dirent].append(entry)
381 else:
382 self.subtree_types.append(self.types[idx])
383 self.subtree_sha1s.append(self.sha1s[idx])
384 self.subtree_names.append(name)
386 def add_or_remove(self, *to_process):
387 """Invokes 'git add' to index the filenames in to_process that exist
388 and 'git rm' for those that do not exist."""
390 if not to_process:
391 return 'No files to add or remove.'
393 to_add = []
394 to_remove = []
396 for filename in to_process:
397 encfilename = filename.encode('utf-8')
398 if os.path.exists(encfilename):
399 to_add.append(filename)
401 if to_add:
402 output = self.git.add(v=True, *to_add)
403 else:
404 output = ''
406 if len(to_add) == len(to_process):
407 # to_process only contained unremoved files --
408 # short-circuit the removal checks
409 return output
411 # Process files to remote
412 for filename in to_process:
413 if not os.path.exists(filename):
414 to_remove.append(filename)
415 output + '\n\n' + self.git.rm(*to_remove)
417 def get_editor(self):
418 return self.get_gui_config('editor')
420 def get_mergetool(self):
421 return self.get_global_config('merge.tool')
423 def get_history_browser(self):
424 return self.get_gui_config('historybrowser')
426 def remember_gui_settings(self):
427 return self.get_cola_config('savewindowsettings')
429 def get_tree_node(self, idx):
430 return (self.get_types()[idx],
431 self.get_sha1s()[idx],
432 self.get_names()[idx] )
434 def get_subtree_node(self, idx):
435 return (self.get_subtree_types()[idx],
436 self.get_subtree_sha1s()[idx],
437 self.get_subtree_names()[idx] )
439 def get_all_branches(self):
440 return (self.get_local_branches() + self.get_remote_branches())
442 def set_remote(self, remote):
443 if not remote:
444 return
445 self.set_param('remote', remote)
446 branches = utils.grep('%s/\S+$' % remote,
447 self.branch_list(remote=True),
448 squash=False)
449 self.set_remote_branches(branches)
451 def add_signoff(self,*rest):
452 """Adds a standard Signed-off by: tag to the end
453 of the current commit message."""
454 msg = self.get_commitmsg()
455 signoff =('\n\nSigned-off-by: %s <%s>\n'
456 % (self.get_local_user_name(), self.get_local_user_email()))
457 if signoff not in msg:
458 self.set_commitmsg(msg + signoff)
460 def apply_diff(self, filename):
461 return self.git.apply(filename, index=True, cached=True)
463 def apply_diff_to_worktree(self, filename):
464 return self.git.apply(filename)
466 def load_commitmsg(self, path):
467 file = open(path, 'r')
468 contents = file.read().decode('utf-8')
469 file.close()
470 self.set_commitmsg(contents)
472 def get_prev_commitmsg(self,*rest):
473 """Queries git for the latest commit message and sets it in
474 self.commitmsg."""
475 commit_msg = []
476 commit_lines = self.git.show('HEAD').decode('utf-8').split('\n')
477 for idx, msg in enumerate(commit_lines):
478 if idx < 4:
479 continue
480 msg = msg.lstrip()
481 if msg.startswith('diff --git'):
482 commit_msg.pop()
483 break
484 commit_msg.append(msg)
485 self.set_commitmsg('\n'.join(commit_msg).rstrip())
487 def load_commitmsg_template(self):
488 try:
489 template = self.get_global_config('commit.template')
490 except AttributeError:
491 return
492 self.load_commitmsg(template)
494 def update_status(self, amend=False):
495 # This allows us to defer notification until the
496 # we finish processing data
497 notify_enabled = self.get_notify()
498 self.set_notify(False)
500 # Reset the staged and unstaged model lists
501 # NOTE: the model's unstaged list is used to
502 # hold both modified and untracked files.
503 self.staged = []
504 self.modified = []
505 self.untracked = []
507 # Read git status items
508 (staged_items,
509 modified_items,
510 untracked_items,
511 unmerged_items) = self.get_workdir_state(amend=amend)
513 # Gather items to be committed
514 for staged in staged_items:
515 if staged not in self.get_staged():
516 self.add_staged(staged)
518 # Gather unindexed items
519 for modified in modified_items:
520 if modified not in self.get_modified():
521 self.add_modified(modified)
523 # Gather untracked items
524 for untracked in untracked_items:
525 if untracked not in self.get_untracked():
526 self.add_untracked(untracked)
528 # Gather unmerged items
529 for unmerged in unmerged_items:
530 if unmerged not in self.get_unmerged():
531 self.add_unmerged(unmerged)
533 self.set_currentbranch(self.current_branch())
534 if self.get_show_untracked():
535 self.set_unstaged(self.get_modified() + self.get_unmerged() +
536 self.get_untracked())
537 else:
538 self.set_unstaged(self.get_modified() + self.get_unmerged())
539 self.set_remotes(self.git.remote().splitlines())
540 self.set_remote_branches(self.branch_list(remote=True))
541 self.set_local_branches(self.branch_list(remote=False))
542 self.set_tags(self.git.tag().splitlines())
543 self.set_revision('')
544 self.set_local_branch('')
545 self.set_remote_branch('')
546 # Re-enable notifications and emit changes
547 self.set_notify(notify_enabled)
548 self.notify_observers('staged','unstaged')
550 def delete_branch(self, branch):
551 return self.git.branch(branch, D=True)
553 def get_revision_sha1(self, idx):
554 return self.get_revisions()[idx]
556 def apply_font_size(self, param, default):
557 old_font = self.get_param(param)
558 if not old_font:
559 old_font = default
560 size = self.get_param(param+'size')
561 props = old_font.split(',')
562 props[1] = str(size)
563 new_font = ','.join(props)
565 self.set_param(param, new_font)
567 def get_commit_diff(self, sha1):
568 commit = self.git.show(sha1)
569 first_newline = commit.index('\n')
570 if commit[first_newline+1:].startswith('Merge:'):
571 return (commit + '\n\n'
572 + self.diff_helper(commit=sha1,
573 cached=False,
574 suppress_header=False))
575 else:
576 return commit
578 def get_filename(self, idx, staged=True):
579 try:
580 if staged:
581 return self.get_staged()[idx]
582 else:
583 return self.get_unstaged()[idx]
584 except IndexError:
585 return None
587 def get_diff_details(self, idx, ref, staged=True):
588 filename = self.get_filename(idx, staged=staged)
589 if not filename:
590 return (None, None, None)
591 encfilename = filename.encode('utf-8')
592 if staged:
593 if os.path.exists(encfilename):
594 status = 'Staged for commit'
595 else:
596 status = 'Staged for removal'
597 diff = self.diff_helper(filename=filename,
598 ref=ref,
599 cached=True)
600 else:
601 if os.path.isdir(encfilename):
602 status = 'Untracked directory'
603 diff = '\n'.join(os.listdir(filename))
605 elif filename in self.get_unmerged():
606 status = 'Unmerged'
607 diff = ('@@@+-+-+-+-+-+-+-+-+-+-+ UNMERGED +-+-+-+-+-+-+-+-+-+-+@@@\n\n'
608 '>>> %s is unmerged.\n' % filename +
609 'Right-click on the filename '
610 'to launch "git mergetool".\n\n\n')
611 diff += self.diff_helper(filename=filename,
612 cached=False,
613 patch_with_raw=False)
614 elif filename in self.get_modified():
615 status = 'Modified, not staged'
616 diff = self.diff_helper(filename=filename,
617 cached=False)
618 else:
619 status = 'Untracked, not staged'
620 diff = 'SHA1: ' + self.git.hash_object(filename)
621 return diff, status, filename
623 def stage_modified(self):
624 output = self.git.add(v=True, *self.get_modified())
625 self.update_status()
626 return output
628 def stage_untracked(self):
629 output = self.git.add(self.get_untracked())
630 self.update_status()
631 return output
633 def reset(self, *items):
634 output = self.git.reset('--', *items)
635 self.update_status()
636 return output
638 def unstage_all(self):
639 self.git.reset('--', *self.get_staged())
640 self.update_status()
642 def save_gui_settings(self):
643 self.config_set('cola.geometry', utils.get_geom(), local=False)
645 def config_set(self, key=None, value=None, local=True):
646 if key and value is not None:
647 # git config category.key value
648 strval = unicode(value).encode('utf-8')
649 if type(value) is bool:
650 # git uses "true" and "false"
651 strval = strval.lower()
652 if local:
653 argv = [ key, strval ]
654 else:
655 argv = [ '--global', key, strval ]
656 return self.git.config(*argv)
657 else:
658 msg = "oops in config_set(key=%s,value=%s,local=%s"
659 raise Exception(msg % (key, value, local))
661 def config_dict(self, local=True):
662 """parses the lines from git config --list into a dictionary"""
664 kwargs = {
665 'list': True,
666 'global': not local, # global is a python keyword
668 config_lines = self.git.config(**kwargs).splitlines()
669 newdict = {}
670 for line in config_lines:
671 k, v = line.split('=', 1)
672 k = k.replace('.','_') # git -> model
673 if v == 'true' or v == 'false':
674 v = bool(eval(v.title()))
675 try:
676 v = int(eval(v))
677 except:
678 pass
679 newdict[k]=v
680 return newdict
682 def commit_with_msg(self, msg, amend=False):
683 """Creates a git commit."""
685 if not msg.endswith('\n'):
686 msg += '\n'
687 # Sure, this is a potential "security risk," but if someone
688 # is trying to intercept/re-write commit messages on your system,
689 # then you probably have bigger problems to worry about.
690 tmpfile = self.get_tmp_filename()
692 # Create the commit message file
693 fh = open(tmpfile, 'w')
694 fh.write(msg)
695 fh.close()
697 # Run 'git commit'
698 (status, stdout, stderr) = self.git.commit(v=True,
699 F=tmpfile,
700 amend=amend,
701 with_extended_output=True)
702 os.unlink(tmpfile)
704 return (status, stdout+stderr)
707 def diffindex(self):
708 return self.git.diff(unified=self.diff_context,
709 stat=True,
710 cached=True)
712 def get_tmp_dir(self):
713 return os.environ.get('TMP', os.environ.get('TMPDIR', '/tmp'))
715 def get_tmp_file_pattern(self):
716 return os.path.join(self.get_tmp_dir(), '*.git.%s.*' % os.getpid())
718 def get_tmp_filename(self, prefix=''):
719 # Allow TMPDIR/TMP with a fallback to /tmp
720 basename = (prefix+'.git.%s.%s'
721 % (os.getpid(), time.time())).replace(os.sep, '-')
722 return os.path.join(self.get_tmp_dir(), basename)
724 def log_helper(self, all=False):
726 Returns a pair of parallel arrays listing the revision sha1's
727 and commit summaries.
729 revs = []
730 summaries = []
731 regex = REV_LIST_REGEX
732 output = self.git.log(pretty='oneline', all=all)
733 for line in output.splitlines():
734 match = regex.match(line)
735 if match:
736 revs.append(match.group(1))
737 summaries.append(match.group(2))
738 return (revs, summaries)
740 def parse_rev_list(self, raw_revs):
741 revs = []
742 for line in raw_revs.splitlines():
743 match = REV_LIST_REGEX.match(line)
744 if match:
745 rev_id = match.group(1)
746 summary = match.group(2)
747 revs.append((rev_id, summary,))
748 return revs
750 def rev_list_range(self, start, end):
751 range = '%s..%s' % (start, end)
752 raw_revs = self.git.rev_list(range, pretty='oneline')
753 return self.parse_rev_list(raw_revs)
755 def diff_helper(self,
756 commit=None,
757 branch=None,
758 ref = None,
759 endref = None,
760 filename=None,
761 cached=True,
762 with_diff_header=False,
763 suppress_header=True,
764 reverse=False,
765 patch_with_raw=True):
766 "Invokes git diff on a filepath."
767 if commit:
768 ref, endref = commit+'^', commit
769 argv = []
770 if ref and endref:
771 argv.append('%s..%s' % (ref, endref))
772 elif ref:
773 argv.append(ref)
774 elif branch:
775 argv.append(branch)
777 if filename:
778 argv.append('--')
779 if type(filename) is list:
780 argv.extend(filename)
781 else:
782 argv.append(filename)
784 output = StringIO()
785 start = False
786 del_tag = 'deleted file mode '
788 headers = []
789 deleted = cached and not os.path.exists(filename.encode('utf-8'))
791 diffoutput = self.git.diff(R=reverse,
792 cached=cached,
793 patch_with_raw=patch_with_raw,
794 unified=self.diff_context,
795 with_raw_output=True,
796 *argv)
797 diff = diffoutput.splitlines()
798 for line in diff:
799 line = unicode(line.decode('utf-8'))
800 if not start and '@@' == line[:2] and '@@' in line[2:]:
801 start = True
802 if start or(deleted and del_tag in line):
803 output.write(line.encode('utf-8') + '\n')
804 else:
805 if with_diff_header:
806 headers.append(line)
807 elif not suppress_header:
808 output.write(line.encode('utf-8') + '\n')
810 result = output.getvalue().decode('utf-8')
811 output.close()
813 if with_diff_header:
814 return('\n'.join(headers), result)
815 else:
816 return result
818 def git_repo_path(self, *subpaths):
819 paths = [ self.git.get_git_dir() ]
820 paths.extend(subpaths)
821 return os.path.realpath(os.path.join(*paths))
823 def get_merge_message_path(self):
824 for file in ('MERGE_MSG', 'SQUASH_MSG'):
825 path = self.git_repo_path(file)
826 if os.path.exists(path):
827 return path
828 return None
830 def get_merge_message(self):
831 return self.git.fmt_merge_msg('--file',
832 self.git_repo_path('FETCH_HEAD'))
834 def abort_merge(self):
835 # Reset the worktree
836 output = self.git.read_tree('HEAD', reset=True, u=True, v=True)
837 # remove MERGE_HEAD
838 merge_head = self.git_repo_path('MERGE_HEAD')
839 if os.path.exists(merge_head):
840 os.unlink(merge_head)
841 # remove MERGE_MESSAGE, etc.
842 merge_msg_path = self.get_merge_message_path()
843 while merge_msg_path:
844 os.unlink(merge_msg_path)
845 merge_msg_path = self.get_merge_message_path()
847 def get_workdir_state(self, amend=False):
848 """RETURNS: A tuple of staged, unstaged untracked, and unmerged
849 file lists.
851 self.partially_staged = set()
852 head = 'HEAD'
853 if amend:
854 head = 'HEAD^'
855 (staged, unstaged, unmerged, untracked) = ([], [], [], [])
857 for idx, line in enumerate(self.git.diff_index(head).splitlines()):
858 rest, name = line.split('\t')
859 status = rest[-1]
860 name = eval_path(name)
861 if status == 'M' or status == 'D':
862 unstaged.append(name)
864 for idx, line in enumerate(self.git.diff_index(head, cached=True)
865 .splitlines()):
866 rest, name = line.split('\t')
867 status = rest[-1]
868 name = eval_path(name)
869 if status == 'M':
870 staged.append(name)
871 # is this file partially staged?
872 diff = self.git.diff('--', name, name_only=True, z=True)
873 if not diff.strip():
874 unstaged.remove(name)
875 else:
876 self.partially_staged.add(name)
877 elif status == 'A':
878 staged.append(name)
879 elif status == 'D':
880 staged.append(name)
881 unstaged.remove(name)
882 elif status == 'U':
883 unmerged.append(name)
885 for line in self.git.ls_files(others=True, exclude_standard=True,
886 z=True).split('\0'):
887 if line:
888 untracked.append(line.decode('utf-8'))
890 return (staged, unstaged, untracked, unmerged)
892 def reset_helper(self, *args, **kwargs):
893 return self.git.reset('--', *args, **kwargs)
895 def remote_url(self, name):
896 return self.git.config('remote.%s.url' % name, get=True)
898 def get_remote_args(self, remote,
899 local_branch='', remote_branch='',
900 ffwd=True, tags=False):
901 if ffwd:
902 branch_arg = '%s:%s' % ( remote_branch, local_branch )
903 else:
904 branch_arg = '+%s:%s' % ( remote_branch, local_branch )
905 args = [remote]
906 if local_branch and remote_branch:
907 args.append(branch_arg)
908 kwargs = {
909 'verbose': True,
910 'tags': tags,
912 return (args, kwargs)
914 def gen_remote_helper(self, gitaction):
915 """Generates a closure that calls git fetch, push or pull
917 def remote_helper(remote, **kwargs):
918 args, kwargs = self.get_remote_args(remote, **kwargs)
919 return gitaction(*args, **kwargs)
920 return remote_helper
922 def parse_ls_tree(self, rev):
923 """Returns a list of(mode, type, sha1, path) tuples."""
924 lines = self.git.ls_tree(rev, r=True).splitlines()
925 output = []
926 regex = re.compile('^(\d+)\W(\w+)\W(\w+)[ \t]+(.*)$')
927 for line in lines:
928 match = regex.match(line)
929 if match:
930 mode = match.group(1)
931 objtype = match.group(2)
932 sha1 = match.group(3)
933 filename = match.group(4)
934 output.append((mode, objtype, sha1, filename,) )
935 return output
937 def format_patch_helper(self, to_export, revs, output='patches'):
938 """writes patches named by to_export to the output directory."""
940 outlines = []
942 cur_rev = to_export[0]
943 cur_master_idx = revs.index(cur_rev)
945 patches_to_export = [ [cur_rev] ]
946 patchset_idx = 0
948 # Group the patches into continuous sets
949 for idx, rev in enumerate(to_export[1:]):
950 # Limit the search to the current neighborhood for efficiency
951 master_idx = revs[ cur_master_idx: ].index(rev)
952 master_idx += cur_master_idx
953 if master_idx == cur_master_idx + 1:
954 patches_to_export[ patchset_idx ].append(rev)
955 cur_master_idx += 1
956 continue
957 else:
958 patches_to_export.append([ rev ])
959 cur_master_idx = master_idx
960 patchset_idx += 1
962 # Export each patchsets
963 for patchset in patches_to_export:
964 cmdoutput = self.export_patchset(patchset[0],
965 patchset[-1],
966 output="patches",
967 n=len(patchset) > 1,
968 thread=True,
969 patch_with_stat=True)
970 outlines.append(cmdoutput)
971 return '\n'.join(outlines)
973 def export_patchset(self, start, end, output="patches", **kwargs):
974 revarg = '%s^..%s' % (start, end)
975 return self.git.format_patch("-o", output, revarg, **kwargs)
977 def current_branch(self):
978 """Parses 'git branch' to find the current branch."""
979 branches = self.git.branch().splitlines()
980 for branch in branches:
981 if branch.startswith('* '):
982 return branch.lstrip('* ')
983 return 'Detached HEAD'
985 def create_branch(self, name, base, track=False):
986 """Creates a branch starting from base. Pass track=True
987 to create a remote tracking branch."""
988 return self.git.branch(name, base, track=track)
990 def cherry_pick_list(self, revs, **kwargs):
991 """Cherry-picks each revision into the current branch.
992 Returns a list of command output strings (1 per cherry pick)"""
993 if not revs:
994 return []
995 cherries = []
996 for rev in revs:
997 cherries.append(self.git.cherry_pick(rev, **kwargs))
998 return '\n'.join(cherries)
1000 def parse_stash_list(self, revids=False):
1001 """Parses "git stash list" and returns a list of stashes."""
1002 stashes = self.git.stash("list").splitlines()
1003 if revids:
1004 return [ s[:s.index(':')] for s in stashes ]
1005 else:
1006 return [ s[s.index(':')+1:] for s in stashes ]
1008 def diffstat(self):
1009 return self.git.diff(
1010 'HEAD^',
1011 unified=self.diff_context,
1012 stat=True)
1014 def pad(self, pstr, num=22):
1015 topad = num-len(pstr)
1016 if topad > 0:
1017 return pstr + ' '*topad
1018 else:
1019 return pstr
1021 def describe(self, revid, descr):
1022 version = self.git.describe(revid, tags=True, always=True,
1023 abbrev=4)
1024 return version + ' - ' + descr
1026 def update_revision_lists(self, filename=None, show_versions=False):
1027 num_results = self.get_num_results()
1028 if filename:
1029 rev_list = self.git.log('--', filename,
1030 max_count=num_results,
1031 pretty='oneline')
1032 else:
1033 rev_list = self.git.log(max_count=num_results,
1034 pretty='oneline', all=True)
1036 commit_list = self.parse_rev_list(rev_list)
1037 commit_list.reverse()
1038 commits = map(lambda x: x[0], commit_list)
1039 descriptions = map(lambda x: x[1], commit_list)
1040 if show_versions:
1041 fancy_descr_list = map(lambda x: self.describe(*x), commit_list)
1042 self.set_descriptions_start(fancy_descr_list)
1043 self.set_descriptions_end(fancy_descr_list)
1044 else:
1045 self.set_descriptions_start(descriptions)
1046 self.set_descriptions_end(descriptions)
1048 self.set_revisions_start(commits)
1049 self.set_revisions_end(commits)
1051 return commits
1053 def get_changed_files(self, start, end):
1054 zfiles_str = self.git.diff('%s..%s' % (start, end),
1055 name_only=True, z=True).strip('\0')
1056 return [ enc.decode('utf-8')
1057 for enc in zfiles_str.split('\0') if enc ]
1059 def get_renamed_files(self, start, end):
1060 files = []
1061 difflines = self.git.diff('%s..%s' % (start, end), M=True).splitlines()
1062 return [ eval_path(r[12:].rstrip())
1063 for r in difflines if r.startswith('rename from ') ]