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 class MainModel(QtCore
.QObject
):
21 """Repository status model"""
23 # Refactor: split this class apart into separate DiffModel, CommitMessageModel,
24 # StatusModel, and an DiffEditorState.
27 about_to_update
= Signal()
28 previous_contents
= Signal(list, list, list, list)
29 commit_message_changed
= Signal(object)
30 diff_text_changed
= Signal()
31 diff_text_updated
= Signal(str)
32 # "diff_type" {text,image} represents the diff viewer mode.
33 diff_type_changed
= Signal(object)
34 # "file_type" {text,image} represents the selected file type.
35 file_type_changed
= Signal(object)
36 images_changed
= Signal(object)
37 mode_changed
= Signal(str)
38 submodules_changed
= Signal()
39 refs_updated
= Signal()
41 worktree_changed
= Signal()
44 mode_none
= 'none' # Default: nothing's happened, do nothing
45 mode_worktree
= 'worktree' # Comparing index to worktree
46 mode_diffstat
= 'diffstat' # Showing a diffstat
47 mode_display
= 'display' # Displaying arbitrary information
48 mode_untracked
= 'untracked' # Dealing with an untracked file
49 mode_untracked_diff
= 'untracked-diff' # Diffing an untracked file
50 mode_index
= 'index' # Comparing index to last commit
51 mode_amend
= 'amend' # Amending a commit
52 mode_diff
= 'diff' # Diffing against an arbitrary commit
54 # Modes where we can checkout files from the $head
55 modes_undoable
= {mode_amend
, mode_diff
, mode_index
, mode_worktree
}
57 # Modes where we can partially stage files
58 modes_partially_stageable
= {
65 # Modes where we can partially unstage files
66 modes_unstageable
= {mode_amend
, mode_diff
, mode_index
}
68 unstaged
= property(lambda self
: self
.modified
+ self
.unmerged
+ self
.untracked
)
69 """An aggregate of the modified, unmerged, and untracked file lists."""
71 def __init__(self
, context
, cwd
=None):
72 """Interface to the main repository status"""
75 self
.context
= context
76 self
.git
= context
.git
77 self
.cfg
= context
.cfg
78 self
.selection
= context
.selection
80 self
.initialized
= False
85 self
.diff_type
= Types
.TEXT
86 self
.file_type
= Types
.TEXT
87 self
.mode
= self
.mode_none
89 self
.is_cherry_picking
= False
90 self
.is_merging
= False
91 self
.is_rebasing
= False
92 self
.is_applying_patch
= False
93 self
.currentbranch
= ''
97 self
.filter_paths
= None
100 self
.commitmsg
= '' # current commit message
101 self
._auto
_commitmsg
= '' # e.g. .git/MERGE_MSG
102 self
._prev
_commitmsg
= '' # saved here when clobbered by .git/MERGE_MSG
104 self
.modified
= [] # modified, staged, untracked, unmerged paths
108 self
.upstream_changed
= [] # paths that've changed upstream
109 self
.staged_deleted
= set()
110 self
.unstaged_deleted
= set()
111 self
.submodules
= set()
112 self
.submodules_list
= None # lazy loaded
114 self
.error
= None # The last error message.
115 self
.ref_sort
= 0 # (0: version, 1:reverse-chrono)
116 self
.local_branches
= []
117 self
.remote_branches
= []
120 self
.set_worktree(cwd
)
122 def is_diff_mode(self
):
123 """Are we in diff mode?"""
124 return self
.mode
== self
.mode_diff
126 def is_unstageable(self
):
127 """Are we in a mode that supports "unstage" actions?"""
128 return self
.mode
in self
.modes_unstageable
130 def is_amend_mode(self
):
131 """Are we amending a commit?"""
132 return self
.mode
== self
.mode_amend
134 def is_undoable(self
):
135 """Can we checkout from the current branch or head ref?"""
136 return self
.mode
in self
.modes_undoable
138 def is_partially_stageable(self
):
139 """Whether partial staging should be allowed."""
140 return self
.mode
in self
.modes_partially_stageable
142 def is_stageable(self
):
143 """Whether staging should be allowed."""
144 return self
.is_partially_stageable() or self
.mode
== self
.mode_untracked
146 def all_branches(self
):
147 return self
.local_branches
+ self
.remote_branches
149 def set_worktree(self
, worktree
):
150 last_worktree
= self
.git
.paths
.worktree
151 self
.git
.set_worktree(worktree
)
153 is_valid
= self
.git
.is_valid()
155 reset
= last_worktree
is None or last_worktree
!= worktree
156 cwd
= self
.git
.getcwd()
157 self
.project
= os
.path
.basename(cwd
)
158 self
.set_directory(cwd
)
160 self
.update_config(reset
=reset
)
162 # Detect the "git init" scenario by checking for branches.
163 # If no branches exist then we cannot use "git rev-parse" yet.
165 refs
= self
.git
.git_path('refs', 'heads')
166 if core
.exists(refs
) and core
.listdir(refs
):
167 # "git rev-parse" exits with a non-zero exit status when the
168 # safe.directory protection is active.
169 status
, _
, err
= self
.git
.rev_parse('HEAD')
170 is_valid
= status
== 0
173 self
.worktree_changed
.emit()
179 def is_git_lfs_enabled(self
):
180 """Return True if `git lfs install` has been run
182 We check for the existence of the "lfs" object-storea, and one of the
183 "git lfs install"-provided hooks. This allows us to detect when
184 "git lfs uninstall" has been run.
187 lfs_filter
= self
.cfg
.get('filter.lfs.clean', default
=False)
188 lfs_dir
= lfs_filter
and self
.git
.git_path('lfs')
189 lfs_hook
= lfs_filter
and self
.cfg
.hooks_path('post-merge')
193 and core
.exists(lfs_dir
)
195 and core
.exists(lfs_hook
)
198 def set_commitmsg(self
, msg
, notify
=True):
201 self
.commit_message_changed
.emit(msg
)
203 def save_commitmsg(self
, msg
=None):
206 path
= self
.git
.git_path('GIT_COLA_MSG')
208 if not msg
.endswith('\n'):
210 core
.write(path
, msg
)
215 def set_diff_text(self
, txt
):
216 """Update the text displayed in the diff editor"""
217 changed
= txt
!= self
.diff_text
219 self
.diff_text_updated
.emit(txt
)
221 self
.diff_text_changed
.emit()
223 def set_diff_type(self
, diff_type
): # text, image
224 """Set the diff type to either text or image"""
225 changed
= diff_type
!= self
.diff_type
226 self
.diff_type
= diff_type
228 self
.diff_type_changed
.emit(diff_type
)
230 def set_file_type(self
, file_type
): # text, image
231 """Set the file type to either text or image"""
232 changed
= file_type
!= self
.file_type
233 self
.file_type
= file_type
235 self
.file_type_changed
.emit(file_type
)
237 def set_images(self
, images
):
238 """Update the images shown in the preview pane"""
240 self
.images_changed
.emit(images
)
242 def set_directory(self
, path
):
243 self
.directory
= path
245 def set_mode(self
, mode
, head
=None):
246 """Set the current editing mode (worktree, index, amending, ...)"""
247 # Do not allow going into index or worktree mode when amending.
248 if self
.is_amend_mode() and mode
!= self
.mode_none
:
250 # We cannot amend in the middle of git cherry-pick, git am or git merge.
252 self
.is_cherry_picking
or self
.is_merging
or self
.is_applying_patch
253 ) and mode
== self
.mode_amend
:
256 # Stay in diff mode until explicitly reset.
257 if self
.mode
== self
.mode_diff
and mode
!= self
.mode_none
:
258 mode
= self
.mode_diff
259 head
= head
or self
.head
261 # If we are amending then we'll use "HEAD^", otherwise use the specified
262 # head or "HEAD" if head has not been specified.
263 if mode
== self
.mode_amend
:
270 self
.mode_changed
.emit(mode
)
272 def update_path_filter(self
, filter_paths
):
273 self
.filter_paths
= filter_paths
274 self
.update_file_status()
276 def emit_about_to_update(self
):
277 self
.previous_contents
.emit(
278 self
.staged
, self
.unmerged
, self
.modified
, self
.untracked
280 self
.about_to_update
.emit()
282 def emit_updated(self
):
285 def update_file_status(self
, update_index
=False):
286 """Update modified/staged files status"""
287 self
.emit_about_to_update()
288 self
.update_files(update_index
=update_index
, emit
=True)
290 def update_file_merge_status(self
):
291 """Update modified/staged files and Merge/Rebase/Cherry-pick status"""
292 self
.emit_about_to_update()
293 self
._update
_merge
_rebase
_status
()
294 self
.update_file_status()
296 def update_status(self
, update_index
=False, reset
=False):
297 # Give observers a chance to respond
298 self
.emit_about_to_update()
299 self
.initialized
= True
300 self
._update
_merge
_rebase
_status
()
301 self
._update
_files
(update_index
=update_index
)
302 self
._update
_remotes
()
303 self
._update
_branches
_and
_tags
()
304 self
._update
_commitmsg
()
307 self
.update_submodules_list()
310 def update_config(self
, emit
=False, reset
=False):
313 self
.annex
= self
.cfg
.is_annex()
314 self
.lfs
= self
.is_git_lfs_enabled()
318 def update_files(self
, update_index
=False, emit
=False):
319 self
._update
_files
(update_index
=update_index
)
323 def _update_files(self
, update_index
=False):
324 context
= self
.context
325 display_untracked
= prefs
.display_untracked(context
)
326 state
= gitcmds
.worktree_state(
329 update_index
=update_index
,
330 display_untracked
=display_untracked
,
331 paths
=self
.filter_paths
,
333 self
.staged
= state
.get('staged', [])
334 self
.modified
= state
.get('modified', [])
335 self
.unmerged
= state
.get('unmerged', [])
336 self
.untracked
= state
.get('untracked', [])
337 self
.upstream_changed
= state
.get('upstream_changed', [])
338 self
.staged_deleted
= state
.get('staged_deleted', set())
339 self
.unstaged_deleted
= state
.get('unstaged_deleted', set())
340 self
.submodules
= state
.get('submodules', set())
342 selection
= self
.selection
346 selection
.update(self
)
347 if selection
.is_empty():
348 self
.set_diff_text('')
352 bool(self
.staged
or self
.modified
or self
.unmerged
or self
.untracked
)
355 def is_empty_repository(self
):
356 return not self
.local_branches
358 def _update_remotes(self
):
359 self
.remotes
= gitcfg
.get_remotes(self
.cfg
)
361 def _update_branches_and_tags(self
):
362 context
= self
.context
367 sort_key
= sort_types
[self
.ref_sort
]
368 local_branches
, remote_branches
, tags
= gitcmds
.all_refs(
369 context
, split
=True, sort_key
=sort_key
371 self
.local_branches
= local_branches
372 self
.remote_branches
= remote_branches
374 # Set these early since they are used to calculate 'upstream_changed'.
375 self
.currentbranch
= gitcmds
.current_branch(self
.context
)
376 self
.refs_updated
.emit()
378 def _update_merge_rebase_status(self
):
379 cherry_pick_head
= self
.git
.git_path('CHERRY_PICK_HEAD')
380 merge_head
= self
.git
.git_path('MERGE_HEAD')
381 rebase_merge
= self
.git
.git_path('rebase-merge')
382 rebase_apply
= self
.git
.git_path('rebase-apply', 'applying')
383 self
.is_cherry_picking
= cherry_pick_head
and core
.exists(cherry_pick_head
)
384 self
.is_merging
= merge_head
and core
.exists(merge_head
)
385 self
.is_rebasing
= rebase_merge
and core
.exists(rebase_merge
)
386 self
.is_applying_patch
= rebase_apply
and core
.exists(rebase_apply
)
387 if self
.mode
== self
.mode_amend
and (
388 self
.is_merging
or self
.is_cherry_picking
or self
.is_applying_patch
390 self
.set_mode(self
.mode_none
)
392 def _update_commitmsg(self
):
393 """Check for merge message files and update the commit message
395 The message is cleared when the merge completes.
397 if self
.is_amend_mode():
399 # Check if there's a message file in .git/
400 context
= self
.context
401 merge_msg_path
= gitcmds
.merge_message_path(context
)
403 msg
= gitcmds
.read_merge_commit_message(context
, merge_msg_path
)
404 if msg
!= self
._auto
_commitmsg
:
405 self
._auto
_commitmsg
= msg
406 self
._prev
_commitmsg
= self
.commitmsg
407 self
.set_commitmsg(msg
)
409 elif self
._auto
_commitmsg
and self
._auto
_commitmsg
== self
.commitmsg
:
410 self
._auto
_commitmsg
= ''
411 self
.set_commitmsg(self
._prev
_commitmsg
)
413 def update_submodules_list(self
):
414 self
.submodules_list
= gitcmds
.list_submodule(self
.context
)
415 self
.submodules_changed
.emit()
417 def update_remotes(self
):
418 self
._update
_remotes
()
421 def update_refs(self
):
422 """Update tag and branch names"""
423 self
.emit_about_to_update()
424 self
._update
_branches
_and
_tags
()
427 def delete_branch(self
, branch
):
428 status
, out
, err
= self
.git
.branch(branch
, D
=True)
430 return status
, out
, err
432 def rename_branch(self
, branch
, new_branch
):
433 status
, out
, err
= self
.git
.branch(branch
, new_branch
, M
=True)
435 return status
, out
, err
437 def remote_url(self
, name
, action
):
438 push
= action
== 'PUSH'
439 return gitcmds
.remote_url(self
.context
, name
, push
=push
)
441 def fetch(self
, remote
, **opts
):
442 result
= run_remote_action(self
.context
, self
.git
.fetch
, remote
, **opts
)
446 def push(self
, remote
, remote_branch
='', local_branch
='', **opts
):
447 # Swap the branches in push mode (reverse of fetch)
449 'local_branch': remote_branch
,
450 'remote_branch': local_branch
,
452 result
= run_remote_action(
453 self
.context
, self
.git
.push
, remote
, push
=True, **opts
458 def pull(self
, remote
, **opts
):
459 result
= run_remote_action(
460 self
.context
, self
.git
.pull
, remote
, pull
=True, **opts
462 # Pull can result in merge conflicts
464 self
.update_files(update_index
=False, emit
=True)
467 def create_branch(self
, name
, base
, track
=False, force
=False):
468 """Create a branch named 'name' from revision 'base'
470 Pass track=True to create a local tracking branch.
472 return self
.git
.branch(name
, base
, track
=track
, force
=force
)
474 def is_commit_published(self
):
475 """Return True if the latest commit exists in any remote branch"""
476 return bool(self
.git
.branch(r
=True, contains
='HEAD')[STDOUT
])
478 def untrack_paths(self
, paths
):
479 context
= self
.context
480 status
, out
, err
= gitcmds
.untrack_paths(context
, paths
)
481 self
.update_file_status()
482 return status
, out
, err
485 """If we've chosen a directory then use it, otherwise use current"""
487 return self
.directory
490 def cycle_ref_sort(self
):
491 """Choose the next ref sort type (version, reverse-chronological)"""
492 self
.set_ref_sort(self
.ref_sort
+ 1)
494 def set_ref_sort(self
, raw_value
):
495 value
= raw_value
% 2 # Currently two sort types
496 if value
== self
.ref_sort
:
498 self
.ref_sort
= value
503 """File types (used for image diff modes)"""
524 """Return arguments for git fetch/push/pull"""
527 what
= refspec_arg(local_branch
, remote_branch
, pull
, push
)
536 kwargs
['rebase'] = True
538 kwargs
['ff_only'] = True
540 kwargs
['no_ff'] = True
542 if push
and version
.check_git(context
, 'force-with-lease'):
543 kwargs
['force_with_lease'] = True
545 kwargs
['force'] = True
547 if push
and set_upstream
:
548 kwargs
['set_upstream'] = True
550 kwargs
['tags'] = True
552 kwargs
['prune'] = True
554 return (args
, kwargs
)
557 def refspec(src
, dst
, push
=False):
558 if push
and src
== dst
:
561 spec
= f
'{src}:{dst}'
565 def refspec_arg(local_branch
, remote_branch
, pull
, push
):
566 """Return the refspec for a fetch or pull command"""
567 if not pull
and local_branch
and remote_branch
:
568 what
= refspec(remote_branch
, local_branch
, push
=push
)
570 what
= local_branch
or remote_branch
or None
574 def run_remote_action(context
, action
, remote
, **kwargs
):
575 args
, kwargs
= remote_args(context
, remote
, **kwargs
)
576 return action(*args
, **kwargs
)