1 # Copyright (c) 2008 David Aguilar
2 """This module provides the central cola model.
10 from cStringIO
import StringIO
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
22 """Returns the main model singleton"""
26 _instance
= MainModel()
30 class MainModel(ObservableModel
):
31 """Provides a friendly wrapper for doing common git operations."""
34 message_updated
= 'updated'
35 message_about_to_update
= 'about_to_update'
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 #####################################################
65 self
.mode
= self
.mode_none
68 self
.currentbranch
= ''
69 self
.trackedbranch
= ''
71 self
.git_version
= self
.git
.version()
74 self
.local_branch
= ''
75 self
.remote_branch
= ''
77 #####################################################
85 self
.upstream_changed
= []
87 #####################################################
90 self
.local_branches
= []
91 self
.remote_branches
= []
96 self
.fetch_helper
= None
97 self
.push_helper
= None
98 self
.pull_helper
= None
99 self
.generate_remote_helpers()
101 self
.use_worktree(cwd
)
104 return self
.mode
in self
.modes_read_only
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()
124 self
._init
_config
_data
()
125 self
.set_project(os
.path
.basename(self
.git
.worktree()))
128 def _init_config_data(self
):
129 """Reads git config --list and creates parameters
131 # These parameters are saved in .gitconfig,
132 # so ideally these should be as short as possible.
134 # config items that are controllable globally
136 self
._local
_and
_global
_defaults
= {
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
= {
149 'cola_fontdiff_size': 12,
150 'cola_savewindowsettings': False,
151 'cola_showoutput': 'errors',
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
:
169 self
.set_param('local_'+k
, v
)
171 # Bootstrap the internal font*size variables
172 for param
in ('global_cola_fontdiff'):
174 if hasattr(self
, param
):
175 font
= getattr(self
, param
)
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('.', '_'),
204 def local_config(self
, key
, default
=None):
205 return self
.param('local_'+key
.replace('.', '_'),
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
):
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():
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_'):]
233 elif param
.startswith('global_'):
234 param
= param
[len('global_'):]
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
)
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
):
257 self
.set_param('remote', remote
)
258 branches
= utils
.grep('%s/\S+$' % remote
,
259 gitcmds
.branch_list(remote
=True),
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
):
271 contents
= core
.decode(core
.read_nointr(fh
))
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')
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()
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())
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')
326 items
= value
.split(',')
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
,
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')
349 size
= self
.cola_config('fontdiff_size')
350 props
= old_font
.split(',')
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):
359 return self
.staged
[idx
]
361 return self
.unstaged
[idx
]
365 def stage_modified(self
):
366 status
, output
= self
.git
.add(v
=True,
371 return (status
, output
)
373 def stage_untracked(self
):
374 status
, output
= self
.git
.add(v
=True,
379 return (status
, output
)
381 def reset(self
, *items
):
382 status
, output
= self
.git
.reset('--',
387 return (status
, output
)
389 def unstage_all(self
):
390 status
, output
= self
.git
.reset(with_stderr
=True,
393 return (status
, output
)
396 status
, output
= self
.git
.add(v
=True,
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()
411 argv
= [ key
, strval
]
413 argv
= [ '--global', key
, strval
]
414 return self
.git
.config(*argv
)
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"""
424 'global': not local
, # global is a python keyword
426 config_lines
= self
.git
.config(**kwargs
).splitlines()
428 for line
in config_lines
:
430 k
, v
= line
.split('=', 1)
432 # the user has an invalid entry in their git config
435 k
= k
.replace('.','_') # git -> model
436 if v
== 'true' or v
== 'false':
437 v
= bool(eval(v
.title()))
445 def commit_with_msg(self
, msg
, amend
=False):
446 """Creates a git commit."""
448 if not msg
.endswith('\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
)
461 status
, out
= self
.git
.commit(F
=tmpfile
, v
=True, amend
=amend
,
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
):
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
):
500 output
= self
.git
.read_tree('HEAD', reset
=True, u
=True, v
=True)
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
,
521 # Swap the branches in push mode (reverse of fetch)
524 local_branch
= remote_branch
527 branch_arg
= '%s:%s' % ( remote_branch
, local_branch
)
529 branch_arg
= '+%s:%s' % ( remote_branch
, local_branch
)
531 if local_branch
and remote_branch
:
532 args
.append(branch_arg
)
534 args
.append(local_branch
)
536 args
.append(remote_branch
)
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
)
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
,
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)"""
571 newstatus
, out
= self
.git
.cherry_pick(rev
,
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()
583 return [ s
[:s
.index(':')] for s
in stashes
]
585 return [ s
[s
.index(':')+1:] for s
in stashes
]
587 def pad(self
, pstr
, num
=22):
588 topad
= num
-len(pstr
)
590 return pstr
+ ' '*topad
594 def describe(self
, revid
, descr
):
595 version
= self
.git
.describe(revid
, tags
=True, always
=True,
597 return version
+ ' - ' + descr
599 def update_revision_lists(self
, filename
=None, show_versions
=False):
600 num_results
= self
.num_results
602 rev_list
= self
.git
.log('--', filename
,
603 max_count
=num_results
,
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
)
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
)
618 self
.set_descriptions_start(descriptions
)
619 self
.set_descriptions_end(descriptions
)
621 self
.set_revisions_start(commits
)
622 self
.set_revisions_end(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,
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."""
642 for path
in set(paths
):
643 if os
.path
.exists(core
.encode(path
)):
647 # `git add -u` doesn't work on untracked files
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.
653 self
.git
.add('--', u
=True, *remove
)
657 """If we've chosen a directory then use it, otherwise os.getcwd()."""
659 return self
.directory