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_head_changed
= 'head_changed'
40 message_mode_about_to_change
= 'mode_about_to_change'
41 message_mode_changed
= 'mode_changed'
42 message_updated
= 'updated'
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()
74 self
.mode
= self
.mode_none
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_head(self
, 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
)
147 if mode
!= self
.mode_none
:
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',
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
)
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()
195 if selection_model().is_empty():
196 self
.set_diff_text('')
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
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:
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!
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
249 max_arg_len
= 32 * 4 * 1024
250 avg_filename_len
= 300
251 size
= max_arg_len
/ avg_filename_len
257 items
= copy
.copy(input_items
)
259 stat
, out
, err
= map_fn(items
[:size
])
260 status
= max(stat
, status
)
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
)
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()
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"""
315 'global': not local
, # global is a python keyword
317 config_lines
= self
.git
.config(**kwargs
)[STDOUT
].splitlines()
319 for line
in config_lines
:
321 k
, v
= line
.split('=', 1)
323 # value-less entry in .gitconfig
325 k
= k
.replace('.','_') # git -> model
326 if v
== 'true' or v
== 'false':
327 v
= bool(eval(v
.title()))
335 def commit_with_msg(self
, msg
, tmpfile
, amend
=False):
336 """Creates a git commit."""
338 if not msg
.endswith('\n'):
341 # Create the commit message file
342 core
.write(tmpfile
, msg
)
345 status
, out
, err
= self
.git
.commit(F
=tmpfile
, v
=True, amend
=amend
)
347 return (status
, out
, err
)
349 def remote_url(self
, name
, action
):
351 url
= self
.git
.config('remote.%s.pushurl' % name
,
355 return self
.git
.config('remote.%s.url' % name
, get
=True)[STDOUT
]
357 def remote_args(self
, remote
,
364 # Swap the branches in push mode (reverse of fetch)
367 local_branch
= remote_branch
370 branch_arg
= '%s:%s' % (remote_branch
, local_branch
)
372 branch_arg
= '+%s:%s' % (remote_branch
, local_branch
)
374 if local_branch
and remote_branch
:
375 args
.append(branch_arg
)
377 args
.append(local_branch
)
379 args
.append(remote_branch
)
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)"""
416 stat
, out
, err
= self
.git
.cherry_pick(rev
)
417 status
= max(stat
, status
)
420 return (status
, '\n'.join(outs
), '\n'.join(errs
))
422 def pad(self
, pstr
, num
=22):
423 topad
= num
-len(pstr
)
425 return pstr
+ ' '*topad
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,
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."""
450 for path
in set(paths
):
451 if core
.exists(path
):
456 self
.notify_observers(self
.message_about_to_update
)
458 # `git add -u` doesn't work on untracked files
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.
466 self
.git
.add('--', u
=True, *remove
[:42])
470 self
.notify_observers(self
.message_updated
)
472 def unstage_paths(self
, paths
):
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
485 """If we've chosen a directory then use it, otherwise os.getcwd()."""
487 return self
.directory