tree-wide: remove pylint cruft
[git-cola.git] / cola / models / main.py
blob40e4ef20d34ef0f9b96f8c4f00e9546cee7d0a2c
1 """The central cola model"""
2 import os
4 from qtpy import QtCore
5 from qtpy.QtCore import Signal
7 from .. import core
8 from .. import gitcmds
9 from .. import gitcfg
10 from .. import version
11 from ..git import STDOUT
12 from . import prefs
15 def create(context):
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.
26 # Signals
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()
40 updated = Signal()
41 worktree_changed = Signal()
43 # States
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 = {
59 mode_amend,
60 mode_diff,
61 mode_worktree,
62 mode_untracked_diff,
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"""
73 super().__init__()
75 self.context = context
76 self.git = context.git
77 self.cfg = context.cfg
78 self.selection = context.selection
80 self.initialized = False
81 self.annex = False
82 self.lfs = False
83 self.head = 'HEAD'
84 self.diff_text = ''
85 self.diff_type = Types.TEXT
86 self.file_type = Types.TEXT
87 self.mode = self.mode_none
88 self.filename = 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 = ''
94 self.directory = ''
95 self.project = ''
96 self.remotes = []
97 self.filter_paths = None
98 self.images = []
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
105 self.staged = []
106 self.untracked = []
107 self.unmerged = []
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 = []
118 self.tags = []
119 if cwd:
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()
154 if 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)
159 core.chdir(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.
164 err = None
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
171 if is_valid:
172 self.error = None
173 self.worktree_changed.emit()
174 else:
175 self.error = err
177 return is_valid
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')
190 return (
191 lfs_filter
192 and lfs_dir
193 and core.exists(lfs_dir)
194 and lfs_hook
195 and core.exists(lfs_hook)
198 def set_commitmsg(self, msg, notify=True):
199 self.commitmsg = msg
200 if notify:
201 self.commit_message_changed.emit(msg)
203 def save_commitmsg(self, msg=None):
204 if msg is None:
205 msg = self.commitmsg
206 path = self.git.git_path('GIT_COLA_MSG')
207 try:
208 if not msg.endswith('\n'):
209 msg += '\n'
210 core.write(path, msg)
211 except OSError:
212 pass
213 return path
215 def set_diff_text(self, txt):
216 """Update the text displayed in the diff editor"""
217 changed = txt != self.diff_text
218 self.diff_text = txt
219 self.diff_text_updated.emit(txt)
220 if changed:
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
227 if changed:
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
234 if changed:
235 self.file_type_changed.emit(file_type)
237 def set_images(self, images):
238 """Update the images shown in the preview pane"""
239 self.images = images
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:
249 return
250 # We cannot amend in the middle of git cherry-pick, git am or git merge.
251 if (
252 self.is_cherry_picking or self.is_merging or self.is_applying_patch
253 ) and mode == self.mode_amend:
254 mode = self.mode
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
260 else:
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:
264 head = 'HEAD^'
265 elif not head:
266 head = 'HEAD'
268 self.head = head
269 self.mode = mode
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):
283 self.updated.emit()
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()
305 self.update_config()
306 if reset:
307 self.update_submodules_list()
308 self.emit_updated()
310 def update_config(self, emit=False, reset=False):
311 if reset:
312 self.cfg.reset()
313 self.annex = self.cfg.is_annex()
314 self.lfs = self.is_git_lfs_enabled()
315 if emit:
316 self.emit_updated()
318 def update_files(self, update_index=False, emit=False):
319 self._update_files(update_index=update_index)
320 if emit:
321 self.emit_updated()
323 def _update_files(self, update_index=False):
324 context = self.context
325 display_untracked = prefs.display_untracked(context)
326 state = gitcmds.worktree_state(
327 context,
328 head=self.head,
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
343 if self.is_empty():
344 selection.reset()
345 else:
346 selection.update(self)
347 if selection.is_empty():
348 self.set_diff_text('')
350 def is_empty(self):
351 return not (
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
363 sort_types = (
364 'version:refname',
365 '-committerdate',
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
373 self.tags = tags
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():
398 return
399 # Check if there's a message file in .git/
400 context = self.context
401 merge_msg_path = gitcmds.merge_message_path(context)
402 if merge_msg_path:
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()
419 self.update_refs()
421 def update_refs(self):
422 """Update tag and branch names"""
423 self.emit_about_to_update()
424 self._update_branches_and_tags()
425 self.emit_updated()
427 def delete_branch(self, branch):
428 status, out, err = self.git.branch(branch, D=True)
429 self.update_refs()
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)
434 self.update_refs()
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)
443 self.update_refs()
444 return result
446 def push(self, remote, remote_branch='', local_branch='', **opts):
447 # Swap the branches in push mode (reverse of fetch)
448 opts.update({
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
455 self.update_refs()
456 return result
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
463 self.update_refs()
464 self.update_files(update_index=False, emit=True)
465 return result
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
484 def getcwd(self):
485 """If we've chosen a directory then use it, otherwise use current"""
486 if self.directory:
487 return self.directory
488 return core.getcwd()
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:
497 return
498 self.ref_sort = value
499 self.update_refs()
502 class Types:
503 """File types (used for image diff modes)"""
505 IMAGE = 'image'
506 TEXT = 'text'
509 def remote_args(
510 context,
511 remote,
512 local_branch='',
513 remote_branch='',
514 ff_only=False,
515 force=False,
516 no_ff=False,
517 tags=False,
518 rebase=False,
519 pull=False,
520 push=False,
521 set_upstream=False,
522 prune=False,
524 """Return arguments for git fetch/push/pull"""
526 args = [remote]
527 what = refspec_arg(local_branch, remote_branch, pull, push)
528 if what:
529 args.append(what)
531 kwargs = {
532 'verbose': True,
534 if pull:
535 if rebase:
536 kwargs['rebase'] = True
537 elif ff_only:
538 kwargs['ff_only'] = True
539 elif no_ff:
540 kwargs['no_ff'] = True
541 elif force:
542 if push and version.check_git(context, 'force-with-lease'):
543 kwargs['force_with_lease'] = True
544 else:
545 kwargs['force'] = True
547 if push and set_upstream:
548 kwargs['set_upstream'] = True
549 if tags:
550 kwargs['tags'] = True
551 if prune:
552 kwargs['prune'] = True
554 return (args, kwargs)
557 def refspec(src, dst, push=False):
558 if push and src == dst:
559 spec = src
560 else:
561 spec = f'{src}:{dst}'
562 return spec
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)
569 else:
570 what = local_branch or remote_branch or None
571 return what
574 def run_remote_action(context, action, remote, **kwargs):
575 args, kwargs = remote_args(context, remote, **kwargs)
576 return action(*args, **kwargs)