1 """The central cola model"""
4 from qtpy
import QtCore
5 from qtpy
.QtCore
import Signal
10 from .. import version
11 from ..git
import STDOUT
16 """Create the repository status model"""
17 return MainModel(context
)
20 # pylint: disable=too-many-public-methods
21 class MainModel(QtCore
.QObject
):
22 """Repository status model"""
24 # Refactor: split this class apart into separate DiffModel, CommitMessageModel,
25 # StatusModel, and an DiffEditorState.
28 about_to_update
= Signal()
29 previous_contents
= Signal(list, list, list, list)
30 commit_message_changed
= Signal(object)
31 diff_text_changed
= Signal()
32 diff_text_updated
= Signal(str)
33 # "diff_type" {text,image} represents the diff viewer mode.
34 diff_type_changed
= Signal(object)
35 # "file_type" {text,image} represents the selected file type.
36 file_type_changed
= Signal(object)
37 images_changed
= Signal(object)
38 mode_changed
= Signal(str)
39 submodules_changed
= Signal()
40 refs_updated
= Signal()
42 worktree_changed
= Signal()
45 mode_none
= 'none' # Default: nothing's happened, do nothing
46 mode_worktree
= 'worktree' # Comparing index to worktree
47 mode_diffstat
= 'diffstat' # Showing a diffstat
48 mode_display
= 'display' # Displaying arbitrary information
49 mode_untracked
= 'untracked' # Dealing with an untracked file
50 mode_untracked_diff
= 'untracked-diff' # Diffing an untracked file
51 mode_index
= 'index' # Comparing index to last commit
52 mode_amend
= 'amend' # Amending a commit
53 mode_diff
= 'diff' # Diffing against an arbitrary commit
55 # Modes where we can checkout files from the $head
56 modes_undoable
= {mode_amend
, mode_diff
, mode_index
, mode_worktree
}
58 # Modes where we can partially stage files
59 modes_partially_stageable
= {
66 # Modes where we can partially unstage files
67 modes_unstageable
= {mode_amend
, mode_diff
, mode_index
}
69 unstaged
= property(lambda self
: self
.modified
+ self
.unmerged
+ self
.untracked
)
70 """An aggregate of the modified, unmerged, and untracked file lists."""
72 def __init__(self
, context
, cwd
=None):
73 """Interface to the main repository status"""
76 self
.context
= context
77 self
.git
= context
.git
78 self
.cfg
= context
.cfg
79 self
.selection
= context
.selection
81 self
.initialized
= False
86 self
.diff_type
= Types
.TEXT
87 self
.file_type
= Types
.TEXT
88 self
.mode
= self
.mode_none
90 self
.is_cherry_picking
= False
91 self
.is_merging
= False
92 self
.is_rebasing
= False
93 self
.is_applying_patch
= False
94 self
.currentbranch
= ''
98 self
.filter_paths
= None
101 self
.commitmsg
= '' # current commit message
102 self
._auto
_commitmsg
= '' # e.g. .git/MERGE_MSG
103 self
._prev
_commitmsg
= '' # saved here when clobbered by .git/MERGE_MSG
105 self
.modified
= [] # modified, staged, untracked, unmerged paths
109 self
.upstream_changed
= [] # paths that've changed upstream
110 self
.staged_deleted
= set()
111 self
.unstaged_deleted
= set()
112 self
.submodules
= set()
113 self
.submodules_list
= None # lazy loaded
115 self
.error
= None # The last error message.
116 self
.ref_sort
= 0 # (0: version, 1:reverse-chrono)
117 self
.local_branches
= []
118 self
.remote_branches
= []
121 self
.set_worktree(cwd
)
123 def is_diff_mode(self
):
124 """Are we in diff mode?"""
125 return self
.mode
== self
.mode_diff
127 def is_unstageable(self
):
128 """Are we in a mode that supports "unstage" actions?"""
129 return self
.mode
in self
.modes_unstageable
131 def is_amend_mode(self
):
132 """Are we amending a commit?"""
133 return self
.mode
== self
.mode_amend
135 def is_undoable(self
):
136 """Can we checkout from the current branch or head ref?"""
137 return self
.mode
in self
.modes_undoable
139 def is_partially_stageable(self
):
140 """Whether partial staging should be allowed."""
141 return self
.mode
in self
.modes_partially_stageable
143 def is_stageable(self
):
144 """Whether staging should be allowed."""
145 return self
.is_partially_stageable() or self
.mode
== self
.mode_untracked
147 def all_branches(self
):
148 return self
.local_branches
+ self
.remote_branches
150 def set_worktree(self
, worktree
):
151 last_worktree
= self
.git
.paths
.worktree
152 self
.git
.set_worktree(worktree
)
154 is_valid
= self
.git
.is_valid()
156 reset
= last_worktree
is None or last_worktree
!= worktree
157 cwd
= self
.git
.getcwd()
158 self
.project
= os
.path
.basename(cwd
)
159 self
.set_directory(cwd
)
161 self
.update_config(reset
=reset
)
163 # Detect the "git init" scenario by checking for branches.
164 # If no branches exist then we cannot use "git rev-parse" yet.
166 refs
= self
.git
.git_path('refs', 'heads')
167 if core
.exists(refs
) and core
.listdir(refs
):
168 # "git rev-parse" exits with a non-zero exit status when the
169 # safe.directory protection is active.
170 status
, _
, err
= self
.git
.rev_parse('HEAD')
171 is_valid
= status
== 0
174 self
.worktree_changed
.emit()
180 def is_git_lfs_enabled(self
):
181 """Return True if `git lfs install` has been run
183 We check for the existence of the "lfs" object-storea, and one of the
184 "git lfs install"-provided hooks. This allows us to detect when
185 "git lfs uninstall" has been run.
188 lfs_filter
= self
.cfg
.get('filter.lfs.clean', default
=False)
189 lfs_dir
= lfs_filter
and self
.git
.git_path('lfs')
190 lfs_hook
= lfs_filter
and self
.cfg
.hooks_path('post-merge')
194 and core
.exists(lfs_dir
)
196 and core
.exists(lfs_hook
)
199 def set_commitmsg(self
, msg
, notify
=True):
202 self
.commit_message_changed
.emit(msg
)
204 def save_commitmsg(self
, msg
=None):
207 path
= self
.git
.git_path('GIT_COLA_MSG')
209 if not msg
.endswith('\n'):
211 core
.write(path
, msg
)
216 def set_diff_text(self
, txt
):
217 """Update the text displayed in the diff editor"""
218 changed
= txt
!= self
.diff_text
220 self
.diff_text_updated
.emit(txt
)
222 self
.diff_text_changed
.emit()
224 def set_diff_type(self
, diff_type
): # text, image
225 """Set the diff type to either text or image"""
226 changed
= diff_type
!= self
.diff_type
227 self
.diff_type
= diff_type
229 self
.diff_type_changed
.emit(diff_type
)
231 def set_file_type(self
, file_type
): # text, image
232 """Set the file type to either text or image"""
233 changed
= file_type
!= self
.file_type
234 self
.file_type
= file_type
236 self
.file_type_changed
.emit(file_type
)
238 def set_images(self
, images
):
239 """Update the images shown in the preview pane"""
241 self
.images_changed
.emit(images
)
243 def set_directory(self
, path
):
244 self
.directory
= path
246 def set_mode(self
, mode
, head
=None):
247 """Set the current editing mode (worktree, index, amending, ...)"""
248 # Do not allow going into index or worktree mode when amending.
249 if self
.is_amend_mode() and mode
!= self
.mode_none
:
251 # We cannot amend in the middle of git cherry-pick, git am or git merge.
253 self
.is_cherry_picking
or self
.is_merging
or self
.is_applying_patch
254 ) and mode
== self
.mode_amend
:
257 # Stay in diff mode until explicitly reset.
258 if self
.mode
== self
.mode_diff
and mode
!= self
.mode_none
:
259 mode
= self
.mode_diff
260 head
= head
or self
.head
262 # If we are amending then we'll use "HEAD^", otherwise use the specified
263 # head or "HEAD" if head has not been specified.
264 if mode
== self
.mode_amend
:
271 self
.mode_changed
.emit(mode
)
273 def update_path_filter(self
, filter_paths
):
274 self
.filter_paths
= filter_paths
275 self
.update_file_status()
277 def emit_about_to_update(self
):
278 self
.previous_contents
.emit(
279 self
.staged
, self
.unmerged
, self
.modified
, self
.untracked
281 self
.about_to_update
.emit()
283 def emit_updated(self
):
286 def update_file_status(self
, update_index
=False):
287 """Update modified/staged files status"""
288 self
.emit_about_to_update()
289 self
.update_files(update_index
=update_index
, emit
=True)
291 def update_file_merge_status(self
):
292 """Update modified/staged files and Merge/Rebase/Cherry-pick status"""
293 self
.emit_about_to_update()
294 self
._update
_merge
_rebase
_status
()
295 self
.update_file_status()
297 def update_status(self
, update_index
=False, reset
=False):
298 # Give observers a chance to respond
299 self
.emit_about_to_update()
300 self
.initialized
= True
301 self
._update
_merge
_rebase
_status
()
302 self
._update
_files
(update_index
=update_index
)
303 self
._update
_remotes
()
304 self
._update
_branches
_and
_tags
()
305 self
._update
_commitmsg
()
308 self
.update_submodules_list()
311 def update_config(self
, emit
=False, reset
=False):
314 self
.annex
= self
.cfg
.is_annex()
315 self
.lfs
= self
.is_git_lfs_enabled()
319 def update_files(self
, update_index
=False, emit
=False):
320 self
._update
_files
(update_index
=update_index
)
324 def _update_files(self
, update_index
=False):
325 context
= self
.context
326 display_untracked
= prefs
.display_untracked(context
)
327 state
= gitcmds
.worktree_state(
330 update_index
=update_index
,
331 display_untracked
=display_untracked
,
332 paths
=self
.filter_paths
,
334 self
.staged
= state
.get('staged', [])
335 self
.modified
= state
.get('modified', [])
336 self
.unmerged
= state
.get('unmerged', [])
337 self
.untracked
= state
.get('untracked', [])
338 self
.upstream_changed
= state
.get('upstream_changed', [])
339 self
.staged_deleted
= state
.get('staged_deleted', set())
340 self
.unstaged_deleted
= state
.get('unstaged_deleted', set())
341 self
.submodules
= state
.get('submodules', set())
343 selection
= self
.selection
347 selection
.update(self
)
348 if selection
.is_empty():
349 self
.set_diff_text('')
353 bool(self
.staged
or self
.modified
or self
.unmerged
or self
.untracked
)
356 def is_empty_repository(self
):
357 return not self
.local_branches
359 def _update_remotes(self
):
360 self
.remotes
= gitcfg
.get_remotes(self
.cfg
)
362 def _update_branches_and_tags(self
):
363 context
= self
.context
368 sort_key
= sort_types
[self
.ref_sort
]
369 local_branches
, remote_branches
, tags
= gitcmds
.all_refs(
370 context
, split
=True, sort_key
=sort_key
372 self
.local_branches
= local_branches
373 self
.remote_branches
= remote_branches
375 # Set these early since they are used to calculate 'upstream_changed'.
376 self
.currentbranch
= gitcmds
.current_branch(self
.context
)
377 self
.refs_updated
.emit()
379 def _update_merge_rebase_status(self
):
380 cherry_pick_head
= self
.git
.git_path('CHERRY_PICK_HEAD')
381 merge_head
= self
.git
.git_path('MERGE_HEAD')
382 rebase_merge
= self
.git
.git_path('rebase-merge')
383 rebase_apply
= self
.git
.git_path('rebase-apply', 'applying')
384 self
.is_cherry_picking
= cherry_pick_head
and core
.exists(cherry_pick_head
)
385 self
.is_merging
= merge_head
and core
.exists(merge_head
)
386 self
.is_rebasing
= rebase_merge
and core
.exists(rebase_merge
)
387 self
.is_applying_patch
= rebase_apply
and core
.exists(rebase_apply
)
388 if self
.mode
== self
.mode_amend
and (
389 self
.is_merging
or self
.is_cherry_picking
or self
.is_applying_patch
391 self
.set_mode(self
.mode_none
)
393 def _update_commitmsg(self
):
394 """Check for merge message files and update the commit message
396 The message is cleared when the merge completes.
398 if self
.is_amend_mode():
400 # Check if there's a message file in .git/
401 context
= self
.context
402 merge_msg_path
= gitcmds
.merge_message_path(context
)
404 msg
= gitcmds
.read_merge_commit_message(context
, merge_msg_path
)
405 if msg
!= self
._auto
_commitmsg
:
406 self
._auto
_commitmsg
= msg
407 self
._prev
_commitmsg
= self
.commitmsg
408 self
.set_commitmsg(msg
)
410 elif self
._auto
_commitmsg
and self
._auto
_commitmsg
== self
.commitmsg
:
411 self
._auto
_commitmsg
= ''
412 self
.set_commitmsg(self
._prev
_commitmsg
)
414 def update_submodules_list(self
):
415 self
.submodules_list
= gitcmds
.list_submodule(self
.context
)
416 self
.submodules_changed
.emit()
418 def update_remotes(self
):
419 self
._update
_remotes
()
422 def update_refs(self
):
423 """Update tag and branch names"""
424 self
.emit_about_to_update()
425 self
._update
_branches
_and
_tags
()
428 def delete_branch(self
, branch
):
429 status
, out
, err
= self
.git
.branch(branch
, D
=True)
431 return status
, out
, err
433 def rename_branch(self
, branch
, new_branch
):
434 status
, out
, err
= self
.git
.branch(branch
, new_branch
, M
=True)
436 return status
, out
, err
438 def remote_url(self
, name
, action
):
439 push
= action
== 'PUSH'
440 return gitcmds
.remote_url(self
.context
, name
, push
=push
)
442 def fetch(self
, remote
, **opts
):
443 result
= run_remote_action(self
.context
, self
.git
.fetch
, remote
, **opts
)
447 def push(self
, remote
, remote_branch
='', local_branch
='', **opts
):
448 # Swap the branches in push mode (reverse of fetch)
450 'local_branch': remote_branch
,
451 '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
= f
'{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
)