usability: Allow un/staging all by right-clicking on top-level items
[git-cola.git] / cola / models / main.py
blob4fd8ea9f6f9c4cabcc6843f3e29697711fdbee2a
1 # Copyright (c) 2008 David Aguilar
2 """This module provides the central cola model.
3 """
5 import os
6 import sys
7 import time
8 import subprocess
9 from cStringIO import StringIO
11 from cola import core
12 from cola import utils
13 from cola import git
14 from cola import gitcfg
15 from cola import gitcmds
16 from cola.compat import set
17 from cola import serializer
18 from cola.models.observable import ObservableModel, OMSerializer
19 from cola.decorators import memoize
22 # Static GitConfig instance
23 _config = gitcfg.instance()
26 # Provides access to a global MainModel instance
27 @memoize
28 def model():
29 """Returns the main model singleton"""
30 return MainModel()
33 class MainSerializer(OMSerializer):
34 def post_decode_hook(self):
35 OMSerializer.post_decode_hook(self)
36 self.obj.generate_remote_helpers()
39 class MainModel(ObservableModel):
40 """Provides a friendly wrapper for doing common git operations."""
42 # Observable messages
43 message_updated = 'updated'
44 message_about_to_update = 'about_to_update'
46 # States
47 mode_none = 'none' # Default: nothing's happened, do nothing
48 mode_worktree = 'worktree' # Comparing index to worktree
49 mode_index = 'index' # Comparing index to last commit
50 mode_amend = 'amend' # Amending a commit
51 mode_grep = 'grep' # We ran Search -> Grep
52 mode_branch = 'branch' # Applying changes from a branch
53 mode_diff = 'diff' # Diffing against an arbitrary branch
54 mode_diff_expr = 'diff_expr' # Diffing using arbitrary expression
55 mode_review = 'review' # Reviewing a branch
57 # Modes where we don't do anything like staging, etc.
58 modes_read_only = (mode_branch, mode_grep,
59 mode_diff, mode_diff_expr, mode_review)
60 # Modes where we can checkout files from the $head
61 modes_undoable = (mode_none, mode_index, mode_worktree)
63 unstaged = property(lambda self: self.modified + self.unmerged + self.untracked)
64 """An aggregate of the modified, unmerged, and untracked file lists."""
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 = git.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.untracked = []
95 self.unmerged = []
96 self.upstream_changed = []
97 self.submodules = set()
99 #####################################################
100 # Refs
101 self.revision = ''
102 self.local_branches = []
103 self.remote_branches = []
104 self.tags = []
105 self.revisions = []
106 self.summaries = []
108 self.fetch_helper = None
109 self.push_helper = None
110 self.pull_helper = None
111 self.generate_remote_helpers()
112 if cwd:
113 self.use_worktree(cwd)
115 #####################################################
116 # Dag
117 self._commits = []
119 def read_only(self):
120 return self.mode in self.modes_read_only
122 def undoable(self):
123 """Whether we can checkout files from the $head."""
124 return self.mode in self.modes_undoable
126 def enable_staging(self):
127 """Whether staging should be allowed."""
128 return self.mode == self.mode_worktree
130 def generate_remote_helpers(self):
131 """Generates helper methods for fetch, push and pull"""
132 self.push_helper = self.gen_remote_helper(self.git.push, push=True)
133 self.fetch_helper = self.gen_remote_helper(self.git.fetch)
134 self.pull_helper = self.gen_remote_helper(self.git.pull)
136 def use_worktree(self, worktree):
137 self.git.load_worktree(worktree)
138 is_valid = self.git.is_valid()
139 if is_valid:
140 self._init_config_data()
141 self.set_project(os.path.basename(self.git.worktree()))
142 return is_valid
144 def _init_config_data(self):
145 """Reads git config --list and creates parameters
146 for each setting."""
147 # These parameters are saved in .gitconfig,
148 # so ideally these should be as short as possible.
150 # config items that are controllable globally
151 # and per-repository
152 self._local_and_global_defaults = {
153 'user_name': '',
154 'user_email': '',
155 'merge_summary': False,
156 'merge_diffstat': True,
157 'merge_verbosity': 2,
158 'gui_diffcontext': 3,
159 'gui_pruneduringfetch': False,
161 # config items that are purely git config --global settings
162 self._global_defaults = {
163 'cola_geometry': '',
164 'cola_fontdiff': '',
165 'cola_fontdiff_size': 12,
166 'cola_savewindowsettings': True,
167 'cola_showoutput': 'errors',
168 'cola_tabwidth': 8,
169 'merge_keepbackup': True,
170 'diff_tool': os.getenv('GIT_DIFF_TOOL', 'xxdiff'),
171 'merge_tool': os.getenv('GIT_MERGE_TOOL', 'xxdiff'),
172 'gui_editor': os.getenv('VISUAL', os.getenv('EDITOR', 'gvim')),
173 'gui_historybrowser': 'gitk',
176 def _underscore(dct):
177 underscore = {}
178 for k, v in dct.iteritems():
179 underscore[k.replace('.', '_')] = v
180 return underscore
182 _config.update()
183 local_dict = _underscore(_config.repo())
184 global_dict = _underscore(_config.user())
186 for k,v in local_dict.iteritems():
187 self.set_param('local_'+k, v)
188 for k,v in global_dict.iteritems():
189 self.set_param('global_'+k, v)
190 if k not in local_dict:
191 local_dict[k]=v
192 self.set_param('local_'+k, v)
194 # Bootstrap the internal font*size variables
195 for param in ('global_cola_fontdiff'):
196 setdefault = True
197 if hasattr(self, param):
198 font = getattr(self, param)
199 if font:
200 setdefault = False
201 size = int(font.split(',')[1])
202 self.set_param(param+'_size', size)
203 param = param[len('global_'):]
204 global_dict[param] = font
205 global_dict[param+'_size'] = size
207 # Load defaults for all undefined items
208 local_and_global_defaults = self._local_and_global_defaults
209 for k,v in local_and_global_defaults.iteritems():
210 if k not in local_dict:
211 self.set_param('local_'+k, v)
212 if k not in global_dict:
213 self.set_param('global_'+k, v)
215 global_defaults = self._global_defaults
216 for k,v in global_defaults.iteritems():
217 if k not in global_dict:
218 self.set_param('global_'+k, v)
220 # Load the diff context
221 self.diff_context = _config.get('gui.diffcontext', 3)
223 def global_config(self, key, default=None):
224 return self.param('global_'+key.replace('.', '_'),
225 default=default)
227 def local_config(self, key, default=None):
228 return self.param('local_'+key.replace('.', '_'),
229 default=default)
231 def cola_config(self, key):
232 return getattr(self, 'global_cola_'+key)
234 def gui_config(self, key):
235 return getattr(self, 'global_gui_'+key)
237 def config_params(self):
238 params = []
239 params.extend(map(lambda x: 'local_' + x,
240 self._local_and_global_defaults.keys()))
241 params.extend(map(lambda x: 'global_' + x,
242 self._local_and_global_defaults.keys()))
243 params.extend(map(lambda x: 'global_' + x,
244 self._global_defaults.keys()))
245 return [ p for p in params if not p.endswith('_size') ]
247 def save_config_param(self, param):
248 if param not in self.config_params():
249 return
250 value = getattr(self, param)
251 if param == 'local_gui_diffcontext':
252 self.diff_context = value
253 if param.startswith('local_'):
254 param = param[len('local_'):]
255 is_local = True
256 elif param.startswith('global_'):
257 param = param[len('global_'):]
258 is_local = False
259 else:
260 raise Exception("Invalid param '%s' passed to " % param
261 +'save_config_param()')
262 param = param.replace('_', '.') # model -> git
263 return self.config_set(param, value, local=is_local)
265 def editor(self):
266 app = self.gui_config('editor')
267 return {'vim': 'gvim'}.get(app, app)
269 def history_browser(self):
270 return self.gui_config('historybrowser')
272 def remember_gui_settings(self):
273 return self.cola_config('savewindowsettings')
275 def all_branches(self):
276 return (self.local_branches + self.remote_branches)
278 def set_remote(self, remote):
279 if not remote:
280 return
281 self.set_param('remote', remote)
282 branches = utils.grep('%s/\S+$' % remote,
283 gitcmds.branch_list(remote=True),
284 squash=False)
285 self.set_remote_branches(branches)
287 def apply_diff(self, filename):
288 return self.git.apply(filename, index=True, cached=True)
290 def apply_diff_to_worktree(self, filename):
291 return self.git.apply(filename)
293 def prev_commitmsg(self):
294 """Queries git for the latest commit message."""
295 return core.decode(self.git.log('-1', no_color=True, pretty='format:%s%n%n%b'))
297 def update_status(self):
298 # Give observers a chance to respond
299 self.notify_message_observers(self.message_about_to_update)
300 # This allows us to defer notification until the
301 # we finish processing data
302 self.notification_enabled = False
304 self._update_files()
305 self._update_refs()
306 self._update_branches_and_tags()
307 self._update_branch_heads()
309 # Re-enable notifications and emit changes
310 self.notification_enabled = True
311 self.notify_observers('staged', 'unstaged')
312 self.notify_message_observers(self.message_updated)
314 self.read_font_sizes()
316 def _update_files(self, worktree_only=False):
317 staged_only = self.read_only()
318 state = gitcmds.worktree_state_dict(head=self.head,
319 staged_only=staged_only)
320 self.staged = state.get('staged', [])
321 self.modified = state.get('modified', [])
322 self.unmerged = state.get('unmerged', [])
323 self.untracked = state.get('untracked', [])
324 self.submodules = state.get('submodules', set())
325 self.upstream_changed = state.get('upstream_changed', [])
327 def _update_refs(self):
328 self.set_remotes(self.git.remote().splitlines())
329 self.set_revision('')
330 self.set_local_branch('')
331 self.set_remote_branch('')
334 def _update_branch_heads(self):
335 # Set these early since they are used to calculate 'upstream_changed'.
336 self.set_trackedbranch(gitcmds.tracked_branch())
337 self.set_currentbranch(gitcmds.current_branch())
339 def _update_branches_and_tags(self):
340 local_branches, remote_branches, tags = gitcmds.all_refs(split=True)
341 self.set_local_branches(local_branches)
342 self.set_remote_branches(remote_branches)
343 self.set_tags(tags)
345 def read_font_sizes(self):
346 """Read font sizes from the configuration."""
347 value = self.cola_config('fontdiff')
348 if not value:
349 return
350 items = value.split(',')
351 if len(items) < 2:
352 return
353 self.global_cola_fontdiff_size = int(float(items[1]))
355 def set_diff_font(self, fontstr):
356 """Set the diff font string."""
357 self.global_cola_fontdiff = fontstr
358 self.read_font_sizes()
360 def delete_branch(self, branch):
361 return self.git.branch(branch,
362 D=True,
363 with_stderr=True,
364 with_status=True)
366 def revision_sha1(self, idx):
367 return self.revisions[idx]
369 def apply_diff_font_size(self, default):
370 old_font = self.cola_config('fontdiff')
371 if not old_font:
372 old_font = default
373 size = self.cola_config('fontdiff_size')
374 props = old_font.split(',')
375 props[1] = str(size)
376 new_font = ','.join(props)
377 self.global_cola_fontdiff = new_font
378 self.notify_observers('global_cola_fontdiff')
380 def stage_modified(self):
381 status, output = self.git.add(v=True,
382 with_stderr=True,
383 with_status=True,
384 *self.modified)
385 self.update_status()
386 return (status, output)
388 def stage_untracked(self):
389 status, output = self.git.add(v=True,
390 with_stderr=True,
391 with_status=True,
392 *self.untracked)
393 self.update_status()
394 return (status, output)
396 def reset(self, *items):
397 status, output = self.git.reset('--',
398 with_stderr=True,
399 with_status=True,
400 *items)
401 self.update_status()
402 return (status, output)
404 def unstage_all(self):
405 status, output = self.git.reset(with_stderr=True,
406 with_status=True)
407 self.update_status()
408 return (status, output)
410 def stage_all(self):
411 status, output = self.git.add(v=True,
412 u=True,
413 with_stderr=True,
414 with_status=True)
415 self.update_status()
416 return (status, output)
418 def config_set(self, key=None, value=None, local=True):
419 if key and value is not None:
420 # git config category.key value
421 strval = unicode(value)
422 if type(value) is bool:
423 # git uses "true" and "false"
424 strval = strval.lower()
425 if local:
426 argv = [ key, strval ]
427 else:
428 argv = [ '--global', key, strval ]
429 return self.git.config(*argv)
430 else:
431 msg = "oops in config_set(key=%s,value=%s,local=%s)"
432 raise Exception(msg % (key, value, local))
434 def config_dict(self, local=True):
435 """parses the lines from git config --list into a dictionary"""
437 kwargs = {
438 'list': True,
439 'global': not local, # global is a python keyword
441 config_lines = self.git.config(**kwargs).splitlines()
442 newdict = {}
443 for line in config_lines:
444 try:
445 k, v = line.split('=', 1)
446 except:
447 # value-less entry in .gitconfig
448 continue
449 v = core.decode(v)
450 k = k.replace('.','_') # git -> model
451 if v == 'true' or v == 'false':
452 v = bool(eval(v.title()))
453 try:
454 v = int(eval(v))
455 except:
456 pass
457 newdict[k]=v
458 return newdict
460 def commit_with_msg(self, msg, amend=False):
461 """Creates a git commit."""
463 if not msg.endswith('\n'):
464 msg += '\n'
465 # Sure, this is a potential "security risk," but if someone
466 # is trying to intercept/re-write commit messages on your system,
467 # then you probably have bigger problems to worry about.
468 tmpfile = self.tmp_filename()
470 # Create the commit message file
471 fh = open(tmpfile, 'w')
472 core.write_nointr(fh, msg)
473 fh.close()
475 # Run 'git commit'
476 status, out = self.git.commit(F=tmpfile, v=True, amend=amend,
477 with_status=True,
478 with_stderr=True)
479 os.unlink(tmpfile)
480 return (status, out)
482 def tmp_dir(self):
483 # Allow TMPDIR/TMP with a fallback to /tmp
484 return os.environ.get('TMP', os.environ.get('TMPDIR', '/tmp'))
486 def tmp_file_pattern(self):
487 return os.path.join(self.tmp_dir(), '*.git-cola.%s.*' % os.getpid())
489 def tmp_filename(self, prefix=''):
490 basename = ((prefix+'.git-cola.%s.%s'
491 % (os.getpid(), time.time())))
492 basename = basename.replace('/', '-')
493 basename = basename.replace('\\', '-')
494 tmpdir = self.tmp_dir()
495 return os.path.join(tmpdir, basename)
497 def remote_url(self, name):
498 return self.git.config('remote.%s.url' % name, get=True)
500 def remote_args(self, remote,
501 local_branch='',
502 remote_branch='',
503 ffwd=True,
504 tags=False,
505 rebase=False,
506 push=False):
507 # Swap the branches in push mode (reverse of fetch)
508 if push:
509 tmp = local_branch
510 local_branch = remote_branch
511 remote_branch = tmp
512 if ffwd:
513 branch_arg = '%s:%s' % ( remote_branch, local_branch )
514 else:
515 branch_arg = '+%s:%s' % ( remote_branch, local_branch )
516 args = [remote]
517 if local_branch and remote_branch:
518 args.append(branch_arg)
519 elif local_branch:
520 args.append(local_branch)
521 elif remote_branch:
522 args.append(remote_branch)
523 kwargs = {
524 'verbose': True,
525 'tags': tags,
526 'rebase': rebase,
527 'with_stderr': True,
528 'with_status': True,
530 return (args, kwargs)
532 def gen_remote_helper(self, gitaction, push=False):
533 """Generates a closure that calls git fetch, push or pull
535 def remote_helper(remote, **kwargs):
536 args, kwargs = self.remote_args(remote, push=push, **kwargs)
537 return gitaction(*args, **kwargs)
538 return remote_helper
540 def create_branch(self, name, base, track=False):
541 """Create a branch named 'name' from revision 'base'
543 Pass track=True to create a local tracking branch.
545 return self.git.branch(name, base, track=track,
546 with_stderr=True,
547 with_status=True)
549 def cherry_pick_list(self, revs, **kwargs):
550 """Cherry-picks each revision into the current branch.
551 Returns a list of command output strings (1 per cherry pick)"""
552 if not revs:
553 return []
554 cherries = []
555 status = 0
556 for rev in revs:
557 newstatus, out = self.git.cherry_pick(rev,
558 with_stderr=True,
559 with_status=True)
560 if status == 0:
561 status += newstatus
562 cherries.append(out)
563 return (status, '\n'.join(cherries))
565 def parse_stash_list(self, revids=False):
566 """Parses "git stash list" and returns a list of stashes."""
567 stashes = self.git.stash("list").splitlines()
568 if revids:
569 return [ s[:s.index(':')] for s in stashes ]
570 else:
571 return [ s[s.index(':')+1:] for s in stashes ]
573 def pad(self, pstr, num=22):
574 topad = num-len(pstr)
575 if topad > 0:
576 return pstr + ' '*topad
577 else:
578 return pstr
580 def is_commit_published(self):
581 head = self.git.rev_parse('HEAD')
582 return bool(self.git.branch(r=True, contains=head))
584 def everything(self):
585 """Returns a sorted list of all files, including untracked files."""
586 ls_files = self.git.ls_files(z=True,
587 cached=True,
588 others=True,
589 exclude_standard=True)
590 return sorted(map(core.decode, [f for f in ls_files.split('\0') if f]))
592 def stage_paths(self, paths):
593 """Stages add/removals to git."""
594 if not paths:
595 self.stage_all()
596 return
597 add = []
598 remove = []
599 sset = set(self.staged)
600 mset = set(self.modified)
601 umset = set(self.unmerged)
602 utset = set(self.untracked)
603 dirs = bool([p for p in paths if os.path.isdir(core.encode(p))])
605 if not dirs:
606 self.notify_message_observers(self.message_about_to_update)
608 for path in set(paths):
609 if not os.path.isdir(core.encode(path)) and path not in sset:
610 self.staged.append(path)
611 if path in umset:
612 self.unmerged.remove(path)
613 if path in mset:
614 self.modified.remove(path)
615 if path in utset:
616 self.untracked.remove(path)
617 if os.path.exists(core.encode(path)):
618 add.append(path)
619 else:
620 remove.append(path)
622 if dirs:
623 self.notify_message_observers(self.message_about_to_update)
625 elif add or remove:
626 self.staged.sort()
628 # `git add -u` doesn't work on untracked files
629 if add:
630 self.git.add('--', *add)
631 # If a path doesn't exist then that means it should be removed
632 # from the index. We use `git add -u` for that.
633 if remove:
634 self.git.add('--', u=True, *remove)
636 if dirs:
637 self._update_files()
639 self.notify_message_observers(self.message_updated)
641 def unstage_paths(self, paths):
642 if not paths:
643 self.unstage_all()
644 return
645 self.notify_message_observers(self.message_about_to_update)
647 staged_set = set(self.staged)
648 gitcmds.unstage_paths(paths)
649 all_paths_set = set(gitcmds.all_files())
650 modified = []
651 untracked = []
653 cur_modified_set = set(self.modified)
654 cur_untracked_set = set(self.untracked)
656 for path in paths:
657 if path in staged_set:
658 self.staged.remove(path)
659 if path in all_paths_set:
660 if path not in cur_modified_set:
661 modified.append(path)
662 else:
663 if path not in cur_untracked_set:
664 untracked.append(path)
666 if modified:
667 self.modified.extend(modified)
668 self.modified.sort()
670 if untracked:
671 self.untracked.extend(untracked)
672 self.untracked.sort()
674 self.notify_message_observers(self.message_updated)
676 def getcwd(self):
677 """If we've chosen a directory then use it, otherwise os.getcwd()."""
678 if self.directory:
679 return self.directory
680 return os.getcwd()
682 serializer.handlers[MainModel] = MainSerializer