dev: format code using "garden fmt" (black)
[git-cola.git] / cola / models / main.py
blobc4a29ab5b8b1dc3d1461be6951cb8b9553b61594
1 """The central cola model"""
2 from __future__ import absolute_import, division, print_function, unicode_literals
3 import os
5 from qtpy import QtCore
6 from qtpy.QtCore import Signal
8 from .. import core
9 from .. import gitcmds
10 from .. import gitcfg
11 from .. import version
12 from ..git import STDOUT
13 from . import prefs
16 def create(context):
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.
28 # Signals
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()
42 updated = Signal()
43 worktree_changed = Signal()
45 # States
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
80 self.annex = False
81 self.lfs = False
82 self.head = 'HEAD'
83 self.diff_text = ''
84 self.diff_type = Types.TEXT
85 self.file_type = Types.TEXT
86 self.mode = self.mode_none
87 self.filename = 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 = ''
93 self.directory = ''
94 self.project = ''
95 self.remotes = []
96 self.filter_paths = None
97 self.images = []
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
104 self.staged = []
105 self.untracked = []
106 self.unmerged = []
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 = []
117 self.tags = []
118 if cwd:
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()
153 if 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)
158 core.chdir(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.
163 err = None
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
170 if is_valid:
171 self.error = None
172 self.worktree_changed.emit()
173 else:
174 self.error = err
176 return is_valid
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')
189 return (
190 lfs_filter
191 and lfs_dir
192 and core.exists(lfs_dir)
193 and lfs_hook
194 and core.exists(lfs_hook)
197 def set_commitmsg(self, msg, notify=True):
198 self.commitmsg = msg
199 if notify:
200 self.commit_message_changed.emit(msg)
202 def save_commitmsg(self, msg=None):
203 if msg is None:
204 msg = self.commitmsg
205 path = self.git.git_path('GIT_COLA_MSG')
206 try:
207 if not msg.endswith('\n'):
208 msg += '\n'
209 core.write(path, msg)
210 except (OSError, IOError):
211 pass
212 return path
214 def set_diff_text(self, txt):
215 """Update the text displayed in the diff editor"""
216 changed = txt != self.diff_text
217 self.diff_text = txt
218 self.diff_text_updated.emit(txt)
219 if changed:
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
226 if changed:
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
233 if changed:
234 self.file_type_changed.emit(file_type)
236 def set_images(self, images):
237 """Update the images shown in the preview pane"""
238 self.images = images
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:
248 return
249 # We cannot amend in the middle of git cherry-pick, git am or git merge.
250 if (
251 self.is_cherry_picking or self.is_merging or self.is_applying_patch
252 ) and mode == self.mode_amend:
253 mode = self.mode
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
259 else:
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:
263 head = 'HEAD^'
264 elif not head:
265 head = 'HEAD'
267 self.head = head
268 self.mode = mode
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):
282 self.updated.emit()
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()
304 self.update_config()
305 if reset:
306 self.update_submodules_list()
307 self.emit_updated()
309 def update_config(self, emit=False, reset=False):
310 if reset:
311 self.cfg.reset()
312 self.annex = self.cfg.is_annex()
313 self.lfs = self.is_git_lfs_enabled()
314 if emit:
315 self.emit_updated()
317 def update_files(self, update_index=False, emit=False):
318 self._update_files(update_index=update_index)
319 if emit:
320 self.emit_updated()
322 def _update_files(self, update_index=False):
323 context = self.context
324 display_untracked = prefs.display_untracked(context)
325 state = gitcmds.worktree_state(
326 context,
327 head=self.head,
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
342 if self.is_empty():
343 selection.reset()
344 else:
345 selection.update(self)
346 if selection.is_empty():
347 self.set_diff_text('')
349 def is_empty(self):
350 return not (
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
362 sort_types = (
363 'version:refname',
364 '-committerdate',
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
372 self.tags = tags
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():
397 return
398 # Check if there's a message file in .git/
399 context = self.context
400 merge_msg_path = gitcmds.merge_message_path(context)
401 if merge_msg_path:
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()
418 self.update_refs()
420 def update_refs(self):
421 """Update tag and branch names"""
422 self.emit_about_to_update()
423 self._update_branches_and_tags()
424 self.emit_updated()
426 def delete_branch(self, branch):
427 status, out, err = self.git.branch(branch, D=True)
428 self.update_refs()
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)
433 self.update_refs()
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)
442 self.update_refs()
443 return result
445 def push(self, remote, remote_branch='', local_branch='', **opts):
446 # Swap the branches in push mode (reverse of fetch)
447 opts.update(
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
456 self.update_refs()
457 return result
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
464 self.update_refs()
465 self.update_files(update_index=False, emit=True)
466 return result
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
485 def getcwd(self):
486 """If we've chosen a directory then use it, otherwise use current"""
487 if self.directory:
488 return self.directory
489 return core.getcwd()
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:
498 return
499 self.ref_sort = value
500 self.update_refs()
503 class Types(object):
504 """File types (used for image diff modes)"""
506 IMAGE = 'image'
507 TEXT = 'text'
510 # Helpers
511 # pylint: disable=too-many-arguments
512 def remote_args(
513 context,
514 remote,
515 local_branch='',
516 remote_branch='',
517 ff_only=False,
518 force=False,
519 no_ff=False,
520 tags=False,
521 rebase=False,
522 pull=False,
523 push=False,
524 set_upstream=False,
525 prune=False,
527 """Return arguments for git fetch/push/pull"""
529 args = [remote]
530 what = refspec_arg(local_branch, remote_branch, pull, push)
531 if what:
532 args.append(what)
534 kwargs = {
535 'verbose': True,
537 if pull:
538 if rebase:
539 kwargs['rebase'] = True
540 elif ff_only:
541 kwargs['ff_only'] = True
542 elif no_ff:
543 kwargs['no_ff'] = True
544 elif force:
545 # pylint: disable=simplifiable-if-statement
546 if push and version.check_git(context, 'force-with-lease'):
547 kwargs['force_with_lease'] = True
548 else:
549 kwargs['force'] = True
551 if push and set_upstream:
552 kwargs['set_upstream'] = True
553 if tags:
554 kwargs['tags'] = True
555 if prune:
556 kwargs['prune'] = True
558 return (args, kwargs)
561 def refspec(src, dst, push=False):
562 if push and src == dst:
563 spec = src
564 else:
565 spec = '%s:%s' % (src, dst)
566 return spec
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)
573 else:
574 what = local_branch or remote_branch or None
575 return what
578 def run_remote_action(context, action, remote, **kwargs):
579 args, kwargs = remote_args(context, remote, **kwargs)
580 return action(*args, **kwargs)