1 # Copyright (c) 2008 David Aguilar
2 """This module provides the central cola model.
4 from __future__
import division
, absolute_import
, unicode_literals
11 from .. import gitcmds
13 from ..git
import STDOUT
14 from ..observable
import Observable
15 from ..decorators
import memoize
16 from ..models
.selection
import selection_model
17 from ..models
import prefs
18 from ..compat
import ustr
23 """Returns the main model singleton"""
27 class MainModel(Observable
):
28 """Provides a friendly wrapper for doing common git operations."""
31 message_about_to_update
= 'about_to_update'
32 message_commit_message_changed
= 'commit_message_changed'
33 message_diff_text_changed
= 'diff_text_changed'
34 message_filename_changed
= 'filename_changed'
35 message_mode_about_to_change
= 'mode_about_to_change'
36 message_mode_changed
= 'mode_changed'
37 message_updated
= 'updated'
40 mode_none
= 'none' # Default: nothing's happened, do nothing
41 mode_worktree
= 'worktree' # Comparing index to worktree
42 mode_untracked
= 'untracked' # Dealing with an untracked file
43 mode_index
= 'index' # Comparing index to last commit
44 mode_amend
= 'amend' # Amending a commit
46 # Modes where we can checkout files from the $head
47 modes_undoable
= set((mode_amend
, mode_index
, mode_worktree
))
49 # Modes where we can partially stage files
50 modes_stageable
= set((mode_amend
, mode_worktree
, mode_untracked
))
52 # Modes where we can partially unstage files
53 modes_unstageable
= set((mode_amend
, mode_index
))
56 lambda self
: self
.modified
+ self
.unmerged
+ self
.untracked
)
57 """An aggregate of the modified, unmerged, and untracked file lists."""
59 def __init__(self
, cwd
=None):
60 """Reads git repository settings and sets several methods
61 so that they refer to the git module. This object
62 encapsulates cola's interaction with git."""
63 Observable
.__init
__(self
)
65 # Initialize the git command object
66 self
.git
= git
.current()
70 self
.mode
= self
.mode_none
72 self
.is_merging
= False
73 self
.is_rebasing
= False
74 self
.currentbranch
= ''
78 self
.filter_paths
= None
80 self
.commitmsg
= '' # current commit message
81 self
._auto
_commitmsg
= '' # e.g. .git/MERGE_MSG
82 self
._prev
_commitmsg
= '' # saved here when clobbered by .git/MERGE_MSG
84 self
.modified
= [] # modified, staged, untracked, unmerged paths
88 self
.upstream_changed
= [] # paths that've changed upstream
89 self
.staged_deleted
= set()
90 self
.unstaged_deleted
= set()
91 self
.submodules
= set()
93 self
.local_branches
= []
94 self
.remote_branches
= []
97 self
.set_worktree(cwd
)
99 def unstageable(self
):
100 return self
.mode
in self
.modes_unstageable
103 return self
.mode
== self
.mode_amend
106 """Whether we can checkout files from the $head."""
107 return self
.mode
in self
.modes_undoable
110 """Whether staging should be allowed."""
111 return self
.mode
in self
.modes_stageable
113 def all_branches(self
):
114 return (self
.local_branches
+ self
.remote_branches
)
116 def set_worktree(self
, worktree
):
117 self
.git
.set_worktree(worktree
)
118 is_valid
= self
.git
.is_valid()
120 cwd
= self
.git
.getcwd()
121 self
.project
= os
.path
.basename(cwd
)
122 self
.set_directory(cwd
)
124 gitcfg
.current().reset()
127 def set_commitmsg(self
, msg
, notify
=True):
130 self
.notify_observers(self
.message_commit_message_changed
, msg
)
132 def save_commitmsg(self
, msg
):
133 path
= self
.git
.git_path('GIT_COLA_MSG')
135 core
.write(path
, msg
)
139 def set_diff_text(self
, txt
):
141 self
.notify_observers(self
.message_diff_text_changed
, txt
)
143 def set_directory(self
, path
):
144 self
.directory
= path
146 def set_filename(self
, filename
):
147 self
.filename
= filename
148 self
.notify_observers(self
.message_filename_changed
, filename
)
150 def set_mode(self
, mode
):
152 if mode
!= self
.mode_none
:
154 if self
.is_merging
and mode
== self
.mode_amend
:
156 if mode
== self
.mode_amend
:
160 self
.notify_observers(self
.message_mode_about_to_change
, mode
)
163 self
.notify_observers(self
.message_mode_changed
, mode
)
165 def apply_diff(self
, filename
):
166 return self
.git
.apply(filename
, index
=True, cached
=True)
168 def apply_diff_to_worktree(self
, filename
):
169 return self
.git
.apply(filename
)
171 def prev_commitmsg(self
, *args
):
172 """Queries git for the latest commit message."""
173 return self
.git
.log('-1', no_color
=True, pretty
='format:%s%n%n%b',
176 def update_path_filter(self
, filter_paths
):
177 self
.filter_paths
= filter_paths
178 self
.update_file_status()
180 def update_file_status(self
, update_index
=False):
181 self
.notify_observers(self
.message_about_to_update
)
182 self
._update
_files
(update_index
=update_index
)
183 self
.notify_observers(self
.message_updated
)
185 def update_status(self
, update_index
=False):
186 # Give observers a chance to respond
187 self
.notify_observers(self
.message_about_to_update
)
188 self
._update
_merge
_rebase
_status
()
189 self
._update
_files
(update_index
=update_index
)
190 self
._update
_remotes
()
191 self
._update
_branches
_and
_tags
()
192 self
._update
_branch
_heads
()
193 self
._update
_commitmsg
()
194 self
.notify_observers(self
.message_updated
)
196 def _update_files(self
, update_index
=False):
197 display_untracked
= prefs
.display_untracked()
198 state
= gitcmds
.worktree_state(head
=self
.head
,
199 update_index
=update_index
,
200 display_untracked
=display_untracked
,
201 paths
=self
.filter_paths
)
202 self
.staged
= state
.get('staged', [])
203 self
.modified
= state
.get('modified', [])
204 self
.unmerged
= state
.get('unmerged', [])
205 self
.untracked
= state
.get('untracked', [])
206 self
.upstream_changed
= state
.get('upstream_changed', [])
207 self
.staged_deleted
= state
.get('staged_deleted', set())
208 self
.unstaged_deleted
= state
.get('unstaged_deleted', set())
209 self
.submodules
= state
.get('submodules', set())
211 sel
= selection_model()
216 if selection_model().is_empty():
217 self
.set_diff_text('')
220 return not(bool(self
.staged
or self
.modified
or
221 self
.unmerged
or self
.untracked
))
223 def is_empty_repository(self
):
224 return not self
.local_branches
226 def _update_remotes(self
):
227 self
.remotes
= self
.git
.remote()[STDOUT
].splitlines()
229 def _update_branch_heads(self
):
230 # Set these early since they are used to calculate 'upstream_changed'.
231 self
.currentbranch
= gitcmds
.current_branch()
233 def _update_branches_and_tags(self
):
234 local_branches
, remote_branches
, tags
= gitcmds
.all_refs(split
=True)
235 self
.local_branches
= local_branches
236 self
.remote_branches
= remote_branches
239 def _update_merge_rebase_status(self
):
240 self
.is_merging
= core
.exists(self
.git
.git_path('MERGE_HEAD'))
241 self
.is_rebasing
= core
.exists(self
.git
.git_path('rebase-merge'))
242 if self
.is_merging
and self
.mode
== self
.mode_amend
:
243 self
.set_mode(self
.mode_none
)
245 def _update_commitmsg(self
):
246 """Check for merge message files and update the commit message
248 The message is cleared when the merge completes
253 # Check if there's a message file in .git/
254 merge_msg_path
= gitcmds
.merge_message_path()
256 msg
= core
.read(merge_msg_path
)
257 if msg
!= self
._auto
_commitmsg
:
258 self
._auto
_commitmsg
= msg
259 self
._prev
_commitmsg
= self
.commitmsg
260 self
.set_commitmsg(msg
)
262 elif self
._auto
_commitmsg
and self
._auto
_commitmsg
== self
.commitmsg
:
263 self
._auto
_commitmsg
= ''
264 self
.set_commitmsg(self
._prev
_commitmsg
)
266 def update_remotes(self
):
267 self
._update
_remotes
()
268 self
._update
_branches
_and
_tags
()
270 def delete_branch(self
, branch
):
271 status
, out
, err
= self
.git
.branch(branch
, D
=True)
272 self
._update
_branches
_and
_tags
()
273 return status
, out
, err
275 def rename_branch(self
, branch
, new_branch
):
276 status
, out
, err
= self
.git
.branch(branch
, new_branch
, M
=True)
277 self
.notify_observers(self
.message_about_to_update
)
278 self
._update
_branches
_and
_tags
()
279 self
._update
_branch
_heads
()
280 self
.notify_observers(self
.message_updated
)
281 return status
, out
, err
283 def _sliced_op(self
, input_items
, map_fn
):
284 """Slice input_items and call map_fn over every slice
286 This exists because of "errno: Argument list too long"
289 # This comment appeared near the top of include/linux/binfmts.h
290 # in the Linux source tree:
293 # * MAX_ARG_PAGES defines the number of pages allocated for arguments
294 # * and envelope for the new program. 32 should suffice, this gives
295 # * a maximum env+arg of 128kB w/4KB pages!
297 # #define MAX_ARG_PAGES 32
299 # 'size' is a heuristic to keep things highly performant by minimizing
300 # the number of slices. If we wanted it to run as few commands as
301 # possible we could call "getconf ARG_MAX" and make a better guess,
302 # but it's probably not worth the complexity (and the extra call to
303 # getconf that we can't do on Windows anyways).
305 # In my testing, getconf ARG_MAX on Mac OS X Mountain Lion reported
306 # 262144 and Debian/Linux-x86_64 reported 2097152.
308 # The hard-coded max_arg_len value is safely below both of these
311 max_arg_len
= 32 * 4 * 1024
312 avg_filename_len
= 300
313 size
= max_arg_len
// avg_filename_len
319 items
= copy
.copy(input_items
)
321 stat
, out
, err
= map_fn(items
[:size
])
322 status
= max(stat
, status
)
327 return (status
, '\n'.join(outs
), '\n'.join(errs
))
329 def _sliced_add(self
, items
):
331 return self
._sliced
_op
(
332 items
, lambda x
: add('--', force
=True, verbose
=True, *x
))
334 def stage_modified(self
):
335 status
, out
, err
= self
._sliced
_add
(self
.modified
)
336 self
.update_file_status()
337 return (status
, out
, err
)
339 def stage_untracked(self
):
340 status
, out
, err
= self
._sliced
_add
(self
.untracked
)
341 self
.update_file_status()
342 return (status
, out
, err
)
344 def reset(self
, *items
):
345 reset
= self
.git
.reset
346 status
, out
, err
= self
._sliced
_op
(items
, lambda x
: reset('--', *x
))
347 self
.update_file_status()
348 return (status
, out
, err
)
350 def unstage_all(self
):
351 """Unstage all files, even while amending"""
352 status
, out
, err
= self
.git
.reset(self
.head
, '--', '.')
353 self
.update_file_status()
354 return (status
, out
, err
)
357 status
, out
, err
= self
.git
.add(v
=True, u
=True)
358 self
.update_file_status()
359 return (status
, out
, err
)
361 def config_set(self
, key
, value
, local
=True):
362 # git config category.key value
364 if type(value
) is bool:
365 # git uses "true" and "false"
366 strval
= strval
.lower()
370 argv
= ['--global', key
, strval
]
371 return self
.git
.config(*argv
)
373 def config_dict(self
, local
=True):
374 """parses the lines from git config --list into a dictionary"""
378 'global': not local
, # global is a python keyword
380 config_lines
= self
.git
.config(**kwargs
)[STDOUT
].splitlines()
382 for line
in config_lines
:
384 k
, v
= line
.split('=', 1)
386 # value-less entry in .gitconfig
388 k
= k
.replace('.', '_') # git -> model
389 if v
== 'true' or v
== 'false':
390 v
= bool(eval(v
.title()))
398 def remote_url(self
, name
, action
):
400 url
= self
.git
.config('remote.%s.pushurl' % name
,
404 return self
.git
.config('remote.%s.url' % name
, get
=True)[STDOUT
]
406 def fetch(self
, remote
, **opts
):
407 return run_remote_action(self
.git
.fetch
, remote
, **opts
)
409 def push(self
, remote
, remote_branch
='', local_branch
='', **opts
):
410 # Swap the branches in push mode (reverse of fetch)
411 opts
.update(dict(local_branch
=remote_branch
,
412 remote_branch
=local_branch
))
413 return run_remote_action(self
.git
.push
, remote
, **opts
)
415 def pull(self
, remote
, **opts
):
416 return run_remote_action(self
.git
.pull
, remote
, pull
=True, **opts
)
418 def create_branch(self
, name
, base
, track
=False, force
=False):
419 """Create a branch named 'name' from revision 'base'
421 Pass track=True to create a local tracking branch.
423 return self
.git
.branch(name
, base
, track
=track
, force
=force
)
425 def cherry_pick_list(self
, revs
, **kwargs
):
426 """Cherry-picks each revision into the current branch.
427 Returns a list of command output strings (1 per cherry pick)"""
434 stat
, out
, err
= self
.git
.cherry_pick(rev
)
435 status
= max(stat
, status
)
438 return (status
, '\n'.join(outs
), '\n'.join(errs
))
440 def pad(self
, pstr
, num
=22):
441 topad
= num
-len(pstr
)
443 return pstr
+ ' '*topad
447 def is_commit_published(self
):
448 head
= self
.git
.rev_parse('HEAD')[STDOUT
]
449 return bool(self
.git
.branch(r
=True, contains
=head
)[STDOUT
])
451 def stage_paths(self
, paths
):
452 """Stages add/removals to git."""
460 for path
in set(paths
):
461 if core
.exists(path
):
466 self
.notify_observers(self
.message_about_to_update
)
468 # `git add -u` doesn't work on untracked files
470 self
._sliced
_add
(add
)
472 # If a path doesn't exist then that means it should be removed
473 # from the index. We use `git add -u` for that.
476 self
.git
.add('--', u
=True, *remove
[:42])
480 self
.notify_observers(self
.message_updated
)
482 def unstage_paths(self
, paths
):
486 gitcmds
.unstage_paths(paths
, head
=self
.head
)
487 self
.update_file_status()
489 def untrack_paths(self
, paths
):
490 status
, out
, err
= gitcmds
.untrack_paths(paths
, head
=self
.head
)
491 self
.update_file_status()
492 return status
, out
, err
495 """If we've chosen a directory then use it, otherwise use current"""
497 return self
.directory
502 def remote_args(remote
,
509 """Return arguments for git fetch/push/pull"""
512 what
= refspec_arg(local_branch
, remote_branch
, ffwd
, pull
)
520 return (args
, kwargs
)
523 def refspec(src
, dst
, ffwd
):
524 spec
= '%s:%s' % (src
, dst
)
530 def refspec_arg(local_branch
, remote_branch
, ffwd
, pull
):
531 """Return the refspec for a fetch or pull command"""
532 if not pull
and local_branch
and remote_branch
:
533 what
= refspec(remote_branch
, local_branch
, ffwd
)
535 what
= local_branch
or remote_branch
or None
539 def run_remote_action(action
, remote
, **kwargs
):
540 args
, kwargs
= remote_args(remote
, **kwargs
)
541 return action(*args
, **kwargs
)