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 FETCH_HEAD
= 'FETCH_HEAD'
22 """Create the repository status model"""
23 return MainModel(context
)
26 class MainModel(QtCore
.QObject
):
27 """Repository status model"""
29 # Refactor: split this class apart into separate DiffModel, CommitMessageModel,
30 # StatusModel, and an DiffEditorState.
33 about_to_update
= Signal()
34 previous_contents
= Signal(list, list, list, list)
35 commit_message_changed
= Signal(object)
36 diff_text_changed
= Signal()
37 diff_text_updated
= Signal(str)
38 # "diff_type" {text,image} represents the diff viewer mode.
39 diff_type_changed
= Signal(object)
40 # "file_type" {text,image} represents the selected file type.
41 file_type_changed
= Signal(object)
42 images_changed
= Signal(object)
43 mode_changed
= Signal(str)
44 submodules_changed
= Signal()
45 refs_updated
= Signal()
47 worktree_changed
= Signal()
50 mode_none
= 'none' # Default: nothing's happened, do nothing
51 mode_worktree
= 'worktree' # Comparing index to worktree
52 mode_diffstat
= 'diffstat' # Showing a diffstat
53 mode_display
= 'display' # Displaying arbitrary information
54 mode_untracked
= 'untracked' # Dealing with an untracked file
55 mode_untracked_diff
= 'untracked-diff' # Diffing an untracked file
56 mode_index
= 'index' # Comparing index to last commit
57 mode_amend
= 'amend' # Amending a commit
58 mode_diff
= 'diff' # Diffing against an arbitrary commit
60 # Modes where we can checkout files from the $head
61 modes_undoable
= {mode_amend
, mode_diff
, mode_index
, mode_worktree
}
63 # Modes where we can partially stage files
64 modes_partially_stageable
= {
71 # Modes where we can partially unstage files
72 modes_unstageable
= {mode_amend
, mode_diff
, mode_index
}
74 unstaged
= property(lambda self
: self
.modified
+ self
.unmerged
+ self
.untracked
)
75 """An aggregate of the modified, unmerged, and untracked file lists."""
77 def __init__(self
, context
, cwd
=None):
78 """Interface to the main repository status"""
81 self
.context
= context
82 self
.git
= context
.git
83 self
.cfg
= context
.cfg
84 self
.selection
= context
.selection
86 self
.initialized
= False
91 self
.diff_type
= Types
.TEXT
92 self
.file_type
= Types
.TEXT
93 self
.mode
= self
.mode_none
95 self
.is_cherry_picking
= False
96 self
.is_merging
= False
97 self
.is_rebasing
= False
98 self
.is_applying_patch
= False
99 self
.currentbranch
= ''
103 self
.filter_paths
= None
106 self
.commitmsg
= '' # current commit message
107 self
._auto
_commitmsg
= '' # e.g. .git/MERGE_MSG
108 self
._prev
_commitmsg
= '' # saved here when clobbered by .git/MERGE_MSG
110 self
.modified
= [] # modified, staged, untracked, unmerged paths
114 self
.upstream_changed
= [] # paths that've changed upstream
115 self
.staged_deleted
= set()
116 self
.unstaged_deleted
= set()
117 self
.submodules
= set()
118 self
.submodules_list
= None # lazy loaded
120 self
.error
= None # The last error message.
121 self
.ref_sort
= 0 # (0: version, 1:reverse-chrono)
122 self
.local_branches
= []
123 self
.remote_branches
= []
126 self
.set_worktree(cwd
)
128 def is_diff_mode(self
):
129 """Are we in diff mode?"""
130 return self
.mode
== self
.mode_diff
132 def is_unstageable(self
):
133 """Are we in a mode that supports "unstage" actions?"""
134 return self
.mode
in self
.modes_unstageable
136 def is_amend_mode(self
):
137 """Are we amending a commit?"""
138 return self
.mode
== self
.mode_amend
140 def is_undoable(self
):
141 """Can we checkout from the current branch or head ref?"""
142 return self
.mode
in self
.modes_undoable
144 def is_partially_stageable(self
):
145 """Whether partial staging should be allowed."""
146 return self
.mode
in self
.modes_partially_stageable
148 def is_stageable(self
):
149 """Whether staging should be allowed."""
150 return self
.is_partially_stageable() or self
.mode
== self
.mode_untracked
152 def all_branches(self
):
153 return self
.local_branches
+ self
.remote_branches
155 def set_worktree(self
, worktree
):
156 last_worktree
= self
.git
.paths
.worktree
157 self
.git
.set_worktree(worktree
)
159 is_valid
= self
.git
.is_valid()
161 reset
= last_worktree
is None or last_worktree
!= worktree
162 cwd
= self
.git
.getcwd()
163 self
.project
= os
.path
.basename(cwd
)
164 self
.set_directory(cwd
)
166 self
.update_config(reset
=reset
)
168 # Detect the "git init" scenario by checking for branches.
169 # If no branches exist then we cannot use "git rev-parse" yet.
171 refs
= self
.git
.git_path('refs', 'heads')
172 if core
.exists(refs
) and core
.listdir(refs
):
173 # "git rev-parse" exits with a non-zero exit status when the
174 # safe.directory protection is active.
175 status
, _
, err
= self
.git
.rev_parse('HEAD')
176 is_valid
= status
== 0
179 self
.worktree_changed
.emit()
185 def is_git_lfs_enabled(self
):
186 """Return True if `git lfs install` has been run
188 We check for the existence of the "lfs" object-storea, and one of the
189 "git lfs install"-provided hooks. This allows us to detect when
190 "git lfs uninstall" has been run.
193 lfs_filter
= self
.cfg
.get('filter.lfs.clean', default
=False)
194 lfs_dir
= lfs_filter
and self
.git
.git_path('lfs')
195 lfs_hook
= lfs_filter
and self
.cfg
.hooks_path('post-merge')
199 and core
.exists(lfs_dir
)
201 and core
.exists(lfs_hook
)
204 def set_commitmsg(self
, msg
, notify
=True):
207 self
.commit_message_changed
.emit(msg
)
209 def save_commitmsg(self
, msg
=None):
212 path
= self
.git
.git_path('GIT_COLA_MSG')
214 if not msg
.endswith('\n'):
216 core
.write(path
, msg
)
221 def set_diff_text(self
, txt
):
222 """Update the text displayed in the diff editor"""
223 changed
= txt
!= self
.diff_text
225 self
.diff_text_updated
.emit(txt
)
227 self
.diff_text_changed
.emit()
229 def set_diff_type(self
, diff_type
): # text, image
230 """Set the diff type to either text or image"""
231 changed
= diff_type
!= self
.diff_type
232 self
.diff_type
= diff_type
234 self
.diff_type_changed
.emit(diff_type
)
236 def set_file_type(self
, file_type
): # text, image
237 """Set the file type to either text or image"""
238 changed
= file_type
!= self
.file_type
239 self
.file_type
= file_type
241 self
.file_type_changed
.emit(file_type
)
243 def set_images(self
, images
):
244 """Update the images shown in the preview pane"""
246 self
.images_changed
.emit(images
)
248 def set_directory(self
, path
):
249 self
.directory
= path
251 def set_mode(self
, mode
, head
=None):
252 """Set the current editing mode (worktree, index, amending, ...)"""
253 # Do not allow going into index or worktree mode when amending.
254 if self
.is_amend_mode() and mode
!= self
.mode_none
:
256 # We cannot amend in the middle of git cherry-pick, git am or git merge.
258 self
.is_cherry_picking
or self
.is_merging
or self
.is_applying_patch
259 ) and mode
== self
.mode_amend
:
262 # Stay in diff mode until explicitly reset.
263 if self
.mode
== self
.mode_diff
and mode
!= self
.mode_none
:
264 mode
= self
.mode_diff
265 head
= head
or self
.head
267 # If we are amending then we'll use "HEAD^", otherwise use the specified
268 # head or "HEAD" if head has not been specified.
269 if mode
== self
.mode_amend
:
276 self
.mode_changed
.emit(mode
)
278 def update_path_filter(self
, filter_paths
):
279 self
.filter_paths
= filter_paths
280 self
.update_file_status()
282 def emit_about_to_update(self
):
283 self
.previous_contents
.emit(
284 self
.staged
, self
.unmerged
, self
.modified
, self
.untracked
286 self
.about_to_update
.emit()
288 def emit_updated(self
):
291 def update_file_status(self
, update_index
=False):
292 """Update modified/staged files status"""
293 self
.emit_about_to_update()
294 self
.update_files(update_index
=update_index
, emit
=True)
296 def update_file_merge_status(self
):
297 """Update modified/staged files and Merge/Rebase/Cherry-pick status"""
298 self
.emit_about_to_update()
299 self
._update
_merge
_rebase
_status
()
300 self
.update_file_status()
302 def update_status(self
, update_index
=False, reset
=False):
303 # Give observers a chance to respond
304 self
.emit_about_to_update()
305 self
.initialized
= True
306 self
._update
_merge
_rebase
_status
()
307 self
._update
_files
(update_index
=update_index
)
308 self
._update
_remotes
()
309 self
._update
_branches
_and
_tags
()
310 self
._update
_commitmsg
()
313 self
.update_submodules_list()
316 def update_config(self
, emit
=False, reset
=False):
319 self
.annex
= self
.cfg
.is_annex()
320 self
.lfs
= self
.is_git_lfs_enabled()
324 def update_files(self
, update_index
=False, emit
=False):
325 self
._update
_files
(update_index
=update_index
)
329 def _update_files(self
, update_index
=False):
330 context
= self
.context
331 display_untracked
= prefs
.display_untracked(context
)
332 state
= gitcmds
.worktree_state(
335 update_index
=update_index
,
336 display_untracked
=display_untracked
,
337 paths
=self
.filter_paths
,
339 self
.staged
= state
.get('staged', [])
340 self
.modified
= state
.get('modified', [])
341 self
.unmerged
= state
.get('unmerged', [])
342 self
.untracked
= state
.get('untracked', [])
343 self
.upstream_changed
= state
.get('upstream_changed', [])
344 self
.staged_deleted
= state
.get('staged_deleted', set())
345 self
.unstaged_deleted
= state
.get('unstaged_deleted', set())
346 self
.submodules
= state
.get('submodules', set())
348 selection
= self
.selection
352 selection
.update(self
)
353 if selection
.is_empty():
354 self
.set_diff_text('')
358 bool(self
.staged
or self
.modified
or self
.unmerged
or self
.untracked
)
361 def is_empty_repository(self
):
362 return not self
.local_branches
364 def _update_remotes(self
):
365 self
.remotes
= gitcfg
.get_remotes(self
.cfg
)
367 def _update_branches_and_tags(self
):
368 context
= self
.context
373 sort_key
= sort_types
[self
.ref_sort
]
374 local_branches
, remote_branches
, tags
= gitcmds
.all_refs(
375 context
, split
=True, sort_key
=sort_key
377 self
.local_branches
= local_branches
378 self
.remote_branches
= remote_branches
380 # Set these early since they are used to calculate 'upstream_changed'.
381 self
.currentbranch
= gitcmds
.current_branch(self
.context
)
382 self
.refs_updated
.emit()
384 def _update_merge_rebase_status(self
):
385 cherry_pick_head
= self
.git
.git_path('CHERRY_PICK_HEAD')
386 merge_head
= self
.git
.git_path('MERGE_HEAD')
387 rebase_merge
= self
.git
.git_path('rebase-merge')
388 rebase_apply
= self
.git
.git_path('rebase-apply', 'applying')
389 self
.is_cherry_picking
= cherry_pick_head
and core
.exists(cherry_pick_head
)
390 self
.is_merging
= merge_head
and core
.exists(merge_head
)
391 self
.is_rebasing
= rebase_merge
and core
.exists(rebase_merge
)
392 self
.is_applying_patch
= rebase_apply
and core
.exists(rebase_apply
)
393 if self
.mode
== self
.mode_amend
and (
394 self
.is_merging
or self
.is_cherry_picking
or self
.is_applying_patch
396 self
.set_mode(self
.mode_none
)
398 def _update_commitmsg(self
):
399 """Check for merge message files and update the commit message
401 The message is cleared when the merge completes.
403 if self
.is_amend_mode():
405 # Check if there's a message file in .git/
406 context
= self
.context
407 merge_msg_path
= gitcmds
.merge_message_path(context
)
409 msg
= gitcmds
.read_merge_commit_message(context
, merge_msg_path
)
410 if msg
!= self
._auto
_commitmsg
:
411 self
._auto
_commitmsg
= msg
412 self
._prev
_commitmsg
= self
.commitmsg
413 self
.set_commitmsg(msg
)
415 elif self
._auto
_commitmsg
and self
._auto
_commitmsg
== self
.commitmsg
:
416 self
._auto
_commitmsg
= ''
417 self
.set_commitmsg(self
._prev
_commitmsg
)
419 def update_submodules_list(self
):
420 self
.submodules_list
= gitcmds
.list_submodule(self
.context
)
421 self
.submodules_changed
.emit()
423 def update_remotes(self
):
424 self
._update
_remotes
()
427 def update_refs(self
):
428 """Update tag and branch names"""
429 self
.emit_about_to_update()
430 self
._update
_branches
_and
_tags
()
433 def delete_branch(self
, branch
):
434 status
, out
, err
= self
.git
.branch(branch
, D
=True)
436 return status
, out
, err
438 def rename_branch(self
, branch
, new_branch
):
439 status
, out
, err
= self
.git
.branch(branch
, new_branch
, M
=True)
441 return status
, out
, err
443 def remote_url(self
, name
, action
):
444 push
= action
== 'PUSH'
445 return gitcmds
.remote_url(self
.context
, name
, push
=push
)
447 def fetch(self
, remote
, **opts
):
448 result
= run_remote_action(self
.context
, self
.git
.fetch
, remote
, FETCH
, **opts
)
452 def push(self
, remote
, remote_branch
='', local_branch
='', **opts
):
453 # Swap the branches in push mode (reverse of fetch)
455 'local_branch': remote_branch
,
456 'remote_branch': local_branch
,
458 result
= run_remote_action(self
.context
, self
.git
.push
, remote
, PUSH
, **opts
)
462 def pull(self
, remote
, **opts
):
463 result
= run_remote_action(self
.context
, self
.git
.pull
, remote
, PULL
, **opts
)
464 # Pull can result in merge conflicts
466 self
.update_files(update_index
=False, emit
=True)
469 def create_branch(self
, name
, base
, track
=False, force
=False):
470 """Create a branch named 'name' from revision 'base'
472 Pass track=True to create a local tracking branch.
474 return self
.git
.branch(name
, base
, track
=track
, force
=force
)
476 def is_commit_published(self
):
477 """Return True if the latest commit exists in any remote branch"""
478 return bool(self
.git
.branch(r
=True, contains
='HEAD')[STDOUT
])
480 def untrack_paths(self
, paths
):
481 context
= self
.context
482 status
, out
, err
= gitcmds
.untrack_paths(context
, paths
)
483 self
.update_file_status()
484 return status
, out
, err
487 """If we've chosen a directory then use it, otherwise use current"""
489 return self
.directory
492 def cycle_ref_sort(self
):
493 """Choose the next ref sort type (version, reverse-chronological)"""
494 self
.set_ref_sort(self
.ref_sort
+ 1)
496 def set_ref_sort(self
, raw_value
):
497 value
= raw_value
% 2 # Currently two sort types
498 if value
== self
.ref_sort
:
500 self
.ref_sort
= value
505 """File types (used for image diff modes)"""
525 """Return arguments for git fetch/push/pull"""
528 what
= refspec_arg(local_branch
, remote_branch
, remote
, action
)
537 kwargs
['rebase'] = True
539 kwargs
['ff_only'] = True
541 kwargs
['no_ff'] = True
543 if action
== PUSH
and version
.check_git(context
, 'force-with-lease'):
544 kwargs
['force_with_lease'] = True
546 kwargs
['force'] = True
548 if action
== PUSH
and set_upstream
:
549 kwargs
['set_upstream'] = True
551 kwargs
['tags'] = True
553 kwargs
['prune'] = True
555 return (args
, kwargs
)
558 def refspec(src
, dst
, action
):
559 if action
== PUSH
and src
== dst
:
562 spec
= f
'{src}:{dst}'
566 def refspec_arg(local_branch
, remote_branch
, remote
, action
):
567 """Return the refspec for a fetch or pull command"""
569 if action
== PUSH
and local_branch
and remote_branch
: # Push with local and remote.
570 ref
= refspec(local_branch
, remote_branch
, action
)
571 elif action
== FETCH
:
572 if local_branch
and remote_branch
: # Fetch with local and remote.
573 if local_branch
== FETCH_HEAD
:
576 ref
= refspec(remote_branch
, local_branch
, action
)
578 # If we are fetching and only a remote branch was specified then setup
579 # a refspec that will fetch into the remote tracking branch only.
582 f
'refs/remotes/{remote}/{remote_branch}',
585 if not ref
and local_branch
!= FETCH_HEAD
:
586 ref
= local_branch
or remote_branch
or None
590 def run_remote_action(context
, fn
, remote
, action
, **kwargs
):
591 """Run fetch, push or pull"""
592 args
, kwargs
= remote_args(context
, remote
, action
, **kwargs
)
593 return fn(*args
, **kwargs
)