models.main: Add mode states
[git-cola.git] / cola / models / main.py
blobf84a8489cd8236f3bbf0c8802e8b68b19d08db6d
1 # Copyright (c) 2008 David Aguilar
2 """This module provides the central cola model.
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 core
14 from cola import utils
15 from cola import errors
16 from cola.models.observable import ObservableModel
18 #+-------------------------------------------------------------------------
19 #+ A regex for matching the output of git(log|rev-list) --pretty=oneline
20 REV_LIST_REGEX = re.compile('([0-9a-f]+)\W(.*)')
22 # Provides access to a global MainModel instance
23 _instance = None
24 def model():
25 """Returns the main model singleton"""
26 global _instance
27 if _instance:
28 return _instance
29 _instance = MainModel()
30 return _instance
32 class GitInitError(errors.ColaError):
33 pass
35 class GitCola(git.Git):
36 """GitPython throws exceptions by default.
37 We suppress exceptions in favor of return values.
38 """
39 def __init__(self):
40 git.Git.__init__(self)
41 self.load_worktree(os.getcwd())
43 def load_worktree(self, path):
44 self._git_dir = path
45 self._worktree = None
46 self.worktree()
48 def worktree(self):
49 if self._worktree:
50 return self._worktree
51 self.git_dir()
52 if self._git_dir:
53 curdir = self._git_dir
54 else:
55 curdir = os.getcwd()
57 if self._is_git_dir(os.path.join(curdir, '.git')):
58 return curdir
60 # Handle bare repositories
61 if (len(os.path.basename(curdir)) > 4
62 and curdir.endswith('.git')):
63 return curdir
64 if 'GIT_WORK_TREE' in os.environ:
65 self._worktree = os.getenv('GIT_WORK_TREE')
66 if not self._worktree or not os.path.isdir(self._worktree):
67 if self._git_dir:
68 gitparent = os.path.join(os.path.abspath(self._git_dir), '..')
69 self._worktree = os.path.abspath(gitparent)
70 self.set_cwd(self._worktree)
71 return self._worktree
73 def is_valid(self):
74 return self._git_dir and self._is_git_dir(self._git_dir)
76 def git_dir(self):
77 if self.is_valid():
78 return self._git_dir
79 if 'GIT_DIR' in os.environ:
80 self._git_dir = os.getenv('GIT_DIR')
81 if self._git_dir:
82 curpath = os.path.abspath(self._git_dir)
83 else:
84 curpath = os.path.abspath(os.getcwd())
85 # Search for a .git directory
86 while curpath:
87 if self._is_git_dir(curpath):
88 self._git_dir = curpath
89 break
90 gitpath = os.path.join(curpath, '.git')
91 if self._is_git_dir(gitpath):
92 self._git_dir = gitpath
93 break
94 curpath, dummy = os.path.split(curpath)
95 if not dummy:
96 break
97 return self._git_dir
99 def _is_git_dir(self, d):
100 """ This is taken from the git setup.c:is_git_directory
101 function."""
102 if (os.path.isdir(d)
103 and os.path.isdir(os.path.join(d, 'objects'))
104 and os.path.isdir(os.path.join(d, 'refs'))):
105 headref = os.path.join(d, 'HEAD')
106 return (os.path.isfile(headref)
107 or (os.path.islink(headref)
108 and os.readlink(headref).startswith('refs')))
109 return False
112 def eval_path(path):
113 """handles quoted paths."""
114 if path.startswith('"') and path.endswith('"'):
115 return core.decode(eval(path))
116 else:
117 return path
120 class MainModel(ObservableModel):
121 """Provides a friendly wrapper for doing common git operations."""
123 # Observable messages
124 message_updated = 'updated'
125 message_about_to_update = 'about_to_update'
127 # States
128 mode_none = 'none' # Default: nothing's happened, do nothing
129 mode_worktree = 'worktree' # Comparing index to worktree
130 mode_index = 'index' # Comparing index to last commit
131 mode_amend = 'amend' # Amending a commit
132 mode_grep = 'grep' # We ran Search -> Grep
133 mode_branch = 'branch' # Applying changes from a branch
134 mode_diff = 'diff' # Diffing against an arbitrary branch
135 mode_diff_expr = 'diff_expr' # Diffing using arbitrary expression
136 mode_review = 'review' # Reviewing a branch
138 # Modes where we don't do anything like staging, etc.
139 modes_read_only = (mode_branch, mode_grep,
140 mode_diff, mode_diff_expr, mode_review)
141 # Modes where we can checkout files from the $head
142 modes_undoable = (mode_none, mode_index, mode_worktree)
144 def __init__(self, cwd=None):
145 """Reads git repository settings and sets several methods
146 so that they refer to the git module. This object
147 encapsulates cola's interaction with git."""
148 ObservableModel.__init__(self)
150 # Initialize the git command object
151 self.git = GitCola()
153 #####################################################
154 # Used in various places
155 self.currentbranch = ''
156 self.trackedbranch = ''
157 self.directory = ''
158 self.git_version = self.git.version()
159 self.project = ''
160 self.remotes = []
161 self.remotename = ''
162 self.local_branch = ''
163 self.remote_branch = ''
165 #####################################################
166 # Used primarily by the main UI
167 self.commitmsg = ''
168 self.modified = []
169 self.staged = []
170 self.unstaged = []
171 self.untracked = []
172 self.unmerged = []
173 self.upstream_changed = []
175 #####################################################
176 # Used by the create branch dialog
177 self.revision = ''
178 self.local_branches = []
179 self.remote_branches = []
180 self.tags = []
182 #####################################################
183 # Used by the commit/repo browser
184 self.revisions = []
185 self.summaries = []
187 # These are parallel lists
188 self.types = []
189 self.sha1s = []
190 self.names = []
192 # All items below here are re-calculated in
193 # init_browser_data()
194 self.directories = []
195 self.directory_entries = {}
197 # These are also parallel lists
198 self.subtree_types = []
199 self.subtree_sha1s = []
200 self.subtree_names = []
202 self.fetch_helper = None
203 self.push_helper = None
204 self.pull_helper = None
205 self.generate_remote_helpers()
206 if cwd:
207 self.use_worktree(cwd)
209 def all_files(self):
210 """Returns the names of all files in the repository"""
211 return [core.decode(f)
212 for f in self.git.ls_files(z=True)
213 .strip('\0').split('\0') if f]
215 def generate_remote_helpers(self):
216 """Generates helper methods for fetch, push and pull"""
217 self.push_helper = self.gen_remote_helper(self.git.push, push=True)
218 self.fetch_helper = self.gen_remote_helper(self.git.fetch)
219 self.pull_helper = self.gen_remote_helper(self.git.pull)
221 def use_worktree(self, worktree):
222 self.git.load_worktree(worktree)
223 is_valid = self.git.is_valid()
224 if is_valid:
225 self._init_config_data()
226 self.project = os.path.basename(self.git.worktree())
227 return is_valid
229 def _init_config_data(self):
230 """Reads git config --list and creates parameters
231 for each setting."""
232 # These parameters are saved in .gitconfig,
233 # so ideally these should be as short as possible.
235 # config items that are controllable globally
236 # and per-repository
237 self._local_and_global_defaults = {
238 'user_name': '',
239 'user_email': '',
240 'merge_summary': False,
241 'merge_diffstat': True,
242 'merge_verbosity': 2,
243 'gui_diffcontext': 3,
244 'gui_pruneduringfetch': False,
246 # config items that are purely git config --global settings
247 self.__global_defaults = {
248 'cola_geometry': '',
249 'cola_fontdiff': '',
250 'cola_fontdiff_size': 12,
251 'cola_savewindowsettings': False,
252 'cola_showoutput': 'errors',
253 'cola_tabwidth': 8,
254 'merge_keepbackup': True,
255 'diff_tool': os.getenv('GIT_DIFF_TOOL', 'xxdiff'),
256 'merge_tool': os.getenv('GIT_MERGE_TOOL', 'xxdiff'),
257 'gui_editor': os.getenv('EDITOR', 'gvim'),
258 'gui_historybrowser': 'gitk',
261 local_dict = self.config_dict(local=True)
262 global_dict = self.config_dict(local=False)
264 for k,v in local_dict.iteritems():
265 self.set_param('local_'+k, v)
266 for k,v in global_dict.iteritems():
267 self.set_param('global_'+k, v)
268 if k not in local_dict:
269 local_dict[k]=v
270 self.set_param('local_'+k, v)
272 # Bootstrap the internal font*size variables
273 for param in ('global_cola_fontdiff'):
274 setdefault = True
275 if hasattr(self, param):
276 font = getattr(self, param)
277 if font:
278 setdefault = False
279 size = int(font.split(',')[1])
280 self.set_param(param+'_size', size)
281 param = param[len('global_'):]
282 global_dict[param] = font
283 global_dict[param+'_size'] = size
285 # Load defaults for all undefined items
286 local_and_global_defaults = self._local_and_global_defaults
287 for k,v in local_and_global_defaults.iteritems():
288 if k not in local_dict:
289 self.set_param('local_'+k, v)
290 if k not in global_dict:
291 self.set_param('global_'+k, v)
293 global_defaults = self.__global_defaults
294 for k,v in global_defaults.iteritems():
295 if k not in global_dict:
296 self.set_param('global_'+k, v)
298 # Load the diff context
299 self.diff_context = self.local_config('gui.diffcontext', 3)
301 def global_config(self, key, default=None):
302 return self.param('global_'+key.replace('.', '_'),
303 default=default)
305 def local_config(self, key, default=None):
306 return self.param('local_'+key.replace('.', '_'),
307 default=default)
309 def cola_config(self, key):
310 return getattr(self, 'global_cola_'+key)
312 def gui_config(self, key):
313 return getattr(self, 'global_gui_'+key)
315 def default_remote(self):
316 branch = self.currentbranch
317 branchconfig = 'branch.%s.remote' % branch
318 return self.local_config(branchconfig, 'origin')
320 def corresponding_remote_ref(self):
321 remote = self.default_remote()
322 branch = self.currentbranch
323 best_match = '%s/%s' % (remote, branch)
324 remote_branches = self.remote_branches
325 if not remote_branches:
326 return remote
327 for rb in remote_branches:
328 if rb == best_match:
329 return rb
330 return remote_branches[0]
332 def diff_filenames(self, arg):
333 """Returns a list of filenames that have been modified"""
334 diff_zstr = self.git.diff(arg, name_only=True, z=True).rstrip('\0')
335 return [core.decode(f) for f in diff_zstr.split('\0') if f]
337 def branch_list(self, remote=False):
338 """Returns a list of local or remote branches
340 This explicitly removes HEAD from the list of remote branches.
342 branches = map(lambda x: x.lstrip('* '),
343 self.git.branch(r=remote).splitlines())
344 if remote:
345 return [b for b in branches if b.find('/HEAD') == -1]
346 return branches
348 def config_params(self):
349 params = []
350 params.extend(map(lambda x: 'local_' + x,
351 self._local_and_global_defaults.keys()))
352 params.extend(map(lambda x: 'global_' + x,
353 self._local_and_global_defaults.keys()))
354 params.extend(map(lambda x: 'global_' + x,
355 self.__global_defaults.keys()))
356 return [ p for p in params if not p.endswith('_size') ]
358 def save_config_param(self, param):
359 if param not in self.config_params():
360 return
361 value = getattr(self, param)
362 if param == 'local_gui_diffcontext':
363 self.diff_context = value
364 if param.startswith('local_'):
365 param = param[len('local_'):]
366 is_local = True
367 elif param.startswith('global_'):
368 param = param[len('global_'):]
369 is_local = False
370 else:
371 raise Exception("Invalid param '%s' passed to " % param
372 +'save_config_param()')
373 param = param.replace('_', '.') # model -> git
374 return self.config_set(param, value, local=is_local)
376 def init_browser_data(self):
377 """This scans over self.(names, sha1s, types) to generate
378 directories, directory_entries, and subtree_*"""
380 # Collect data for the model
381 if not self.currentbranch:
382 return
384 self.subtree_types = []
385 self.subtree_sha1s = []
386 self.subtree_names = []
387 self.directories = []
388 self.directory_entries = {}
390 # Lookup the tree info
391 tree_info = self.parse_ls_tree(self.currentbranch)
393 self.set_types(map( lambda(x): x[1], tree_info ))
394 self.set_sha1s(map( lambda(x): x[2], tree_info ))
395 self.set_names(map( lambda(x): x[3], tree_info ))
397 if self.directory: self.directories.append('..')
399 dir_entries = self.directory_entries
400 dir_regex = re.compile('([^/]+)/')
401 dirs_seen = {}
402 subdirs_seen = {}
404 for idx, name in enumerate(self.names):
405 if not name.startswith(self.directory):
406 continue
407 name = name[ len(self.directory): ]
408 if name.count('/'):
409 # This is a directory...
410 match = dir_regex.match(name)
411 if not match:
412 continue
413 dirent = match.group(1) + '/'
414 if dirent not in self.directory_entries:
415 self.directory_entries[dirent] = []
417 if dirent not in dirs_seen:
418 dirs_seen[dirent] = True
419 self.directories.append(dirent)
421 entry = name.replace(dirent, '')
422 entry_match = dir_regex.match(entry)
423 if entry_match:
424 subdir = entry_match.group(1) + '/'
425 if subdir in subdirs_seen:
426 continue
427 subdirs_seen[subdir] = True
428 dir_entries[dirent].append(subdir)
429 else:
430 dir_entries[dirent].append(entry)
431 else:
432 self.subtree_types.append(self.types[idx])
433 self.subtree_sha1s.append(self.sha1s[idx])
434 self.subtree_names.append(name)
436 def add_or_remove(self, to_process):
437 """Invokes 'git add' to index the filenames in to_process that exist
438 and 'git rm' for those that do not exist."""
440 if not to_process:
441 return 'No files to add or remove.'
443 to_add = []
444 to_remove = []
446 for filename in to_process:
447 encfilename = core.encode(filename)
448 if os.path.exists(encfilename):
449 to_add.append(filename)
451 status = 0
452 if to_add:
453 newstatus, output = self.git.add(v=True,
454 with_stderr=True,
455 with_status=True,
456 *to_add)
457 status += newstatus
458 else:
459 output = ''
461 if len(to_add) == len(to_process):
462 # to_process only contained unremoved files --
463 # short-circuit the removal checks
464 return (status, output)
466 # Process files to remote
467 for filename in to_process:
468 if not os.path.exists(filename):
469 to_remove.append(filename)
470 newstatus, out = self.git.rm(with_stderr=True,
471 with_status=True,
472 *to_remove)
473 if status == 0:
474 status += newstatus
475 output + '\n\n' + out
476 return (status, output)
478 def editor(self):
479 return self.gui_config('editor')
481 def history_browser(self):
482 return self.gui_config('historybrowser')
484 def remember_gui_settings(self):
485 return self.cola_config('savewindowsettings')
487 def subtree_node(self, idx):
488 return (self.subtree_types[idx],
489 self.subtree_sha1s[idx],
490 self.subtree_names[idx])
492 def all_branches(self):
493 return (self.local_branches + self.remote_branches)
495 def set_remote(self, remote):
496 if not remote:
497 return
498 self.set_param('remote', remote)
499 branches = utils.grep('%s/\S+$' % remote,
500 self.branch_list(remote=True),
501 squash=False)
502 self.set_remote_branches(branches)
504 def add_signoff(self,*rest):
505 """Adds a standard Signed-off by: tag to the end
506 of the current commit message."""
507 msg = self.commitmsg
508 signoff =('\n\nSigned-off-by: %s <%s>\n'
509 % (self.local_user_name, self.local_user_email))
510 if signoff not in msg:
511 self.set_commitmsg(msg + signoff)
513 def apply_diff(self, filename):
514 return self.git.apply(filename, index=True, cached=True)
516 def apply_diff_to_worktree(self, filename):
517 return self.git.apply(filename)
519 def load_commitmsg(self, path):
520 fh = open(path, 'r')
521 contents = core.decode(core.read_nointr(fh))
522 fh.close()
523 self.set_commitmsg(contents)
525 def get_prev_commitmsg(self,*rest):
526 """Queries git for the latest commit message and sets it in
527 self.commitmsg."""
528 commit_msg = []
529 commit_lines = core.decode(self.git.show('HEAD')).split('\n')
530 for idx, msg in enumerate(commit_lines):
531 if idx < 4:
532 continue
533 msg = msg.lstrip()
534 if msg.startswith('diff --git'):
535 commit_msg.pop()
536 break
537 commit_msg.append(msg)
538 self.set_commitmsg('\n'.join(commit_msg).rstrip())
540 def load_commitmsg_template(self):
541 template = self.global_config('commit.template')
542 if template:
543 self.load_commitmsg(template)
545 def update_status(self, head='HEAD', staged_only=False):
546 # Give observers a chance to respond
547 self.notify_message_observers(self.message_about_to_update)
548 # This allows us to defer notification until the
549 # we finish processing data
550 notify_enabled = self.notification_enabled
551 self.notification_enabled = False
553 (self.staged,
554 self.modified,
555 self.unmerged,
556 self.untracked,
557 self.upstream_changed) = self.worktree_state(head=head,
558 staged_only=staged_only)
559 # NOTE: the model's unstaged list holds an aggregate of the
560 # the modified, unmerged, and untracked file lists.
561 self.set_unstaged(self.modified + self.unmerged + self.untracked)
562 self.set_currentbranch(self.current_branch())
563 self.set_remotes(self.git.remote().splitlines())
564 self.set_remote_branches(self.branch_list(remote=True))
565 self.set_trackedbranch(self.tracked_branch())
566 self.set_local_branches(self.branch_list(remote=False))
567 self.set_tags(self.git.tag().splitlines())
568 self.set_revision('')
569 self.set_local_branch('')
570 self.set_remote_branch('')
571 # Re-enable notifications and emit changes
572 self.notification_enabled = notify_enabled
574 # Read the font size by default
575 self.read_font_sizes()
577 self.notify_observers('staged','unstaged')
578 self.notify_message_observers(self.message_updated)
580 def read_font_sizes(self):
581 """Read font sizes from the configuration."""
582 value = self.cola_config('fontdiff')
583 if not value:
584 return
585 items = value.split(',')
586 if len(items) < 2:
587 return
588 self.global_cola_fontdiff_size = int(items[1])
590 def set_diff_font(self, fontstr):
591 """Set the diff font string."""
592 self.global_cola_fontdiff = fontstr
593 self.read_font_sizes()
595 def delete_branch(self, branch):
596 return self.git.branch(branch,
597 D=True,
598 with_stderr=True,
599 with_status=True)
601 def revision_sha1(self, idx):
602 return self.revisions[idx]
604 def apply_diff_font_size(self, default):
605 old_font = self.cola_config('fontdiff')
606 if not old_font:
607 old_font = default
608 size = self.cola_config('fontdiff_size')
609 props = old_font.split(',')
610 props[1] = str(size)
611 new_font = ','.join(props)
612 self.global_cola_fontdiff = new_font
613 self.notify_observers('global_cola_fontdiff')
615 def commit_diff(self, sha1):
616 commit = self.git.show(sha1)
617 first_newline = commit.index('\n')
618 if commit[first_newline+1:].startswith('Merge:'):
619 return (core.decode(commit) + '\n\n' +
620 core.decode(self.diff_helper(commit=sha1,
621 cached=False,
622 suppress_header=False)))
623 else:
624 return core.decode(commit)
626 def filename(self, idx, staged=True):
627 try:
628 if staged:
629 return self.staged[idx]
630 else:
631 return self.unstaged[idx]
632 except IndexError:
633 return None
635 def diff_details(self, idx, ref, staged=True):
637 Return a "diff" for an entry by index relative to ref.
639 `staged` indicates whether we should consider this as a
640 staged or unstaged entry.
643 filename = self.filename(idx, staged=staged)
644 if not filename:
645 return (None, None)
646 encfilename = core.encode(filename)
647 if staged:
648 diff = self.diff_helper(filename=filename,
649 ref=ref,
650 cached=True)
651 else:
652 if os.path.isdir(encfilename):
653 diff = '\n'.join(os.listdir(filename))
655 elif filename in self.unmerged:
656 diff = ('@@@ Unmerged @@@\n'
657 '- %s is unmerged.\n+ ' % filename +
658 'Right-click the file to launch "git mergetool".\n'
659 '@@@ Unmerged @@@\n\n')
660 diff += self.diff_helper(filename=filename,
661 cached=False)
662 elif filename in self.modified:
663 diff = self.diff_helper(filename=filename,
664 cached=False)
665 else:
666 diff = 'SHA1: ' + self.git.hash_object(filename)
667 return (diff, filename)
669 def diff_for_expr(self, idx, expr):
671 Return a diff for an arbitrary diff expression.
673 `idx` is the index of the entry in the staged files list.
676 filename = self.filename(idx, staged=True)
677 if not filename:
678 return (None, None)
679 diff = self.diff_helper(filename=filename, ref=expr, cached=False)
680 return (diff, filename)
682 def stage_modified(self):
683 status, output = self.git.add(v=True,
684 with_stderr=True,
685 with_status=True,
686 *self.modified)
687 self.update_status()
688 return (status, output)
690 def stage_untracked(self):
691 status, output = self.git.add(v=True,
692 with_stderr=True,
693 with_status=True,
694 *self.untracked)
695 self.update_status()
696 return (status, output)
698 def reset(self, *items):
699 status, output = self.git.reset('--',
700 with_stderr=True,
701 with_status=True,
702 *items)
703 self.update_status()
704 return (status, output)
706 def unstage_all(self):
707 status, output = self.git.reset(with_stderr=True,
708 with_status=True)
709 self.update_status()
710 return (status, output)
712 def stage_all(self):
713 status, output = self.git.add(v=True,
714 u=True,
715 with_stderr=True,
716 with_status=True)
717 self.update_status()
718 return (status, output)
720 def config_set(self, key=None, value=None, local=True):
721 if key and value is not None:
722 # git config category.key value
723 strval = unicode(value)
724 if type(value) is bool:
725 # git uses "true" and "false"
726 strval = strval.lower()
727 if local:
728 argv = [ key, strval ]
729 else:
730 argv = [ '--global', key, strval ]
731 return self.git.config(*argv)
732 else:
733 msg = "oops in config_set(key=%s,value=%s,local=%s)"
734 raise Exception(msg % (key, value, local))
736 def config_dict(self, local=True):
737 """parses the lines from git config --list into a dictionary"""
739 kwargs = {
740 'list': True,
741 'global': not local, # global is a python keyword
743 config_lines = self.git.config(**kwargs).splitlines()
744 newdict = {}
745 for line in config_lines:
746 try:
747 k, v = line.split('=', 1)
748 except:
749 # the user has an invalid entry in their git config
750 continue
751 v = core.decode(v)
752 k = k.replace('.','_') # git -> model
753 if v == 'true' or v == 'false':
754 v = bool(eval(v.title()))
755 try:
756 v = int(eval(v))
757 except:
758 pass
759 newdict[k]=v
760 return newdict
762 def commit_with_msg(self, msg, amend=False):
763 """Creates a git commit."""
765 if not msg.endswith('\n'):
766 msg += '\n'
767 # Sure, this is a potential "security risk," but if someone
768 # is trying to intercept/re-write commit messages on your system,
769 # then you probably have bigger problems to worry about.
770 tmpfile = self.tmp_filename()
772 # Create the commit message file
773 fh = open(tmpfile, 'w')
774 core.write_nointr(fh, msg)
775 fh.close()
777 # Run 'git commit'
778 status, out = self.git.commit(F=tmpfile, v=True, amend=amend,
779 with_status=True,
780 with_stderr=True)
781 os.unlink(tmpfile)
782 return (status, out)
784 def diffindex(self):
785 return self.git.diff(unified=self.diff_context,
786 no_color=True,
787 stat=True,
788 cached=True)
790 def tmp_dir(self):
791 # Allow TMPDIR/TMP with a fallback to /tmp
792 return os.environ.get('TMP', os.environ.get('TMPDIR', '/tmp'))
794 def tmp_file_pattern(self):
795 return os.path.join(self.tmp_dir(), '*.git-cola.%s.*' % os.getpid())
797 def tmp_filename(self, prefix=''):
798 basename = ((prefix+'.git-cola.%s.%s'
799 % (os.getpid(), time.time())))
800 basename = basename.replace('/', '-')
801 basename = basename.replace('\\', '-')
802 tmpdir = self.tmp_dir()
803 return os.path.join(tmpdir, basename)
805 def log_helper(self, all=False, extra_args=None):
807 Returns a pair of parallel arrays listing the revision sha1's
808 and commit summaries.
810 revs = []
811 summaries = []
812 regex = REV_LIST_REGEX
813 args = []
814 if extra_args:
815 args = extra_args
816 output = self.git.log(pretty='oneline', all=all, *args)
817 for line in map(core.decode, output.splitlines()):
818 match = regex.match(line)
819 if match:
820 revs.append(match.group(1))
821 summaries.append(match.group(2))
822 return (revs, summaries)
824 def parse_rev_list(self, raw_revs):
825 revs = []
826 for line in map(core.decode, raw_revs.splitlines()):
827 match = REV_LIST_REGEX.match(line)
828 if match:
829 rev_id = match.group(1)
830 summary = match.group(2)
831 revs.append((rev_id, summary,))
832 return revs
834 def rev_list_range(self, start, end):
835 range = '%s..%s' % (start, end)
836 raw_revs = self.git.rev_list(range, pretty='oneline')
837 return self.parse_rev_list(raw_revs)
839 def diff_helper(self,
840 commit=None,
841 branch=None,
842 ref=None,
843 endref=None,
844 filename=None,
845 cached=True,
846 with_diff_header=False,
847 suppress_header=True,
848 reverse=False):
849 "Invokes git diff on a filepath."
850 if commit:
851 ref, endref = commit+'^', commit
852 argv = []
853 if ref and endref:
854 argv.append('%s..%s' % (ref, endref))
855 elif ref:
856 for r in ref.strip().split():
857 argv.append(r)
858 elif branch:
859 argv.append(branch)
861 if filename:
862 argv.append('--')
863 if type(filename) is list:
864 argv.extend(filename)
865 else:
866 argv.append(filename)
868 start = False
869 del_tag = 'deleted file mode '
871 headers = []
872 deleted = cached and not os.path.exists(core.encode(filename))
874 diffoutput = self.git.diff(R=reverse,
875 M=True,
876 no_color=True,
877 cached=cached,
878 unified=self.diff_context,
879 with_raw_output=True,
880 with_stderr=True,
881 *argv)
883 # Handle 'git init'
884 if diffoutput.startswith('fatal:'):
885 if with_diff_header:
886 return ('', '')
887 else:
888 return ''
890 output = StringIO()
892 diff = diffoutput.split('\n')
893 for line in map(core.decode, diff):
894 if not start and '@@' == line[:2] and '@@' in line[2:]:
895 start = True
896 if start or (deleted and del_tag in line):
897 output.write(core.encode(line) + '\n')
898 else:
899 if with_diff_header:
900 headers.append(core.encode(line))
901 elif not suppress_header:
902 output.write(core.encode(line) + '\n')
904 result = core.decode(output.getvalue())
905 output.close()
907 if with_diff_header:
908 return('\n'.join(headers), result)
909 else:
910 return result
912 def git_repo_path(self, *subpaths):
913 paths = [self.git.git_dir()]
914 paths.extend(subpaths)
915 return os.path.realpath(os.path.join(*paths))
917 def merge_message_path(self):
918 for file in ('MERGE_MSG', 'SQUASH_MSG'):
919 path = self.git_repo_path(file)
920 if os.path.exists(path):
921 return path
922 return None
924 def merge_message(self):
925 return self.git.fmt_merge_msg('--file',
926 self.git_repo_path('FETCH_HEAD'))
928 def abort_merge(self):
929 # Reset the worktree
930 output = self.git.read_tree('HEAD', reset=True, u=True, v=True)
931 # remove MERGE_HEAD
932 merge_head = self.git_repo_path('MERGE_HEAD')
933 if os.path.exists(merge_head):
934 os.unlink(merge_head)
935 # remove MERGE_MESSAGE, etc.
936 merge_msg_path = self.merge_message_path()
937 while merge_msg_path:
938 os.unlink(merge_msg_path)
939 merge_msg_path = self.merge_message_path()
941 def _is_modified(self, name):
942 status, out = self.git.diff('--', name,
943 name_only=True,
944 exit_code=True,
945 with_status=True)
946 return status != 0
949 def _branch_status(self, branch):
951 Returns a tuple of staged, unstaged, untracked, and unmerged files
953 This shows only the changes that were introduced in branch
956 status, output = self.git.diff(name_only=True,
957 M=True, z=True,
958 with_stderr=True,
959 with_status=True,
960 *branch.strip().split())
961 if status != 0:
962 return ([], [], [], [])
963 staged = []
964 for name in output.strip('\0').split('\0'):
965 if not name:
966 continue
967 staged.append(core.decode(name))
969 return (staged, [], [], [])
971 def worktree_state(self, head='HEAD', staged_only=False):
972 """Return a tuple of files in various states of being
974 Can be staged, unstaged, untracked, unmerged, or changed
975 upstream.
978 self.git.update_index(refresh=True)
979 if staged_only:
980 return self._branch_status(head)
982 staged_set = set()
983 modified_set = set()
984 upstream_changed_set = set()
986 (staged, modified, unmerged, untracked, upstream_changed) = (
987 [], [], [], [], [])
988 try:
989 output = self.git.diff_index(head,
990 cached=True,
991 with_stderr=True)
992 if output.startswith('fatal:'):
993 raise GitInitError('git init')
994 for line in output.splitlines():
995 rest, name = line.split('\t', 1)
996 status = rest[-1]
997 name = eval_path(name)
998 if status == 'M':
999 staged.append(name)
1000 staged_set.add(name)
1001 # This file will also show up as 'M' without --cached
1002 # so by default don't consider it modified unless
1003 # it's truly modified
1004 modified_set.add(name)
1005 if not staged_only and self._is_modified(name):
1006 modified.append(name)
1007 elif status == 'A':
1008 staged.append(name)
1009 staged_set.add(name)
1010 elif status == 'D':
1011 staged.append(name)
1012 staged_set.add(name)
1013 modified_set.add(name)
1014 elif status == 'U':
1015 unmerged.append(name)
1016 modified_set.add(name)
1018 except GitInitError:
1019 # handle git init
1020 staged.extend(self.all_files())
1022 try:
1023 output = self.git.diff_index(head, with_stderr=True)
1024 if output.startswith('fatal:'):
1025 raise GitInitError('git init')
1026 for line in output.splitlines():
1027 info, name = line.split('\t', 1)
1028 status = info.split()[-1]
1029 if status == 'M' or status == 'D':
1030 name = eval_path(name)
1031 if name not in modified_set:
1032 modified.append(name)
1033 elif status == 'A':
1034 name = eval_path(name)
1035 # newly-added yet modified
1036 if (name not in modified_set and not staged_only and
1037 self._is_modified(name)):
1038 modified.append(name)
1040 except GitInitError:
1041 # handle git init
1042 for name in (self.git.ls_files(modified=True, z=True)
1043 .split('\0')):
1044 if name:
1045 modified.append(core.decode(name))
1047 for name in self.git.ls_files(others=True, exclude_standard=True,
1048 z=True).split('\0'):
1049 if name:
1050 untracked.append(core.decode(name))
1052 # Look for upstream modified files if this is a tracking branch
1053 if self.trackedbranch:
1054 try:
1055 output = self.git.diff_index(self.trackedbranch,
1056 with_stderr=True)
1057 if output.startswith('fatal:'):
1058 raise GitInitError('git init')
1059 for line in output.splitlines():
1060 info, name = line.split('\t', 1)
1061 status = info.split()[-1]
1062 # TODO
1063 # For now we'll just call anything here 'changed
1064 # upstream'. Maybe one day we'll elaborate more on
1065 # what the change is.
1066 if status == 'M' or status == 'D':
1067 name = eval_path(name)
1068 if name not in upstream_changed_set:
1069 upstream_changed.append(name)
1070 upstream_changed_set.add(name)
1072 except GitInitError:
1073 # handle git init
1074 pass
1076 # Keep stuff sorted
1077 staged.sort()
1078 modified.sort()
1079 unmerged.sort()
1080 untracked.sort()
1081 upstream_changed.sort()
1083 return (staged, modified, unmerged, untracked, upstream_changed)
1085 def reset_helper(self, args):
1086 """Removes files from the index
1088 This handles the git init case, which is why it's not
1089 just 'git reset name'. For the git init case this falls
1090 back to 'git rm --cached'.
1093 # fake the status because 'git reset' returns 1
1094 # regardless of success/failure
1095 status = 0
1096 output = self.git.reset('--', with_stderr=True, *args)
1097 # handle git init: we have to use 'git rm --cached'
1098 # detect this condition by checking if the file is still staged
1099 state = self.worktree_state()
1100 staged = state[0]
1101 rmargs = [a for a in args if a in staged]
1102 if not rmargs:
1103 return (status, output)
1104 output += self.git.rm('--', cached=True, with_stderr=True, *rmargs)
1105 return (status, output)
1107 def remote_url(self, name):
1108 return self.git.config('remote.%s.url' % name, get=True)
1110 def remote_args(self, remote,
1111 local_branch='',
1112 remote_branch='',
1113 ffwd=True,
1114 tags=False,
1115 rebase=False,
1116 push=False):
1117 # Swap the branches in push mode (reverse of fetch)
1118 if push:
1119 tmp = local_branch
1120 local_branch = remote_branch
1121 remote_branch = tmp
1122 if ffwd:
1123 branch_arg = '%s:%s' % ( remote_branch, local_branch )
1124 else:
1125 branch_arg = '+%s:%s' % ( remote_branch, local_branch )
1126 args = [remote]
1127 if local_branch and remote_branch:
1128 args.append(branch_arg)
1129 elif local_branch:
1130 args.append(local_branch)
1131 elif remote_branch:
1132 args.append(remote_branch)
1133 kwargs = {
1134 'verbose': True,
1135 'tags': tags,
1136 'rebase': rebase,
1137 'with_stderr': True,
1138 'with_status': True,
1140 return (args, kwargs)
1142 def gen_remote_helper(self, gitaction, push=False):
1143 """Generates a closure that calls git fetch, push or pull
1145 def remote_helper(remote, **kwargs):
1146 args, kwargs = self.remote_args(remote, push=push, **kwargs)
1147 return gitaction(*args, **kwargs)
1148 return remote_helper
1150 def parse_ls_tree(self, rev):
1151 """Returns a list of(mode, type, sha1, path) tuples."""
1152 lines = self.git.ls_tree(rev, r=True).splitlines()
1153 output = []
1154 regex = re.compile('^(\d+)\W(\w+)\W(\w+)[ \t]+(.*)$')
1155 for line in lines:
1156 match = regex.match(line)
1157 if match:
1158 mode = match.group(1)
1159 objtype = match.group(2)
1160 sha1 = match.group(3)
1161 filename = match.group(4)
1162 output.append((mode, objtype, sha1, filename,) )
1163 return output
1165 def format_patch_helper(self, to_export, revs, output='patches'):
1166 """writes patches named by to_export to the output directory."""
1168 outlines = []
1170 cur_rev = to_export[0]
1171 cur_master_idx = revs.index(cur_rev)
1173 patches_to_export = [ [cur_rev] ]
1174 patchset_idx = 0
1176 # Group the patches into continuous sets
1177 for idx, rev in enumerate(to_export[1:]):
1178 # Limit the search to the current neighborhood for efficiency
1179 master_idx = revs[ cur_master_idx: ].index(rev)
1180 master_idx += cur_master_idx
1181 if master_idx == cur_master_idx + 1:
1182 patches_to_export[ patchset_idx ].append(rev)
1183 cur_master_idx += 1
1184 continue
1185 else:
1186 patches_to_export.append([ rev ])
1187 cur_master_idx = master_idx
1188 patchset_idx += 1
1190 # Export each patchsets
1191 status = 0
1192 for patchset in patches_to_export:
1193 newstatus, out = self.export_patchset(patchset[0],
1194 patchset[-1],
1195 output='patches',
1196 n=len(patchset) > 1,
1197 thread=True,
1198 patch_with_stat=True)
1199 outlines.append(out)
1200 if status == 0:
1201 status += newstatus
1202 return (status, '\n'.join(outlines))
1204 def export_patchset(self, start, end, output="patches", **kwargs):
1205 revarg = '%s^..%s' % (start, end)
1206 return self.git.format_patch('-o', output, revarg,
1207 with_stderr=True,
1208 with_status=True,
1209 **kwargs)
1211 def current_branch(self):
1212 """Parses 'git symbolic-ref' to find the current branch."""
1213 headref = self.git.symbolic_ref('HEAD', with_stderr=True)
1214 if headref.startswith('refs/heads/'):
1215 return headref[11:]
1216 elif headref.startswith('fatal:'):
1217 return ''
1218 return headref
1220 def tracked_branch(self):
1221 """The name of the branch that current branch is tracking"""
1222 remote = self.git.config('branch.'+self.currentbranch+'.remote',
1223 get=True, with_stderr=True)
1224 if not remote:
1225 return ''
1226 headref = self.git.config('branch.'+self.currentbranch+'.merge',
1227 get=True, with_stderr=True)
1228 if headref.startswith('refs/heads/'):
1229 tracked_branch = headref[11:]
1230 return remote + '/' + tracked_branch
1231 return ''
1233 def create_branch(self, name, base, track=False):
1234 """Create a branch named 'name' from revision 'base'
1236 Pass track=True to create a local tracking branch.
1238 return self.git.branch(name, base, track=track,
1239 with_stderr=True,
1240 with_status=True)
1242 def cherry_pick_list(self, revs, **kwargs):
1243 """Cherry-picks each revision into the current branch.
1244 Returns a list of command output strings (1 per cherry pick)"""
1245 if not revs:
1246 return []
1247 cherries = []
1248 status = 0
1249 for rev in revs:
1250 newstatus, out = self.git.cherry_pick(rev,
1251 with_stderr=True,
1252 with_status=True)
1253 if status == 0:
1254 status += newstatus
1255 cherries.append(out)
1256 return (status, '\n'.join(cherries))
1258 def parse_stash_list(self, revids=False):
1259 """Parses "git stash list" and returns a list of stashes."""
1260 stashes = self.git.stash("list").splitlines()
1261 if revids:
1262 return [ s[:s.index(':')] for s in stashes ]
1263 else:
1264 return [ s[s.index(':')+1:] for s in stashes ]
1266 def diffstat(self):
1267 return self.git.diff(
1268 'HEAD^',
1269 unified=self.diff_context,
1270 no_color=True,
1271 stat=True)
1273 def pad(self, pstr, num=22):
1274 topad = num-len(pstr)
1275 if topad > 0:
1276 return pstr + ' '*topad
1277 else:
1278 return pstr
1280 def describe(self, revid, descr):
1281 version = self.git.describe(revid, tags=True, always=True,
1282 abbrev=4)
1283 return version + ' - ' + descr
1285 def update_revision_lists(self, filename=None, show_versions=False):
1286 num_results = self.num_results
1287 if filename:
1288 rev_list = self.git.log('--', filename,
1289 max_count=num_results,
1290 pretty='oneline')
1291 else:
1292 rev_list = self.git.log(max_count=num_results,
1293 pretty='oneline', all=True)
1295 commit_list = self.parse_rev_list(rev_list)
1296 commit_list.reverse()
1297 commits = map(lambda x: x[0], commit_list)
1298 descriptions = map(lambda x: core.decode(x[1]), commit_list)
1299 if show_versions:
1300 fancy_descr_list = map(lambda x: self.describe(*x), commit_list)
1301 self.set_descriptions_start(fancy_descr_list)
1302 self.set_descriptions_end(fancy_descr_list)
1303 else:
1304 self.set_descriptions_start(descriptions)
1305 self.set_descriptions_end(descriptions)
1307 self.set_revisions_start(commits)
1308 self.set_revisions_end(commits)
1310 return commits
1312 def changed_files(self, start, end):
1313 zfiles_str = self.git.diff('%s..%s' % (start, end),
1314 name_only=True, z=True).strip('\0')
1315 return [core.decode(enc) for enc in zfiles_str.split('\0') if enc]
1317 def renamed_files(self, start, end):
1318 difflines = self.git.diff('%s..%s' % (start, end),
1319 no_color=True,
1320 M=True).splitlines()
1321 return [ eval_path(r[12:].rstrip())
1322 for r in difflines if r.startswith('rename from ') ]
1324 def is_commit_published(self):
1325 head = self.git.rev_parse('HEAD')
1326 return bool(self.git.branch(r=True, contains=head))
1328 def merge_base_to(self, ref):
1329 """Given `ref`, return $(git merge-base ref HEAD)..ref."""
1330 base = self.git.merge_base('HEAD', ref)
1331 return '%s..%s' % (base, ref)
1333 def everything(self):
1334 """Returns a sorted list of all files, including untracked files."""
1335 files = self.all_files() + self.untracked
1336 files.sort()
1337 return files