1 """The central cola model"""
2 from __future__
import absolute_import
, division
, print_function
, unicode_literals
5 from qtpy
import QtCore
6 from qtpy
.QtCore
import Signal
11 from .. import version
12 from ..git
import STDOUT
17 """Create the repository status model"""
18 return MainModel(context
)
21 # pylint: disable=too-many-public-methods
22 class MainModel(QtCore
.QObject
):
23 """Repository status model"""
25 # Refactor: split this class apart into separate DiffModel, CommitMessageModel,
26 # StatusModel, and an DiffEditorState.
29 about_to_update
= Signal()
30 previous_contents
= Signal(list, list, list, list)
31 commit_message_changed
= Signal(object)
32 diff_text_changed
= Signal()
33 diff_text_updated
= Signal(str)
34 # "diff_type" {text,image} represents the diff viewer mode.
35 diff_type_changed
= Signal(object)
36 # "file_type" {text,image} represents the selected file type.
37 file_type_changed
= Signal(object)
38 images_changed
= Signal(object)
39 mode_changed
= Signal(str)
40 submodules_changed
= Signal()
41 refs_updated
= Signal()
43 worktree_changed
= Signal()
46 mode_none
= 'none' # Default: nothing's happened, do nothing
47 mode_worktree
= 'worktree' # Comparing index to worktree
48 mode_diffstat
= 'diffstat' # Showing a diffstat
49 mode_display
= 'display' # Displaying arbitrary information
50 mode_untracked
= 'untracked' # Dealing with an untracked file
51 mode_untracked_diff
= 'untracked-diff' # Diffing an untracked file
52 mode_index
= 'index' # Comparing index to last commit
53 mode_amend
= 'amend' # Amending a commit
54 mode_diff
= 'diff' # Diffing against an arbitrary commit
56 # Modes where we can checkout files from the $head
57 modes_undoable
= set((mode_amend
, mode_diff
, mode_index
, mode_worktree
))
59 # Modes where we can partially stage files
60 modes_partially_stageable
= set(
61 (mode_amend
, mode_diff
, mode_worktree
, mode_untracked_diff
)
64 # Modes where we can partially unstage files
65 modes_unstageable
= set((mode_amend
, mode_diff
, mode_index
))
67 unstaged
= property(lambda self
: self
.modified
+ self
.unmerged
+ self
.untracked
)
68 """An aggregate of the modified, unmerged, and untracked file lists."""
70 def __init__(self
, context
, cwd
=None):
71 """Interface to the main repository status"""
72 super(MainModel
, self
).__init
__()
74 self
.context
= context
75 self
.git
= context
.git
76 self
.cfg
= context
.cfg
77 self
.selection
= context
.selection
79 self
.initialized
= False
84 self
.diff_type
= Types
.TEXT
85 self
.file_type
= Types
.TEXT
86 self
.mode
= self
.mode_none
88 self
.is_cherry_picking
= False
89 self
.is_merging
= False
90 self
.is_rebasing
= False
91 self
.is_applying_patch
= False
92 self
.currentbranch
= ''
96 self
.filter_paths
= None
99 self
.commitmsg
= '' # current commit message
100 self
._auto
_commitmsg
= '' # e.g. .git/MERGE_MSG
101 self
._prev
_commitmsg
= '' # saved here when clobbered by .git/MERGE_MSG
103 self
.modified
= [] # modified, staged, untracked, unmerged paths
107 self
.upstream_changed
= [] # paths that've changed upstream
108 self
.staged_deleted
= set()
109 self
.unstaged_deleted
= set()
110 self
.submodules
= set()
111 self
.submodules_list
= None # lazy loaded
113 self
.error
= None # The last error message.
114 self
.ref_sort
= 0 # (0: version, 1:reverse-chrono)
115 self
.local_branches
= []
116 self
.remote_branches
= []
119 self
.set_worktree(cwd
)
121 def is_diff_mode(self
):
122 """Are we in diff mode?"""
123 return self
.mode
== self
.mode_diff
125 def is_unstageable(self
):
126 """Are we in a mode that supports "unstage" actions?"""
127 return self
.mode
in self
.modes_unstageable
129 def is_amend_mode(self
):
130 """Are we amending a commit?"""
131 return self
.mode
== self
.mode_amend
133 def is_undoable(self
):
134 """Can we checkout from the current branch or head ref?"""
135 return self
.mode
in self
.modes_undoable
137 def is_partially_stageable(self
):
138 """Whether partial staging should be allowed."""
139 return self
.mode
in self
.modes_partially_stageable
141 def is_stageable(self
):
142 """Whether staging should be allowed."""
143 return self
.is_partially_stageable() or self
.mode
== self
.mode_untracked
145 def all_branches(self
):
146 return self
.local_branches
+ self
.remote_branches
148 def set_worktree(self
, worktree
):
149 last_worktree
= self
.git
.paths
.worktree
150 self
.git
.set_worktree(worktree
)
152 is_valid
= self
.git
.is_valid()
154 reset
= last_worktree
is None or last_worktree
!= worktree
155 cwd
= self
.git
.getcwd()
156 self
.project
= os
.path
.basename(cwd
)
157 self
.set_directory(cwd
)
159 self
.update_config(reset
=reset
)
161 # Detect the "git init" scenario by checking for branches.
162 # If no branches exist then we cannot use "git rev-parse" yet.
164 refs
= self
.git
.git_path('refs', 'heads')
165 if core
.exists(refs
) and core
.listdir(refs
):
166 # "git rev-parse" exits with a non-zero exit status when the
167 # safe.directory protection is active.
168 status
, _
, err
= self
.git
.rev_parse('HEAD')
169 is_valid
= status
== 0
172 self
.worktree_changed
.emit()
178 def is_git_lfs_enabled(self
):
179 """Return True if `git lfs install` has been run
181 We check for the existence of the "lfs" object-storea, and one of the
182 "git lfs install"-provided hooks. This allows us to detect when
183 "git lfs uninstall" has been run.
186 lfs_filter
= self
.cfg
.get('filter.lfs.clean', default
=False)
187 lfs_dir
= lfs_filter
and self
.git
.git_path('lfs')
188 lfs_hook
= lfs_filter
and self
.cfg
.hooks_path('post-merge')
192 and core
.exists(lfs_dir
)
194 and core
.exists(lfs_hook
)
197 def set_commitmsg(self
, msg
, notify
=True):
200 self
.commit_message_changed
.emit(msg
)
202 def save_commitmsg(self
, msg
=None):
205 path
= self
.git
.git_path('GIT_COLA_MSG')
207 if not msg
.endswith('\n'):
209 core
.write(path
, msg
)
210 except (OSError, IOError):
214 def set_diff_text(self
, txt
):
215 """Update the text displayed in the diff editor"""
216 changed
= txt
!= self
.diff_text
218 self
.diff_text_updated
.emit(txt
)
220 self
.diff_text_changed
.emit()
222 def set_diff_type(self
, diff_type
): # text, image
223 """Set the diff type to either text or image"""
224 changed
= diff_type
!= self
.diff_type
225 self
.diff_type
= diff_type
227 self
.diff_type_changed
.emit(diff_type
)
229 def set_file_type(self
, file_type
): # text, image
230 """Set the file type to either text or image"""
231 changed
= file_type
!= self
.file_type
232 self
.file_type
= file_type
234 self
.file_type_changed
.emit(file_type
)
236 def set_images(self
, images
):
237 """Update the images shown in the preview pane"""
239 self
.images_changed
.emit(images
)
241 def set_directory(self
, path
):
242 self
.directory
= path
244 def set_mode(self
, mode
, head
=None):
245 """Set the current editing mode (worktree, index, amending, ...)"""
246 # Do not allow going into index or worktree mode when amending.
247 if self
.is_amend_mode() and mode
!= self
.mode_none
:
249 # We cannot amend in the middle of git cherry-pick, git am or git merge.
251 self
.is_cherry_picking
or self
.is_merging
or self
.is_applying_patch
252 ) and mode
== self
.mode_amend
:
255 # Stay in diff mode until explicitly reset.
256 if self
.mode
== self
.mode_diff
and mode
!= self
.mode_none
:
257 mode
= self
.mode_diff
258 head
= head
or self
.head
260 # If we are amending then we'll use "HEAD^", otherwise use the specified
261 # head or "HEAD" if head has not been specified.
262 if mode
== self
.mode_amend
:
269 self
.mode_changed
.emit(mode
)
271 def update_path_filter(self
, filter_paths
):
272 self
.filter_paths
= filter_paths
273 self
.update_file_status()
275 def emit_about_to_update(self
):
276 self
.previous_contents
.emit(
277 self
.staged
, self
.unmerged
, self
.modified
, self
.untracked
279 self
.about_to_update
.emit()
281 def emit_updated(self
):
284 def update_file_status(self
, update_index
=False):
285 """Update modified/staged files status"""
286 self
.emit_about_to_update()
287 self
.update_files(update_index
=update_index
, emit
=True)
289 def update_file_merge_status(self
):
290 """Update modified/staged files and Merge/Rebase/Cherry-pick status"""
291 self
.emit_about_to_update()
292 self
._update
_merge
_rebase
_status
()
293 self
.update_file_status()
295 def update_status(self
, update_index
=False, reset
=False):
296 # Give observers a chance to respond
297 self
.emit_about_to_update()
298 self
.initialized
= True
299 self
._update
_merge
_rebase
_status
()
300 self
._update
_files
(update_index
=update_index
)
301 self
._update
_remotes
()
302 self
._update
_branches
_and
_tags
()
303 self
._update
_commitmsg
()
306 self
.update_submodules_list()
309 def update_config(self
, emit
=False, reset
=False):
312 self
.annex
= self
.cfg
.is_annex()
313 self
.lfs
= self
.is_git_lfs_enabled()
317 def update_files(self
, update_index
=False, emit
=False):
318 self
._update
_files
(update_index
=update_index
)
322 def _update_files(self
, update_index
=False):
323 context
= self
.context
324 display_untracked
= prefs
.display_untracked(context
)
325 state
= gitcmds
.worktree_state(
328 update_index
=update_index
,
329 display_untracked
=display_untracked
,
330 paths
=self
.filter_paths
,
332 self
.staged
= state
.get('staged', [])
333 self
.modified
= state
.get('modified', [])
334 self
.unmerged
= state
.get('unmerged', [])
335 self
.untracked
= state
.get('untracked', [])
336 self
.upstream_changed
= state
.get('upstream_changed', [])
337 self
.staged_deleted
= state
.get('staged_deleted', set())
338 self
.unstaged_deleted
= state
.get('unstaged_deleted', set())
339 self
.submodules
= state
.get('submodules', set())
341 selection
= self
.selection
345 selection
.update(self
)
346 if selection
.is_empty():
347 self
.set_diff_text('')
351 bool(self
.staged
or self
.modified
or self
.unmerged
or self
.untracked
)
354 def is_empty_repository(self
):
355 return not self
.local_branches
357 def _update_remotes(self
):
358 self
.remotes
= gitcfg
.get_remotes(self
.cfg
)
360 def _update_branches_and_tags(self
):
361 context
= self
.context
366 sort_key
= sort_types
[self
.ref_sort
]
367 local_branches
, remote_branches
, tags
= gitcmds
.all_refs(
368 context
, split
=True, sort_key
=sort_key
370 self
.local_branches
= local_branches
371 self
.remote_branches
= remote_branches
373 # Set these early since they are used to calculate 'upstream_changed'.
374 self
.currentbranch
= gitcmds
.current_branch(self
.context
)
375 self
.refs_updated
.emit()
377 def _update_merge_rebase_status(self
):
378 cherry_pick_head
= self
.git
.git_path('CHERRY_PICK_HEAD')
379 merge_head
= self
.git
.git_path('MERGE_HEAD')
380 rebase_merge
= self
.git
.git_path('rebase-merge')
381 rebase_apply
= self
.git
.git_path('rebase-apply', 'applying')
382 self
.is_cherry_picking
= cherry_pick_head
and core
.exists(cherry_pick_head
)
383 self
.is_merging
= merge_head
and core
.exists(merge_head
)
384 self
.is_rebasing
= rebase_merge
and core
.exists(rebase_merge
)
385 self
.is_applying_patch
= rebase_apply
and core
.exists(rebase_apply
)
386 if self
.mode
== self
.mode_amend
and (
387 self
.is_merging
or self
.is_cherry_picking
or self
.is_applying_patch
389 self
.set_mode(self
.mode_none
)
391 def _update_commitmsg(self
):
392 """Check for merge message files and update the commit message
394 The message is cleared when the merge completes.
396 if self
.is_amend_mode():
398 # Check if there's a message file in .git/
399 context
= self
.context
400 merge_msg_path
= gitcmds
.merge_message_path(context
)
402 msg
= core
.read(merge_msg_path
)
403 if msg
!= self
._auto
_commitmsg
:
404 self
._auto
_commitmsg
= msg
405 self
._prev
_commitmsg
= self
.commitmsg
406 self
.set_commitmsg(msg
)
408 elif self
._auto
_commitmsg
and self
._auto
_commitmsg
== self
.commitmsg
:
409 self
._auto
_commitmsg
= ''
410 self
.set_commitmsg(self
._prev
_commitmsg
)
412 def update_submodules_list(self
):
413 self
.submodules_list
= gitcmds
.list_submodule(self
.context
)
414 self
.submodules_changed
.emit()
416 def update_remotes(self
):
417 self
._update
_remotes
()
420 def update_refs(self
):
421 """Update tag and branch names"""
422 self
.emit_about_to_update()
423 self
._update
_branches
_and
_tags
()
426 def delete_branch(self
, branch
):
427 status
, out
, err
= self
.git
.branch(branch
, D
=True)
429 return status
, out
, err
431 def rename_branch(self
, branch
, new_branch
):
432 status
, out
, err
= self
.git
.branch(branch
, new_branch
, M
=True)
434 return status
, out
, err
436 def remote_url(self
, name
, action
):
437 push
= action
== 'PUSH'
438 return gitcmds
.remote_url(self
.context
, name
, push
=push
)
440 def fetch(self
, remote
, **opts
):
441 result
= run_remote_action(self
.context
, self
.git
.fetch
, remote
, **opts
)
445 def push(self
, remote
, remote_branch
='', local_branch
='', **opts
):
446 # Swap the branches in push mode (reverse of fetch)
449 'local_branch': remote_branch
,
450 'remote_branch': local_branch
,
453 result
= run_remote_action(
454 self
.context
, self
.git
.push
, remote
, push
=True, **opts
459 def pull(self
, remote
, **opts
):
460 result
= run_remote_action(
461 self
.context
, self
.git
.pull
, remote
, pull
=True, **opts
463 # Pull can result in merge conflicts
465 self
.update_files(update_index
=False, emit
=True)
468 def create_branch(self
, name
, base
, track
=False, force
=False):
469 """Create a branch named 'name' from revision 'base'
471 Pass track=True to create a local tracking branch.
473 return self
.git
.branch(name
, base
, track
=track
, force
=force
)
475 def is_commit_published(self
):
476 """Return True if the latest commit exists in any remote branch"""
477 return bool(self
.git
.branch(r
=True, contains
='HEAD')[STDOUT
])
479 def untrack_paths(self
, paths
):
480 context
= self
.context
481 status
, out
, err
= gitcmds
.untrack_paths(context
, paths
)
482 self
.update_file_status()
483 return status
, out
, err
486 """If we've chosen a directory then use it, otherwise use current"""
488 return self
.directory
491 def cycle_ref_sort(self
):
492 """Choose the next ref sort type (version, reverse-chronological)"""
493 self
.set_ref_sort(self
.ref_sort
+ 1)
495 def set_ref_sort(self
, raw_value
):
496 value
= raw_value
% 2 # Currently two sort types
497 if value
== self
.ref_sort
:
499 self
.ref_sort
= value
504 """File types (used for image diff modes)"""
511 # pylint: disable=too-many-arguments
527 """Return arguments for git fetch/push/pull"""
530 what
= refspec_arg(local_branch
, remote_branch
, pull
, push
)
539 kwargs
['rebase'] = True
541 kwargs
['ff_only'] = True
543 kwargs
['no_ff'] = True
545 # pylint: disable=simplifiable-if-statement
546 if push
and version
.check_git(context
, 'force-with-lease'):
547 kwargs
['force_with_lease'] = True
549 kwargs
['force'] = True
551 if push
and set_upstream
:
552 kwargs
['set_upstream'] = True
554 kwargs
['tags'] = True
556 kwargs
['prune'] = True
558 return (args
, kwargs
)
561 def refspec(src
, dst
, push
=False):
562 if push
and src
== dst
:
565 spec
= '%s:%s' % (src
, dst
)
569 def refspec_arg(local_branch
, remote_branch
, pull
, push
):
570 """Return the refspec for a fetch or pull command"""
571 if not pull
and local_branch
and remote_branch
:
572 what
= refspec(remote_branch
, local_branch
, push
=push
)
574 what
= local_branch
or remote_branch
or None
578 def run_remote_action(context
, action
, remote
, **kwargs
):
579 args
, kwargs
= remote_args(context
, remote
, **kwargs
)
580 return action(*args
, **kwargs
)