1 # Copyright (c) 2008 David Aguilar
2 """This module provides the central cola model.
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()
26 """Returns the main model singleton"""
30 class MainModel(Observable
):
31 """Provides a friendly wrapper for doing common git operations."""
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'
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()
73 self
.mode
= self
.mode_none
75 self
.is_merging
= False
76 self
.is_rebasing
= False
77 self
.currentbranch
= ''
87 self
.upstream_changed
= []
88 self
.submodules
= set()
90 self
.local_branches
= []
91 self
.remote_branches
= []
94 self
.set_worktree(cwd
)
96 def unstageable(self
):
97 return self
.mode
in self
.modes_unstageable
100 return self
.mode
== self
.mode_amend
103 """Whether we can checkout files from the $head."""
104 return self
.mode
in self
.modes_undoable
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()
117 self
.project
= os
.path
.basename(self
.git
.worktree())
120 def set_commitmsg(self
, 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
):
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
):
142 if mode
!= self
.mode_none
:
144 if self
.is_merging
and mode
== self
.mode_amend
:
146 if mode
== self
.mode_amend
:
150 self
.notify_observers(self
.message_mode_about_to_change
, 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',
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
)
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()
198 if selection_model().is_empty():
199 self
.set_diff_text('')
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
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:
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!
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
253 max_arg_len
= 32 * 4 * 1024
254 avg_filename_len
= 300
255 size
= max_arg_len
/ avg_filename_len
261 items
= copy
.copy(input_items
)
263 stat
, out
, err
= map_fn(items
[:size
])
264 status
= max(stat
, status
)
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
)
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()
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"""
319 'global': not local
, # global is a python keyword
321 config_lines
= self
.git
.config(**kwargs
)[STDOUT
].splitlines()
323 for line
in config_lines
:
325 k
, v
= line
.split('=', 1)
327 # value-less entry in .gitconfig
329 k
= k
.replace('.','_') # git -> model
330 if v
== 'true' or v
== 'false':
331 v
= bool(eval(v
.title()))
339 def commit_with_msg(self
, msg
, tmpfile
, amend
=False):
340 """Creates a git commit."""
342 if not msg
.endswith('\n'):
345 # Create the commit message file
346 core
.write(tmpfile
, msg
)
349 status
, out
, err
= self
.git
.commit(F
=tmpfile
, v
=True, amend
=amend
)
351 return (status
, out
, err
)
353 def remote_url(self
, name
, action
):
355 url
= self
.git
.config('remote.%s.pushurl' % name
,
359 return self
.git
.config('remote.%s.url' % name
, get
=True)[STDOUT
]
361 def remote_args(self
, remote
,
368 # Swap the branches in push mode (reverse of fetch)
371 local_branch
= remote_branch
374 branch_arg
= '%s:%s' % (remote_branch
, local_branch
)
376 branch_arg
= '+%s:%s' % (remote_branch
, local_branch
)
378 if local_branch
and remote_branch
:
379 args
.append(branch_arg
)
381 args
.append(local_branch
)
383 args
.append(remote_branch
)
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)"""
420 stat
, out
, err
= self
.git
.cherry_pick(rev
)
421 status
= max(stat
, status
)
424 return (status
, '\n'.join(outs
), '\n'.join(errs
))
426 def pad(self
, pstr
, num
=22):
427 topad
= num
-len(pstr
)
429 return pstr
+ ' '*topad
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,
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."""
454 for path
in set(paths
):
455 if core
.exists(path
):
460 self
.notify_observers(self
.message_about_to_update
)
462 # `git add -u` doesn't work on untracked files
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.
470 self
.git
.add('--', u
=True, *remove
[:42])
474 self
.notify_observers(self
.message_updated
)
476 def unstage_paths(self
, paths
):
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
489 """If we've chosen a directory then use it, otherwise os.getcwd()."""
491 return self
.directory