doc: add Ved to the credits
[git-cola.git] / cola / models / main.py
blob82ef542d4095c9191f86dc45d4e1c35975473f26
1 # Copyright (c) 2008 David Aguilar
2 """This module provides the central cola model.
3 """
4 from __future__ import division, absolute_import, unicode_literals
6 import copy
7 import os
9 from .. import core
10 from .. import git
11 from .. import gitcmds
12 from .. import gitcfg
13 from ..git import STDOUT
14 from ..observable import Observable
15 from ..decorators import memoize
16 from ..models.selection import selection_model
17 from ..models import prefs
18 from ..compat import ustr
21 @memoize
22 def model():
23 """Returns the main model singleton"""
24 return MainModel()
27 class MainModel(Observable):
28 """Provides a friendly wrapper for doing common git operations."""
30 # Observable messages
31 message_about_to_update = 'about_to_update'
32 message_commit_message_changed = 'commit_message_changed'
33 message_diff_text_changed = 'diff_text_changed'
34 message_filename_changed = 'filename_changed'
35 message_mode_about_to_change = 'mode_about_to_change'
36 message_mode_changed = 'mode_changed'
37 message_updated = 'updated'
39 # States
40 mode_none = 'none' # Default: nothing's happened, do nothing
41 mode_worktree = 'worktree' # Comparing index to worktree
42 mode_untracked = 'untracked' # Dealing with an untracked file
43 mode_index = 'index' # Comparing index to last commit
44 mode_amend = 'amend' # Amending a commit
46 # Modes where we can checkout files from the $head
47 modes_undoable = set((mode_amend, mode_index, mode_worktree))
49 # Modes where we can partially stage files
50 modes_stageable = set((mode_amend, mode_worktree, mode_untracked))
52 # Modes where we can partially unstage files
53 modes_unstageable = set((mode_amend, mode_index))
55 unstaged = property(
56 lambda self: self.modified + self.unmerged + self.untracked)
57 """An aggregate of the modified, unmerged, and untracked file lists."""
59 def __init__(self, cwd=None):
60 """Reads git repository settings and sets several methods
61 so that they refer to the git module. This object
62 encapsulates cola's interaction with git."""
63 Observable.__init__(self)
65 # Initialize the git command object
66 self.git = git.current()
68 self.head = 'HEAD'
69 self.diff_text = ''
70 self.mode = self.mode_none
71 self.filename = None
72 self.is_merging = False
73 self.is_rebasing = False
74 self.currentbranch = ''
75 self.directory = ''
76 self.project = ''
77 self.remotes = []
78 self.filter_paths = None
80 self.commitmsg = '' # current commit message
81 self._auto_commitmsg = '' # e.g. .git/MERGE_MSG
82 self._prev_commitmsg = '' # saved here when clobbered by .git/MERGE_MSG
84 self.modified = [] # modified, staged, untracked, unmerged paths
85 self.staged = []
86 self.untracked = []
87 self.unmerged = []
88 self.upstream_changed = [] # paths that've changed upstream
89 self.staged_deleted = set()
90 self.unstaged_deleted = set()
91 self.submodules = set()
93 self.local_branches = []
94 self.remote_branches = []
95 self.tags = []
96 if cwd:
97 self.set_worktree(cwd)
99 def unstageable(self):
100 return self.mode in self.modes_unstageable
102 def amending(self):
103 return self.mode == self.mode_amend
105 def undoable(self):
106 """Whether we can checkout files from the $head."""
107 return self.mode in self.modes_undoable
109 def stageable(self):
110 """Whether staging should be allowed."""
111 return self.mode in self.modes_stageable
113 def all_branches(self):
114 return (self.local_branches + self.remote_branches)
116 def set_worktree(self, worktree):
117 self.git.set_worktree(worktree)
118 is_valid = self.git.is_valid()
119 if is_valid:
120 cwd = self.git.getcwd()
121 self.project = os.path.basename(cwd)
122 self.set_directory(cwd)
123 core.chdir(cwd)
124 gitcfg.current().reset()
125 return is_valid
127 def set_commitmsg(self, msg, notify=True):
128 self.commitmsg = msg
129 if notify:
130 self.notify_observers(self.message_commit_message_changed, msg)
132 def save_commitmsg(self, msg):
133 path = self.git.git_path('GIT_COLA_MSG')
134 try:
135 core.write(path, msg)
136 except:
137 pass
139 def set_diff_text(self, txt):
140 self.diff_text = txt
141 self.notify_observers(self.message_diff_text_changed, txt)
143 def set_directory(self, path):
144 self.directory = path
146 def set_filename(self, filename):
147 self.filename = filename
148 self.notify_observers(self.message_filename_changed, filename)
150 def set_mode(self, mode):
151 if self.amending():
152 if mode != self.mode_none:
153 return
154 if self.is_merging and mode == self.mode_amend:
155 mode = self.mode
156 if mode == self.mode_amend:
157 head = 'HEAD^'
158 else:
159 head = 'HEAD'
160 self.notify_observers(self.message_mode_about_to_change, mode)
161 self.head = head
162 self.mode = mode
163 self.notify_observers(self.message_mode_changed, mode)
165 def apply_diff(self, filename):
166 return self.git.apply(filename, index=True, cached=True)
168 def apply_diff_to_worktree(self, filename):
169 return self.git.apply(filename)
171 def prev_commitmsg(self, *args):
172 """Queries git for the latest commit message."""
173 return self.git.log('-1', no_color=True, pretty='format:%s%n%n%b',
174 *args)[STDOUT]
176 def update_path_filter(self, filter_paths):
177 self.filter_paths = filter_paths
178 self.update_file_status()
180 def update_file_status(self, update_index=False):
181 self.notify_observers(self.message_about_to_update)
182 self._update_files(update_index=update_index)
183 self.notify_observers(self.message_updated)
185 def update_status(self, update_index=False):
186 # Give observers a chance to respond
187 self.notify_observers(self.message_about_to_update)
188 self._update_merge_rebase_status()
189 self._update_files(update_index=update_index)
190 self._update_remotes()
191 self._update_branches_and_tags()
192 self._update_branch_heads()
193 self._update_commitmsg()
194 self.notify_observers(self.message_updated)
196 def _update_files(self, update_index=False):
197 display_untracked = prefs.display_untracked()
198 state = gitcmds.worktree_state(head=self.head,
199 update_index=update_index,
200 display_untracked=display_untracked,
201 paths=self.filter_paths)
202 self.staged = state.get('staged', [])
203 self.modified = state.get('modified', [])
204 self.unmerged = state.get('unmerged', [])
205 self.untracked = state.get('untracked', [])
206 self.upstream_changed = state.get('upstream_changed', [])
207 self.staged_deleted = state.get('staged_deleted', set())
208 self.unstaged_deleted = state.get('unstaged_deleted', set())
209 self.submodules = state.get('submodules', set())
211 sel = selection_model()
212 if self.is_empty():
213 sel.reset()
214 else:
215 sel.update(self)
216 if selection_model().is_empty():
217 self.set_diff_text('')
219 def is_empty(self):
220 return not(bool(self.staged or self.modified or
221 self.unmerged or self.untracked))
223 def is_empty_repository(self):
224 return not self.local_branches
226 def _update_remotes(self):
227 self.remotes = self.git.remote()[STDOUT].splitlines()
229 def _update_branch_heads(self):
230 # Set these early since they are used to calculate 'upstream_changed'.
231 self.currentbranch = gitcmds.current_branch()
233 def _update_branches_and_tags(self):
234 local_branches, remote_branches, tags = gitcmds.all_refs(split=True)
235 self.local_branches = local_branches
236 self.remote_branches = remote_branches
237 self.tags = tags
239 def _update_merge_rebase_status(self):
240 self.is_merging = core.exists(self.git.git_path('MERGE_HEAD'))
241 self.is_rebasing = core.exists(self.git.git_path('rebase-merge'))
242 if self.is_merging and self.mode == self.mode_amend:
243 self.set_mode(self.mode_none)
245 def _update_commitmsg(self):
246 """Check for merge message files and update the commit message
248 The message is cleared when the merge completes
251 if self.amending():
252 return
253 # Check if there's a message file in .git/
254 merge_msg_path = gitcmds.merge_message_path()
255 if merge_msg_path:
256 msg = core.read(merge_msg_path)
257 if msg != self._auto_commitmsg:
258 self._auto_commitmsg = msg
259 self._prev_commitmsg = self.commitmsg
260 self.set_commitmsg(msg)
262 elif self._auto_commitmsg and self._auto_commitmsg == self.commitmsg:
263 self._auto_commitmsg = ''
264 self.set_commitmsg(self._prev_commitmsg)
266 def update_remotes(self):
267 self._update_remotes()
268 self._update_branches_and_tags()
270 def delete_branch(self, branch):
271 status, out, err = self.git.branch(branch, D=True)
272 self._update_branches_and_tags()
273 return status, out, err
275 def rename_branch(self, branch, new_branch):
276 status, out, err = self.git.branch(branch, new_branch, M=True)
277 self.notify_observers(self.message_about_to_update)
278 self._update_branches_and_tags()
279 self._update_branch_heads()
280 self.notify_observers(self.message_updated)
281 return status, out, err
283 def _sliced_op(self, input_items, map_fn):
284 """Slice input_items and call map_fn over every slice
286 This exists because of "errno: Argument list too long"
289 # This comment appeared near the top of include/linux/binfmts.h
290 # in the Linux source tree:
292 # /*
293 # * MAX_ARG_PAGES defines the number of pages allocated for arguments
294 # * and envelope for the new program. 32 should suffice, this gives
295 # * a maximum env+arg of 128kB w/4KB pages!
296 # */
297 # #define MAX_ARG_PAGES 32
299 # 'size' is a heuristic to keep things highly performant by minimizing
300 # the number of slices. If we wanted it to run as few commands as
301 # possible we could call "getconf ARG_MAX" and make a better guess,
302 # but it's probably not worth the complexity (and the extra call to
303 # getconf that we can't do on Windows anyways).
305 # In my testing, getconf ARG_MAX on Mac OS X Mountain Lion reported
306 # 262144 and Debian/Linux-x86_64 reported 2097152.
308 # The hard-coded max_arg_len value is safely below both of these
309 # real-world values.
311 max_arg_len = 32 * 4 * 1024
312 avg_filename_len = 300
313 size = max_arg_len // avg_filename_len
315 status = 0
316 outs = []
317 errs = []
319 items = copy.copy(input_items)
320 while items:
321 stat, out, err = map_fn(items[:size])
322 status = max(stat, status)
323 outs.append(out)
324 errs.append(err)
325 items = items[size:]
327 return (status, '\n'.join(outs), '\n'.join(errs))
329 def _sliced_add(self, items):
330 add = self.git.add
331 return self._sliced_op(
332 items, lambda x: add('--', force=True, verbose=True, *x))
334 def stage_modified(self):
335 status, out, err = self._sliced_add(self.modified)
336 self.update_file_status()
337 return (status, out, err)
339 def stage_untracked(self):
340 status, out, err = self._sliced_add(self.untracked)
341 self.update_file_status()
342 return (status, out, err)
344 def reset(self, *items):
345 reset = self.git.reset
346 status, out, err = self._sliced_op(items, lambda x: reset('--', *x))
347 self.update_file_status()
348 return (status, out, err)
350 def unstage_all(self):
351 """Unstage all files, even while amending"""
352 status, out, err = self.git.reset(self.head, '--', '.')
353 self.update_file_status()
354 return (status, out, err)
356 def stage_all(self):
357 status, out, err = self.git.add(v=True, u=True)
358 self.update_file_status()
359 return (status, out, err)
361 def config_set(self, key, value, local=True):
362 # git config category.key value
363 strval = ustr(value)
364 if type(value) is bool:
365 # git uses "true" and "false"
366 strval = strval.lower()
367 if local:
368 argv = [key, strval]
369 else:
370 argv = ['--global', key, strval]
371 return self.git.config(*argv)
373 def config_dict(self, local=True):
374 """parses the lines from git config --list into a dictionary"""
376 kwargs = {
377 'list': True,
378 'global': not local, # global is a python keyword
380 config_lines = self.git.config(**kwargs)[STDOUT].splitlines()
381 newdict = {}
382 for line in config_lines:
383 try:
384 k, v = line.split('=', 1)
385 except:
386 # value-less entry in .gitconfig
387 continue
388 k = k.replace('.', '_') # git -> model
389 if v == 'true' or v == 'false':
390 v = bool(eval(v.title()))
391 try:
392 v = int(eval(v))
393 except:
394 pass
395 newdict[k] = v
396 return newdict
398 def remote_url(self, name, action):
399 if action == 'push':
400 url = self.git.config('remote.%s.pushurl' % name,
401 get=True)[STDOUT]
402 if url:
403 return url
404 return self.git.config('remote.%s.url' % name, get=True)[STDOUT]
406 def fetch(self, remote, **opts):
407 return run_remote_action(self.git.fetch, remote, **opts)
409 def push(self, remote, remote_branch='', local_branch='', **opts):
410 # Swap the branches in push mode (reverse of fetch)
411 opts.update(dict(local_branch=remote_branch,
412 remote_branch=local_branch))
413 return run_remote_action(self.git.push, remote, **opts)
415 def pull(self, remote, **opts):
416 return run_remote_action(self.git.pull, remote, pull=True, **opts)
418 def create_branch(self, name, base, track=False, force=False):
419 """Create a branch named 'name' from revision 'base'
421 Pass track=True to create a local tracking branch.
423 return self.git.branch(name, base, track=track, force=force)
425 def cherry_pick_list(self, revs, **kwargs):
426 """Cherry-picks each revision into the current branch.
427 Returns a list of command output strings (1 per cherry pick)"""
428 if not revs:
429 return []
430 outs = []
431 errs = []
432 status = 0
433 for rev in revs:
434 stat, out, err = self.git.cherry_pick(rev)
435 status = max(stat, status)
436 outs.append(out)
437 errs.append(err)
438 return (status, '\n'.join(outs), '\n'.join(errs))
440 def pad(self, pstr, num=22):
441 topad = num-len(pstr)
442 if topad > 0:
443 return pstr + ' '*topad
444 else:
445 return pstr
447 def is_commit_published(self):
448 head = self.git.rev_parse('HEAD')[STDOUT]
449 return bool(self.git.branch(r=True, contains=head)[STDOUT])
451 def stage_paths(self, paths):
452 """Stages add/removals to git."""
453 if not paths:
454 self.stage_all()
455 return
457 add = []
458 remove = []
460 for path in set(paths):
461 if core.exists(path):
462 add.append(path)
463 else:
464 remove.append(path)
466 self.notify_observers(self.message_about_to_update)
468 # `git add -u` doesn't work on untracked files
469 if add:
470 self._sliced_add(add)
472 # If a path doesn't exist then that means it should be removed
473 # from the index. We use `git add -u` for that.
474 if remove:
475 while remove:
476 self.git.add('--', u=True, *remove[:42])
477 remove = remove[42:]
479 self._update_files()
480 self.notify_observers(self.message_updated)
482 def unstage_paths(self, paths):
483 if not paths:
484 self.unstage_all()
485 return
486 gitcmds.unstage_paths(paths, head=self.head)
487 self.update_file_status()
489 def untrack_paths(self, paths):
490 status, out, err = gitcmds.untrack_paths(paths, head=self.head)
491 self.update_file_status()
492 return status, out, err
494 def getcwd(self):
495 """If we've chosen a directory then use it, otherwise use current"""
496 if self.directory:
497 return self.directory
498 return core.getcwd()
501 # Helpers
502 def remote_args(remote,
503 local_branch='',
504 remote_branch='',
505 ffwd=True,
506 tags=False,
507 rebase=False,
508 pull=False):
509 """Return arguments for git fetch/push/pull"""
511 args = [remote]
512 what = refspec_arg(local_branch, remote_branch, ffwd, pull)
513 if what:
514 args.append(what)
515 kwargs = {
516 'verbose': True,
517 'tags': tags,
518 'rebase': rebase,
520 return (args, kwargs)
523 def refspec(src, dst, ffwd):
524 spec = '%s:%s' % (src, dst)
525 if not ffwd:
526 spec = '+' + spec
527 return spec
530 def refspec_arg(local_branch, remote_branch, ffwd, pull):
531 """Return the refspec for a fetch or pull command"""
532 if not pull and local_branch and remote_branch:
533 what = refspec(remote_branch, local_branch, ffwd)
534 else:
535 what = local_branch or remote_branch or None
536 return what
539 def run_remote_action(action, remote, **kwargs):
540 args, kwargs = remote_args(remote, **kwargs)
541 return action(*args, **kwargs)