commit: strip commentary from .git/MERGE_MSG
[git-cola.git] / cola / models / main.py
blobf32127210ad9583f2dbf21f66a8d5e91d041cbaa
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 # 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.
27 # Signals
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()
41 updated = Signal()
42 worktree_changed = Signal()
44 # States
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 = {
60 mode_amend,
61 mode_diff,
62 mode_worktree,
63 mode_untracked_diff,
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"""
74 super().__init__()
76 self.context = context
77 self.git = context.git
78 self.cfg = context.cfg
79 self.selection = context.selection
81 self.initialized = False
82 self.annex = False
83 self.lfs = False
84 self.head = 'HEAD'
85 self.diff_text = ''
86 self.diff_type = Types.TEXT
87 self.file_type = Types.TEXT
88 self.mode = self.mode_none
89 self.filename = 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 = ''
95 self.directory = ''
96 self.project = ''
97 self.remotes = []
98 self.filter_paths = None
99 self.images = []
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
106 self.staged = []
107 self.untracked = []
108 self.unmerged = []
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 = []
119 self.tags = []
120 if cwd:
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()
155 if 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)
160 core.chdir(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.
165 err = None
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
172 if is_valid:
173 self.error = None
174 self.worktree_changed.emit()
175 else:
176 self.error = err
178 return is_valid
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')
191 return (
192 lfs_filter
193 and lfs_dir
194 and core.exists(lfs_dir)
195 and lfs_hook
196 and core.exists(lfs_hook)
199 def set_commitmsg(self, msg, notify=True):
200 self.commitmsg = msg
201 if notify:
202 self.commit_message_changed.emit(msg)
204 def save_commitmsg(self, msg=None):
205 if msg is None:
206 msg = self.commitmsg
207 path = self.git.git_path('GIT_COLA_MSG')
208 try:
209 if not msg.endswith('\n'):
210 msg += '\n'
211 core.write(path, msg)
212 except OSError:
213 pass
214 return path
216 def set_diff_text(self, txt):
217 """Update the text displayed in the diff editor"""
218 changed = txt != self.diff_text
219 self.diff_text = txt
220 self.diff_text_updated.emit(txt)
221 if changed:
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
228 if changed:
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
235 if changed:
236 self.file_type_changed.emit(file_type)
238 def set_images(self, images):
239 """Update the images shown in the preview pane"""
240 self.images = images
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:
250 return
251 # We cannot amend in the middle of git cherry-pick, git am or git merge.
252 if (
253 self.is_cherry_picking or self.is_merging or self.is_applying_patch
254 ) and mode == self.mode_amend:
255 mode = self.mode
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
261 else:
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:
265 head = 'HEAD^'
266 elif not head:
267 head = 'HEAD'
269 self.head = head
270 self.mode = mode
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):
284 self.updated.emit()
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()
306 self.update_config()
307 if reset:
308 self.update_submodules_list()
309 self.emit_updated()
311 def update_config(self, emit=False, reset=False):
312 if reset:
313 self.cfg.reset()
314 self.annex = self.cfg.is_annex()
315 self.lfs = self.is_git_lfs_enabled()
316 if emit:
317 self.emit_updated()
319 def update_files(self, update_index=False, emit=False):
320 self._update_files(update_index=update_index)
321 if emit:
322 self.emit_updated()
324 def _update_files(self, update_index=False):
325 context = self.context
326 display_untracked = prefs.display_untracked(context)
327 state = gitcmds.worktree_state(
328 context,
329 head=self.head,
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
344 if self.is_empty():
345 selection.reset()
346 else:
347 selection.update(self)
348 if selection.is_empty():
349 self.set_diff_text('')
351 def is_empty(self):
352 return not (
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
364 sort_types = (
365 'version:refname',
366 '-committerdate',
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
374 self.tags = tags
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():
399 return
400 # Check if there's a message file in .git/
401 context = self.context
402 merge_msg_path = gitcmds.merge_message_path(context)
403 if merge_msg_path:
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()
420 self.update_refs()
422 def update_refs(self):
423 """Update tag and branch names"""
424 self.emit_about_to_update()
425 self._update_branches_and_tags()
426 self.emit_updated()
428 def delete_branch(self, branch):
429 status, out, err = self.git.branch(branch, D=True)
430 self.update_refs()
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)
435 self.update_refs()
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)
444 self.update_refs()
445 return result
447 def push(self, remote, remote_branch='', local_branch='', **opts):
448 # Swap the branches in push mode (reverse of fetch)
449 opts.update({
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
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:
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 = f'{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)