models.main: manage 'head' from within set_mode() only
[git-cola.git] / cola / models / main.py
blob28064888cda9f1ab4d49dbcceb8ca53d504097d0
1 # Copyright (c) 2008 David Aguilar
2 """This module provides the central cola model.
3 """
5 import os
6 import copy
8 from cola import core
9 from cola import git
10 from cola import gitcfg
11 from cola import gitcmds
12 from cola.compat import set
13 from cola.git import STDOUT
14 from cola.observable import Observable
15 from cola.decorators import memoize
16 from cola.models.selection import selection_model
17 from cola.models import prefs
20 # Static GitConfig instance
21 _config = gitcfg.instance()
24 @memoize
25 def model():
26 """Returns the main model singleton"""
27 return MainModel()
30 class MainModel(Observable):
31 """Provides a friendly wrapper for doing common git operations."""
33 # Observable messages
34 message_about_to_update = 'about_to_update'
35 message_commit_message_changed = 'commit_message_changed'
36 message_diff_text_changed = 'diff_text_changed'
37 message_directory_changed = 'directory_changed'
38 message_filename_changed = 'filename_changed'
39 message_mode_about_to_change = 'mode_about_to_change'
40 message_mode_changed = 'mode_changed'
41 message_updated = 'updated'
43 # States
44 mode_none = 'none' # Default: nothing's happened, do nothing
45 mode_worktree = 'worktree' # Comparing index to worktree
46 mode_untracked = 'untracked' # Dealing with an untracked file
47 mode_index = 'index' # Comparing index to last commit
48 mode_amend = 'amend' # Amending a commit
50 # Modes where we can checkout files from the $head
51 modes_undoable = set((mode_amend, mode_index, mode_worktree))
53 # Modes where we can partially stage files
54 modes_stageable = set((mode_amend, mode_worktree, mode_untracked))
56 # Modes where we can partially unstage files
57 modes_unstageable = set((mode_amend, mode_index))
59 unstaged = property(lambda self: self.modified + self.unmerged + self.untracked)
60 """An aggregate of the modified, unmerged, and untracked file lists."""
62 def __init__(self, cwd=None):
63 """Reads git repository settings and sets several methods
64 so that they refer to the git module. This object
65 encapsulates cola's interaction with git."""
66 Observable.__init__(self)
68 # Initialize the git command object
69 self.git = git.instance()
71 self.head = 'HEAD'
72 self.diff_text = ''
73 self.mode = self.mode_none
74 self.filename = None
75 self.is_merging = False
76 self.is_rebasing = False
77 self.currentbranch = ''
78 self.directory = ''
79 self.project = ''
80 self.remotes = []
82 self.commitmsg = ''
83 self.modified = []
84 self.staged = []
85 self.untracked = []
86 self.unmerged = []
87 self.upstream_changed = []
88 self.submodules = set()
90 self.local_branches = []
91 self.remote_branches = []
92 self.tags = []
93 if cwd:
94 self.set_worktree(cwd)
96 def unstageable(self):
97 return self.mode in self.modes_unstageable
99 def amending(self):
100 return self.mode == self.mode_amend
102 def undoable(self):
103 """Whether we can checkout files from the $head."""
104 return self.mode in self.modes_undoable
106 def stageable(self):
107 """Whether staging should be allowed."""
108 return self.mode in self.modes_stageable
110 def all_branches(self):
111 return (self.local_branches + self.remote_branches)
113 def set_worktree(self, worktree):
114 self.git.set_worktree(worktree)
115 is_valid = self.git.is_valid()
116 if is_valid:
117 self.project = os.path.basename(self.git.worktree())
118 return is_valid
120 def set_commitmsg(self, msg):
121 self.commitmsg = msg
122 self.notify_observers(self.message_commit_message_changed, msg)
124 def save_commitmsg(self, msg):
125 path = self.git.git_path('GIT_COLA_MSG')
126 core.write(path, msg)
128 def set_diff_text(self, txt):
129 self.diff_text = txt
130 self.notify_observers(self.message_diff_text_changed, txt)
132 def set_directory(self, path):
133 self.directory = path
134 self.notify_observers(self.message_directory_changed, path)
136 def set_filename(self, filename):
137 self.filename = filename
138 self.notify_observers(self.message_filename_changed, filename)
140 def set_mode(self, mode):
141 if self.amending():
142 if mode != self.mode_none:
143 return
144 if self.is_merging and mode == self.mode_amend:
145 mode = self.mode
146 if mode == self.mode_amend:
147 head = 'HEAD^'
148 else:
149 head = 'HEAD'
150 self.notify_observers(self.message_mode_about_to_change, mode)
151 self.head = head
152 self.mode = mode
153 self.notify_observers(self.message_mode_changed, mode)
155 def apply_diff(self, filename):
156 return self.git.apply(filename, index=True, cached=True)
158 def apply_diff_to_worktree(self, filename):
159 return self.git.apply(filename)
161 def prev_commitmsg(self, *args):
162 """Queries git for the latest commit message."""
163 return self.git.log('-1', no_color=True, pretty='format:%s%n%n%b',
164 *args)[STDOUT]
166 def update_file_status(self, update_index=False):
167 self.notify_observers(self.message_about_to_update)
168 self._update_files(update_index=update_index)
169 self.notify_observers(self.message_updated)
171 def update_status(self, update_index=False):
172 # Give observers a chance to respond
173 self.notify_observers(self.message_about_to_update)
174 self._update_files(update_index=update_index)
175 self._update_refs()
176 self._update_branches_and_tags()
177 self._update_branch_heads()
178 self._update_merge_rebase_status()
179 self.notify_observers(self.message_updated)
181 def _update_files(self, update_index=False):
182 display_untracked = prefs.display_untracked()
183 state = gitcmds.worktree_state_dict(head=self.head,
184 update_index=update_index,
185 display_untracked=display_untracked)
186 self.staged = state.get('staged', [])
187 self.modified = state.get('modified', [])
188 self.unmerged = state.get('unmerged', [])
189 self.untracked = state.get('untracked', [])
190 self.submodules = state.get('submodules', set())
191 self.upstream_changed = state.get('upstream_changed', [])
193 sel = selection_model()
194 if self.is_empty():
195 sel.reset()
196 else:
197 sel.update(self)
198 if selection_model().is_empty():
199 self.set_diff_text('')
201 def is_empty(self):
202 return not(bool(self.staged or self.modified or
203 self.unmerged or self.untracked))
205 def _update_refs(self):
206 self.remotes = self.git.remote()[STDOUT].splitlines()
208 def _update_branch_heads(self):
209 # Set these early since they are used to calculate 'upstream_changed'.
210 self.currentbranch = gitcmds.current_branch()
212 def _update_branches_and_tags(self):
213 local_branches, remote_branches, tags = gitcmds.all_refs(split=True)
214 self.local_branches = local_branches
215 self.remote_branches = remote_branches
216 self.tags = tags
218 def _update_merge_rebase_status(self):
219 self.is_merging = core.exists(self.git.git_path('MERGE_HEAD'))
220 self.is_rebasing = core.exists(self.git.git_path('rebase-merge'))
222 def delete_branch(self, branch):
223 return self.git.branch(branch, D=True)
225 def _sliced_op(self, input_items, map_fn):
226 """Slice input_items and call map_fn over every slice
228 This exists because of "errno: Argument list too long"
231 # This comment appeared near the top of include/linux/binfmts.h
232 # in the Linux source tree:
234 # /*
235 # * MAX_ARG_PAGES defines the number of pages allocated for arguments
236 # * and envelope for the new program. 32 should suffice, this gives
237 # * a maximum env+arg of 128kB w/4KB pages!
238 # */
239 # #define MAX_ARG_PAGES 32
241 # 'size' is a heuristic to keep things highly performant by minimizing
242 # the number of slices. If we wanted it to run as few commands as
243 # possible we could call "getconf ARG_MAX" and make a better guess,
244 # but it's probably not worth the complexity (and the extra call to
245 # getconf that we can't do on Windows anyways).
247 # In my testing, getconf ARG_MAX on Mac OS X Mountain Lion reported
248 # 262144 and Debian/Linux-x86_64 reported 2097152.
250 # The hard-coded max_arg_len value is safely below both of these
251 # real-world values.
253 max_arg_len = 32 * 4 * 1024
254 avg_filename_len = 300
255 size = max_arg_len / avg_filename_len
257 status = 0
258 outs = []
259 errs = []
261 items = copy.copy(input_items)
262 while items:
263 stat, out, err = map_fn(items[:size])
264 status = max(stat, status)
265 outs.append(out)
266 errs.append(err)
267 items = items[size:]
269 return (status, '\n'.join(outs), '\n'.join(errs))
271 def _sliced_add(self, input_items):
272 lambda_fn = lambda x: self.git.add('--', force=True, verbose=True, *x)
273 return self._sliced_op(input_items, lambda_fn)
275 def stage_modified(self):
276 status, out, err = self._sliced_add(self.modified)
277 self.update_file_status()
278 return (status, out, err)
280 def stage_untracked(self):
281 status, out, err = self._sliced_add(self.untracked)
282 self.update_file_status()
283 return (status, out, err)
285 def reset(self, *items):
286 lambda_fn = lambda x: self.git.reset('--', *x)
287 status, out, err = self._sliced_op(items, lambda_fn)
288 self.update_file_status()
289 return (status, out, err)
291 def unstage_all(self):
292 """Unstage all files, even while amending"""
293 status, out, err = self.git.reset(self.head, '--', '.')
294 self.update_file_status()
295 return (status, out, err)
297 def stage_all(self):
298 status, out, err = self.git.add(v=True, u=True)
299 self.update_file_status()
300 return (status, out, err)
302 def config_set(self, key, value, local=True):
303 # git config category.key value
304 strval = unicode(value)
305 if type(value) is bool:
306 # git uses "true" and "false"
307 strval = strval.lower()
308 if local:
309 argv = [key, strval]
310 else:
311 argv = ['--global', key, strval]
312 return self.git.config(*argv)
314 def config_dict(self, local=True):
315 """parses the lines from git config --list into a dictionary"""
317 kwargs = {
318 'list': True,
319 'global': not local, # global is a python keyword
321 config_lines = self.git.config(**kwargs)[STDOUT].splitlines()
322 newdict = {}
323 for line in config_lines:
324 try:
325 k, v = line.split('=', 1)
326 except:
327 # value-less entry in .gitconfig
328 continue
329 k = k.replace('.','_') # git -> model
330 if v == 'true' or v == 'false':
331 v = bool(eval(v.title()))
332 try:
333 v = int(eval(v))
334 except:
335 pass
336 newdict[k]=v
337 return newdict
339 def commit_with_msg(self, msg, tmpfile, amend=False):
340 """Creates a git commit."""
342 if not msg.endswith('\n'):
343 msg += '\n'
345 # Create the commit message file
346 core.write(tmpfile, msg)
348 # Run 'git commit'
349 status, out, err = self.git.commit(F=tmpfile, v=True, amend=amend)
350 core.unlink(tmpfile)
351 return (status, out, err)
353 def remote_url(self, name, action):
354 if action == 'push':
355 url = self.git.config('remote.%s.pushurl' % name,
356 get=True)[STDOUT]
357 if url:
358 return url
359 return self.git.config('remote.%s.url' % name, get=True)[STDOUT]
361 def remote_args(self, remote,
362 local_branch='',
363 remote_branch='',
364 ffwd=True,
365 tags=False,
366 rebase=False,
367 push=False):
368 # Swap the branches in push mode (reverse of fetch)
369 if push:
370 tmp = local_branch
371 local_branch = remote_branch
372 remote_branch = tmp
373 if ffwd:
374 branch_arg = '%s:%s' % (remote_branch, local_branch)
375 else:
376 branch_arg = '+%s:%s' % (remote_branch, local_branch)
377 args = [remote]
378 if local_branch and remote_branch:
379 args.append(branch_arg)
380 elif local_branch:
381 args.append(local_branch)
382 elif remote_branch:
383 args.append(remote_branch)
384 kwargs = {
385 'verbose': True,
386 'tags': tags,
387 'rebase': rebase,
389 return (args, kwargs)
391 def run_remote_action(self, action, remote, push=False, **kwargs):
392 args, kwargs = self.remote_args(remote, push=push, **kwargs)
393 return action(*args, **kwargs)
395 def fetch(self, remote, **opts):
396 return self.run_remote_action(self.git.fetch, remote, **opts)
398 def push(self, remote, **opts):
399 return self.run_remote_action(self.git.push, remote, push=True, **opts)
401 def pull(self, remote, **opts):
402 return self.run_remote_action(self.git.pull, remote, push=True, **opts)
404 def create_branch(self, name, base, track=False, force=False):
405 """Create a branch named 'name' from revision 'base'
407 Pass track=True to create a local tracking branch.
409 return self.git.branch(name, base, track=track, force=force)
411 def cherry_pick_list(self, revs, **kwargs):
412 """Cherry-picks each revision into the current branch.
413 Returns a list of command output strings (1 per cherry pick)"""
414 if not revs:
415 return []
416 outs = []
417 errs = []
418 status = 0
419 for rev in revs:
420 stat, out, err = self.git.cherry_pick(rev)
421 status = max(stat, status)
422 outs.append(out)
423 errs.append(err)
424 return (status, '\n'.join(outs), '\n'.join(errs))
426 def pad(self, pstr, num=22):
427 topad = num-len(pstr)
428 if topad > 0:
429 return pstr + ' '*topad
430 else:
431 return pstr
433 def is_commit_published(self):
434 head = self.git.rev_parse('HEAD')[STDOUT]
435 return bool(self.git.branch(r=True, contains=head)[STDOUT])
437 def everything(self):
438 """Returns a sorted list of all files, including untracked files."""
439 ls_files = self.git.ls_files(z=True,
440 cached=True,
441 others=True,
442 exclude_standard=True)[STDOUT]
443 return sorted([f for f in ls_files.split('\0') if f])
445 def stage_paths(self, paths):
446 """Stages add/removals to git."""
447 if not paths:
448 self.stage_all()
449 return
451 add = []
452 remove = []
454 for path in set(paths):
455 if core.exists(path):
456 add.append(path)
457 else:
458 remove.append(path)
460 self.notify_observers(self.message_about_to_update)
462 # `git add -u` doesn't work on untracked files
463 if add:
464 self._sliced_add(add)
466 # If a path doesn't exist then that means it should be removed
467 # from the index. We use `git add -u` for that.
468 if remove:
469 while remove:
470 self.git.add('--', u=True, *remove[:42])
471 remove = remove[42:]
473 self._update_files()
474 self.notify_observers(self.message_updated)
476 def unstage_paths(self, paths):
477 if not paths:
478 self.unstage_all()
479 return
480 gitcmds.unstage_paths(paths, head=self.head)
481 self.update_file_status()
483 def untrack_paths(self, paths):
484 status, out, err = gitcmds.untrack_paths(paths, head=self.head)
485 self.update_file_status()
486 return status, out, err
488 def getcwd(self):
489 """If we've chosen a directory then use it, otherwise os.getcwd()."""
490 if self.directory:
491 return self.directory
492 return core.getcwd()