models.main: Add an 'updated' notification message
[git-cola.git] / cola / models / main.py
blob0f2234b7ae1246a993a020a227b615726e8faae5
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'
126 def __init__(self, cwd=None):
127 """Reads git repository settings and sets several methods
128 so that they refer to the git module. This object
129 encapsulates cola's interaction with git."""
130 ObservableModel.__init__(self)
132 # Initialize the git command object
133 self.git = GitCola()
135 #####################################################
136 # Used in various places
137 self.currentbranch = ''
138 self.trackedbranch = ''
139 self.directory = ''
140 self.git_version = self.git.version()
141 self.project = ''
142 self.remotes = []
143 self.remotename = ''
144 self.local_branch = ''
145 self.remote_branch = ''
147 #####################################################
148 # Used primarily by the main UI
149 self.commitmsg = ''
150 self.modified = []
151 self.staged = []
152 self.unstaged = []
153 self.untracked = []
154 self.unmerged = []
155 self.upstream_changed = []
157 #####################################################
158 # Used by the create branch dialog
159 self.revision = ''
160 self.local_branches = []
161 self.remote_branches = []
162 self.tags = []
164 #####################################################
165 # Used by the commit/repo browser
166 self.revisions = []
167 self.summaries = []
169 # These are parallel lists
170 self.types = []
171 self.sha1s = []
172 self.names = []
174 # All items below here are re-calculated in
175 # init_browser_data()
176 self.directories = []
177 self.directory_entries = {}
179 # These are also parallel lists
180 self.subtree_types = []
181 self.subtree_sha1s = []
182 self.subtree_names = []
184 self.fetch_helper = None
185 self.push_helper = None
186 self.pull_helper = None
187 self.generate_remote_helpers()
188 if cwd:
189 self.use_worktree(cwd)
191 def all_files(self):
192 """Returns the names of all files in the repository"""
193 return [core.decode(f)
194 for f in self.git.ls_files(z=True)
195 .strip('\0').split('\0') if f]
197 def generate_remote_helpers(self):
198 """Generates helper methods for fetch, push and pull"""
199 self.push_helper = self.gen_remote_helper(self.git.push, push=True)
200 self.fetch_helper = self.gen_remote_helper(self.git.fetch)
201 self.pull_helper = self.gen_remote_helper(self.git.pull)
203 def use_worktree(self, worktree):
204 self.git.load_worktree(worktree)
205 is_valid = self.git.is_valid()
206 if is_valid:
207 self._init_config_data()
208 self.project = os.path.basename(self.git.worktree())
209 return is_valid
211 def _init_config_data(self):
212 """Reads git config --list and creates parameters
213 for each setting."""
214 # These parameters are saved in .gitconfig,
215 # so ideally these should be as short as possible.
217 # config items that are controllable globally
218 # and per-repository
219 self._local_and_global_defaults = {
220 'user_name': '',
221 'user_email': '',
222 'merge_summary': False,
223 'merge_diffstat': True,
224 'merge_verbosity': 2,
225 'gui_diffcontext': 3,
226 'gui_pruneduringfetch': False,
228 # config items that are purely git config --global settings
229 self.__global_defaults = {
230 'cola_geometry': '',
231 'cola_fontdiff': '',
232 'cola_fontdiff_size': 12,
233 'cola_savewindowsettings': False,
234 'cola_showoutput': 'errors',
235 'cola_tabwidth': 8,
236 'merge_keepbackup': True,
237 'diff_tool': os.getenv('GIT_DIFF_TOOL', 'xxdiff'),
238 'merge_tool': os.getenv('GIT_MERGE_TOOL', 'xxdiff'),
239 'gui_editor': os.getenv('EDITOR', 'gvim'),
240 'gui_historybrowser': 'gitk',
243 local_dict = self.config_dict(local=True)
244 global_dict = self.config_dict(local=False)
246 for k,v in local_dict.iteritems():
247 self.set_param('local_'+k, v)
248 for k,v in global_dict.iteritems():
249 self.set_param('global_'+k, v)
250 if k not in local_dict:
251 local_dict[k]=v
252 self.set_param('local_'+k, v)
254 # Bootstrap the internal font*size variables
255 for param in ('global_cola_fontdiff'):
256 setdefault = True
257 if hasattr(self, param):
258 font = getattr(self, param)
259 if font:
260 setdefault = False
261 size = int(font.split(',')[1])
262 self.set_param(param+'_size', size)
263 param = param[len('global_'):]
264 global_dict[param] = font
265 global_dict[param+'_size'] = size
267 # Load defaults for all undefined items
268 local_and_global_defaults = self._local_and_global_defaults
269 for k,v in local_and_global_defaults.iteritems():
270 if k not in local_dict:
271 self.set_param('local_'+k, v)
272 if k not in global_dict:
273 self.set_param('global_'+k, v)
275 global_defaults = self.__global_defaults
276 for k,v in global_defaults.iteritems():
277 if k not in global_dict:
278 self.set_param('global_'+k, v)
280 # Load the diff context
281 self.diff_context = self.local_config('gui.diffcontext', 3)
283 def global_config(self, key, default=None):
284 return self.param('global_'+key.replace('.', '_'),
285 default=default)
287 def local_config(self, key, default=None):
288 return self.param('local_'+key.replace('.', '_'),
289 default=default)
291 def cola_config(self, key):
292 return getattr(self, 'global_cola_'+key)
294 def gui_config(self, key):
295 return getattr(self, 'global_gui_'+key)
297 def default_remote(self):
298 branch = self.currentbranch
299 branchconfig = 'branch.%s.remote' % branch
300 return self.local_config(branchconfig, 'origin')
302 def corresponding_remote_ref(self):
303 remote = self.default_remote()
304 branch = self.currentbranch
305 best_match = '%s/%s' % (remote, branch)
306 remote_branches = self.remote_branches
307 if not remote_branches:
308 return remote
309 for rb in remote_branches:
310 if rb == best_match:
311 return rb
312 return remote_branches[0]
314 def diff_filenames(self, arg):
315 """Returns a list of filenames that have been modified"""
316 diff_zstr = self.git.diff(arg, name_only=True, z=True).rstrip('\0')
317 return [core.decode(f) for f in diff_zstr.split('\0') if f]
319 def branch_list(self, remote=False):
320 """Returns a list of local or remote branches
322 This explicitly removes HEAD from the list of remote branches.
324 branches = map(lambda x: x.lstrip('* '),
325 self.git.branch(r=remote).splitlines())
326 if remote:
327 return [b for b in branches if b.find('/HEAD') == -1]
328 return branches
330 def config_params(self):
331 params = []
332 params.extend(map(lambda x: 'local_' + x,
333 self._local_and_global_defaults.keys()))
334 params.extend(map(lambda x: 'global_' + x,
335 self._local_and_global_defaults.keys()))
336 params.extend(map(lambda x: 'global_' + x,
337 self.__global_defaults.keys()))
338 return [ p for p in params if not p.endswith('_size') ]
340 def save_config_param(self, param):
341 if param not in self.config_params():
342 return
343 value = getattr(self, param)
344 if param == 'local_gui_diffcontext':
345 self.diff_context = value
346 if param.startswith('local_'):
347 param = param[len('local_'):]
348 is_local = True
349 elif param.startswith('global_'):
350 param = param[len('global_'):]
351 is_local = False
352 else:
353 raise Exception("Invalid param '%s' passed to " % param
354 +'save_config_param()')
355 param = param.replace('_', '.') # model -> git
356 return self.config_set(param, value, local=is_local)
358 def init_browser_data(self):
359 """This scans over self.(names, sha1s, types) to generate
360 directories, directory_entries, and subtree_*"""
362 # Collect data for the model
363 if not self.currentbranch:
364 return
366 self.subtree_types = []
367 self.subtree_sha1s = []
368 self.subtree_names = []
369 self.directories = []
370 self.directory_entries = {}
372 # Lookup the tree info
373 tree_info = self.parse_ls_tree(self.currentbranch)
375 self.set_types(map( lambda(x): x[1], tree_info ))
376 self.set_sha1s(map( lambda(x): x[2], tree_info ))
377 self.set_names(map( lambda(x): x[3], tree_info ))
379 if self.directory: self.directories.append('..')
381 dir_entries = self.directory_entries
382 dir_regex = re.compile('([^/]+)/')
383 dirs_seen = {}
384 subdirs_seen = {}
386 for idx, name in enumerate(self.names):
387 if not name.startswith(self.directory):
388 continue
389 name = name[ len(self.directory): ]
390 if name.count('/'):
391 # This is a directory...
392 match = dir_regex.match(name)
393 if not match:
394 continue
395 dirent = match.group(1) + '/'
396 if dirent not in self.directory_entries:
397 self.directory_entries[dirent] = []
399 if dirent not in dirs_seen:
400 dirs_seen[dirent] = True
401 self.directories.append(dirent)
403 entry = name.replace(dirent, '')
404 entry_match = dir_regex.match(entry)
405 if entry_match:
406 subdir = entry_match.group(1) + '/'
407 if subdir in subdirs_seen:
408 continue
409 subdirs_seen[subdir] = True
410 dir_entries[dirent].append(subdir)
411 else:
412 dir_entries[dirent].append(entry)
413 else:
414 self.subtree_types.append(self.types[idx])
415 self.subtree_sha1s.append(self.sha1s[idx])
416 self.subtree_names.append(name)
418 def add_or_remove(self, to_process):
419 """Invokes 'git add' to index the filenames in to_process that exist
420 and 'git rm' for those that do not exist."""
422 if not to_process:
423 return 'No files to add or remove.'
425 to_add = []
426 to_remove = []
428 for filename in to_process:
429 encfilename = core.encode(filename)
430 if os.path.exists(encfilename):
431 to_add.append(filename)
433 status = 0
434 if to_add:
435 newstatus, output = self.git.add(v=True,
436 with_stderr=True,
437 with_status=True,
438 *to_add)
439 status += newstatus
440 else:
441 output = ''
443 if len(to_add) == len(to_process):
444 # to_process only contained unremoved files --
445 # short-circuit the removal checks
446 return (status, output)
448 # Process files to remote
449 for filename in to_process:
450 if not os.path.exists(filename):
451 to_remove.append(filename)
452 newstatus, out = self.git.rm(with_stderr=True,
453 with_status=True,
454 *to_remove)
455 if status == 0:
456 status += newstatus
457 output + '\n\n' + out
458 return (status, output)
460 def editor(self):
461 return self.gui_config('editor')
463 def history_browser(self):
464 return self.gui_config('historybrowser')
466 def remember_gui_settings(self):
467 return self.cola_config('savewindowsettings')
469 def subtree_node(self, idx):
470 return (self.subtree_types[idx],
471 self.subtree_sha1s[idx],
472 self.subtree_names[idx])
474 def all_branches(self):
475 return (self.local_branches + self.remote_branches)
477 def set_remote(self, remote):
478 if not remote:
479 return
480 self.set_param('remote', remote)
481 branches = utils.grep('%s/\S+$' % remote,
482 self.branch_list(remote=True),
483 squash=False)
484 self.set_remote_branches(branches)
486 def add_signoff(self,*rest):
487 """Adds a standard Signed-off by: tag to the end
488 of the current commit message."""
489 msg = self.commitmsg
490 signoff =('\n\nSigned-off-by: %s <%s>\n'
491 % (self.local_user_name, self.local_user_email))
492 if signoff not in msg:
493 self.set_commitmsg(msg + signoff)
495 def apply_diff(self, filename):
496 return self.git.apply(filename, index=True, cached=True)
498 def apply_diff_to_worktree(self, filename):
499 return self.git.apply(filename)
501 def load_commitmsg(self, path):
502 fh = open(path, 'r')
503 contents = core.decode(core.read_nointr(fh))
504 fh.close()
505 self.set_commitmsg(contents)
507 def get_prev_commitmsg(self,*rest):
508 """Queries git for the latest commit message and sets it in
509 self.commitmsg."""
510 commit_msg = []
511 commit_lines = core.decode(self.git.show('HEAD')).split('\n')
512 for idx, msg in enumerate(commit_lines):
513 if idx < 4:
514 continue
515 msg = msg.lstrip()
516 if msg.startswith('diff --git'):
517 commit_msg.pop()
518 break
519 commit_msg.append(msg)
520 self.set_commitmsg('\n'.join(commit_msg).rstrip())
522 def load_commitmsg_template(self):
523 template = self.global_config('commit.template')
524 if template:
525 self.load_commitmsg(template)
527 def update_status(self, head='HEAD', staged_only=False):
528 # Give observers a chance to respond
529 self.notify_message_observers(self.message_about_to_update)
530 # This allows us to defer notification until the
531 # we finish processing data
532 notify_enabled = self.notification_enabled
533 self.notification_enabled = False
535 (self.staged,
536 self.modified,
537 self.unmerged,
538 self.untracked,
539 self.upstream_changed) = self.worktree_state(head=head,
540 staged_only=staged_only)
541 # NOTE: the model's unstaged list holds an aggregate of the
542 # the modified, unmerged, and untracked file lists.
543 self.set_unstaged(self.modified + self.unmerged + self.untracked)
544 self.set_currentbranch(self.current_branch())
545 self.set_remotes(self.git.remote().splitlines())
546 self.set_remote_branches(self.branch_list(remote=True))
547 self.set_trackedbranch(self.tracked_branch())
548 self.set_local_branches(self.branch_list(remote=False))
549 self.set_tags(self.git.tag().splitlines())
550 self.set_revision('')
551 self.set_local_branch('')
552 self.set_remote_branch('')
553 # Re-enable notifications and emit changes
554 self.notification_enabled = notify_enabled
556 # Read the font size by default
557 self.read_font_sizes()
559 self.notify_observers('staged','unstaged')
560 self.notify_message_observers(self.message_updated)
562 def read_font_sizes(self):
563 """Read font sizes from the configuration."""
564 value = self.cola_config('fontdiff')
565 if not value:
566 return
567 items = value.split(',')
568 if len(items) < 2:
569 return
570 self.global_cola_fontdiff_size = int(items[1])
572 def set_diff_font(self, fontstr):
573 """Set the diff font string."""
574 self.global_cola_fontdiff = fontstr
575 self.read_font_sizes()
577 def delete_branch(self, branch):
578 return self.git.branch(branch,
579 D=True,
580 with_stderr=True,
581 with_status=True)
583 def revision_sha1(self, idx):
584 return self.revisions[idx]
586 def apply_diff_font_size(self, default):
587 old_font = self.cola_config('fontdiff')
588 if not old_font:
589 old_font = default
590 size = self.cola_config('fontdiff_size')
591 props = old_font.split(',')
592 props[1] = str(size)
593 new_font = ','.join(props)
594 self.global_cola_fontdiff = new_font
595 self.notify_observers('global_cola_fontdiff')
597 def commit_diff(self, sha1):
598 commit = self.git.show(sha1)
599 first_newline = commit.index('\n')
600 if commit[first_newline+1:].startswith('Merge:'):
601 return (core.decode(commit) + '\n\n' +
602 core.decode(self.diff_helper(commit=sha1,
603 cached=False,
604 suppress_header=False)))
605 else:
606 return core.decode(commit)
608 def filename(self, idx, staged=True):
609 try:
610 if staged:
611 return self.staged[idx]
612 else:
613 return self.unstaged[idx]
614 except IndexError:
615 return None
617 def diff_details(self, idx, ref, staged=True):
619 Return a "diff" for an entry by index relative to ref.
621 `staged` indicates whether we should consider this as a
622 staged or unstaged entry.
625 filename = self.filename(idx, staged=staged)
626 if not filename:
627 return (None, None)
628 encfilename = core.encode(filename)
629 if staged:
630 diff = self.diff_helper(filename=filename,
631 ref=ref,
632 cached=True)
633 else:
634 if os.path.isdir(encfilename):
635 diff = '\n'.join(os.listdir(filename))
637 elif filename in self.unmerged:
638 diff = ('@@@ Unmerged @@@\n'
639 '- %s is unmerged.\n+ ' % filename +
640 'Right-click the file to launch "git mergetool".\n'
641 '@@@ Unmerged @@@\n\n')
642 diff += self.diff_helper(filename=filename,
643 cached=False)
644 elif filename in self.modified:
645 diff = self.diff_helper(filename=filename,
646 cached=False)
647 else:
648 diff = 'SHA1: ' + self.git.hash_object(filename)
649 return (diff, filename)
651 def diff_for_expr(self, idx, expr):
653 Return a diff for an arbitrary diff expression.
655 `idx` is the index of the entry in the staged files list.
658 filename = self.filename(idx, staged=True)
659 if not filename:
660 return (None, None)
661 diff = self.diff_helper(filename=filename, ref=expr, cached=False)
662 return (diff, filename)
664 def stage_modified(self):
665 status, output = self.git.add(v=True,
666 with_stderr=True,
667 with_status=True,
668 *self.modified)
669 self.update_status()
670 return (status, output)
672 def stage_untracked(self):
673 status, output = self.git.add(v=True,
674 with_stderr=True,
675 with_status=True,
676 *self.untracked)
677 self.update_status()
678 return (status, output)
680 def reset(self, *items):
681 status, output = self.git.reset('--',
682 with_stderr=True,
683 with_status=True,
684 *items)
685 self.update_status()
686 return (status, output)
688 def unstage_all(self):
689 status, output = self.git.reset(with_stderr=True,
690 with_status=True)
691 self.update_status()
692 return (status, output)
694 def stage_all(self):
695 status, output = self.git.add(v=True,
696 u=True,
697 with_stderr=True,
698 with_status=True)
699 self.update_status()
700 return (status, output)
702 def config_set(self, key=None, value=None, local=True):
703 if key and value is not None:
704 # git config category.key value
705 strval = unicode(value)
706 if type(value) is bool:
707 # git uses "true" and "false"
708 strval = strval.lower()
709 if local:
710 argv = [ key, strval ]
711 else:
712 argv = [ '--global', key, strval ]
713 return self.git.config(*argv)
714 else:
715 msg = "oops in config_set(key=%s,value=%s,local=%s)"
716 raise Exception(msg % (key, value, local))
718 def config_dict(self, local=True):
719 """parses the lines from git config --list into a dictionary"""
721 kwargs = {
722 'list': True,
723 'global': not local, # global is a python keyword
725 config_lines = self.git.config(**kwargs).splitlines()
726 newdict = {}
727 for line in config_lines:
728 try:
729 k, v = line.split('=', 1)
730 except:
731 # the user has an invalid entry in their git config
732 continue
733 v = core.decode(v)
734 k = k.replace('.','_') # git -> model
735 if v == 'true' or v == 'false':
736 v = bool(eval(v.title()))
737 try:
738 v = int(eval(v))
739 except:
740 pass
741 newdict[k]=v
742 return newdict
744 def commit_with_msg(self, msg, amend=False):
745 """Creates a git commit."""
747 if not msg.endswith('\n'):
748 msg += '\n'
749 # Sure, this is a potential "security risk," but if someone
750 # is trying to intercept/re-write commit messages on your system,
751 # then you probably have bigger problems to worry about.
752 tmpfile = self.tmp_filename()
754 # Create the commit message file
755 fh = open(tmpfile, 'w')
756 core.write_nointr(fh, msg)
757 fh.close()
759 # Run 'git commit'
760 status, out = self.git.commit(F=tmpfile, v=True, amend=amend,
761 with_status=True,
762 with_stderr=True)
763 os.unlink(tmpfile)
764 return (status, out)
766 def diffindex(self):
767 return self.git.diff(unified=self.diff_context,
768 no_color=True,
769 stat=True,
770 cached=True)
772 def tmp_dir(self):
773 # Allow TMPDIR/TMP with a fallback to /tmp
774 return os.environ.get('TMP', os.environ.get('TMPDIR', '/tmp'))
776 def tmp_file_pattern(self):
777 return os.path.join(self.tmp_dir(), '*.git-cola.%s.*' % os.getpid())
779 def tmp_filename(self, prefix=''):
780 basename = ((prefix+'.git-cola.%s.%s'
781 % (os.getpid(), time.time())))
782 basename = basename.replace('/', '-')
783 basename = basename.replace('\\', '-')
784 tmpdir = self.tmp_dir()
785 return os.path.join(tmpdir, basename)
787 def log_helper(self, all=False, extra_args=None):
789 Returns a pair of parallel arrays listing the revision sha1's
790 and commit summaries.
792 revs = []
793 summaries = []
794 regex = REV_LIST_REGEX
795 args = []
796 if extra_args:
797 args = extra_args
798 output = self.git.log(pretty='oneline', all=all, *args)
799 for line in map(core.decode, output.splitlines()):
800 match = regex.match(line)
801 if match:
802 revs.append(match.group(1))
803 summaries.append(match.group(2))
804 return (revs, summaries)
806 def parse_rev_list(self, raw_revs):
807 revs = []
808 for line in map(core.decode, raw_revs.splitlines()):
809 match = REV_LIST_REGEX.match(line)
810 if match:
811 rev_id = match.group(1)
812 summary = match.group(2)
813 revs.append((rev_id, summary,))
814 return revs
816 def rev_list_range(self, start, end):
817 range = '%s..%s' % (start, end)
818 raw_revs = self.git.rev_list(range, pretty='oneline')
819 return self.parse_rev_list(raw_revs)
821 def diff_helper(self,
822 commit=None,
823 branch=None,
824 ref=None,
825 endref=None,
826 filename=None,
827 cached=True,
828 with_diff_header=False,
829 suppress_header=True,
830 reverse=False):
831 "Invokes git diff on a filepath."
832 if commit:
833 ref, endref = commit+'^', commit
834 argv = []
835 if ref and endref:
836 argv.append('%s..%s' % (ref, endref))
837 elif ref:
838 for r in ref.strip().split():
839 argv.append(r)
840 elif branch:
841 argv.append(branch)
843 if filename:
844 argv.append('--')
845 if type(filename) is list:
846 argv.extend(filename)
847 else:
848 argv.append(filename)
850 start = False
851 del_tag = 'deleted file mode '
853 headers = []
854 deleted = cached and not os.path.exists(core.encode(filename))
856 diffoutput = self.git.diff(R=reverse,
857 M=True,
858 no_color=True,
859 cached=cached,
860 unified=self.diff_context,
861 with_raw_output=True,
862 with_stderr=True,
863 *argv)
865 # Handle 'git init'
866 if diffoutput.startswith('fatal:'):
867 if with_diff_header:
868 return ('', '')
869 else:
870 return ''
872 output = StringIO()
874 diff = diffoutput.split('\n')
875 for line in map(core.decode, diff):
876 if not start and '@@' == line[:2] and '@@' in line[2:]:
877 start = True
878 if start or (deleted and del_tag in line):
879 output.write(core.encode(line) + '\n')
880 else:
881 if with_diff_header:
882 headers.append(core.encode(line))
883 elif not suppress_header:
884 output.write(core.encode(line) + '\n')
886 result = core.decode(output.getvalue())
887 output.close()
889 if with_diff_header:
890 return('\n'.join(headers), result)
891 else:
892 return result
894 def git_repo_path(self, *subpaths):
895 paths = [self.git.git_dir()]
896 paths.extend(subpaths)
897 return os.path.realpath(os.path.join(*paths))
899 def merge_message_path(self):
900 for file in ('MERGE_MSG', 'SQUASH_MSG'):
901 path = self.git_repo_path(file)
902 if os.path.exists(path):
903 return path
904 return None
906 def merge_message(self):
907 return self.git.fmt_merge_msg('--file',
908 self.git_repo_path('FETCH_HEAD'))
910 def abort_merge(self):
911 # Reset the worktree
912 output = self.git.read_tree('HEAD', reset=True, u=True, v=True)
913 # remove MERGE_HEAD
914 merge_head = self.git_repo_path('MERGE_HEAD')
915 if os.path.exists(merge_head):
916 os.unlink(merge_head)
917 # remove MERGE_MESSAGE, etc.
918 merge_msg_path = self.merge_message_path()
919 while merge_msg_path:
920 os.unlink(merge_msg_path)
921 merge_msg_path = self.merge_message_path()
923 def _is_modified(self, name):
924 status, out = self.git.diff('--', name,
925 name_only=True,
926 exit_code=True,
927 with_status=True)
928 return status != 0
931 def _branch_status(self, branch):
933 Returns a tuple of staged, unstaged, untracked, and unmerged files
935 This shows only the changes that were introduced in branch
938 status, output = self.git.diff(name_only=True,
939 M=True, z=True,
940 with_stderr=True,
941 with_status=True,
942 *branch.strip().split())
943 if status != 0:
944 return ([], [], [], [])
945 staged = []
946 for name in output.strip('\0').split('\0'):
947 if not name:
948 continue
949 staged.append(core.decode(name))
951 return (staged, [], [], [])
953 def worktree_state(self, head='HEAD', staged_only=False):
954 """Return a tuple of files in various states of being
956 Can be staged, unstaged, untracked, unmerged, or changed
957 upstream.
960 self.git.update_index(refresh=True)
961 if staged_only:
962 return self._branch_status(head)
964 staged_set = set()
965 modified_set = set()
966 upstream_changed_set = set()
968 (staged, modified, unmerged, untracked, upstream_changed) = (
969 [], [], [], [], [])
970 try:
971 output = self.git.diff_index(head,
972 cached=True,
973 with_stderr=True)
974 if output.startswith('fatal:'):
975 raise GitInitError('git init')
976 for line in output.splitlines():
977 rest, name = line.split('\t', 1)
978 status = rest[-1]
979 name = eval_path(name)
980 if status == 'M':
981 staged.append(name)
982 staged_set.add(name)
983 # This file will also show up as 'M' without --cached
984 # so by default don't consider it modified unless
985 # it's truly modified
986 modified_set.add(name)
987 if not staged_only and self._is_modified(name):
988 modified.append(name)
989 elif status == 'A':
990 staged.append(name)
991 staged_set.add(name)
992 elif status == 'D':
993 staged.append(name)
994 staged_set.add(name)
995 modified_set.add(name)
996 elif status == 'U':
997 unmerged.append(name)
998 modified_set.add(name)
1000 except GitInitError:
1001 # handle git init
1002 staged.extend(self.all_files())
1004 try:
1005 output = self.git.diff_index(head, with_stderr=True)
1006 if output.startswith('fatal:'):
1007 raise GitInitError('git init')
1008 for line in output.splitlines():
1009 info, name = line.split('\t', 1)
1010 status = info.split()[-1]
1011 if status == 'M' or status == 'D':
1012 name = eval_path(name)
1013 if name not in modified_set:
1014 modified.append(name)
1015 elif status == 'A':
1016 name = eval_path(name)
1017 # newly-added yet modified
1018 if (name not in modified_set and not staged_only and
1019 self._is_modified(name)):
1020 modified.append(name)
1022 except GitInitError:
1023 # handle git init
1024 for name in (self.git.ls_files(modified=True, z=True)
1025 .split('\0')):
1026 if name:
1027 modified.append(core.decode(name))
1029 for name in self.git.ls_files(others=True, exclude_standard=True,
1030 z=True).split('\0'):
1031 if name:
1032 untracked.append(core.decode(name))
1034 # Look for upstream modified files if this is a tracking branch
1035 if self.trackedbranch:
1036 try:
1037 output = self.git.diff_index(self.trackedbranch,
1038 with_stderr=True)
1039 if output.startswith('fatal:'):
1040 raise GitInitError('git init')
1041 for line in output.splitlines():
1042 info, name = line.split('\t', 1)
1043 status = info.split()[-1]
1044 # TODO
1045 # For now we'll just call anything here 'changed
1046 # upstream'. Maybe one day we'll elaborate more on
1047 # what the change is.
1048 if status == 'M' or status == 'D':
1049 name = eval_path(name)
1050 if name not in upstream_changed_set:
1051 upstream_changed.append(name)
1052 upstream_changed_set.add(name)
1054 except GitInitError:
1055 # handle git init
1056 pass
1058 # Keep stuff sorted
1059 staged.sort()
1060 modified.sort()
1061 unmerged.sort()
1062 untracked.sort()
1063 upstream_changed.sort()
1065 return (staged, modified, unmerged, untracked, upstream_changed)
1067 def reset_helper(self, args):
1068 """Removes files from the index
1070 This handles the git init case, which is why it's not
1071 just 'git reset name'. For the git init case this falls
1072 back to 'git rm --cached'.
1075 # fake the status because 'git reset' returns 1
1076 # regardless of success/failure
1077 status = 0
1078 output = self.git.reset('--', with_stderr=True, *args)
1079 # handle git init: we have to use 'git rm --cached'
1080 # detect this condition by checking if the file is still staged
1081 state = self.worktree_state()
1082 staged = state[0]
1083 rmargs = [a for a in args if a in staged]
1084 if not rmargs:
1085 return (status, output)
1086 output += self.git.rm('--', cached=True, with_stderr=True, *rmargs)
1087 return (status, output)
1089 def remote_url(self, name):
1090 return self.git.config('remote.%s.url' % name, get=True)
1092 def remote_args(self, remote,
1093 local_branch='',
1094 remote_branch='',
1095 ffwd=True,
1096 tags=False,
1097 rebase=False,
1098 push=False):
1099 # Swap the branches in push mode (reverse of fetch)
1100 if push:
1101 tmp = local_branch
1102 local_branch = remote_branch
1103 remote_branch = tmp
1104 if ffwd:
1105 branch_arg = '%s:%s' % ( remote_branch, local_branch )
1106 else:
1107 branch_arg = '+%s:%s' % ( remote_branch, local_branch )
1108 args = [remote]
1109 if local_branch and remote_branch:
1110 args.append(branch_arg)
1111 elif local_branch:
1112 args.append(local_branch)
1113 elif remote_branch:
1114 args.append(remote_branch)
1115 kwargs = {
1116 'verbose': True,
1117 'tags': tags,
1118 'rebase': rebase,
1119 'with_stderr': True,
1120 'with_status': True,
1122 return (args, kwargs)
1124 def gen_remote_helper(self, gitaction, push=False):
1125 """Generates a closure that calls git fetch, push or pull
1127 def remote_helper(remote, **kwargs):
1128 args, kwargs = self.remote_args(remote, push=push, **kwargs)
1129 return gitaction(*args, **kwargs)
1130 return remote_helper
1132 def parse_ls_tree(self, rev):
1133 """Returns a list of(mode, type, sha1, path) tuples."""
1134 lines = self.git.ls_tree(rev, r=True).splitlines()
1135 output = []
1136 regex = re.compile('^(\d+)\W(\w+)\W(\w+)[ \t]+(.*)$')
1137 for line in lines:
1138 match = regex.match(line)
1139 if match:
1140 mode = match.group(1)
1141 objtype = match.group(2)
1142 sha1 = match.group(3)
1143 filename = match.group(4)
1144 output.append((mode, objtype, sha1, filename,) )
1145 return output
1147 def format_patch_helper(self, to_export, revs, output='patches'):
1148 """writes patches named by to_export to the output directory."""
1150 outlines = []
1152 cur_rev = to_export[0]
1153 cur_master_idx = revs.index(cur_rev)
1155 patches_to_export = [ [cur_rev] ]
1156 patchset_idx = 0
1158 # Group the patches into continuous sets
1159 for idx, rev in enumerate(to_export[1:]):
1160 # Limit the search to the current neighborhood for efficiency
1161 master_idx = revs[ cur_master_idx: ].index(rev)
1162 master_idx += cur_master_idx
1163 if master_idx == cur_master_idx + 1:
1164 patches_to_export[ patchset_idx ].append(rev)
1165 cur_master_idx += 1
1166 continue
1167 else:
1168 patches_to_export.append([ rev ])
1169 cur_master_idx = master_idx
1170 patchset_idx += 1
1172 # Export each patchsets
1173 status = 0
1174 for patchset in patches_to_export:
1175 newstatus, out = self.export_patchset(patchset[0],
1176 patchset[-1],
1177 output='patches',
1178 n=len(patchset) > 1,
1179 thread=True,
1180 patch_with_stat=True)
1181 outlines.append(out)
1182 if status == 0:
1183 status += newstatus
1184 return (status, '\n'.join(outlines))
1186 def export_patchset(self, start, end, output="patches", **kwargs):
1187 revarg = '%s^..%s' % (start, end)
1188 return self.git.format_patch('-o', output, revarg,
1189 with_stderr=True,
1190 with_status=True,
1191 **kwargs)
1193 def current_branch(self):
1194 """Parses 'git symbolic-ref' to find the current branch."""
1195 headref = self.git.symbolic_ref('HEAD', with_stderr=True)
1196 if headref.startswith('refs/heads/'):
1197 return headref[11:]
1198 elif headref.startswith('fatal:'):
1199 return ''
1200 return headref
1202 def tracked_branch(self):
1203 """The name of the branch that current branch is tracking"""
1204 remote = self.git.config('branch.'+self.currentbranch+'.remote',
1205 get=True, with_stderr=True)
1206 if not remote:
1207 return ''
1208 headref = self.git.config('branch.'+self.currentbranch+'.merge',
1209 get=True, with_stderr=True)
1210 if headref.startswith('refs/heads/'):
1211 tracked_branch = headref[11:]
1212 return remote + '/' + tracked_branch
1213 return ''
1215 def create_branch(self, name, base, track=False):
1216 """Create a branch named 'name' from revision 'base'
1218 Pass track=True to create a local tracking branch.
1220 return self.git.branch(name, base, track=track,
1221 with_stderr=True,
1222 with_status=True)
1224 def cherry_pick_list(self, revs, **kwargs):
1225 """Cherry-picks each revision into the current branch.
1226 Returns a list of command output strings (1 per cherry pick)"""
1227 if not revs:
1228 return []
1229 cherries = []
1230 status = 0
1231 for rev in revs:
1232 newstatus, out = self.git.cherry_pick(rev,
1233 with_stderr=True,
1234 with_status=True)
1235 if status == 0:
1236 status += newstatus
1237 cherries.append(out)
1238 return (status, '\n'.join(cherries))
1240 def parse_stash_list(self, revids=False):
1241 """Parses "git stash list" and returns a list of stashes."""
1242 stashes = self.git.stash("list").splitlines()
1243 if revids:
1244 return [ s[:s.index(':')] for s in stashes ]
1245 else:
1246 return [ s[s.index(':')+1:] for s in stashes ]
1248 def diffstat(self):
1249 return self.git.diff(
1250 'HEAD^',
1251 unified=self.diff_context,
1252 no_color=True,
1253 stat=True)
1255 def pad(self, pstr, num=22):
1256 topad = num-len(pstr)
1257 if topad > 0:
1258 return pstr + ' '*topad
1259 else:
1260 return pstr
1262 def describe(self, revid, descr):
1263 version = self.git.describe(revid, tags=True, always=True,
1264 abbrev=4)
1265 return version + ' - ' + descr
1267 def update_revision_lists(self, filename=None, show_versions=False):
1268 num_results = self.num_results
1269 if filename:
1270 rev_list = self.git.log('--', filename,
1271 max_count=num_results,
1272 pretty='oneline')
1273 else:
1274 rev_list = self.git.log(max_count=num_results,
1275 pretty='oneline', all=True)
1277 commit_list = self.parse_rev_list(rev_list)
1278 commit_list.reverse()
1279 commits = map(lambda x: x[0], commit_list)
1280 descriptions = map(lambda x: core.decode(x[1]), commit_list)
1281 if show_versions:
1282 fancy_descr_list = map(lambda x: self.describe(*x), commit_list)
1283 self.set_descriptions_start(fancy_descr_list)
1284 self.set_descriptions_end(fancy_descr_list)
1285 else:
1286 self.set_descriptions_start(descriptions)
1287 self.set_descriptions_end(descriptions)
1289 self.set_revisions_start(commits)
1290 self.set_revisions_end(commits)
1292 return commits
1294 def changed_files(self, start, end):
1295 zfiles_str = self.git.diff('%s..%s' % (start, end),
1296 name_only=True, z=True).strip('\0')
1297 return [core.decode(enc) for enc in zfiles_str.split('\0') if enc]
1299 def renamed_files(self, start, end):
1300 difflines = self.git.diff('%s..%s' % (start, end),
1301 no_color=True,
1302 M=True).splitlines()
1303 return [ eval_path(r[12:].rstrip())
1304 for r in difflines if r.startswith('rename from ') ]
1306 def is_commit_published(self):
1307 head = self.git.rev_parse('HEAD')
1308 return bool(self.git.branch(r=True, contains=head))
1310 def merge_base_to(self, ref):
1311 """Given `ref`, return $(git merge-base ref HEAD)..ref."""
1312 base = self.git.merge_base('HEAD', ref)
1313 return '%s..%s' % (base, ref)
1315 def everything(self):
1316 """Returns a sorted list of all files, including untracked files."""
1317 files = self.all_files() + self.untracked
1318 files.sort()
1319 return files