models.main: Fix the tests for {stage,unstage,revert}_paths()
[git-cola.git] / cola / models / main.py
blobaa12a4c2b6d70845bdf6775aa1c4b5e21eee87c0
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'
47 message_paths_staged = 'paths_staged'
48 message_paths_unstaged = 'paths_unstaged'
49 message_paths_reverted = 'paths_reverted'
51 # States
52 mode_none = 'none' # Default: nothing's happened, do nothing
53 mode_worktree = 'worktree' # Comparing index to worktree
54 mode_index = 'index' # Comparing index to last commit
55 mode_amend = 'amend' # Amending a commit
56 mode_grep = 'grep' # We ran Search -> Grep
57 mode_branch = 'branch' # Applying changes from a branch
58 mode_diff = 'diff' # Diffing against an arbitrary branch
59 mode_diff_expr = 'diff_expr' # Diffing using arbitrary expression
60 mode_review = 'review' # Reviewing a branch
62 # Modes where we don't do anything like staging, etc.
63 modes_read_only = (mode_branch, mode_grep,
64 mode_diff, mode_diff_expr, mode_review)
65 # Modes where we can checkout files from the $head
66 modes_undoable = (mode_none, mode_index, mode_worktree)
68 def __init__(self, cwd=None):
69 """Reads git repository settings and sets several methods
70 so that they refer to the git module. This object
71 encapsulates cola's interaction with git."""
72 ObservableModel.__init__(self)
74 # Initialize the git command object
75 self.git = gitcola.GitCola()
77 #####################################################
78 self.head = 'HEAD'
79 self.mode = self.mode_none
80 self.diff_text = ''
81 self.filename = None
82 self.currentbranch = ''
83 self.trackedbranch = ''
84 self.directory = ''
85 self.git_version = self.git.version()
86 self.remotes = []
87 self.remotename = ''
88 self.local_branch = ''
89 self.remote_branch = ''
91 #####################################################
92 # Status info
93 self.commitmsg = ''
94 self.modified = []
95 self.staged = []
96 self.unstaged = []
97 self.untracked = []
98 self.unmerged = []
99 self.upstream_changed = []
101 #####################################################
102 # Refs
103 self.revision = ''
104 self.local_branches = []
105 self.remote_branches = []
106 self.tags = []
107 self.revisions = []
108 self.summaries = []
110 # These are parallel lists
111 # ref^{tree}
112 self.types = []
113 self.sha1s = []
114 self.names = []
116 self.directories = []
117 self.directory_entries = {}
119 # parallel lists
120 self.subtree_types = []
121 self.subtree_sha1s = []
122 self.subtree_names = []
124 self.fetch_helper = None
125 self.push_helper = None
126 self.pull_helper = None
127 self.generate_remote_helpers()
128 if cwd:
129 self.use_worktree(cwd)
131 def read_only(self):
132 return self.mode in self.modes_read_only
134 def undoable(self):
135 """Whether we can checkout files from the $head."""
136 return self.mode in self.modes_undoable
138 def enable_staging(self):
139 """Whether staging should be allowed."""
140 return self.mode == self.mode_worktree
142 def all_files(self):
143 """Returns the names of all files in the repository"""
144 return [core.decode(f)
145 for f in self.git.ls_files(z=True)
146 .strip('\0').split('\0') if f]
148 def generate_remote_helpers(self):
149 """Generates helper methods for fetch, push and pull"""
150 self.push_helper = self.gen_remote_helper(self.git.push, push=True)
151 self.fetch_helper = self.gen_remote_helper(self.git.fetch)
152 self.pull_helper = self.gen_remote_helper(self.git.pull)
154 def use_worktree(self, worktree):
155 self.git.load_worktree(worktree)
156 is_valid = self.git.is_valid()
157 if is_valid:
158 self._init_config_data()
159 self.set_project(os.path.basename(self.git.worktree()))
160 return is_valid
162 def _init_config_data(self):
163 """Reads git config --list and creates parameters
164 for each setting."""
165 # These parameters are saved in .gitconfig,
166 # so ideally these should be as short as possible.
168 # config items that are controllable globally
169 # and per-repository
170 self._local_and_global_defaults = {
171 'user_name': '',
172 'user_email': '',
173 'merge_summary': False,
174 'merge_diffstat': True,
175 'merge_verbosity': 2,
176 'gui_diffcontext': 3,
177 'gui_pruneduringfetch': False,
179 # config items that are purely git config --global settings
180 self.__global_defaults = {
181 'cola_geometry': '',
182 'cola_fontdiff': '',
183 'cola_fontdiff_size': 12,
184 'cola_savewindowsettings': False,
185 'cola_showoutput': 'errors',
186 'cola_tabwidth': 8,
187 'merge_keepbackup': True,
188 'diff_tool': os.getenv('GIT_DIFF_TOOL', 'xxdiff'),
189 'merge_tool': os.getenv('GIT_MERGE_TOOL', 'xxdiff'),
190 'gui_editor': os.getenv('EDITOR', 'gvim'),
191 'gui_historybrowser': 'gitk',
194 local_dict = self.config_dict(local=True)
195 global_dict = self.config_dict(local=False)
197 for k,v in local_dict.iteritems():
198 self.set_param('local_'+k, v)
199 for k,v in global_dict.iteritems():
200 self.set_param('global_'+k, v)
201 if k not in local_dict:
202 local_dict[k]=v
203 self.set_param('local_'+k, v)
205 # Bootstrap the internal font*size variables
206 for param in ('global_cola_fontdiff'):
207 setdefault = True
208 if hasattr(self, param):
209 font = getattr(self, param)
210 if font:
211 setdefault = False
212 size = int(font.split(',')[1])
213 self.set_param(param+'_size', size)
214 param = param[len('global_'):]
215 global_dict[param] = font
216 global_dict[param+'_size'] = size
218 # Load defaults for all undefined items
219 local_and_global_defaults = self._local_and_global_defaults
220 for k,v in local_and_global_defaults.iteritems():
221 if k not in local_dict:
222 self.set_param('local_'+k, v)
223 if k not in global_dict:
224 self.set_param('global_'+k, v)
226 global_defaults = self.__global_defaults
227 for k,v in global_defaults.iteritems():
228 if k not in global_dict:
229 self.set_param('global_'+k, v)
231 # Load the diff context
232 self.diff_context = self.local_config('gui.diffcontext', 3)
234 def global_config(self, key, default=None):
235 return self.param('global_'+key.replace('.', '_'),
236 default=default)
238 def local_config(self, key, default=None):
239 return self.param('local_'+key.replace('.', '_'),
240 default=default)
242 def cola_config(self, key):
243 return getattr(self, 'global_cola_'+key)
245 def gui_config(self, key):
246 return getattr(self, 'global_gui_'+key)
248 def default_remote(self):
249 branch = self.currentbranch
250 branchconfig = 'branch.%s.remote' % branch
251 return self.local_config(branchconfig, 'origin')
253 def corresponding_remote_ref(self):
254 remote = self.default_remote()
255 branch = self.currentbranch
256 best_match = '%s/%s' % (remote, branch)
257 remote_branches = self.remote_branches
258 if not remote_branches:
259 return remote
260 for rb in remote_branches:
261 if rb == best_match:
262 return rb
263 return remote_branches[0]
265 def diff_filenames(self, arg):
266 """Returns a list of filenames that have been modified"""
267 diff_zstr = self.git.diff(arg, name_only=True, z=True).rstrip('\0')
268 return [core.decode(f) for f in diff_zstr.split('\0') if f]
270 def branch_list(self, remote=False):
271 """Returns a list of local or remote branches
273 This explicitly removes HEAD from the list of remote branches.
275 branches = map(lambda x: x.lstrip('* '),
276 self.git.branch(r=remote).splitlines())
277 if remote:
278 return [b for b in branches if b.find('/HEAD') == -1]
279 return branches
281 def config_params(self):
282 params = []
283 params.extend(map(lambda x: 'local_' + x,
284 self._local_and_global_defaults.keys()))
285 params.extend(map(lambda x: 'global_' + x,
286 self._local_and_global_defaults.keys()))
287 params.extend(map(lambda x: 'global_' + x,
288 self.__global_defaults.keys()))
289 return [ p for p in params if not p.endswith('_size') ]
291 def save_config_param(self, param):
292 if param not in self.config_params():
293 return
294 value = getattr(self, param)
295 if param == 'local_gui_diffcontext':
296 self.diff_context = value
297 if param.startswith('local_'):
298 param = param[len('local_'):]
299 is_local = True
300 elif param.startswith('global_'):
301 param = param[len('global_'):]
302 is_local = False
303 else:
304 raise Exception("Invalid param '%s' passed to " % param
305 +'save_config_param()')
306 param = param.replace('_', '.') # model -> git
307 return self.config_set(param, value, local=is_local)
309 def init_browser_data(self):
310 """This scans over self.(names, sha1s, types) to generate
311 directories, directory_entries, and subtree_*"""
313 # Collect data for the model
314 if not self.currentbranch:
315 return
317 self.subtree_types = []
318 self.subtree_sha1s = []
319 self.subtree_names = []
320 self.directories = []
321 self.directory_entries = {}
323 # Lookup the tree info
324 tree_info = self.parse_ls_tree(self.currentbranch)
326 self.set_types(map(lambda(x): x[1], tree_info ))
327 self.set_sha1s(map(lambda(x): x[2], tree_info ))
328 self.set_names(map(lambda(x): x[3], tree_info ))
330 if self.directory: self.directories.append('..')
332 dir_entries = self.directory_entries
333 dir_regex = re.compile('([^/]+)/')
334 dirs_seen = {}
335 subdirs_seen = {}
337 for idx, name in enumerate(self.names):
338 if not name.startswith(self.directory):
339 continue
340 name = name[ len(self.directory): ]
341 if name.count('/'):
342 # This is a directory...
343 match = dir_regex.match(name)
344 if not match:
345 continue
346 dirent = match.group(1) + '/'
347 if dirent not in self.directory_entries:
348 self.directory_entries[dirent] = []
350 if dirent not in dirs_seen:
351 dirs_seen[dirent] = True
352 self.directories.append(dirent)
354 entry = name.replace(dirent, '')
355 entry_match = dir_regex.match(entry)
356 if entry_match:
357 subdir = entry_match.group(1) + '/'
358 if subdir in subdirs_seen:
359 continue
360 subdirs_seen[subdir] = True
361 dir_entries[dirent].append(subdir)
362 else:
363 dir_entries[dirent].append(entry)
364 else:
365 self.subtree_types.append(self.types[idx])
366 self.subtree_sha1s.append(self.sha1s[idx])
367 self.subtree_names.append(name)
369 def add_or_remove(self, to_process):
370 """Invokes 'git add' to index the filenames in to_process that exist
371 and 'git rm' for those that do not exist."""
373 if not to_process:
374 return 'No files to add or remove.'
376 to_add = []
377 to_remove = []
379 for filename in to_process:
380 encfilename = core.encode(filename)
381 if os.path.exists(encfilename):
382 to_add.append(filename)
384 status = 0
385 if to_add:
386 newstatus, output = self.git.add(v=True,
387 with_stderr=True,
388 with_status=True,
389 *to_add)
390 status += newstatus
391 else:
392 output = ''
394 if len(to_add) == len(to_process):
395 # to_process only contained unremoved files --
396 # short-circuit the removal checks
397 return (status, output)
399 # Process files to remote
400 for filename in to_process:
401 if not os.path.exists(filename):
402 to_remove.append(filename)
403 newstatus, out = self.git.rm(with_stderr=True,
404 with_status=True,
405 *to_remove)
406 if status == 0:
407 status += newstatus
408 output + '\n\n' + out
409 return (status, output)
411 def editor(self):
412 return self.gui_config('editor')
414 def history_browser(self):
415 return self.gui_config('historybrowser')
417 def remember_gui_settings(self):
418 return self.cola_config('savewindowsettings')
420 def subtree_node(self, idx):
421 return (self.subtree_types[idx],
422 self.subtree_sha1s[idx],
423 self.subtree_names[idx])
425 def all_branches(self):
426 return (self.local_branches + self.remote_branches)
428 def set_remote(self, remote):
429 if not remote:
430 return
431 self.set_param('remote', remote)
432 branches = utils.grep('%s/\S+$' % remote,
433 self.branch_list(remote=True),
434 squash=False)
435 self.set_remote_branches(branches)
437 def apply_diff(self, filename):
438 return self.git.apply(filename, index=True, cached=True)
440 def apply_diff_to_worktree(self, filename):
441 return self.git.apply(filename)
443 def load_commitmsg(self, path):
444 fh = open(path, 'r')
445 contents = core.decode(core.read_nointr(fh))
446 fh.close()
447 self.set_commitmsg(contents)
449 def prev_commitmsg(self):
450 """Queries git for the latest commit message."""
451 return core.decode(self.git.log('-1', pretty='format:%s%n%n%b'))
453 def load_commitmsg_template(self):
454 template = self.global_config('commit.template')
455 if template:
456 self.load_commitmsg(template)
458 def update_status(self):
459 # Give observers a chance to respond
460 self.notify_message_observers(self.message_about_to_update)
461 # This allows us to defer notification until the
462 # we finish processing data
463 staged_only = self.read_only()
464 head = self.head
465 notify_enabled = self.notification_enabled
466 self.notification_enabled = False
468 (self.staged,
469 self.modified,
470 self.unmerged,
471 self.untracked,
472 self.upstream_changed) = self.worktree_state(head=head,
473 staged_only=staged_only)
474 # NOTE: the model's unstaged list holds an aggregate of the
475 # the modified, unmerged, and untracked file lists.
476 self.set_unstaged(self.modified + self.unmerged + self.untracked)
477 self.set_currentbranch(self.current_branch())
478 self.set_remotes(self.git.remote().splitlines())
479 self.set_remote_branches(self.branch_list(remote=True))
480 self.set_trackedbranch(self.tracked_branch())
481 self.set_local_branches(self.branch_list(remote=False))
482 self.set_tags(self.git.tag().splitlines())
483 self.set_revision('')
484 self.set_local_branch('')
485 self.set_remote_branch('')
486 # Re-enable notifications and emit changes
487 self.notification_enabled = notify_enabled
489 self.read_font_sizes()
490 self.notify_observers('staged','unstaged')
491 self.notify_message_observers(self.message_updated)
493 def read_font_sizes(self):
494 """Read font sizes from the configuration."""
495 value = self.cola_config('fontdiff')
496 if not value:
497 return
498 items = value.split(',')
499 if len(items) < 2:
500 return
501 self.global_cola_fontdiff_size = int(items[1])
503 def set_diff_font(self, fontstr):
504 """Set the diff font string."""
505 self.global_cola_fontdiff = fontstr
506 self.read_font_sizes()
508 def delete_branch(self, branch):
509 return self.git.branch(branch,
510 D=True,
511 with_stderr=True,
512 with_status=True)
514 def revision_sha1(self, idx):
515 return self.revisions[idx]
517 def apply_diff_font_size(self, default):
518 old_font = self.cola_config('fontdiff')
519 if not old_font:
520 old_font = default
521 size = self.cola_config('fontdiff_size')
522 props = old_font.split(',')
523 props[1] = str(size)
524 new_font = ','.join(props)
525 self.global_cola_fontdiff = new_font
526 self.notify_observers('global_cola_fontdiff')
528 def commit_diff(self, sha1):
529 commit = self.git.show(sha1)
530 first_newline = commit.index('\n')
531 if commit[first_newline+1:].startswith('Merge:'):
532 return (core.decode(commit) + '\n\n' +
533 core.decode(self.diff_helper(commit=sha1,
534 cached=False,
535 suppress_header=False)))
536 else:
537 return core.decode(commit)
539 def filename(self, idx, staged=True):
540 try:
541 if staged:
542 return self.staged[idx]
543 else:
544 return self.unstaged[idx]
545 except IndexError:
546 return None
548 def diff_details(self, idx, ref, staged=True):
550 Return a "diff" for an entry by index relative to ref.
552 `staged` indicates whether we should consider this as a
553 staged or unstaged entry.
556 filename = self.filename(idx, staged=staged)
557 if not filename:
558 return (None, None)
559 encfilename = core.encode(filename)
560 if staged:
561 diff = self.diff_helper(filename=filename,
562 ref=ref,
563 cached=True)
564 else:
565 if os.path.isdir(encfilename):
566 diff = '\n'.join(os.listdir(filename))
568 elif filename in self.unmerged:
569 diff = ('@@@ Unmerged @@@\n'
570 '- %s is unmerged.\n+ ' % filename +
571 'Right-click the file to launch "git mergetool".\n'
572 '@@@ Unmerged @@@\n\n')
573 diff += self.diff_helper(filename=filename,
574 cached=False)
575 elif filename in self.modified:
576 diff = self.diff_helper(filename=filename,
577 cached=False)
578 else:
579 diff = 'SHA1: ' + self.git.hash_object(filename)
580 return (diff, filename)
582 def stage_modified(self):
583 status, output = self.git.add(v=True,
584 with_stderr=True,
585 with_status=True,
586 *self.modified)
587 self.update_status()
588 return (status, output)
590 def stage_untracked(self):
591 status, output = self.git.add(v=True,
592 with_stderr=True,
593 with_status=True,
594 *self.untracked)
595 self.update_status()
596 return (status, output)
598 def reset(self, *items):
599 status, output = self.git.reset('--',
600 with_stderr=True,
601 with_status=True,
602 *items)
603 self.update_status()
604 return (status, output)
606 def unstage_all(self):
607 status, output = self.git.reset(with_stderr=True,
608 with_status=True)
609 self.update_status()
610 return (status, output)
612 def stage_all(self):
613 status, output = self.git.add(v=True,
614 u=True,
615 with_stderr=True,
616 with_status=True)
617 self.update_status()
618 return (status, output)
620 def config_set(self, key=None, value=None, local=True):
621 if key and value is not None:
622 # git config category.key value
623 strval = unicode(value)
624 if type(value) is bool:
625 # git uses "true" and "false"
626 strval = strval.lower()
627 if local:
628 argv = [ key, strval ]
629 else:
630 argv = [ '--global', key, strval ]
631 return self.git.config(*argv)
632 else:
633 msg = "oops in config_set(key=%s,value=%s,local=%s)"
634 raise Exception(msg % (key, value, local))
636 def config_dict(self, local=True):
637 """parses the lines from git config --list into a dictionary"""
639 kwargs = {
640 'list': True,
641 'global': not local, # global is a python keyword
643 config_lines = self.git.config(**kwargs).splitlines()
644 newdict = {}
645 for line in config_lines:
646 try:
647 k, v = line.split('=', 1)
648 except:
649 # the user has an invalid entry in their git config
650 continue
651 v = core.decode(v)
652 k = k.replace('.','_') # git -> model
653 if v == 'true' or v == 'false':
654 v = bool(eval(v.title()))
655 try:
656 v = int(eval(v))
657 except:
658 pass
659 newdict[k]=v
660 return newdict
662 def commit_with_msg(self, msg, amend=False):
663 """Creates a git commit."""
665 if not msg.endswith('\n'):
666 msg += '\n'
667 # Sure, this is a potential "security risk," but if someone
668 # is trying to intercept/re-write commit messages on your system,
669 # then you probably have bigger problems to worry about.
670 tmpfile = self.tmp_filename()
672 # Create the commit message file
673 fh = open(tmpfile, 'w')
674 core.write_nointr(fh, msg)
675 fh.close()
677 # Run 'git commit'
678 status, out = self.git.commit(F=tmpfile, v=True, amend=amend,
679 with_status=True,
680 with_stderr=True)
681 os.unlink(tmpfile)
682 return (status, out)
684 def tmp_dir(self):
685 # Allow TMPDIR/TMP with a fallback to /tmp
686 return os.environ.get('TMP', os.environ.get('TMPDIR', '/tmp'))
688 def tmp_file_pattern(self):
689 return os.path.join(self.tmp_dir(), '*.git-cola.%s.*' % os.getpid())
691 def tmp_filename(self, prefix=''):
692 basename = ((prefix+'.git-cola.%s.%s'
693 % (os.getpid(), time.time())))
694 basename = basename.replace('/', '-')
695 basename = basename.replace('\\', '-')
696 tmpdir = self.tmp_dir()
697 return os.path.join(tmpdir, basename)
699 def log_helper(self, all=False, extra_args=None):
701 Returns a pair of parallel arrays listing the revision sha1's
702 and commit summaries.
704 revs = []
705 summaries = []
706 regex = REV_LIST_REGEX
707 args = []
708 if extra_args:
709 args = extra_args
710 output = self.git.log(pretty='oneline', all=all, *args)
711 for line in map(core.decode, output.splitlines()):
712 match = regex.match(line)
713 if match:
714 revs.append(match.group(1))
715 summaries.append(match.group(2))
716 return (revs, summaries)
718 def parse_rev_list(self, raw_revs):
719 revs = []
720 for line in map(core.decode, raw_revs.splitlines()):
721 match = REV_LIST_REGEX.match(line)
722 if match:
723 rev_id = match.group(1)
724 summary = match.group(2)
725 revs.append((rev_id, summary,))
726 return revs
728 def rev_list_range(self, start, end):
729 range = '%s..%s' % (start, end)
730 raw_revs = self.git.rev_list(range, pretty='oneline')
731 return self.parse_rev_list(raw_revs)
733 def diff_helper(self,
734 commit=None,
735 branch=None,
736 ref=None,
737 endref=None,
738 filename=None,
739 cached=True,
740 with_diff_header=False,
741 suppress_header=True,
742 reverse=False):
743 "Invokes git diff on a filepath."
744 if commit:
745 ref, endref = commit+'^', commit
746 argv = []
747 if ref and endref:
748 argv.append('%s..%s' % (ref, endref))
749 elif ref:
750 for r in ref.strip().split():
751 argv.append(r)
752 elif branch:
753 argv.append(branch)
755 if filename:
756 argv.append('--')
757 if type(filename) is list:
758 argv.extend(filename)
759 else:
760 argv.append(filename)
762 start = False
763 del_tag = 'deleted file mode '
765 headers = []
766 deleted = cached and not os.path.exists(core.encode(filename))
768 diffoutput = self.git.diff(R=reverse,
769 M=True,
770 no_color=True,
771 cached=cached,
772 unified=self.diff_context,
773 with_raw_output=True,
774 with_stderr=True,
775 *argv)
777 # Handle 'git init'
778 if diffoutput.startswith('fatal:'):
779 if with_diff_header:
780 return ('', '')
781 else:
782 return ''
784 output = StringIO()
786 diff = diffoutput.split('\n')
787 for line in map(core.decode, diff):
788 if not start and '@@' == line[:2] and '@@' in line[2:]:
789 start = True
790 if start or (deleted and del_tag in line):
791 output.write(core.encode(line) + '\n')
792 else:
793 if with_diff_header:
794 headers.append(core.encode(line))
795 elif not suppress_header:
796 output.write(core.encode(line) + '\n')
798 result = core.decode(output.getvalue())
799 output.close()
801 if with_diff_header:
802 return('\n'.join(headers), result)
803 else:
804 return result
806 def git_repo_path(self, *subpaths):
807 paths = [self.git.git_dir()]
808 paths.extend(subpaths)
809 return os.path.realpath(os.path.join(*paths))
811 def merge_message_path(self):
812 for file in ('MERGE_MSG', 'SQUASH_MSG'):
813 path = self.git_repo_path(file)
814 if os.path.exists(path):
815 return path
816 return None
818 def merge_message(self):
819 return self.git.fmt_merge_msg('--file',
820 self.git_repo_path('FETCH_HEAD'))
822 def abort_merge(self):
823 # Reset the worktree
824 output = self.git.read_tree('HEAD', reset=True, u=True, v=True)
825 # remove MERGE_HEAD
826 merge_head = self.git_repo_path('MERGE_HEAD')
827 if os.path.exists(merge_head):
828 os.unlink(merge_head)
829 # remove MERGE_MESSAGE, etc.
830 merge_msg_path = self.merge_message_path()
831 while merge_msg_path:
832 os.unlink(merge_msg_path)
833 merge_msg_path = self.merge_message_path()
835 def _is_modified(self, name):
836 status, out = self.git.diff('--', name,
837 name_only=True,
838 exit_code=True,
839 with_status=True)
840 return status != 0
843 def _branch_status(self, branch):
845 Returns a tuple of staged, unstaged, untracked, and unmerged files
847 This shows only the changes that were introduced in branch
850 status, output = self.git.diff(name_only=True,
851 M=True, z=True,
852 with_stderr=True,
853 with_status=True,
854 *branch.strip().split())
855 if status != 0:
856 return ([], [], [], [], [])
857 staged = []
858 for name in output.strip('\0').split('\0'):
859 if not name:
860 continue
861 staged.append(core.decode(name))
863 return (staged, [], [], [], staged)
865 def worktree_state(self, head='HEAD', staged_only=False):
866 """Return a tuple of files in various states of being
868 Can be staged, unstaged, untracked, unmerged, or changed
869 upstream.
872 self.git.update_index(refresh=True)
873 if staged_only:
874 return self._branch_status(head)
876 staged_set = set()
877 modified_set = set()
878 upstream_changed_set = set()
880 (staged, modified, unmerged, untracked, upstream_changed) = (
881 [], [], [], [], [])
882 try:
883 output = self.git.diff_index(head,
884 cached=True,
885 with_stderr=True)
886 if output.startswith('fatal:'):
887 raise errors.GitInitError('git init')
888 for line in output.splitlines():
889 rest, name = line.split('\t', 1)
890 status = rest[-1]
891 name = eval_path(name)
892 if status == 'M':
893 staged.append(name)
894 staged_set.add(name)
895 # This file will also show up as 'M' without --cached
896 # so by default don't consider it modified unless
897 # it's truly modified
898 modified_set.add(name)
899 if not staged_only and self._is_modified(name):
900 modified.append(name)
901 elif status == 'A':
902 staged.append(name)
903 staged_set.add(name)
904 elif status == 'D':
905 staged.append(name)
906 staged_set.add(name)
907 modified_set.add(name)
908 elif status == 'U':
909 unmerged.append(name)
910 modified_set.add(name)
912 except errors.GitInitError:
913 # handle git init
914 staged.extend(self.all_files())
916 try:
917 output = self.git.diff_index(head, with_stderr=True)
918 if output.startswith('fatal:'):
919 raise errors.GitInitError('git init')
920 for line in output.splitlines():
921 info, name = line.split('\t', 1)
922 status = info.split()[-1]
923 if status == 'M' or status == 'D':
924 name = eval_path(name)
925 if name not in modified_set:
926 modified.append(name)
927 elif status == 'A':
928 name = eval_path(name)
929 # newly-added yet modified
930 if (name not in modified_set and not staged_only and
931 self._is_modified(name)):
932 modified.append(name)
934 except errors.GitInitError:
935 # handle git init
936 for name in (self.git.ls_files(modified=True, z=True)
937 .split('\0')):
938 if name:
939 modified.append(core.decode(name))
941 for name in self.git.ls_files(others=True, exclude_standard=True,
942 z=True).split('\0'):
943 if name:
944 untracked.append(core.decode(name))
946 # Look for upstream modified files if this is a tracking branch
947 if self.trackedbranch:
948 try:
949 output = self.git.diff('..'+self.trackedbranch,
950 name_only=True, z=True)
951 if output.startswith('fatal:'):
952 raise errors.GitInitError('git init')
953 for name in output.split('\0'):
954 if not name:
955 continue
956 name = core.decode(name)
957 upstream_changed.append(name)
958 upstream_changed_set.add(name)
960 except errors.GitInitError:
961 # handle git init
962 pass
964 # Keep stuff sorted
965 staged.sort()
966 modified.sort()
967 unmerged.sort()
968 untracked.sort()
969 upstream_changed.sort()
971 return (staged, modified, unmerged, untracked, upstream_changed)
973 def reset_helper(self, args):
974 """Removes files from the index
976 This handles the git init case, which is why it's not
977 just 'git reset name'. For the git init case this falls
978 back to 'git rm --cached'.
981 # fake the status because 'git reset' returns 1
982 # regardless of success/failure
983 status = 0
984 output = self.git.reset('--', with_stderr=True, *args)
985 # handle git init: we have to use 'git rm --cached'
986 # detect this condition by checking if the file is still staged
987 state = self.worktree_state()
988 staged = state[0]
989 rmargs = [a for a in args if a in staged]
990 if not rmargs:
991 return (status, output)
992 output += self.git.rm('--', cached=True, with_stderr=True, *rmargs)
993 return (status, output)
995 def remote_url(self, name):
996 return self.git.config('remote.%s.url' % name, get=True)
998 def remote_args(self, remote,
999 local_branch='',
1000 remote_branch='',
1001 ffwd=True,
1002 tags=False,
1003 rebase=False,
1004 push=False):
1005 # Swap the branches in push mode (reverse of fetch)
1006 if push:
1007 tmp = local_branch
1008 local_branch = remote_branch
1009 remote_branch = tmp
1010 if ffwd:
1011 branch_arg = '%s:%s' % ( remote_branch, local_branch )
1012 else:
1013 branch_arg = '+%s:%s' % ( remote_branch, local_branch )
1014 args = [remote]
1015 if local_branch and remote_branch:
1016 args.append(branch_arg)
1017 elif local_branch:
1018 args.append(local_branch)
1019 elif remote_branch:
1020 args.append(remote_branch)
1021 kwargs = {
1022 'verbose': True,
1023 'tags': tags,
1024 'rebase': rebase,
1025 'with_stderr': True,
1026 'with_status': True,
1028 return (args, kwargs)
1030 def gen_remote_helper(self, gitaction, push=False):
1031 """Generates a closure that calls git fetch, push or pull
1033 def remote_helper(remote, **kwargs):
1034 args, kwargs = self.remote_args(remote, push=push, **kwargs)
1035 return gitaction(*args, **kwargs)
1036 return remote_helper
1038 def parse_ls_tree(self, rev):
1039 """Returns a list of(mode, type, sha1, path) tuples."""
1040 lines = self.git.ls_tree(rev, r=True).splitlines()
1041 output = []
1042 regex = re.compile('^(\d+)\W(\w+)\W(\w+)[ \t]+(.*)$')
1043 for line in lines:
1044 match = regex.match(line)
1045 if match:
1046 mode = match.group(1)
1047 objtype = match.group(2)
1048 sha1 = match.group(3)
1049 filename = match.group(4)
1050 output.append((mode, objtype, sha1, filename,) )
1051 return output
1053 def format_patch_helper(self, to_export, revs, output='patches'):
1054 """writes patches named by to_export to the output directory."""
1056 outlines = []
1058 cur_rev = to_export[0]
1059 cur_master_idx = revs.index(cur_rev)
1061 patches_to_export = [ [cur_rev] ]
1062 patchset_idx = 0
1064 # Group the patches into continuous sets
1065 for idx, rev in enumerate(to_export[1:]):
1066 # Limit the search to the current neighborhood for efficiency
1067 master_idx = revs[ cur_master_idx: ].index(rev)
1068 master_idx += cur_master_idx
1069 if master_idx == cur_master_idx + 1:
1070 patches_to_export[ patchset_idx ].append(rev)
1071 cur_master_idx += 1
1072 continue
1073 else:
1074 patches_to_export.append([ rev ])
1075 cur_master_idx = master_idx
1076 patchset_idx += 1
1078 # Export each patchsets
1079 status = 0
1080 for patchset in patches_to_export:
1081 newstatus, out = self.export_patchset(patchset[0],
1082 patchset[-1],
1083 output='patches',
1084 n=len(patchset) > 1,
1085 thread=True,
1086 patch_with_stat=True)
1087 outlines.append(out)
1088 if status == 0:
1089 status += newstatus
1090 return (status, '\n'.join(outlines))
1092 def export_patchset(self, start, end, output="patches", **kwargs):
1093 revarg = '%s^..%s' % (start, end)
1094 return self.git.format_patch('-o', output, revarg,
1095 with_stderr=True,
1096 with_status=True,
1097 **kwargs)
1099 def current_branch(self):
1100 """Parses 'git symbolic-ref' to find the current branch."""
1101 headref = self.git.symbolic_ref('HEAD', with_stderr=True)
1102 if headref.startswith('refs/heads/'):
1103 return headref[11:]
1104 elif headref.startswith('fatal:'):
1105 return ''
1106 return headref
1108 def tracked_branch(self):
1109 """The name of the branch that current branch is tracking"""
1110 remote = self.git.config('branch.'+self.currentbranch+'.remote',
1111 get=True, with_stderr=True)
1112 if not remote:
1113 return ''
1114 headref = self.git.config('branch.'+self.currentbranch+'.merge',
1115 get=True, with_stderr=True)
1116 if headref.startswith('refs/heads/'):
1117 tracked_branch = headref[11:]
1118 return remote + '/' + tracked_branch
1119 return ''
1121 def create_branch(self, name, base, track=False):
1122 """Create a branch named 'name' from revision 'base'
1124 Pass track=True to create a local tracking branch.
1126 return self.git.branch(name, base, track=track,
1127 with_stderr=True,
1128 with_status=True)
1130 def cherry_pick_list(self, revs, **kwargs):
1131 """Cherry-picks each revision into the current branch.
1132 Returns a list of command output strings (1 per cherry pick)"""
1133 if not revs:
1134 return []
1135 cherries = []
1136 status = 0
1137 for rev in revs:
1138 newstatus, out = self.git.cherry_pick(rev,
1139 with_stderr=True,
1140 with_status=True)
1141 if status == 0:
1142 status += newstatus
1143 cherries.append(out)
1144 return (status, '\n'.join(cherries))
1146 def parse_stash_list(self, revids=False):
1147 """Parses "git stash list" and returns a list of stashes."""
1148 stashes = self.git.stash("list").splitlines()
1149 if revids:
1150 return [ s[:s.index(':')] for s in stashes ]
1151 else:
1152 return [ s[s.index(':')+1:] for s in stashes ]
1154 def pad(self, pstr, num=22):
1155 topad = num-len(pstr)
1156 if topad > 0:
1157 return pstr + ' '*topad
1158 else:
1159 return pstr
1161 def describe(self, revid, descr):
1162 version = self.git.describe(revid, tags=True, always=True,
1163 abbrev=4)
1164 return version + ' - ' + descr
1166 def update_revision_lists(self, filename=None, show_versions=False):
1167 num_results = self.num_results
1168 if filename:
1169 rev_list = self.git.log('--', filename,
1170 max_count=num_results,
1171 pretty='oneline')
1172 else:
1173 rev_list = self.git.log(max_count=num_results,
1174 pretty='oneline', all=True)
1176 commit_list = self.parse_rev_list(rev_list)
1177 commit_list.reverse()
1178 commits = map(lambda x: x[0], commit_list)
1179 descriptions = map(lambda x: core.decode(x[1]), commit_list)
1180 if show_versions:
1181 fancy_descr_list = map(lambda x: self.describe(*x), commit_list)
1182 self.set_descriptions_start(fancy_descr_list)
1183 self.set_descriptions_end(fancy_descr_list)
1184 else:
1185 self.set_descriptions_start(descriptions)
1186 self.set_descriptions_end(descriptions)
1188 self.set_revisions_start(commits)
1189 self.set_revisions_end(commits)
1191 return commits
1193 def changed_files(self, start, end):
1194 zfiles_str = self.git.diff('%s..%s' % (start, end),
1195 name_only=True, z=True).strip('\0')
1196 return [core.decode(enc) for enc in zfiles_str.split('\0') if enc]
1198 def renamed_files(self, start, end):
1199 difflines = self.git.diff('%s..%s' % (start, end),
1200 no_color=True,
1201 M=True).splitlines()
1202 return [ eval_path(r[12:].rstrip())
1203 for r in difflines if r.startswith('rename from ') ]
1205 def is_commit_published(self):
1206 head = self.git.rev_parse('HEAD')
1207 return bool(self.git.branch(r=True, contains=head))
1209 def merge_base_to(self, ref):
1210 """Given `ref`, return $(git merge-base ref HEAD)..ref."""
1211 base = self.git.merge_base('HEAD', ref)
1212 return '%s..%s' % (base, ref)
1214 def everything(self):
1215 """Returns a sorted list of all files, including untracked files."""
1216 files = self.all_files() + self.untracked
1217 files.sort()
1218 return files
1220 def stage_paths(self, paths):
1221 """Adds paths to git and notifies observers."""
1223 # Grab the old lists of untracked + modified files
1224 self.update_status()
1225 old_modified = set(self.modified)
1226 old_untracked = set(self.untracked)
1228 # Add paths and scan for changes
1229 paths = set(paths)
1230 for path in paths:
1231 # If a path doesn't exist then that means it should be removed
1232 # from the index. We use `git add -u` for that.
1233 # GITBUG: `git add -u` doesn't on untracked files.
1234 if os.path.exists(core.encode(path)):
1235 self.git.add('--', path)
1236 else:
1237 self.git.add('--', path, u=True)
1238 self.update_status()
1240 # Grab the new lists of untracked + modified files
1241 new_modified = set(self.modified)
1242 new_untracked = set(self.untracked)
1244 # Handle 'git add' on a directory
1245 newly_not_modified = utils.add_parents(old_modified - new_modified)
1246 newly_not_untracked = utils.add_parents(old_untracked - new_untracked)
1247 for path in newly_not_modified.union(newly_not_untracked):
1248 paths.add(path)
1250 self.notify_message_observers(self.message_paths_staged, paths=paths)
1252 def unstage_paths(self, paths):
1253 """Unstages paths from the staging area and notifies observers."""
1254 paths = set(paths)
1256 # Grab the old list of staged files
1257 self.update_status()
1258 old_staged = set(self.staged)
1260 # Reset and scan for new changes
1261 self.reset_helper(paths)
1262 self.update_status()
1264 # Grab the new list of staged file
1265 new_staged = set(self.staged)
1267 # Handle 'git reset' on a directory
1268 newly_unstaged = utils.add_parents(old_staged - new_staged)
1269 for path in newly_unstaged:
1270 paths.add(path)
1272 self.notify_message_observers(self.message_paths_unstaged, paths=paths)
1274 def revert_paths(self, paths):
1275 """Revert paths to the content from HEAD."""
1276 paths = set(paths)
1278 # Grab the old set of changed files
1279 self.update_status()
1280 old_modified = set(self.modified)
1281 old_staged = set(self.staged)
1282 old_changed = old_modified.union(old_staged)
1284 # Checkout and scan for changes
1285 self.git.checkout('HEAD', '--', *paths)
1286 self.update_status()
1288 # Grab the new set of changed files
1289 new_modified = set(self.modified)
1290 new_staged = set(self.staged)
1291 new_changed = new_modified.union(new_staged)
1293 # Handle 'git checkout' on a directory
1294 newly_reverted = utils.add_parents(old_changed - new_changed)
1296 for path in newly_reverted:
1297 paths.add(path)
1299 self.notify_message_observers(self.message_paths_reverted, paths=paths)
1301 def getcwd(self):
1302 """If we've chosen a directory then use it, otherwise os.getcwd()."""
1303 if self.directory:
1304 return self.directory
1305 return os.getcwd()