refactor: eliminate `cola.prefs` package
[git-cola.git] / cola / main / model.py
blob8bd03e595b41fa711ab2143d6a85b2e9d7b1b3d3
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_head_changed = 'head_changed'
40 message_mode_about_to_change = 'mode_about_to_change'
41 message_mode_changed = 'mode_changed'
42 message_updated = 'updated'
44 # States
45 mode_none = 'none' # Default: nothing's happened, do nothing
46 mode_worktree = 'worktree' # Comparing index to worktree
47 mode_untracked = 'untracked' # Dealing with an untracked file
48 mode_index = 'index' # Comparing index to last commit
49 mode_amend = 'amend' # Amending a commit
51 # Modes where we can checkout files from the $head
52 modes_undoable = set((mode_amend, mode_index, mode_worktree))
54 # Modes where we can partially stage files
55 modes_stageable = set((mode_amend, mode_worktree, mode_untracked))
57 # Modes where we can partially unstage files
58 modes_unstageable = set((mode_amend, mode_index))
60 unstaged = property(lambda self: self.modified + self.unmerged + self.untracked)
61 """An aggregate of the modified, unmerged, and untracked file lists."""
63 def __init__(self, cwd=None):
64 """Reads git repository settings and sets several methods
65 so that they refer to the git module. This object
66 encapsulates cola's interaction with git."""
67 Observable.__init__(self)
69 # Initialize the git command object
70 self.git = git.instance()
72 self.head = 'HEAD'
73 self.diff_text = ''
74 self.mode = self.mode_none
75 self.filename = None
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_head(self, head):
141 self.head = head
142 self.notify_observers(self.message_head_changed, head)
144 def set_mode(self, mode):
145 self.notify_observers(self.message_mode_about_to_change, mode)
146 if self.amending():
147 if mode != self.mode_none:
148 return
149 self.mode = mode
150 self.notify_observers(self.message_mode_changed, mode)
152 def apply_diff(self, filename):
153 return self.git.apply(filename, index=True, cached=True)
155 def apply_diff_to_worktree(self, filename):
156 return self.git.apply(filename)
158 def prev_commitmsg(self, *args):
159 """Queries git for the latest commit message."""
160 return self.git.log('-1', no_color=True, pretty='format:%s%n%n%b',
161 *args)[STDOUT]
163 def update_file_status(self, update_index=False):
164 self.notify_observers(self.message_about_to_update)
165 self._update_files(update_index=update_index)
166 self.notify_observers(self.message_updated)
168 def update_status(self, update_index=False):
169 # Give observers a chance to respond
170 self.notify_observers(self.message_about_to_update)
171 self._update_files(update_index=update_index)
172 self._update_refs()
173 self._update_branches_and_tags()
174 self._update_branch_heads()
175 self._update_rebase_status()
176 self.notify_observers(self.message_updated)
178 def _update_files(self, update_index=False):
179 display_untracked = prefs.display_untracked()
180 state = gitcmds.worktree_state_dict(head=self.head,
181 update_index=update_index,
182 display_untracked=display_untracked)
183 self.staged = state.get('staged', [])
184 self.modified = state.get('modified', [])
185 self.unmerged = state.get('unmerged', [])
186 self.untracked = state.get('untracked', [])
187 self.submodules = state.get('submodules', set())
188 self.upstream_changed = state.get('upstream_changed', [])
190 sel = selection_model()
191 if self.is_empty():
192 sel.reset()
193 else:
194 sel.update(self)
195 if selection_model().is_empty():
196 self.set_diff_text('')
198 def is_empty(self):
199 return not(bool(self.staged or self.modified or
200 self.unmerged or self.untracked))
202 def _update_refs(self):
203 self.remotes = self.git.remote()[STDOUT].splitlines()
205 def _update_branch_heads(self):
206 # Set these early since they are used to calculate 'upstream_changed'.
207 self.currentbranch = gitcmds.current_branch()
209 def _update_branches_and_tags(self):
210 local_branches, remote_branches, tags = gitcmds.all_refs(split=True)
211 self.local_branches = local_branches
212 self.remote_branches = remote_branches
213 self.tags = tags
215 def _update_rebase_status(self):
216 self.is_rebasing = core.exists(self.git.git_path('rebase-merge'))
218 def delete_branch(self, branch):
219 return self.git.branch(branch, D=True)
221 def _sliced_op(self, input_items, map_fn):
222 """Slice input_items and call map_fn over every slice
224 This exists because of "errno: Argument list too long"
227 # This comment appeared near the top of include/linux/binfmts.h
228 # in the Linux source tree:
230 # /*
231 # * MAX_ARG_PAGES defines the number of pages allocated for arguments
232 # * and envelope for the new program. 32 should suffice, this gives
233 # * a maximum env+arg of 128kB w/4KB pages!
234 # */
235 # #define MAX_ARG_PAGES 32
237 # 'size' is a heuristic to keep things highly performant by minimizing
238 # the number of slices. If we wanted it to run as few commands as
239 # possible we could call "getconf ARG_MAX" and make a better guess,
240 # but it's probably not worth the complexity (and the extra call to
241 # getconf that we can't do on Windows anyways).
243 # In my testing, getconf ARG_MAX on Mac OS X Mountain Lion reported
244 # 262144 and Debian/Linux-x86_64 reported 2097152.
246 # The hard-coded max_arg_len value is safely below both of these
247 # real-world values.
249 max_arg_len = 32 * 4 * 1024
250 avg_filename_len = 300
251 size = max_arg_len / avg_filename_len
253 status = 0
254 outs = []
255 errs = []
257 items = copy.copy(input_items)
258 while items:
259 stat, out, err = map_fn(items[:size])
260 status = max(stat, status)
261 outs.append(out)
262 errs.append(err)
263 items = items[size:]
265 return (status, '\n'.join(outs), '\n'.join(errs))
267 def _sliced_add(self, input_items):
268 lambda_fn = lambda x: self.git.add('--', force=True, verbose=True, *x)
269 return self._sliced_op(input_items, lambda_fn)
271 def stage_modified(self):
272 status, out, err = self._sliced_add(self.modified)
273 self.update_file_status()
274 return (status, out, err)
276 def stage_untracked(self):
277 status, out, err = self._sliced_add(self.untracked)
278 self.update_file_status()
279 return (status, out, err)
281 def reset(self, *items):
282 lambda_fn = lambda x: self.git.reset('--', *x)
283 status, out, err = self._sliced_op(items, lambda_fn)
284 self.update_file_status()
285 return (status, out, err)
287 def unstage_all(self):
288 """Unstage all files, even while amending"""
289 status, out, err = self.git.reset(self.head, '--', '.')
290 self.update_file_status()
291 return (status, out, err)
293 def stage_all(self):
294 status, out, err = self.git.add(v=True, u=True)
295 self.update_file_status()
296 return (status, out, err)
298 def config_set(self, key, value, local=True):
299 # git config category.key value
300 strval = unicode(value)
301 if type(value) is bool:
302 # git uses "true" and "false"
303 strval = strval.lower()
304 if local:
305 argv = [key, strval]
306 else:
307 argv = ['--global', key, strval]
308 return self.git.config(*argv)
310 def config_dict(self, local=True):
311 """parses the lines from git config --list into a dictionary"""
313 kwargs = {
314 'list': True,
315 'global': not local, # global is a python keyword
317 config_lines = self.git.config(**kwargs)[STDOUT].splitlines()
318 newdict = {}
319 for line in config_lines:
320 try:
321 k, v = line.split('=', 1)
322 except:
323 # value-less entry in .gitconfig
324 continue
325 k = k.replace('.','_') # git -> model
326 if v == 'true' or v == 'false':
327 v = bool(eval(v.title()))
328 try:
329 v = int(eval(v))
330 except:
331 pass
332 newdict[k]=v
333 return newdict
335 def commit_with_msg(self, msg, tmpfile, amend=False):
336 """Creates a git commit."""
338 if not msg.endswith('\n'):
339 msg += '\n'
341 # Create the commit message file
342 core.write(tmpfile, msg)
344 # Run 'git commit'
345 status, out, err = self.git.commit(F=tmpfile, v=True, amend=amend)
346 core.unlink(tmpfile)
347 return (status, out, err)
349 def remote_url(self, name, action):
350 if action == 'push':
351 url = self.git.config('remote.%s.pushurl' % name,
352 get=True)[STDOUT]
353 if url:
354 return url
355 return self.git.config('remote.%s.url' % name, get=True)[STDOUT]
357 def remote_args(self, remote,
358 local_branch='',
359 remote_branch='',
360 ffwd=True,
361 tags=False,
362 rebase=False,
363 push=False):
364 # Swap the branches in push mode (reverse of fetch)
365 if push:
366 tmp = local_branch
367 local_branch = remote_branch
368 remote_branch = tmp
369 if ffwd:
370 branch_arg = '%s:%s' % (remote_branch, local_branch)
371 else:
372 branch_arg = '+%s:%s' % (remote_branch, local_branch)
373 args = [remote]
374 if local_branch and remote_branch:
375 args.append(branch_arg)
376 elif local_branch:
377 args.append(local_branch)
378 elif remote_branch:
379 args.append(remote_branch)
380 kwargs = {
381 'verbose': True,
382 'tags': tags,
383 'rebase': rebase,
385 return (args, kwargs)
387 def run_remote_action(self, action, remote, push=False, **kwargs):
388 args, kwargs = self.remote_args(remote, push=push, **kwargs)
389 return action(*args, **kwargs)
391 def fetch(self, remote, **opts):
392 return self.run_remote_action(self.git.fetch, remote, **opts)
394 def push(self, remote, **opts):
395 return self.run_remote_action(self.git.push, remote, push=True, **opts)
397 def pull(self, remote, **opts):
398 return self.run_remote_action(self.git.pull, remote, push=True, **opts)
400 def create_branch(self, name, base, track=False, force=False):
401 """Create a branch named 'name' from revision 'base'
403 Pass track=True to create a local tracking branch.
405 return self.git.branch(name, base, track=track, force=force)
407 def cherry_pick_list(self, revs, **kwargs):
408 """Cherry-picks each revision into the current branch.
409 Returns a list of command output strings (1 per cherry pick)"""
410 if not revs:
411 return []
412 outs = []
413 errs = []
414 status = 0
415 for rev in revs:
416 stat, out, err = self.git.cherry_pick(rev)
417 status = max(stat, status)
418 outs.append(out)
419 errs.append(err)
420 return (status, '\n'.join(outs), '\n'.join(errs))
422 def pad(self, pstr, num=22):
423 topad = num-len(pstr)
424 if topad > 0:
425 return pstr + ' '*topad
426 else:
427 return pstr
429 def is_commit_published(self):
430 head = self.git.rev_parse('HEAD')[STDOUT]
431 return bool(self.git.branch(r=True, contains=head)[STDOUT])
433 def everything(self):
434 """Returns a sorted list of all files, including untracked files."""
435 ls_files = self.git.ls_files(z=True,
436 cached=True,
437 others=True,
438 exclude_standard=True)[STDOUT]
439 return sorted([f for f in ls_files.split('\0') if f])
441 def stage_paths(self, paths):
442 """Stages add/removals to git."""
443 if not paths:
444 self.stage_all()
445 return
447 add = []
448 remove = []
450 for path in set(paths):
451 if core.exists(path):
452 add.append(path)
453 else:
454 remove.append(path)
456 self.notify_observers(self.message_about_to_update)
458 # `git add -u` doesn't work on untracked files
459 if add:
460 self._sliced_add(add)
462 # If a path doesn't exist then that means it should be removed
463 # from the index. We use `git add -u` for that.
464 if remove:
465 while remove:
466 self.git.add('--', u=True, *remove[:42])
467 remove = remove[42:]
469 self._update_files()
470 self.notify_observers(self.message_updated)
472 def unstage_paths(self, paths):
473 if not paths:
474 self.unstage_all()
475 return
476 gitcmds.unstage_paths(paths, head=self.head)
477 self.update_file_status()
479 def untrack_paths(self, paths):
480 status, out, err = gitcmds.untrack_paths(paths, head=self.head)
481 self.update_file_status()
482 return status, out, err
484 def getcwd(self):
485 """If we've chosen a directory then use it, otherwise os.getcwd()."""
486 if self.directory:
487 return self.directory
488 return core.getcwd()