gitcmds: Factor out parse_rev_list() and friends
[git-cola.git] / cola / models / main.py
blob65fc5b4308247e0828ba85c069bd5ac90f20b6ae
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 gitcmd
15 from cola import gitcmds
16 from cola.models.observable import ObservableModel
19 # Provides access to a global MainModel instance
20 _instance = None
21 def model():
22 """Returns the main model singleton"""
23 global _instance
24 if _instance:
25 return _instance
26 _instance = MainModel()
27 return _instance
30 class MainModel(ObservableModel):
31 """Provides a friendly wrapper for doing common git operations."""
33 # Observable messages
34 message_updated = 'updated'
35 message_about_to_update = 'about_to_update'
37 # States
38 mode_none = 'none' # Default: nothing's happened, do nothing
39 mode_worktree = 'worktree' # Comparing index to worktree
40 mode_index = 'index' # Comparing index to last commit
41 mode_amend = 'amend' # Amending a commit
42 mode_grep = 'grep' # We ran Search -> Grep
43 mode_branch = 'branch' # Applying changes from a branch
44 mode_diff = 'diff' # Diffing against an arbitrary branch
45 mode_diff_expr = 'diff_expr' # Diffing using arbitrary expression
46 mode_review = 'review' # Reviewing a branch
48 # Modes where we don't do anything like staging, etc.
49 modes_read_only = (mode_branch, mode_grep,
50 mode_diff, mode_diff_expr, mode_review)
51 # Modes where we can checkout files from the $head
52 modes_undoable = (mode_none, mode_index, mode_worktree)
54 def __init__(self, cwd=None):
55 """Reads git repository settings and sets several methods
56 so that they refer to the git module. This object
57 encapsulates cola's interaction with git."""
58 ObservableModel.__init__(self)
60 # Initialize the git command object
61 self.git = gitcmd.instance()
63 #####################################################
64 self.head = 'HEAD'
65 self.mode = self.mode_none
66 self.diff_text = ''
67 self.filename = None
68 self.currentbranch = ''
69 self.trackedbranch = ''
70 self.directory = ''
71 self.git_version = self.git.version()
72 self.remotes = []
73 self.remotename = ''
74 self.local_branch = ''
75 self.remote_branch = ''
77 #####################################################
78 # Status info
79 self.commitmsg = ''
80 self.modified = []
81 self.staged = []
82 self.unstaged = []
83 self.untracked = []
84 self.unmerged = []
85 self.upstream_changed = []
87 #####################################################
88 # Refs
89 self.revision = ''
90 self.local_branches = []
91 self.remote_branches = []
92 self.tags = []
93 self.revisions = []
94 self.summaries = []
96 self.fetch_helper = None
97 self.push_helper = None
98 self.pull_helper = None
99 self.generate_remote_helpers()
100 if cwd:
101 self.use_worktree(cwd)
103 def read_only(self):
104 return self.mode in self.modes_read_only
106 def undoable(self):
107 """Whether we can checkout files from the $head."""
108 return self.mode in self.modes_undoable
110 def enable_staging(self):
111 """Whether staging should be allowed."""
112 return self.mode == self.mode_worktree
114 def generate_remote_helpers(self):
115 """Generates helper methods for fetch, push and pull"""
116 self.push_helper = self.gen_remote_helper(self.git.push, push=True)
117 self.fetch_helper = self.gen_remote_helper(self.git.fetch)
118 self.pull_helper = self.gen_remote_helper(self.git.pull)
120 def use_worktree(self, worktree):
121 self.git.load_worktree(worktree)
122 is_valid = self.git.is_valid()
123 if is_valid:
124 self._init_config_data()
125 self.set_project(os.path.basename(self.git.worktree()))
126 return is_valid
128 def _init_config_data(self):
129 """Reads git config --list and creates parameters
130 for each setting."""
131 # These parameters are saved in .gitconfig,
132 # so ideally these should be as short as possible.
134 # config items that are controllable globally
135 # and per-repository
136 self._local_and_global_defaults = {
137 'user_name': '',
138 'user_email': '',
139 'merge_summary': False,
140 'merge_diffstat': True,
141 'merge_verbosity': 2,
142 'gui_diffcontext': 3,
143 'gui_pruneduringfetch': False,
145 # config items that are purely git config --global settings
146 self._global_defaults = {
147 'cola_geometry': '',
148 'cola_fontdiff': '',
149 'cola_fontdiff_size': 12,
150 'cola_savewindowsettings': False,
151 'cola_showoutput': 'errors',
152 'cola_tabwidth': 8,
153 'merge_keepbackup': True,
154 'diff_tool': os.getenv('GIT_DIFF_TOOL', 'xxdiff'),
155 'merge_tool': os.getenv('GIT_MERGE_TOOL', 'xxdiff'),
156 'gui_editor': os.getenv('EDITOR', 'gvim'),
157 'gui_historybrowser': 'gitk',
160 local_dict = self.config_dict(local=True)
161 global_dict = self.config_dict(local=False)
163 for k,v in local_dict.iteritems():
164 self.set_param('local_'+k, v)
165 for k,v in global_dict.iteritems():
166 self.set_param('global_'+k, v)
167 if k not in local_dict:
168 local_dict[k]=v
169 self.set_param('local_'+k, v)
171 # Bootstrap the internal font*size variables
172 for param in ('global_cola_fontdiff'):
173 setdefault = True
174 if hasattr(self, param):
175 font = getattr(self, param)
176 if font:
177 setdefault = False
178 size = int(font.split(',')[1])
179 self.set_param(param+'_size', size)
180 param = param[len('global_'):]
181 global_dict[param] = font
182 global_dict[param+'_size'] = size
184 # Load defaults for all undefined items
185 local_and_global_defaults = self._local_and_global_defaults
186 for k,v in local_and_global_defaults.iteritems():
187 if k not in local_dict:
188 self.set_param('local_'+k, v)
189 if k not in global_dict:
190 self.set_param('global_'+k, v)
192 global_defaults = self._global_defaults
193 for k,v in global_defaults.iteritems():
194 if k not in global_dict:
195 self.set_param('global_'+k, v)
197 # Load the diff context
198 self.diff_context = self.local_config('gui.diffcontext', 3)
200 def global_config(self, key, default=None):
201 return self.param('global_'+key.replace('.', '_'),
202 default=default)
204 def local_config(self, key, default=None):
205 return self.param('local_'+key.replace('.', '_'),
206 default=default)
208 def cola_config(self, key):
209 return getattr(self, 'global_cola_'+key)
211 def gui_config(self, key):
212 return getattr(self, 'global_gui_'+key)
214 def config_params(self):
215 params = []
216 params.extend(map(lambda x: 'local_' + x,
217 self._local_and_global_defaults.keys()))
218 params.extend(map(lambda x: 'global_' + x,
219 self._local_and_global_defaults.keys()))
220 params.extend(map(lambda x: 'global_' + x,
221 self._global_defaults.keys()))
222 return [ p for p in params if not p.endswith('_size') ]
224 def save_config_param(self, param):
225 if param not in self.config_params():
226 return
227 value = getattr(self, param)
228 if param == 'local_gui_diffcontext':
229 self.diff_context = value
230 if param.startswith('local_'):
231 param = param[len('local_'):]
232 is_local = True
233 elif param.startswith('global_'):
234 param = param[len('global_'):]
235 is_local = False
236 else:
237 raise Exception("Invalid param '%s' passed to " % param
238 +'save_config_param()')
239 param = param.replace('_', '.') # model -> git
240 return self.config_set(param, value, local=is_local)
242 def editor(self):
243 return self.gui_config('editor')
245 def history_browser(self):
246 return self.gui_config('historybrowser')
248 def remember_gui_settings(self):
249 return self.cola_config('savewindowsettings')
251 def all_branches(self):
252 return (self.local_branches + self.remote_branches)
254 def set_remote(self, remote):
255 if not remote:
256 return
257 self.set_param('remote', remote)
258 branches = utils.grep('%s/\S+$' % remote,
259 gitcmds.branch_list(remote=True),
260 squash=False)
261 self.set_remote_branches(branches)
263 def apply_diff(self, filename):
264 return self.git.apply(filename, index=True, cached=True)
266 def apply_diff_to_worktree(self, filename):
267 return self.git.apply(filename)
269 def load_commitmsg(self, path):
270 fh = open(path, 'r')
271 contents = core.decode(core.read_nointr(fh))
272 fh.close()
273 self.set_commitmsg(contents)
275 def prev_commitmsg(self):
276 """Queries git for the latest commit message."""
277 return core.decode(self.git.log('-1', pretty='format:%s%n%n%b'))
279 def load_commitmsg_template(self):
280 template = self.global_config('commit.template')
281 if template:
282 self.load_commitmsg(template)
284 def update_status(self):
285 # Give observers a chance to respond
286 self.notify_message_observers(self.message_about_to_update)
287 # This allows us to defer notification until the
288 # we finish processing data
289 staged_only = self.read_only()
290 head = self.head
291 notify_enabled = self.notification_enabled
292 self.notification_enabled = False
294 # Set these early since they are used to calculate 'upstream_changed'.
295 self.set_trackedbranch(gitcmds.tracked_branch())
296 self.set_currentbranch(gitcmds.current_branch())
298 (self.staged,
299 self.modified,
300 self.unmerged,
301 self.untracked,
302 self.upstream_changed) = gitcmds.worktree_state(head=head,
303 staged_only=staged_only)
304 # NOTE: the model's unstaged list holds an aggregate of the
305 # the modified, unmerged, and untracked file lists.
306 self.set_unstaged(self.modified + self.unmerged + self.untracked)
307 self.set_remotes(self.git.remote().splitlines())
308 self.set_tags(gitcmds.tag_list())
309 self.set_remote_branches(gitcmds.branch_list(remote=True))
310 self.set_local_branches(gitcmds.branch_list(remote=False))
311 self.set_revision('')
312 self.set_local_branch('')
313 self.set_remote_branch('')
314 # Re-enable notifications and emit changes
315 self.notification_enabled = notify_enabled
317 self.read_font_sizes()
318 self.notify_observers('staged', 'unstaged')
319 self.notify_message_observers(self.message_updated)
321 def read_font_sizes(self):
322 """Read font sizes from the configuration."""
323 value = self.cola_config('fontdiff')
324 if not value:
325 return
326 items = value.split(',')
327 if len(items) < 2:
328 return
329 self.global_cola_fontdiff_size = int(float(items[1]))
331 def set_diff_font(self, fontstr):
332 """Set the diff font string."""
333 self.global_cola_fontdiff = fontstr
334 self.read_font_sizes()
336 def delete_branch(self, branch):
337 return self.git.branch(branch,
338 D=True,
339 with_stderr=True,
340 with_status=True)
342 def revision_sha1(self, idx):
343 return self.revisions[idx]
345 def apply_diff_font_size(self, default):
346 old_font = self.cola_config('fontdiff')
347 if not old_font:
348 old_font = default
349 size = self.cola_config('fontdiff_size')
350 props = old_font.split(',')
351 props[1] = str(size)
352 new_font = ','.join(props)
353 self.global_cola_fontdiff = new_font
354 self.notify_observers('global_cola_fontdiff')
356 def filename(self, idx, staged=True):
357 try:
358 if staged:
359 return self.staged[idx]
360 else:
361 return self.unstaged[idx]
362 except IndexError:
363 return None
365 def stage_modified(self):
366 status, output = self.git.add(v=True,
367 with_stderr=True,
368 with_status=True,
369 *self.modified)
370 self.update_status()
371 return (status, output)
373 def stage_untracked(self):
374 status, output = self.git.add(v=True,
375 with_stderr=True,
376 with_status=True,
377 *self.untracked)
378 self.update_status()
379 return (status, output)
381 def reset(self, *items):
382 status, output = self.git.reset('--',
383 with_stderr=True,
384 with_status=True,
385 *items)
386 self.update_status()
387 return (status, output)
389 def unstage_all(self):
390 status, output = self.git.reset(with_stderr=True,
391 with_status=True)
392 self.update_status()
393 return (status, output)
395 def stage_all(self):
396 status, output = self.git.add(v=True,
397 u=True,
398 with_stderr=True,
399 with_status=True)
400 self.update_status()
401 return (status, output)
403 def config_set(self, key=None, value=None, local=True):
404 if key and value is not None:
405 # git config category.key value
406 strval = unicode(value)
407 if type(value) is bool:
408 # git uses "true" and "false"
409 strval = strval.lower()
410 if local:
411 argv = [ key, strval ]
412 else:
413 argv = [ '--global', key, strval ]
414 return self.git.config(*argv)
415 else:
416 msg = "oops in config_set(key=%s,value=%s,local=%s)"
417 raise Exception(msg % (key, value, local))
419 def config_dict(self, local=True):
420 """parses the lines from git config --list into a dictionary"""
422 kwargs = {
423 'list': True,
424 'global': not local, # global is a python keyword
426 config_lines = self.git.config(**kwargs).splitlines()
427 newdict = {}
428 for line in config_lines:
429 try:
430 k, v = line.split('=', 1)
431 except:
432 # the user has an invalid entry in their git config
433 continue
434 v = core.decode(v)
435 k = k.replace('.','_') # git -> model
436 if v == 'true' or v == 'false':
437 v = bool(eval(v.title()))
438 try:
439 v = int(eval(v))
440 except:
441 pass
442 newdict[k]=v
443 return newdict
445 def commit_with_msg(self, msg, amend=False):
446 """Creates a git commit."""
448 if not msg.endswith('\n'):
449 msg += '\n'
450 # Sure, this is a potential "security risk," but if someone
451 # is trying to intercept/re-write commit messages on your system,
452 # then you probably have bigger problems to worry about.
453 tmpfile = self.tmp_filename()
455 # Create the commit message file
456 fh = open(tmpfile, 'w')
457 core.write_nointr(fh, msg)
458 fh.close()
460 # Run 'git commit'
461 status, out = self.git.commit(F=tmpfile, v=True, amend=amend,
462 with_status=True,
463 with_stderr=True)
464 os.unlink(tmpfile)
465 return (status, out)
467 def tmp_dir(self):
468 # Allow TMPDIR/TMP with a fallback to /tmp
469 return os.environ.get('TMP', os.environ.get('TMPDIR', '/tmp'))
471 def tmp_file_pattern(self):
472 return os.path.join(self.tmp_dir(), '*.git-cola.%s.*' % os.getpid())
474 def tmp_filename(self, prefix=''):
475 basename = ((prefix+'.git-cola.%s.%s'
476 % (os.getpid(), time.time())))
477 basename = basename.replace('/', '-')
478 basename = basename.replace('\\', '-')
479 tmpdir = self.tmp_dir()
480 return os.path.join(tmpdir, basename)
482 def git_repo_path(self, *subpaths):
483 paths = [self.git.git_dir()]
484 paths.extend(subpaths)
485 return os.path.realpath(os.path.join(*paths))
487 def merge_message_path(self):
488 for basename in ('MERGE_MSG', 'SQUASH_MSG'):
489 path = self.git_repo_path(basename)
490 if os.path.exists(path):
491 return path
492 return None
494 def merge_message(self):
495 return self.git.fmt_merge_msg('--file',
496 self.git_repo_path('FETCH_HEAD'))
498 def abort_merge(self):
499 # Reset the worktree
500 output = self.git.read_tree('HEAD', reset=True, u=True, v=True)
501 # remove MERGE_HEAD
502 merge_head = self.git_repo_path('MERGE_HEAD')
503 if os.path.exists(merge_head):
504 os.unlink(merge_head)
505 # remove MERGE_MESSAGE, etc.
506 merge_msg_path = self.merge_message_path()
507 while merge_msg_path:
508 os.unlink(merge_msg_path)
509 merge_msg_path = self.merge_message_path()
511 def remote_url(self, name):
512 return self.git.config('remote.%s.url' % name, get=True)
514 def remote_args(self, remote,
515 local_branch='',
516 remote_branch='',
517 ffwd=True,
518 tags=False,
519 rebase=False,
520 push=False):
521 # Swap the branches in push mode (reverse of fetch)
522 if push:
523 tmp = local_branch
524 local_branch = remote_branch
525 remote_branch = tmp
526 if ffwd:
527 branch_arg = '%s:%s' % ( remote_branch, local_branch )
528 else:
529 branch_arg = '+%s:%s' % ( remote_branch, local_branch )
530 args = [remote]
531 if local_branch and remote_branch:
532 args.append(branch_arg)
533 elif local_branch:
534 args.append(local_branch)
535 elif remote_branch:
536 args.append(remote_branch)
537 kwargs = {
538 'verbose': True,
539 'tags': tags,
540 'rebase': rebase,
541 'with_stderr': True,
542 'with_status': True,
544 return (args, kwargs)
546 def gen_remote_helper(self, gitaction, push=False):
547 """Generates a closure that calls git fetch, push or pull
549 def remote_helper(remote, **kwargs):
550 args, kwargs = self.remote_args(remote, push=push, **kwargs)
551 return gitaction(*args, **kwargs)
552 return remote_helper
554 def create_branch(self, name, base, track=False):
555 """Create a branch named 'name' from revision 'base'
557 Pass track=True to create a local tracking branch.
559 return self.git.branch(name, base, track=track,
560 with_stderr=True,
561 with_status=True)
563 def cherry_pick_list(self, revs, **kwargs):
564 """Cherry-picks each revision into the current branch.
565 Returns a list of command output strings (1 per cherry pick)"""
566 if not revs:
567 return []
568 cherries = []
569 status = 0
570 for rev in revs:
571 newstatus, out = self.git.cherry_pick(rev,
572 with_stderr=True,
573 with_status=True)
574 if status == 0:
575 status += newstatus
576 cherries.append(out)
577 return (status, '\n'.join(cherries))
579 def parse_stash_list(self, revids=False):
580 """Parses "git stash list" and returns a list of stashes."""
581 stashes = self.git.stash("list").splitlines()
582 if revids:
583 return [ s[:s.index(':')] for s in stashes ]
584 else:
585 return [ s[s.index(':')+1:] for s in stashes ]
587 def pad(self, pstr, num=22):
588 topad = num-len(pstr)
589 if topad > 0:
590 return pstr + ' '*topad
591 else:
592 return pstr
594 def describe(self, revid, descr):
595 version = self.git.describe(revid, tags=True, always=True,
596 abbrev=4)
597 return version + ' - ' + descr
599 def update_revision_lists(self, filename=None, show_versions=False):
600 num_results = self.num_results
601 if filename:
602 rev_list = self.git.log('--', filename,
603 max_count=num_results,
604 pretty='oneline')
605 else:
606 rev_list = self.git.log(max_count=num_results,
607 pretty='oneline', all=True)
609 commit_list = self.parse_rev_list(rev_list)
610 commit_list.reverse()
611 commits = map(lambda x: x[0], commit_list)
612 descriptions = map(lambda x: core.decode(x[1]), commit_list)
613 if show_versions:
614 fancy_descr_list = map(lambda x: self.describe(*x), commit_list)
615 self.set_descriptions_start(fancy_descr_list)
616 self.set_descriptions_end(fancy_descr_list)
617 else:
618 self.set_descriptions_start(descriptions)
619 self.set_descriptions_end(descriptions)
621 self.set_revisions_start(commits)
622 self.set_revisions_end(commits)
624 return commits
626 def is_commit_published(self):
627 head = self.git.rev_parse('HEAD')
628 return bool(self.git.branch(r=True, contains=head))
630 def everything(self):
631 """Returns a sorted list of all files, including untracked files."""
632 ls_files = self.git.ls_files(z=True,
633 cached=True,
634 others=True,
635 exclude_standard=True)
636 return sorted(map(core.decode, [f for f in ls_files.split('\0') if f]))
638 def stage_paths(self, paths):
639 """Stages add/removals to git."""
640 add = []
641 remove = []
642 for path in set(paths):
643 if os.path.exists(core.encode(path)):
644 add.append(path)
645 else:
646 remove.append(path)
647 # `git add -u` doesn't work on untracked files
648 if add:
649 self.git.add('--', *add)
650 # If a path doesn't exist then that means it should be removed
651 # from the index. We use `git add -u` for that.
652 if remove:
653 self.git.add('--', u=True, *remove)
654 self.update_status()
656 def getcwd(self):
657 """If we've chosen a directory then use it, otherwise os.getcwd()."""
658 if self.directory:
659 return self.directory
660 return os.getcwd()