models.main: Remove unused add_or_remove() method
[git-cola.git] / cola / models / main.py
blob00b8853e1ae8c8ce19bc2d24bbb742df29148659
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 core
13 from cola import utils
14 from cola import errors
15 from cola import gitcmd
16 from cola import gitcmds
17 from cola.models.observable import ObservableModel
19 #+-------------------------------------------------------------------------
20 #+ A regex for matching the output of git(log|rev-list) --pretty=oneline
21 REV_LIST_REGEX = re.compile('([0-9a-f]+)\W(.*)')
23 # Provides access to a global MainModel instance
24 _instance = None
25 def model():
26 """Returns the main model singleton"""
27 global _instance
28 if _instance:
29 return _instance
30 _instance = MainModel()
31 return _instance
34 def eval_path(path):
35 """handles quoted paths."""
36 if path.startswith('"') and path.endswith('"'):
37 return core.decode(eval(path))
38 else:
39 return path
42 class MainModel(ObservableModel):
43 """Provides a friendly wrapper for doing common git operations."""
45 # Observable messages
46 message_updated = 'updated'
47 message_about_to_update = 'about_to_update'
49 # States
50 mode_none = 'none' # Default: nothing's happened, do nothing
51 mode_worktree = 'worktree' # Comparing index to worktree
52 mode_index = 'index' # Comparing index to last commit
53 mode_amend = 'amend' # Amending a commit
54 mode_grep = 'grep' # We ran Search -> Grep
55 mode_branch = 'branch' # Applying changes from a branch
56 mode_diff = 'diff' # Diffing against an arbitrary branch
57 mode_diff_expr = 'diff_expr' # Diffing using arbitrary expression
58 mode_review = 'review' # Reviewing a branch
60 # Modes where we don't do anything like staging, etc.
61 modes_read_only = (mode_branch, mode_grep,
62 mode_diff, mode_diff_expr, mode_review)
63 # Modes where we can checkout files from the $head
64 modes_undoable = (mode_none, mode_index, mode_worktree)
66 def __init__(self, cwd=None):
67 """Reads git repository settings and sets several methods
68 so that they refer to the git module. This object
69 encapsulates cola's interaction with git."""
70 ObservableModel.__init__(self)
72 # Initialize the git command object
73 self.git = gitcmd.instance()
75 #####################################################
76 self.head = 'HEAD'
77 self.mode = self.mode_none
78 self.diff_text = ''
79 self.filename = None
80 self.currentbranch = ''
81 self.trackedbranch = ''
82 self.directory = ''
83 self.git_version = self.git.version()
84 self.remotes = []
85 self.remotename = ''
86 self.local_branch = ''
87 self.remote_branch = ''
89 #####################################################
90 # Status info
91 self.commitmsg = ''
92 self.modified = []
93 self.staged = []
94 self.unstaged = []
95 self.untracked = []
96 self.unmerged = []
97 self.upstream_changed = []
99 #####################################################
100 # Refs
101 self.revision = ''
102 self.local_branches = []
103 self.remote_branches = []
104 self.tags = []
105 self.revisions = []
106 self.summaries = []
108 # These are parallel lists
109 # ref^{tree}
110 self.types = []
111 self.sha1s = []
112 self.names = []
114 self.directories = []
115 self.directory_entries = {}
117 # parallel lists
118 self.subtree_types = []
119 self.subtree_sha1s = []
120 self.subtree_names = []
122 self.fetch_helper = None
123 self.push_helper = None
124 self.pull_helper = None
125 self.generate_remote_helpers()
126 if cwd:
127 self.use_worktree(cwd)
129 def read_only(self):
130 return self.mode in self.modes_read_only
132 def undoable(self):
133 """Whether we can checkout files from the $head."""
134 return self.mode in self.modes_undoable
136 def enable_staging(self):
137 """Whether staging should be allowed."""
138 return self.mode == self.mode_worktree
140 def generate_remote_helpers(self):
141 """Generates helper methods for fetch, push and pull"""
142 self.push_helper = self.gen_remote_helper(self.git.push, push=True)
143 self.fetch_helper = self.gen_remote_helper(self.git.fetch)
144 self.pull_helper = self.gen_remote_helper(self.git.pull)
146 def use_worktree(self, worktree):
147 self.git.load_worktree(worktree)
148 is_valid = self.git.is_valid()
149 if is_valid:
150 self._init_config_data()
151 self.set_project(os.path.basename(self.git.worktree()))
152 return is_valid
154 def _init_config_data(self):
155 """Reads git config --list and creates parameters
156 for each setting."""
157 # These parameters are saved in .gitconfig,
158 # so ideally these should be as short as possible.
160 # config items that are controllable globally
161 # and per-repository
162 self._local_and_global_defaults = {
163 'user_name': '',
164 'user_email': '',
165 'merge_summary': False,
166 'merge_diffstat': True,
167 'merge_verbosity': 2,
168 'gui_diffcontext': 3,
169 'gui_pruneduringfetch': False,
171 # config items that are purely git config --global settings
172 self._global_defaults = {
173 'cola_geometry': '',
174 'cola_fontdiff': '',
175 'cola_fontdiff_size': 12,
176 'cola_savewindowsettings': False,
177 'cola_showoutput': 'errors',
178 'cola_tabwidth': 8,
179 'merge_keepbackup': True,
180 'diff_tool': os.getenv('GIT_DIFF_TOOL', 'xxdiff'),
181 'merge_tool': os.getenv('GIT_MERGE_TOOL', 'xxdiff'),
182 'gui_editor': os.getenv('EDITOR', 'gvim'),
183 'gui_historybrowser': 'gitk',
186 local_dict = self.config_dict(local=True)
187 global_dict = self.config_dict(local=False)
189 for k,v in local_dict.iteritems():
190 self.set_param('local_'+k, v)
191 for k,v in global_dict.iteritems():
192 self.set_param('global_'+k, v)
193 if k not in local_dict:
194 local_dict[k]=v
195 self.set_param('local_'+k, v)
197 # Bootstrap the internal font*size variables
198 for param in ('global_cola_fontdiff'):
199 setdefault = True
200 if hasattr(self, param):
201 font = getattr(self, param)
202 if font:
203 setdefault = False
204 size = int(font.split(',')[1])
205 self.set_param(param+'_size', size)
206 param = param[len('global_'):]
207 global_dict[param] = font
208 global_dict[param+'_size'] = size
210 # Load defaults for all undefined items
211 local_and_global_defaults = self._local_and_global_defaults
212 for k,v in local_and_global_defaults.iteritems():
213 if k not in local_dict:
214 self.set_param('local_'+k, v)
215 if k not in global_dict:
216 self.set_param('global_'+k, v)
218 global_defaults = self._global_defaults
219 for k,v in global_defaults.iteritems():
220 if k not in global_dict:
221 self.set_param('global_'+k, v)
223 # Load the diff context
224 self.diff_context = self.local_config('gui.diffcontext', 3)
226 def global_config(self, key, default=None):
227 return self.param('global_'+key.replace('.', '_'),
228 default=default)
230 def local_config(self, key, default=None):
231 return self.param('local_'+key.replace('.', '_'),
232 default=default)
234 def cola_config(self, key):
235 return getattr(self, 'global_cola_'+key)
237 def gui_config(self, key):
238 return getattr(self, 'global_gui_'+key)
240 def config_params(self):
241 params = []
242 params.extend(map(lambda x: 'local_' + x,
243 self._local_and_global_defaults.keys()))
244 params.extend(map(lambda x: 'global_' + x,
245 self._local_and_global_defaults.keys()))
246 params.extend(map(lambda x: 'global_' + x,
247 self._global_defaults.keys()))
248 return [ p for p in params if not p.endswith('_size') ]
250 def save_config_param(self, param):
251 if param not in self.config_params():
252 return
253 value = getattr(self, param)
254 if param == 'local_gui_diffcontext':
255 self.diff_context = value
256 if param.startswith('local_'):
257 param = param[len('local_'):]
258 is_local = True
259 elif param.startswith('global_'):
260 param = param[len('global_'):]
261 is_local = False
262 else:
263 raise Exception("Invalid param '%s' passed to " % param
264 +'save_config_param()')
265 param = param.replace('_', '.') # model -> git
266 return self.config_set(param, value, local=is_local)
268 def init_browser_data(self):
269 """This scans over self.(names, sha1s, types) to generate
270 directories, directory_entries, and subtree_*"""
272 # Collect data for the model
273 if not self.currentbranch:
274 return
276 self.subtree_types = []
277 self.subtree_sha1s = []
278 self.subtree_names = []
279 self.directories = []
280 self.directory_entries = {}
282 # Lookup the tree info
283 tree_info = self.parse_ls_tree(self.currentbranch)
285 self.set_types(map(lambda(x): x[1], tree_info ))
286 self.set_sha1s(map(lambda(x): x[2], tree_info ))
287 self.set_names(map(lambda(x): x[3], tree_info ))
289 if self.directory: self.directories.append('..')
291 dir_entries = self.directory_entries
292 dir_regex = re.compile('([^/]+)/')
293 dirs_seen = {}
294 subdirs_seen = {}
296 for idx, name in enumerate(self.names):
297 if not name.startswith(self.directory):
298 continue
299 name = name[ len(self.directory): ]
300 if name.count('/'):
301 # This is a directory...
302 match = dir_regex.match(name)
303 if not match:
304 continue
305 dirent = match.group(1) + '/'
306 if dirent not in self.directory_entries:
307 self.directory_entries[dirent] = []
309 if dirent not in dirs_seen:
310 dirs_seen[dirent] = True
311 self.directories.append(dirent)
313 entry = name.replace(dirent, '')
314 entry_match = dir_regex.match(entry)
315 if entry_match:
316 subdir = entry_match.group(1) + '/'
317 if subdir in subdirs_seen:
318 continue
319 subdirs_seen[subdir] = True
320 dir_entries[dirent].append(subdir)
321 else:
322 dir_entries[dirent].append(entry)
323 else:
324 self.subtree_types.append(self.types[idx])
325 self.subtree_sha1s.append(self.sha1s[idx])
326 self.subtree_names.append(name)
328 def editor(self):
329 return self.gui_config('editor')
331 def history_browser(self):
332 return self.gui_config('historybrowser')
334 def remember_gui_settings(self):
335 return self.cola_config('savewindowsettings')
337 def subtree_node(self, idx):
338 return (self.subtree_types[idx],
339 self.subtree_sha1s[idx],
340 self.subtree_names[idx])
342 def all_branches(self):
343 return (self.local_branches + self.remote_branches)
345 def set_remote(self, remote):
346 if not remote:
347 return
348 self.set_param('remote', remote)
349 branches = utils.grep('%s/\S+$' % remote,
350 gitcmds.branch_list(remote=True),
351 squash=False)
352 self.set_remote_branches(branches)
354 def apply_diff(self, filename):
355 return self.git.apply(filename, index=True, cached=True)
357 def apply_diff_to_worktree(self, filename):
358 return self.git.apply(filename)
360 def load_commitmsg(self, path):
361 fh = open(path, 'r')
362 contents = core.decode(core.read_nointr(fh))
363 fh.close()
364 self.set_commitmsg(contents)
366 def prev_commitmsg(self):
367 """Queries git for the latest commit message."""
368 return core.decode(self.git.log('-1', pretty='format:%s%n%n%b'))
370 def load_commitmsg_template(self):
371 template = self.global_config('commit.template')
372 if template:
373 self.load_commitmsg(template)
375 def update_status(self):
376 # Give observers a chance to respond
377 self.notify_message_observers(self.message_about_to_update)
378 # This allows us to defer notification until the
379 # we finish processing data
380 staged_only = self.read_only()
381 head = self.head
382 notify_enabled = self.notification_enabled
383 self.notification_enabled = False
385 # Set these early since they are used to calculate 'upstream_changed'.
386 self.set_trackedbranch(gitcmds.tracked_branch())
387 self.set_currentbranch(gitcmds.current_branch())
389 (self.staged,
390 self.modified,
391 self.unmerged,
392 self.untracked,
393 self.upstream_changed) = self.worktree_state(head=head,
394 staged_only=staged_only)
395 # NOTE: the model's unstaged list holds an aggregate of the
396 # the modified, unmerged, and untracked file lists.
397 self.set_unstaged(self.modified + self.unmerged + self.untracked)
398 self.set_remotes(self.git.remote().splitlines())
399 self.set_tags(gitcmds.tag_list())
400 self.set_remote_branches(gitcmds.branch_list(remote=True))
401 self.set_local_branches(gitcmds.branch_list(remote=False))
402 self.set_revision('')
403 self.set_local_branch('')
404 self.set_remote_branch('')
405 # Re-enable notifications and emit changes
406 self.notification_enabled = notify_enabled
408 self.read_font_sizes()
409 self.notify_observers('staged', 'unstaged')
410 self.notify_message_observers(self.message_updated)
412 def read_font_sizes(self):
413 """Read font sizes from the configuration."""
414 value = self.cola_config('fontdiff')
415 if not value:
416 return
417 items = value.split(',')
418 if len(items) < 2:
419 return
420 self.global_cola_fontdiff_size = int(float(items[1]))
422 def set_diff_font(self, fontstr):
423 """Set the diff font string."""
424 self.global_cola_fontdiff = fontstr
425 self.read_font_sizes()
427 def delete_branch(self, branch):
428 return self.git.branch(branch,
429 D=True,
430 with_stderr=True,
431 with_status=True)
433 def revision_sha1(self, idx):
434 return self.revisions[idx]
436 def apply_diff_font_size(self, default):
437 old_font = self.cola_config('fontdiff')
438 if not old_font:
439 old_font = default
440 size = self.cola_config('fontdiff_size')
441 props = old_font.split(',')
442 props[1] = str(size)
443 new_font = ','.join(props)
444 self.global_cola_fontdiff = new_font
445 self.notify_observers('global_cola_fontdiff')
447 def commit_diff(self, sha1):
448 commit = self.git.show(sha1)
449 first_newline = commit.index('\n')
450 if commit[first_newline+1:].startswith('Merge:'):
451 return (core.decode(commit) + '\n\n' +
452 core.decode(self.diff_helper(commit=sha1,
453 cached=False,
454 suppress_header=False)))
455 else:
456 return core.decode(commit)
458 def filename(self, idx, staged=True):
459 try:
460 if staged:
461 return self.staged[idx]
462 else:
463 return self.unstaged[idx]
464 except IndexError:
465 return None
467 def diff_details(self, idx, ref, staged=True):
469 Return a "diff" for an entry by index relative to ref.
471 `staged` indicates whether we should consider this as a
472 staged or unstaged entry.
475 filename = self.filename(idx, staged=staged)
476 if not filename:
477 return (None, None)
478 encfilename = core.encode(filename)
479 if staged:
480 diff = self.diff_helper(filename=filename,
481 ref=ref,
482 cached=True)
483 else:
484 if os.path.isdir(encfilename):
485 diff = '\n'.join(os.listdir(filename))
487 elif filename in self.unmerged:
488 diff = ('@@@ Unmerged @@@\n'
489 '- %s is unmerged.\n+ ' % filename +
490 'Right-click the file to launch "git mergetool".\n'
491 '@@@ Unmerged @@@\n\n')
492 diff += self.diff_helper(filename=filename,
493 cached=False)
494 elif filename in self.modified:
495 diff = self.diff_helper(filename=filename,
496 cached=False)
497 else:
498 diff = 'SHA1: ' + self.git.hash_object(filename)
499 return (diff, filename)
501 def stage_modified(self):
502 status, output = self.git.add(v=True,
503 with_stderr=True,
504 with_status=True,
505 *self.modified)
506 self.update_status()
507 return (status, output)
509 def stage_untracked(self):
510 status, output = self.git.add(v=True,
511 with_stderr=True,
512 with_status=True,
513 *self.untracked)
514 self.update_status()
515 return (status, output)
517 def reset(self, *items):
518 status, output = self.git.reset('--',
519 with_stderr=True,
520 with_status=True,
521 *items)
522 self.update_status()
523 return (status, output)
525 def unstage_all(self):
526 status, output = self.git.reset(with_stderr=True,
527 with_status=True)
528 self.update_status()
529 return (status, output)
531 def stage_all(self):
532 status, output = self.git.add(v=True,
533 u=True,
534 with_stderr=True,
535 with_status=True)
536 self.update_status()
537 return (status, output)
539 def config_set(self, key=None, value=None, local=True):
540 if key and value is not None:
541 # git config category.key value
542 strval = unicode(value)
543 if type(value) is bool:
544 # git uses "true" and "false"
545 strval = strval.lower()
546 if local:
547 argv = [ key, strval ]
548 else:
549 argv = [ '--global', key, strval ]
550 return self.git.config(*argv)
551 else:
552 msg = "oops in config_set(key=%s,value=%s,local=%s)"
553 raise Exception(msg % (key, value, local))
555 def config_dict(self, local=True):
556 """parses the lines from git config --list into a dictionary"""
558 kwargs = {
559 'list': True,
560 'global': not local, # global is a python keyword
562 config_lines = self.git.config(**kwargs).splitlines()
563 newdict = {}
564 for line in config_lines:
565 try:
566 k, v = line.split('=', 1)
567 except:
568 # the user has an invalid entry in their git config
569 continue
570 v = core.decode(v)
571 k = k.replace('.','_') # git -> model
572 if v == 'true' or v == 'false':
573 v = bool(eval(v.title()))
574 try:
575 v = int(eval(v))
576 except:
577 pass
578 newdict[k]=v
579 return newdict
581 def commit_with_msg(self, msg, amend=False):
582 """Creates a git commit."""
584 if not msg.endswith('\n'):
585 msg += '\n'
586 # Sure, this is a potential "security risk," but if someone
587 # is trying to intercept/re-write commit messages on your system,
588 # then you probably have bigger problems to worry about.
589 tmpfile = self.tmp_filename()
591 # Create the commit message file
592 fh = open(tmpfile, 'w')
593 core.write_nointr(fh, msg)
594 fh.close()
596 # Run 'git commit'
597 status, out = self.git.commit(F=tmpfile, v=True, amend=amend,
598 with_status=True,
599 with_stderr=True)
600 os.unlink(tmpfile)
601 return (status, out)
603 def tmp_dir(self):
604 # Allow TMPDIR/TMP with a fallback to /tmp
605 return os.environ.get('TMP', os.environ.get('TMPDIR', '/tmp'))
607 def tmp_file_pattern(self):
608 return os.path.join(self.tmp_dir(), '*.git-cola.%s.*' % os.getpid())
610 def tmp_filename(self, prefix=''):
611 basename = ((prefix+'.git-cola.%s.%s'
612 % (os.getpid(), time.time())))
613 basename = basename.replace('/', '-')
614 basename = basename.replace('\\', '-')
615 tmpdir = self.tmp_dir()
616 return os.path.join(tmpdir, basename)
618 def log_helper(self, all=False, extra_args=None):
620 Returns a pair of parallel arrays listing the revision sha1's
621 and commit summaries.
623 revs = []
624 summaries = []
625 regex = REV_LIST_REGEX
626 args = []
627 if extra_args:
628 args = extra_args
629 output = self.git.log(pretty='oneline', all=all, *args)
630 for line in map(core.decode, output.splitlines()):
631 match = regex.match(line)
632 if match:
633 revs.append(match.group(1))
634 summaries.append(match.group(2))
635 return (revs, summaries)
637 def parse_rev_list(self, raw_revs):
638 revs = []
639 for line in map(core.decode, raw_revs.splitlines()):
640 match = REV_LIST_REGEX.match(line)
641 if match:
642 rev_id = match.group(1)
643 summary = match.group(2)
644 revs.append((rev_id, summary,))
645 return revs
647 def rev_list_range(self, start, end):
648 range = '%s..%s' % (start, end)
649 raw_revs = self.git.rev_list(range, pretty='oneline')
650 return self.parse_rev_list(raw_revs)
652 def diff_helper(self,
653 commit=None,
654 branch=None,
655 ref=None,
656 endref=None,
657 filename=None,
658 cached=True,
659 with_diff_header=False,
660 suppress_header=True,
661 reverse=False):
662 "Invokes git diff on a filepath."
663 if commit:
664 ref, endref = commit+'^', commit
665 argv = []
666 if ref and endref:
667 argv.append('%s..%s' % (ref, endref))
668 elif ref:
669 for r in ref.strip().split():
670 argv.append(r)
671 elif branch:
672 argv.append(branch)
674 if filename:
675 argv.append('--')
676 if type(filename) is list:
677 argv.extend(filename)
678 else:
679 argv.append(filename)
681 start = False
682 del_tag = 'deleted file mode '
684 headers = []
685 deleted = cached and not os.path.exists(core.encode(filename))
687 diffoutput = self.git.diff(R=reverse,
688 M=True,
689 no_color=True,
690 cached=cached,
691 unified=self.diff_context,
692 with_raw_output=True,
693 with_stderr=True,
694 *argv)
696 # Handle 'git init'
697 if diffoutput.startswith('fatal:'):
698 if with_diff_header:
699 return ('', '')
700 else:
701 return ''
703 output = StringIO()
705 diff = diffoutput.split('\n')
706 for line in map(core.decode, diff):
707 if not start and '@@' == line[:2] and '@@' in line[2:]:
708 start = True
709 if start or (deleted and del_tag in line):
710 output.write(core.encode(line) + '\n')
711 else:
712 if with_diff_header:
713 headers.append(core.encode(line))
714 elif not suppress_header:
715 output.write(core.encode(line) + '\n')
717 result = core.decode(output.getvalue())
718 output.close()
720 if with_diff_header:
721 return('\n'.join(headers), result)
722 else:
723 return result
725 def git_repo_path(self, *subpaths):
726 paths = [self.git.git_dir()]
727 paths.extend(subpaths)
728 return os.path.realpath(os.path.join(*paths))
730 def merge_message_path(self):
731 for basename in ('MERGE_MSG', 'SQUASH_MSG'):
732 path = self.git_repo_path(basename)
733 if os.path.exists(path):
734 return path
735 return None
737 def merge_message(self):
738 return self.git.fmt_merge_msg('--file',
739 self.git_repo_path('FETCH_HEAD'))
741 def abort_merge(self):
742 # Reset the worktree
743 output = self.git.read_tree('HEAD', reset=True, u=True, v=True)
744 # remove MERGE_HEAD
745 merge_head = self.git_repo_path('MERGE_HEAD')
746 if os.path.exists(merge_head):
747 os.unlink(merge_head)
748 # remove MERGE_MESSAGE, etc.
749 merge_msg_path = self.merge_message_path()
750 while merge_msg_path:
751 os.unlink(merge_msg_path)
752 merge_msg_path = self.merge_message_path()
754 def _is_modified(self, name):
755 status, out = self.git.diff('--', name,
756 name_only=True,
757 exit_code=True,
758 with_status=True)
759 return status != 0
762 def _branch_status(self, branch):
764 Returns a tuple of staged, unstaged, untracked, and unmerged files
766 This shows only the changes that were introduced in branch
769 status, output = self.git.diff(name_only=True,
770 M=True, z=True,
771 with_stderr=True,
772 with_status=True,
773 *branch.strip().split())
774 if status != 0:
775 return ([], [], [], [], [])
777 staged = map(core.decode, [n for n in output.split('\0') if n])
778 return (staged, [], [], [], staged)
780 def worktree_state(self, head='HEAD', staged_only=False):
781 """Return a tuple of files in various states of being
783 Can be staged, unstaged, untracked, unmerged, or changed
784 upstream.
787 self.git.update_index(refresh=True)
788 if staged_only:
789 return self._branch_status(head)
791 staged_set = set()
792 modified_set = set()
793 upstream_changed_set = set()
795 (staged, modified, unmerged, untracked, upstream_changed) = (
796 [], [], [], [], [])
797 try:
798 output = self.git.diff_index(head,
799 cached=True,
800 with_stderr=True)
801 if output.startswith('fatal:'):
802 raise errors.GitInitError('git init')
803 for line in output.splitlines():
804 rest, name = line.split('\t', 1)
805 status = rest[-1]
806 name = eval_path(name)
807 if status == 'M':
808 staged.append(name)
809 staged_set.add(name)
810 # This file will also show up as 'M' without --cached
811 # so by default don't consider it modified unless
812 # it's truly modified
813 modified_set.add(name)
814 if not staged_only and self._is_modified(name):
815 modified.append(name)
816 elif status == 'A':
817 staged.append(name)
818 staged_set.add(name)
819 elif status == 'D':
820 staged.append(name)
821 staged_set.add(name)
822 modified_set.add(name)
823 elif status == 'U':
824 unmerged.append(name)
825 modified_set.add(name)
827 except errors.GitInitError:
828 # handle git init
829 staged.extend(gitcmds.all_files())
831 try:
832 output = self.git.diff_index(head, with_stderr=True)
833 if output.startswith('fatal:'):
834 raise errors.GitInitError('git init')
835 for line in output.splitlines():
836 info, name = line.split('\t', 1)
837 status = info.split()[-1]
838 if status == 'M' or status == 'D':
839 name = eval_path(name)
840 if name not in modified_set:
841 modified.append(name)
842 elif status == 'A':
843 name = eval_path(name)
844 # newly-added yet modified
845 if (name not in modified_set and not staged_only and
846 self._is_modified(name)):
847 modified.append(name)
849 except errors.GitInitError:
850 # handle git init
851 ls_files = (self.git.ls_files(modified=True, z=True)[:-1]
852 .split('\0'))
853 modified.extend(map(core.decode, [f for f in ls_files if f]))
855 untracked.extend(gitcmds.untracked_files())
857 # Look for upstream modified files if this is a tracking branch
858 if self.trackedbranch:
859 try:
860 diff_expr = self.merge_base_to(self.trackedbranch)
861 output = self.git.diff(diff_expr,
862 name_only=True,
863 z=True)
865 if output.startswith('fatal:'):
866 raise errors.GitInitError('git init')
868 for name in [n for n in output.split('\0') if n]:
869 name = core.decode(name)
870 upstream_changed.append(name)
871 upstream_changed_set.add(name)
873 except errors.GitInitError:
874 # handle git init
875 pass
877 # Keep stuff sorted
878 staged.sort()
879 modified.sort()
880 unmerged.sort()
881 untracked.sort()
882 upstream_changed.sort()
884 return (staged, modified, unmerged, untracked, upstream_changed)
886 def reset_helper(self, args):
887 """Removes files from the index
889 This handles the git init case, which is why it's not
890 just 'git reset name'. For the git init case this falls
891 back to 'git rm --cached'.
894 # fake the status because 'git reset' returns 1
895 # regardless of success/failure
896 status = 0
897 output = self.git.reset('--', with_stderr=True, *args)
898 # handle git init: we have to use 'git rm --cached'
899 # detect this condition by checking if the file is still staged
900 state = self.worktree_state()
901 staged = state[0]
902 rmargs = [a for a in args if a in staged]
903 if not rmargs:
904 return (status, output)
905 output += self.git.rm('--', cached=True, with_stderr=True, *rmargs)
906 return (status, output)
908 def remote_url(self, name):
909 return self.git.config('remote.%s.url' % name, get=True)
911 def remote_args(self, remote,
912 local_branch='',
913 remote_branch='',
914 ffwd=True,
915 tags=False,
916 rebase=False,
917 push=False):
918 # Swap the branches in push mode (reverse of fetch)
919 if push:
920 tmp = local_branch
921 local_branch = remote_branch
922 remote_branch = tmp
923 if ffwd:
924 branch_arg = '%s:%s' % ( remote_branch, local_branch )
925 else:
926 branch_arg = '+%s:%s' % ( remote_branch, local_branch )
927 args = [remote]
928 if local_branch and remote_branch:
929 args.append(branch_arg)
930 elif local_branch:
931 args.append(local_branch)
932 elif remote_branch:
933 args.append(remote_branch)
934 kwargs = {
935 'verbose': True,
936 'tags': tags,
937 'rebase': rebase,
938 'with_stderr': True,
939 'with_status': True,
941 return (args, kwargs)
943 def gen_remote_helper(self, gitaction, push=False):
944 """Generates a closure that calls git fetch, push or pull
946 def remote_helper(remote, **kwargs):
947 args, kwargs = self.remote_args(remote, push=push, **kwargs)
948 return gitaction(*args, **kwargs)
949 return remote_helper
951 def parse_ls_tree(self, rev):
952 """Returns a list of(mode, type, sha1, path) tuples."""
953 lines = self.git.ls_tree(rev, r=True).splitlines()
954 output = []
955 regex = re.compile('^(\d+)\W(\w+)\W(\w+)[ \t]+(.*)$')
956 for line in lines:
957 match = regex.match(line)
958 if match:
959 mode = match.group(1)
960 objtype = match.group(2)
961 sha1 = match.group(3)
962 filename = match.group(4)
963 output.append((mode, objtype, sha1, filename,) )
964 return output
966 def format_patch_helper(self, to_export, revs, output='patches'):
967 """writes patches named by to_export to the output directory."""
969 outlines = []
971 cur_rev = to_export[0]
972 cur_master_idx = revs.index(cur_rev)
974 patches_to_export = [ [cur_rev] ]
975 patchset_idx = 0
977 # Group the patches into continuous sets
978 for idx, rev in enumerate(to_export[1:]):
979 # Limit the search to the current neighborhood for efficiency
980 master_idx = revs[ cur_master_idx: ].index(rev)
981 master_idx += cur_master_idx
982 if master_idx == cur_master_idx + 1:
983 patches_to_export[ patchset_idx ].append(rev)
984 cur_master_idx += 1
985 continue
986 else:
987 patches_to_export.append([ rev ])
988 cur_master_idx = master_idx
989 patchset_idx += 1
991 # Export each patchsets
992 status = 0
993 for patchset in patches_to_export:
994 newstatus, out = self.export_patchset(patchset[0],
995 patchset[-1],
996 output='patches',
997 n=len(patchset) > 1,
998 thread=True,
999 patch_with_stat=True)
1000 outlines.append(out)
1001 if status == 0:
1002 status += newstatus
1003 return (status, '\n'.join(outlines))
1005 def export_patchset(self, start, end, output="patches", **kwargs):
1006 revarg = '%s^..%s' % (start, end)
1007 return self.git.format_patch('-o', output, revarg,
1008 with_stderr=True,
1009 with_status=True,
1010 **kwargs)
1012 def create_branch(self, name, base, track=False):
1013 """Create a branch named 'name' from revision 'base'
1015 Pass track=True to create a local tracking branch.
1017 return self.git.branch(name, base, track=track,
1018 with_stderr=True,
1019 with_status=True)
1021 def cherry_pick_list(self, revs, **kwargs):
1022 """Cherry-picks each revision into the current branch.
1023 Returns a list of command output strings (1 per cherry pick)"""
1024 if not revs:
1025 return []
1026 cherries = []
1027 status = 0
1028 for rev in revs:
1029 newstatus, out = self.git.cherry_pick(rev,
1030 with_stderr=True,
1031 with_status=True)
1032 if status == 0:
1033 status += newstatus
1034 cherries.append(out)
1035 return (status, '\n'.join(cherries))
1037 def parse_stash_list(self, revids=False):
1038 """Parses "git stash list" and returns a list of stashes."""
1039 stashes = self.git.stash("list").splitlines()
1040 if revids:
1041 return [ s[:s.index(':')] for s in stashes ]
1042 else:
1043 return [ s[s.index(':')+1:] for s in stashes ]
1045 def pad(self, pstr, num=22):
1046 topad = num-len(pstr)
1047 if topad > 0:
1048 return pstr + ' '*topad
1049 else:
1050 return pstr
1052 def describe(self, revid, descr):
1053 version = self.git.describe(revid, tags=True, always=True,
1054 abbrev=4)
1055 return version + ' - ' + descr
1057 def update_revision_lists(self, filename=None, show_versions=False):
1058 num_results = self.num_results
1059 if filename:
1060 rev_list = self.git.log('--', filename,
1061 max_count=num_results,
1062 pretty='oneline')
1063 else:
1064 rev_list = self.git.log(max_count=num_results,
1065 pretty='oneline', all=True)
1067 commit_list = self.parse_rev_list(rev_list)
1068 commit_list.reverse()
1069 commits = map(lambda x: x[0], commit_list)
1070 descriptions = map(lambda x: core.decode(x[1]), commit_list)
1071 if show_versions:
1072 fancy_descr_list = map(lambda x: self.describe(*x), commit_list)
1073 self.set_descriptions_start(fancy_descr_list)
1074 self.set_descriptions_end(fancy_descr_list)
1075 else:
1076 self.set_descriptions_start(descriptions)
1077 self.set_descriptions_end(descriptions)
1079 self.set_revisions_start(commits)
1080 self.set_revisions_end(commits)
1082 return commits
1084 def changed_files(self, start, end):
1085 zfiles_str = self.git.diff('%s..%s' % (start, end),
1086 name_only=True, z=True).strip('\0')
1087 return [core.decode(enc) for enc in zfiles_str.split('\0') if enc]
1089 def renamed_files(self, start, end):
1090 difflines = self.git.diff('%s..%s' % (start, end),
1091 no_color=True,
1092 M=True).splitlines()
1093 return [ eval_path(r[12:].rstrip())
1094 for r in difflines if r.startswith('rename from ') ]
1096 def is_commit_published(self):
1097 head = self.git.rev_parse('HEAD')
1098 return bool(self.git.branch(r=True, contains=head))
1100 def merge_base_to(self, ref):
1101 """Given `ref`, return $(git merge-base ref HEAD)..ref."""
1102 base = self.git.merge_base('HEAD', ref)
1103 return '%s..%s' % (base, ref)
1105 def everything(self):
1106 """Returns a sorted list of all files, including untracked files."""
1107 ls_files = self.git.ls_files(z=True,
1108 cached=True,
1109 others=True,
1110 exclude_standard=True)
1111 return sorted(map(core.decode, [f for f in ls_files.split('\0') if f]))
1113 def stage_paths(self, paths):
1114 """Stages add/removals to git."""
1115 add = []
1116 remove = []
1117 for path in set(paths):
1118 if os.path.exists(core.encode(path)):
1119 add.append(path)
1120 else:
1121 remove.append(path)
1122 # `git add -u` doesn't work on untracked files
1123 if add:
1124 self.git.add('--', *add)
1125 # If a path doesn't exist then that means it should be removed
1126 # from the index. We use `git add -u` for that.
1127 if remove:
1128 self.git.add('--', u=True, *remove)
1129 self.update_status()
1131 def unstage_paths(self, paths):
1132 """Unstages paths from the staging area and notifies observers."""
1133 self.reset_helper(set(paths))
1134 self.update_status()
1136 def revert_paths(self, paths):
1137 """Revert paths to the content from HEAD."""
1138 self.git.checkout('HEAD', '--', *set(paths))
1139 self.update_status()
1141 def getcwd(self):
1142 """If we've chosen a directory then use it, otherwise os.getcwd()."""
1143 if self.directory:
1144 return self.directory
1145 return os.getcwd()