models.main: Guard against non-int font sizes
[git-cola.git] / cola / models / main.py
blobd8dad66b7e124853efa23f94c5e904f1215b737d
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 gitcola
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
33 def eval_path(path):
34 """handles quoted paths."""
35 if path.startswith('"') and path.endswith('"'):
36 return core.decode(eval(path))
37 else:
38 return path
41 class MainModel(ObservableModel):
42 """Provides a friendly wrapper for doing common git operations."""
44 # Observable messages
45 message_updated = 'updated'
46 message_about_to_update = 'about_to_update'
48 # States
49 mode_none = 'none' # Default: nothing's happened, do nothing
50 mode_worktree = 'worktree' # Comparing index to worktree
51 mode_index = 'index' # Comparing index to last commit
52 mode_amend = 'amend' # Amending a commit
53 mode_grep = 'grep' # We ran Search -> Grep
54 mode_branch = 'branch' # Applying changes from a branch
55 mode_diff = 'diff' # Diffing against an arbitrary branch
56 mode_diff_expr = 'diff_expr' # Diffing using arbitrary expression
57 mode_review = 'review' # Reviewing a branch
59 # Modes where we don't do anything like staging, etc.
60 modes_read_only = (mode_branch, mode_grep,
61 mode_diff, mode_diff_expr, mode_review)
62 # Modes where we can checkout files from the $head
63 modes_undoable = (mode_none, mode_index, mode_worktree)
65 def __init__(self, cwd=None):
66 """Reads git repository settings and sets several methods
67 so that they refer to the git module. This object
68 encapsulates cola's interaction with git."""
69 ObservableModel.__init__(self)
71 # Initialize the git command object
72 self.git = gitcola.GitCola()
74 #####################################################
75 self.head = 'HEAD'
76 self.mode = self.mode_none
77 self.diff_text = ''
78 self.filename = None
79 self.currentbranch = ''
80 self.trackedbranch = ''
81 self.directory = ''
82 self.git_version = self.git.version()
83 self.remotes = []
84 self.remotename = ''
85 self.local_branch = ''
86 self.remote_branch = ''
88 #####################################################
89 # Status info
90 self.commitmsg = ''
91 self.modified = []
92 self.staged = []
93 self.unstaged = []
94 self.untracked = []
95 self.unmerged = []
96 self.upstream_changed = []
98 #####################################################
99 # Refs
100 self.revision = ''
101 self.local_branches = []
102 self.remote_branches = []
103 self.tags = []
104 self.revisions = []
105 self.summaries = []
107 # These are parallel lists
108 # ref^{tree}
109 self.types = []
110 self.sha1s = []
111 self.names = []
113 self.directories = []
114 self.directory_entries = {}
116 # parallel lists
117 self.subtree_types = []
118 self.subtree_sha1s = []
119 self.subtree_names = []
121 self.fetch_helper = None
122 self.push_helper = None
123 self.pull_helper = None
124 self.generate_remote_helpers()
125 if cwd:
126 self.use_worktree(cwd)
128 def read_only(self):
129 return self.mode in self.modes_read_only
131 def undoable(self):
132 """Whether we can checkout files from the $head."""
133 return self.mode in self.modes_undoable
135 def enable_staging(self):
136 """Whether staging should be allowed."""
137 return self.mode == self.mode_worktree
139 def all_files(self):
140 """Returns the names of all files in the repository"""
141 return [core.decode(f)
142 for f in self.git.ls_files(z=True)
143 .strip('\0').split('\0') if f]
145 def generate_remote_helpers(self):
146 """Generates helper methods for fetch, push and pull"""
147 self.push_helper = self.gen_remote_helper(self.git.push, push=True)
148 self.fetch_helper = self.gen_remote_helper(self.git.fetch)
149 self.pull_helper = self.gen_remote_helper(self.git.pull)
151 def use_worktree(self, worktree):
152 self.git.load_worktree(worktree)
153 is_valid = self.git.is_valid()
154 if is_valid:
155 self._init_config_data()
156 self.set_project(os.path.basename(self.git.worktree()))
157 return is_valid
159 def _init_config_data(self):
160 """Reads git config --list and creates parameters
161 for each setting."""
162 # These parameters are saved in .gitconfig,
163 # so ideally these should be as short as possible.
165 # config items that are controllable globally
166 # and per-repository
167 self._local_and_global_defaults = {
168 'user_name': '',
169 'user_email': '',
170 'merge_summary': False,
171 'merge_diffstat': True,
172 'merge_verbosity': 2,
173 'gui_diffcontext': 3,
174 'gui_pruneduringfetch': False,
176 # config items that are purely git config --global settings
177 self._global_defaults = {
178 'cola_geometry': '',
179 'cola_fontdiff': '',
180 'cola_fontdiff_size': 12,
181 'cola_savewindowsettings': False,
182 'cola_showoutput': 'errors',
183 'cola_tabwidth': 8,
184 'merge_keepbackup': True,
185 'diff_tool': os.getenv('GIT_DIFF_TOOL', 'xxdiff'),
186 'merge_tool': os.getenv('GIT_MERGE_TOOL', 'xxdiff'),
187 'gui_editor': os.getenv('EDITOR', 'gvim'),
188 'gui_historybrowser': 'gitk',
191 local_dict = self.config_dict(local=True)
192 global_dict = self.config_dict(local=False)
194 for k,v in local_dict.iteritems():
195 self.set_param('local_'+k, v)
196 for k,v in global_dict.iteritems():
197 self.set_param('global_'+k, v)
198 if k not in local_dict:
199 local_dict[k]=v
200 self.set_param('local_'+k, v)
202 # Bootstrap the internal font*size variables
203 for param in ('global_cola_fontdiff'):
204 setdefault = True
205 if hasattr(self, param):
206 font = getattr(self, param)
207 if font:
208 setdefault = False
209 size = int(font.split(',')[1])
210 self.set_param(param+'_size', size)
211 param = param[len('global_'):]
212 global_dict[param] = font
213 global_dict[param+'_size'] = size
215 # Load defaults for all undefined items
216 local_and_global_defaults = self._local_and_global_defaults
217 for k,v in local_and_global_defaults.iteritems():
218 if k not in local_dict:
219 self.set_param('local_'+k, v)
220 if k not in global_dict:
221 self.set_param('global_'+k, v)
223 global_defaults = self._global_defaults
224 for k,v in global_defaults.iteritems():
225 if k not in global_dict:
226 self.set_param('global_'+k, v)
228 # Load the diff context
229 self.diff_context = self.local_config('gui.diffcontext', 3)
231 def global_config(self, key, default=None):
232 return self.param('global_'+key.replace('.', '_'),
233 default=default)
235 def local_config(self, key, default=None):
236 return self.param('local_'+key.replace('.', '_'),
237 default=default)
239 def cola_config(self, key):
240 return getattr(self, 'global_cola_'+key)
242 def gui_config(self, key):
243 return getattr(self, 'global_gui_'+key)
245 def branch_list(self, remote=False):
246 """Returns a list of local or remote branches
248 This explicitly removes HEAD from the list of remote branches.
250 branches = map(lambda x: x.lstrip('* '),
251 self.git.branch(r=remote).splitlines())
252 if remote:
253 return [b for b in branches if b.find('/HEAD') == -1]
254 return branches
256 def config_params(self):
257 params = []
258 params.extend(map(lambda x: 'local_' + x,
259 self._local_and_global_defaults.keys()))
260 params.extend(map(lambda x: 'global_' + x,
261 self._local_and_global_defaults.keys()))
262 params.extend(map(lambda x: 'global_' + x,
263 self._global_defaults.keys()))
264 return [ p for p in params if not p.endswith('_size') ]
266 def save_config_param(self, param):
267 if param not in self.config_params():
268 return
269 value = getattr(self, param)
270 if param == 'local_gui_diffcontext':
271 self.diff_context = value
272 if param.startswith('local_'):
273 param = param[len('local_'):]
274 is_local = True
275 elif param.startswith('global_'):
276 param = param[len('global_'):]
277 is_local = False
278 else:
279 raise Exception("Invalid param '%s' passed to " % param
280 +'save_config_param()')
281 param = param.replace('_', '.') # model -> git
282 return self.config_set(param, value, local=is_local)
284 def init_browser_data(self):
285 """This scans over self.(names, sha1s, types) to generate
286 directories, directory_entries, and subtree_*"""
288 # Collect data for the model
289 if not self.currentbranch:
290 return
292 self.subtree_types = []
293 self.subtree_sha1s = []
294 self.subtree_names = []
295 self.directories = []
296 self.directory_entries = {}
298 # Lookup the tree info
299 tree_info = self.parse_ls_tree(self.currentbranch)
301 self.set_types(map(lambda(x): x[1], tree_info ))
302 self.set_sha1s(map(lambda(x): x[2], tree_info ))
303 self.set_names(map(lambda(x): x[3], tree_info ))
305 if self.directory: self.directories.append('..')
307 dir_entries = self.directory_entries
308 dir_regex = re.compile('([^/]+)/')
309 dirs_seen = {}
310 subdirs_seen = {}
312 for idx, name in enumerate(self.names):
313 if not name.startswith(self.directory):
314 continue
315 name = name[ len(self.directory): ]
316 if name.count('/'):
317 # This is a directory...
318 match = dir_regex.match(name)
319 if not match:
320 continue
321 dirent = match.group(1) + '/'
322 if dirent not in self.directory_entries:
323 self.directory_entries[dirent] = []
325 if dirent not in dirs_seen:
326 dirs_seen[dirent] = True
327 self.directories.append(dirent)
329 entry = name.replace(dirent, '')
330 entry_match = dir_regex.match(entry)
331 if entry_match:
332 subdir = entry_match.group(1) + '/'
333 if subdir in subdirs_seen:
334 continue
335 subdirs_seen[subdir] = True
336 dir_entries[dirent].append(subdir)
337 else:
338 dir_entries[dirent].append(entry)
339 else:
340 self.subtree_types.append(self.types[idx])
341 self.subtree_sha1s.append(self.sha1s[idx])
342 self.subtree_names.append(name)
344 def add_or_remove(self, to_process):
345 """Invokes 'git add' to index the filenames in to_process that exist
346 and 'git rm' for those that do not exist."""
348 if not to_process:
349 return 'No files to add or remove.'
351 to_add = []
352 to_remove = []
354 for filename in to_process:
355 encfilename = core.encode(filename)
356 if os.path.exists(encfilename):
357 to_add.append(filename)
359 status = 0
360 if to_add:
361 newstatus, output = self.git.add(v=True,
362 with_stderr=True,
363 with_status=True,
364 *to_add)
365 status += newstatus
366 else:
367 output = ''
369 if len(to_add) == len(to_process):
370 # to_process only contained unremoved files --
371 # short-circuit the removal checks
372 return (status, output)
374 # Process files to remote
375 for filename in to_process:
376 if not os.path.exists(filename):
377 to_remove.append(filename)
378 newstatus, out = self.git.rm(with_stderr=True,
379 with_status=True,
380 *to_remove)
381 if status == 0:
382 status += newstatus
383 output + '\n\n' + out
384 return (status, output)
386 def editor(self):
387 return self.gui_config('editor')
389 def history_browser(self):
390 return self.gui_config('historybrowser')
392 def remember_gui_settings(self):
393 return self.cola_config('savewindowsettings')
395 def subtree_node(self, idx):
396 return (self.subtree_types[idx],
397 self.subtree_sha1s[idx],
398 self.subtree_names[idx])
400 def all_branches(self):
401 return (self.local_branches + self.remote_branches)
403 def set_remote(self, remote):
404 if not remote:
405 return
406 self.set_param('remote', remote)
407 branches = utils.grep('%s/\S+$' % remote,
408 self.branch_list(remote=True),
409 squash=False)
410 self.set_remote_branches(branches)
412 def apply_diff(self, filename):
413 return self.git.apply(filename, index=True, cached=True)
415 def apply_diff_to_worktree(self, filename):
416 return self.git.apply(filename)
418 def load_commitmsg(self, path):
419 fh = open(path, 'r')
420 contents = core.decode(core.read_nointr(fh))
421 fh.close()
422 self.set_commitmsg(contents)
424 def prev_commitmsg(self):
425 """Queries git for the latest commit message."""
426 return core.decode(self.git.log('-1', pretty='format:%s%n%n%b'))
428 def load_commitmsg_template(self):
429 template = self.global_config('commit.template')
430 if template:
431 self.load_commitmsg(template)
433 def update_status(self):
434 # Give observers a chance to respond
435 self.notify_message_observers(self.message_about_to_update)
436 # This allows us to defer notification until the
437 # we finish processing data
438 staged_only = self.read_only()
439 head = self.head
440 notify_enabled = self.notification_enabled
441 self.notification_enabled = False
443 # Set these early since they are used to calculate 'upstream_changed'.
444 self.set_currentbranch(self.current_branch())
445 self.set_trackedbranch(self.tracked_branch())
447 (self.staged,
448 self.modified,
449 self.unmerged,
450 self.untracked,
451 self.upstream_changed) = self.worktree_state(head=head,
452 staged_only=staged_only)
453 # NOTE: the model's unstaged list holds an aggregate of the
454 # the modified, unmerged, and untracked file lists.
455 self.set_unstaged(self.modified + self.unmerged + self.untracked)
456 self.set_remotes(self.git.remote().splitlines())
457 self.set_remote_branches(self.branch_list(remote=True))
458 self.set_local_branches(self.branch_list(remote=False))
459 self.set_tags(self.git.tag().splitlines())
460 self.set_revision('')
461 self.set_local_branch('')
462 self.set_remote_branch('')
463 # Re-enable notifications and emit changes
464 self.notification_enabled = notify_enabled
466 self.read_font_sizes()
467 self.notify_observers('staged','unstaged')
468 self.notify_message_observers(self.message_updated)
470 def read_font_sizes(self):
471 """Read font sizes from the configuration."""
472 value = self.cola_config('fontdiff')
473 if not value:
474 return
475 items = value.split(',')
476 if len(items) < 2:
477 return
478 self.global_cola_fontdiff_size = int(float(items[1]))
480 def set_diff_font(self, fontstr):
481 """Set the diff font string."""
482 self.global_cola_fontdiff = fontstr
483 self.read_font_sizes()
485 def delete_branch(self, branch):
486 return self.git.branch(branch,
487 D=True,
488 with_stderr=True,
489 with_status=True)
491 def revision_sha1(self, idx):
492 return self.revisions[idx]
494 def apply_diff_font_size(self, default):
495 old_font = self.cola_config('fontdiff')
496 if not old_font:
497 old_font = default
498 size = self.cola_config('fontdiff_size')
499 props = old_font.split(',')
500 props[1] = str(size)
501 new_font = ','.join(props)
502 self.global_cola_fontdiff = new_font
503 self.notify_observers('global_cola_fontdiff')
505 def commit_diff(self, sha1):
506 commit = self.git.show(sha1)
507 first_newline = commit.index('\n')
508 if commit[first_newline+1:].startswith('Merge:'):
509 return (core.decode(commit) + '\n\n' +
510 core.decode(self.diff_helper(commit=sha1,
511 cached=False,
512 suppress_header=False)))
513 else:
514 return core.decode(commit)
516 def filename(self, idx, staged=True):
517 try:
518 if staged:
519 return self.staged[idx]
520 else:
521 return self.unstaged[idx]
522 except IndexError:
523 return None
525 def diff_details(self, idx, ref, staged=True):
527 Return a "diff" for an entry by index relative to ref.
529 `staged` indicates whether we should consider this as a
530 staged or unstaged entry.
533 filename = self.filename(idx, staged=staged)
534 if not filename:
535 return (None, None)
536 encfilename = core.encode(filename)
537 if staged:
538 diff = self.diff_helper(filename=filename,
539 ref=ref,
540 cached=True)
541 else:
542 if os.path.isdir(encfilename):
543 diff = '\n'.join(os.listdir(filename))
545 elif filename in self.unmerged:
546 diff = ('@@@ Unmerged @@@\n'
547 '- %s is unmerged.\n+ ' % filename +
548 'Right-click the file to launch "git mergetool".\n'
549 '@@@ Unmerged @@@\n\n')
550 diff += self.diff_helper(filename=filename,
551 cached=False)
552 elif filename in self.modified:
553 diff = self.diff_helper(filename=filename,
554 cached=False)
555 else:
556 diff = 'SHA1: ' + self.git.hash_object(filename)
557 return (diff, filename)
559 def stage_modified(self):
560 status, output = self.git.add(v=True,
561 with_stderr=True,
562 with_status=True,
563 *self.modified)
564 self.update_status()
565 return (status, output)
567 def stage_untracked(self):
568 status, output = self.git.add(v=True,
569 with_stderr=True,
570 with_status=True,
571 *self.untracked)
572 self.update_status()
573 return (status, output)
575 def reset(self, *items):
576 status, output = self.git.reset('--',
577 with_stderr=True,
578 with_status=True,
579 *items)
580 self.update_status()
581 return (status, output)
583 def unstage_all(self):
584 status, output = self.git.reset(with_stderr=True,
585 with_status=True)
586 self.update_status()
587 return (status, output)
589 def stage_all(self):
590 status, output = self.git.add(v=True,
591 u=True,
592 with_stderr=True,
593 with_status=True)
594 self.update_status()
595 return (status, output)
597 def config_set(self, key=None, value=None, local=True):
598 if key and value is not None:
599 # git config category.key value
600 strval = unicode(value)
601 if type(value) is bool:
602 # git uses "true" and "false"
603 strval = strval.lower()
604 if local:
605 argv = [ key, strval ]
606 else:
607 argv = [ '--global', key, strval ]
608 return self.git.config(*argv)
609 else:
610 msg = "oops in config_set(key=%s,value=%s,local=%s)"
611 raise Exception(msg % (key, value, local))
613 def config_dict(self, local=True):
614 """parses the lines from git config --list into a dictionary"""
616 kwargs = {
617 'list': True,
618 'global': not local, # global is a python keyword
620 config_lines = self.git.config(**kwargs).splitlines()
621 newdict = {}
622 for line in config_lines:
623 try:
624 k, v = line.split('=', 1)
625 except:
626 # the user has an invalid entry in their git config
627 continue
628 v = core.decode(v)
629 k = k.replace('.','_') # git -> model
630 if v == 'true' or v == 'false':
631 v = bool(eval(v.title()))
632 try:
633 v = int(eval(v))
634 except:
635 pass
636 newdict[k]=v
637 return newdict
639 def commit_with_msg(self, msg, amend=False):
640 """Creates a git commit."""
642 if not msg.endswith('\n'):
643 msg += '\n'
644 # Sure, this is a potential "security risk," but if someone
645 # is trying to intercept/re-write commit messages on your system,
646 # then you probably have bigger problems to worry about.
647 tmpfile = self.tmp_filename()
649 # Create the commit message file
650 fh = open(tmpfile, 'w')
651 core.write_nointr(fh, msg)
652 fh.close()
654 # Run 'git commit'
655 status, out = self.git.commit(F=tmpfile, v=True, amend=amend,
656 with_status=True,
657 with_stderr=True)
658 os.unlink(tmpfile)
659 return (status, out)
661 def tmp_dir(self):
662 # Allow TMPDIR/TMP with a fallback to /tmp
663 return os.environ.get('TMP', os.environ.get('TMPDIR', '/tmp'))
665 def tmp_file_pattern(self):
666 return os.path.join(self.tmp_dir(), '*.git-cola.%s.*' % os.getpid())
668 def tmp_filename(self, prefix=''):
669 basename = ((prefix+'.git-cola.%s.%s'
670 % (os.getpid(), time.time())))
671 basename = basename.replace('/', '-')
672 basename = basename.replace('\\', '-')
673 tmpdir = self.tmp_dir()
674 return os.path.join(tmpdir, basename)
676 def log_helper(self, all=False, extra_args=None):
678 Returns a pair of parallel arrays listing the revision sha1's
679 and commit summaries.
681 revs = []
682 summaries = []
683 regex = REV_LIST_REGEX
684 args = []
685 if extra_args:
686 args = extra_args
687 output = self.git.log(pretty='oneline', all=all, *args)
688 for line in map(core.decode, output.splitlines()):
689 match = regex.match(line)
690 if match:
691 revs.append(match.group(1))
692 summaries.append(match.group(2))
693 return (revs, summaries)
695 def parse_rev_list(self, raw_revs):
696 revs = []
697 for line in map(core.decode, raw_revs.splitlines()):
698 match = REV_LIST_REGEX.match(line)
699 if match:
700 rev_id = match.group(1)
701 summary = match.group(2)
702 revs.append((rev_id, summary,))
703 return revs
705 def rev_list_range(self, start, end):
706 range = '%s..%s' % (start, end)
707 raw_revs = self.git.rev_list(range, pretty='oneline')
708 return self.parse_rev_list(raw_revs)
710 def diff_helper(self,
711 commit=None,
712 branch=None,
713 ref=None,
714 endref=None,
715 filename=None,
716 cached=True,
717 with_diff_header=False,
718 suppress_header=True,
719 reverse=False):
720 "Invokes git diff on a filepath."
721 if commit:
722 ref, endref = commit+'^', commit
723 argv = []
724 if ref and endref:
725 argv.append('%s..%s' % (ref, endref))
726 elif ref:
727 for r in ref.strip().split():
728 argv.append(r)
729 elif branch:
730 argv.append(branch)
732 if filename:
733 argv.append('--')
734 if type(filename) is list:
735 argv.extend(filename)
736 else:
737 argv.append(filename)
739 start = False
740 del_tag = 'deleted file mode '
742 headers = []
743 deleted = cached and not os.path.exists(core.encode(filename))
745 diffoutput = self.git.diff(R=reverse,
746 M=True,
747 no_color=True,
748 cached=cached,
749 unified=self.diff_context,
750 with_raw_output=True,
751 with_stderr=True,
752 *argv)
754 # Handle 'git init'
755 if diffoutput.startswith('fatal:'):
756 if with_diff_header:
757 return ('', '')
758 else:
759 return ''
761 output = StringIO()
763 diff = diffoutput.split('\n')
764 for line in map(core.decode, diff):
765 if not start and '@@' == line[:2] and '@@' in line[2:]:
766 start = True
767 if start or (deleted and del_tag in line):
768 output.write(core.encode(line) + '\n')
769 else:
770 if with_diff_header:
771 headers.append(core.encode(line))
772 elif not suppress_header:
773 output.write(core.encode(line) + '\n')
775 result = core.decode(output.getvalue())
776 output.close()
778 if with_diff_header:
779 return('\n'.join(headers), result)
780 else:
781 return result
783 def git_repo_path(self, *subpaths):
784 paths = [self.git.git_dir()]
785 paths.extend(subpaths)
786 return os.path.realpath(os.path.join(*paths))
788 def merge_message_path(self):
789 for basename in ('MERGE_MSG', 'SQUASH_MSG'):
790 path = self.git_repo_path(basename)
791 if os.path.exists(path):
792 return path
793 return None
795 def merge_message(self):
796 return self.git.fmt_merge_msg('--file',
797 self.git_repo_path('FETCH_HEAD'))
799 def abort_merge(self):
800 # Reset the worktree
801 output = self.git.read_tree('HEAD', reset=True, u=True, v=True)
802 # remove MERGE_HEAD
803 merge_head = self.git_repo_path('MERGE_HEAD')
804 if os.path.exists(merge_head):
805 os.unlink(merge_head)
806 # remove MERGE_MESSAGE, etc.
807 merge_msg_path = self.merge_message_path()
808 while merge_msg_path:
809 os.unlink(merge_msg_path)
810 merge_msg_path = self.merge_message_path()
812 def _is_modified(self, name):
813 status, out = self.git.diff('--', name,
814 name_only=True,
815 exit_code=True,
816 with_status=True)
817 return status != 0
820 def _branch_status(self, branch):
822 Returns a tuple of staged, unstaged, untracked, and unmerged files
824 This shows only the changes that were introduced in branch
827 status, output = self.git.diff(name_only=True,
828 M=True, z=True,
829 with_stderr=True,
830 with_status=True,
831 *branch.strip().split())
832 if status != 0:
833 return ([], [], [], [], [])
835 staged = map(core.decode, [n for n in output.split('\0') if n])
836 return (staged, [], [], [], staged)
838 def worktree_state(self, head='HEAD', staged_only=False):
839 """Return a tuple of files in various states of being
841 Can be staged, unstaged, untracked, unmerged, or changed
842 upstream.
845 self.git.update_index(refresh=True)
846 if staged_only:
847 return self._branch_status(head)
849 staged_set = set()
850 modified_set = set()
851 upstream_changed_set = set()
853 (staged, modified, unmerged, untracked, upstream_changed) = (
854 [], [], [], [], [])
855 try:
856 output = self.git.diff_index(head,
857 cached=True,
858 with_stderr=True)
859 if output.startswith('fatal:'):
860 raise errors.GitInitError('git init')
861 for line in output.splitlines():
862 rest, name = line.split('\t', 1)
863 status = rest[-1]
864 name = eval_path(name)
865 if status == 'M':
866 staged.append(name)
867 staged_set.add(name)
868 # This file will also show up as 'M' without --cached
869 # so by default don't consider it modified unless
870 # it's truly modified
871 modified_set.add(name)
872 if not staged_only and self._is_modified(name):
873 modified.append(name)
874 elif status == 'A':
875 staged.append(name)
876 staged_set.add(name)
877 elif status == 'D':
878 staged.append(name)
879 staged_set.add(name)
880 modified_set.add(name)
881 elif status == 'U':
882 unmerged.append(name)
883 modified_set.add(name)
885 except errors.GitInitError:
886 # handle git init
887 staged.extend(self.all_files())
889 try:
890 output = self.git.diff_index(head, with_stderr=True)
891 if output.startswith('fatal:'):
892 raise errors.GitInitError('git init')
893 for line in output.splitlines():
894 info, name = line.split('\t', 1)
895 status = info.split()[-1]
896 if status == 'M' or status == 'D':
897 name = eval_path(name)
898 if name not in modified_set:
899 modified.append(name)
900 elif status == 'A':
901 name = eval_path(name)
902 # newly-added yet modified
903 if (name not in modified_set and not staged_only and
904 self._is_modified(name)):
905 modified.append(name)
907 except errors.GitInitError:
908 # handle git init
909 ls_files = (self.git.ls_files(modified=True, z=True)[:-1]
910 .split('\0'))
911 modified.extend(map(core.decode, [f for f in ls_files if f]))
913 untracked.extend(self.untracked_files())
915 # Look for upstream modified files if this is a tracking branch
916 if self.trackedbranch:
917 try:
918 diff_expr = self.merge_base_to(self.trackedbranch)
919 output = self.git.diff(diff_expr,
920 name_only=True,
921 z=True)
923 if output.startswith('fatal:'):
924 raise errors.GitInitError('git init')
926 for name in [n for n in output.split('\0') if n]:
927 name = core.decode(name)
928 upstream_changed.append(name)
929 upstream_changed_set.add(name)
931 except errors.GitInitError:
932 # handle git init
933 pass
935 # Keep stuff sorted
936 staged.sort()
937 modified.sort()
938 unmerged.sort()
939 untracked.sort()
940 upstream_changed.sort()
942 return (staged, modified, unmerged, untracked, upstream_changed)
944 def reset_helper(self, args):
945 """Removes files from the index
947 This handles the git init case, which is why it's not
948 just 'git reset name'. For the git init case this falls
949 back to 'git rm --cached'.
952 # fake the status because 'git reset' returns 1
953 # regardless of success/failure
954 status = 0
955 output = self.git.reset('--', with_stderr=True, *args)
956 # handle git init: we have to use 'git rm --cached'
957 # detect this condition by checking if the file is still staged
958 state = self.worktree_state()
959 staged = state[0]
960 rmargs = [a for a in args if a in staged]
961 if not rmargs:
962 return (status, output)
963 output += self.git.rm('--', cached=True, with_stderr=True, *rmargs)
964 return (status, output)
966 def remote_url(self, name):
967 return self.git.config('remote.%s.url' % name, get=True)
969 def remote_args(self, remote,
970 local_branch='',
971 remote_branch='',
972 ffwd=True,
973 tags=False,
974 rebase=False,
975 push=False):
976 # Swap the branches in push mode (reverse of fetch)
977 if push:
978 tmp = local_branch
979 local_branch = remote_branch
980 remote_branch = tmp
981 if ffwd:
982 branch_arg = '%s:%s' % ( remote_branch, local_branch )
983 else:
984 branch_arg = '+%s:%s' % ( remote_branch, local_branch )
985 args = [remote]
986 if local_branch and remote_branch:
987 args.append(branch_arg)
988 elif local_branch:
989 args.append(local_branch)
990 elif remote_branch:
991 args.append(remote_branch)
992 kwargs = {
993 'verbose': True,
994 'tags': tags,
995 'rebase': rebase,
996 'with_stderr': True,
997 'with_status': True,
999 return (args, kwargs)
1001 def gen_remote_helper(self, gitaction, push=False):
1002 """Generates a closure that calls git fetch, push or pull
1004 def remote_helper(remote, **kwargs):
1005 args, kwargs = self.remote_args(remote, push=push, **kwargs)
1006 return gitaction(*args, **kwargs)
1007 return remote_helper
1009 def parse_ls_tree(self, rev):
1010 """Returns a list of(mode, type, sha1, path) tuples."""
1011 lines = self.git.ls_tree(rev, r=True).splitlines()
1012 output = []
1013 regex = re.compile('^(\d+)\W(\w+)\W(\w+)[ \t]+(.*)$')
1014 for line in lines:
1015 match = regex.match(line)
1016 if match:
1017 mode = match.group(1)
1018 objtype = match.group(2)
1019 sha1 = match.group(3)
1020 filename = match.group(4)
1021 output.append((mode, objtype, sha1, filename,) )
1022 return output
1024 def format_patch_helper(self, to_export, revs, output='patches'):
1025 """writes patches named by to_export to the output directory."""
1027 outlines = []
1029 cur_rev = to_export[0]
1030 cur_master_idx = revs.index(cur_rev)
1032 patches_to_export = [ [cur_rev] ]
1033 patchset_idx = 0
1035 # Group the patches into continuous sets
1036 for idx, rev in enumerate(to_export[1:]):
1037 # Limit the search to the current neighborhood for efficiency
1038 master_idx = revs[ cur_master_idx: ].index(rev)
1039 master_idx += cur_master_idx
1040 if master_idx == cur_master_idx + 1:
1041 patches_to_export[ patchset_idx ].append(rev)
1042 cur_master_idx += 1
1043 continue
1044 else:
1045 patches_to_export.append([ rev ])
1046 cur_master_idx = master_idx
1047 patchset_idx += 1
1049 # Export each patchsets
1050 status = 0
1051 for patchset in patches_to_export:
1052 newstatus, out = self.export_patchset(patchset[0],
1053 patchset[-1],
1054 output='patches',
1055 n=len(patchset) > 1,
1056 thread=True,
1057 patch_with_stat=True)
1058 outlines.append(out)
1059 if status == 0:
1060 status += newstatus
1061 return (status, '\n'.join(outlines))
1063 def export_patchset(self, start, end, output="patches", **kwargs):
1064 revarg = '%s^..%s' % (start, end)
1065 return self.git.format_patch('-o', output, revarg,
1066 with_stderr=True,
1067 with_status=True,
1068 **kwargs)
1070 def current_branch(self):
1071 """Parses 'git symbolic-ref' to find the current branch."""
1072 headref = self.git.symbolic_ref('HEAD', with_stderr=True)
1073 if headref.startswith('refs/heads/'):
1074 return headref[11:]
1075 elif headref.startswith('fatal:'):
1076 return ''
1077 return headref
1079 def tracked_branch(self):
1080 """The name of the branch that current branch is tracking"""
1081 remote = self.git.config('branch.'+self.currentbranch+'.remote',
1082 get=True, with_stderr=True)
1083 if not remote:
1084 return ''
1085 headref = self.git.config('branch.'+self.currentbranch+'.merge',
1086 get=True, with_stderr=True)
1087 if headref.startswith('refs/heads/'):
1088 tracked_branch = headref[11:]
1089 return remote + '/' + tracked_branch
1090 return ''
1092 def create_branch(self, name, base, track=False):
1093 """Create a branch named 'name' from revision 'base'
1095 Pass track=True to create a local tracking branch.
1097 return self.git.branch(name, base, track=track,
1098 with_stderr=True,
1099 with_status=True)
1101 def cherry_pick_list(self, revs, **kwargs):
1102 """Cherry-picks each revision into the current branch.
1103 Returns a list of command output strings (1 per cherry pick)"""
1104 if not revs:
1105 return []
1106 cherries = []
1107 status = 0
1108 for rev in revs:
1109 newstatus, out = self.git.cherry_pick(rev,
1110 with_stderr=True,
1111 with_status=True)
1112 if status == 0:
1113 status += newstatus
1114 cherries.append(out)
1115 return (status, '\n'.join(cherries))
1117 def parse_stash_list(self, revids=False):
1118 """Parses "git stash list" and returns a list of stashes."""
1119 stashes = self.git.stash("list").splitlines()
1120 if revids:
1121 return [ s[:s.index(':')] for s in stashes ]
1122 else:
1123 return [ s[s.index(':')+1:] for s in stashes ]
1125 def pad(self, pstr, num=22):
1126 topad = num-len(pstr)
1127 if topad > 0:
1128 return pstr + ' '*topad
1129 else:
1130 return pstr
1132 def describe(self, revid, descr):
1133 version = self.git.describe(revid, tags=True, always=True,
1134 abbrev=4)
1135 return version + ' - ' + descr
1137 def update_revision_lists(self, filename=None, show_versions=False):
1138 num_results = self.num_results
1139 if filename:
1140 rev_list = self.git.log('--', filename,
1141 max_count=num_results,
1142 pretty='oneline')
1143 else:
1144 rev_list = self.git.log(max_count=num_results,
1145 pretty='oneline', all=True)
1147 commit_list = self.parse_rev_list(rev_list)
1148 commit_list.reverse()
1149 commits = map(lambda x: x[0], commit_list)
1150 descriptions = map(lambda x: core.decode(x[1]), commit_list)
1151 if show_versions:
1152 fancy_descr_list = map(lambda x: self.describe(*x), commit_list)
1153 self.set_descriptions_start(fancy_descr_list)
1154 self.set_descriptions_end(fancy_descr_list)
1155 else:
1156 self.set_descriptions_start(descriptions)
1157 self.set_descriptions_end(descriptions)
1159 self.set_revisions_start(commits)
1160 self.set_revisions_end(commits)
1162 return commits
1164 def changed_files(self, start, end):
1165 zfiles_str = self.git.diff('%s..%s' % (start, end),
1166 name_only=True, z=True).strip('\0')
1167 return [core.decode(enc) for enc in zfiles_str.split('\0') if enc]
1169 def renamed_files(self, start, end):
1170 difflines = self.git.diff('%s..%s' % (start, end),
1171 no_color=True,
1172 M=True).splitlines()
1173 return [ eval_path(r[12:].rstrip())
1174 for r in difflines if r.startswith('rename from ') ]
1176 def is_commit_published(self):
1177 head = self.git.rev_parse('HEAD')
1178 return bool(self.git.branch(r=True, contains=head))
1180 def merge_base_to(self, ref):
1181 """Given `ref`, return $(git merge-base ref HEAD)..ref."""
1182 base = self.git.merge_base('HEAD', ref)
1183 return '%s..%s' % (base, ref)
1185 def everything(self):
1186 """Returns a sorted list of all files, including untracked files."""
1187 ls_files = self.git.ls_files(z=True,
1188 cached=True,
1189 others=True,
1190 exclude_standard=True)
1191 return sorted(map(core.decode, [f for f in ls_files.split('\0') if f]))
1193 def untracked_files(self):
1194 """Returns a sorted list of all files, including untracked files."""
1195 # -1 for trailing NULL
1196 ls_files = self.git.ls_files(z=True,
1197 others=True,
1198 exclude_standard=True)
1199 return map(core.decode, [f for f in ls_files.split('\0') if f])
1201 def stage_paths(self, paths):
1202 """Stages add/removals to git."""
1203 add = []
1204 remove = []
1205 for path in set(paths):
1206 if os.path.exists(core.encode(path)):
1207 add.append(path)
1208 else:
1209 remove.append(path)
1210 # `git add -u` doesn't work on untracked files
1211 if add:
1212 self.git.add('--', *add)
1213 # If a path doesn't exist then that means it should be removed
1214 # from the index. We use `git add -u` for that.
1215 if remove:
1216 self.git.add('--', u=True, *remove)
1217 self.update_status()
1219 def unstage_paths(self, paths):
1220 """Unstages paths from the staging area and notifies observers."""
1221 self.reset_helper(set(paths))
1222 self.update_status()
1224 def revert_paths(self, paths):
1225 """Revert paths to the content from HEAD."""
1226 self.git.checkout('HEAD', '--', *set(paths))
1227 self.update_status()
1229 def getcwd(self):
1230 """If we've chosen a directory then use it, otherwise os.getcwd()."""
1231 if self.directory:
1232 return self.directory
1233 return os.getcwd()