1 # Copyright (c) 2008 David Aguilar
2 """This module provides the central cola model.
9 from cStringIO
import StringIO
12 from cola
import utils
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
29 """Returns the main model singleton"""
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."""
43 message_updated
= 'updated'
44 message_about_to_update
= 'about_to_update'
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 #####################################################
77 self
.mode
= self
.mode_none
80 self
.currentbranch
= ''
81 self
.trackedbranch
= ''
83 self
.git_version
= self
.git
.version()
86 self
.local_branch
= ''
87 self
.remote_branch
= ''
89 #####################################################
96 self
.upstream_changed
= []
97 self
.submodules
= set()
99 #####################################################
102 self
.local_branches
= []
103 self
.remote_branches
= []
108 self
.fetch_helper
= None
109 self
.push_helper
= None
110 self
.pull_helper
= None
111 self
.generate_remote_helpers()
113 self
.use_worktree(cwd
)
115 #####################################################
120 return self
.mode
in self
.modes_read_only
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()
140 self
._init
_config
_data
()
141 self
.set_project(os
.path
.basename(self
.git
.worktree()))
144 def _init_config_data(self
):
145 """Reads git config --list and creates parameters
147 # These parameters are saved in .gitconfig,
148 # so ideally these should be as short as possible.
150 # config items that are controllable globally
152 self
._local
_and
_global
_defaults
= {
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
= {
165 'cola_fontdiff_size': 12,
166 'cola_savewindowsettings': True,
167 'cola_showoutput': 'errors',
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
):
178 for k
, v
in dct
.iteritems():
179 underscore
[k
.replace('.', '_')] = v
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
:
192 self
.set_param('local_'+k
, v
)
194 # Bootstrap the internal font*size variables
195 for param
in ('global_cola_fontdiff'):
197 if hasattr(self
, param
):
198 font
= getattr(self
, param
)
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('.', '_'),
227 def local_config(self
, key
, default
=None):
228 return self
.param('local_'+key
.replace('.', '_'),
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
):
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():
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_'):]
256 elif param
.startswith('global_'):
257 param
= param
[len('global_'):]
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
)
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
):
281 self
.set_param('remote', remote
)
282 branches
= utils
.grep('%s/\S+$' % remote
,
283 gitcmds
.branch_list(remote
=True),
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
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
)
345 def read_font_sizes(self
):
346 """Read font sizes from the configuration."""
347 value
= self
.cola_config('fontdiff')
350 items
= value
.split(',')
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
,
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')
373 size
= self
.cola_config('fontdiff_size')
374 props
= old_font
.split(',')
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,
386 return (status
, output
)
388 def stage_untracked(self
):
389 status
, output
= self
.git
.add(v
=True,
394 return (status
, output
)
396 def reset(self
, *items
):
397 status
, output
= self
.git
.reset('--',
402 return (status
, output
)
404 def unstage_all(self
):
405 status
, output
= self
.git
.reset(with_stderr
=True,
408 return (status
, output
)
411 status
, output
= self
.git
.add(v
=True,
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()
426 argv
= [ key
, strval
]
428 argv
= [ '--global', key
, strval
]
429 return self
.git
.config(*argv
)
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"""
439 'global': not local
, # global is a python keyword
441 config_lines
= self
.git
.config(**kwargs
).splitlines()
443 for line
in config_lines
:
445 k
, v
= line
.split('=', 1)
447 # value-less entry in .gitconfig
450 k
= k
.replace('.','_') # git -> model
451 if v
== 'true' or v
== 'false':
452 v
= bool(eval(v
.title()))
460 def commit_with_msg(self
, msg
, amend
=False):
461 """Creates a git commit."""
463 if not msg
.endswith('\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
)
476 status
, out
= self
.git
.commit(F
=tmpfile
, v
=True, amend
=amend
,
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
,
507 # Swap the branches in push mode (reverse of fetch)
510 local_branch
= remote_branch
513 branch_arg
= '%s:%s' % ( remote_branch
, local_branch
)
515 branch_arg
= '+%s:%s' % ( remote_branch
, local_branch
)
517 if local_branch
and remote_branch
:
518 args
.append(branch_arg
)
520 args
.append(local_branch
)
522 args
.append(remote_branch
)
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
)
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
,
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)"""
557 newstatus
, out
= self
.git
.cherry_pick(rev
,
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()
569 return [ s
[:s
.index(':')] for s
in stashes
]
571 return [ s
[s
.index(':')+1:] for s
in stashes
]
573 def pad(self
, pstr
, num
=22):
574 topad
= num
-len(pstr
)
576 return pstr
+ ' '*topad
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,
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."""
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
))])
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
)
612 self
.unmerged
.remove(path
)
614 self
.modified
.remove(path
)
616 self
.untracked
.remove(path
)
617 if os
.path
.exists(core
.encode(path
)):
623 self
.notify_message_observers(self
.message_about_to_update
)
628 # `git add -u` doesn't work on untracked files
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.
634 self
.git
.add('--', u
=True, *remove
)
639 self
.notify_message_observers(self
.message_updated
)
641 def unstage_paths(self
, paths
):
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())
653 cur_modified_set
= set(self
.modified
)
654 cur_untracked_set
= set(self
.untracked
)
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
)
663 if path
not in cur_untracked_set
:
664 untracked
.append(path
)
667 self
.modified
.extend(modified
)
671 self
.untracked
.extend(untracked
)
672 self
.untracked
.sort()
674 self
.notify_message_observers(self
.message_updated
)
677 """If we've chosen a directory then use it, otherwise os.getcwd()."""
679 return self
.directory
682 serializer
.handlers
[MainModel
] = MainSerializer