gitcmds: honor core.hooksPath
[git-cola.git] / cola / models / main.py
blob7494531f746f67f659a0937648d2efd92ce09f86
1 # Copyright (C) 2007-2018 David Aguilar
2 """This module provides the central cola model.
3 """
4 from __future__ import division, absolute_import, unicode_literals
6 import os
8 from .. import core
9 from .. import gitcmds
10 from .. import version
11 from ..git import STDOUT
12 from ..observable import Observable
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(Observable):
23 """Repository status model"""
25 # TODO this class can probably be split apart into a DiffModel,
26 # CommitMessageModel, StatusModel, and an AppStatusStateMachine.
28 # Observable messages
29 message_about_to_update = 'about_to_update'
30 message_commit_message_changed = 'commit_message_changed'
31 message_diff_text_changed = 'diff_text_changed'
32 message_diff_text_updated = 'diff_text_updated'
33 # "diff_type" {text,image} represents the diff viewer mode.
34 message_diff_type_changed = 'diff_type_changed'
35 # "file_type" {text,image} represents the selected file type.
36 message_file_type_changed = 'file_type_changed'
37 message_filename_changed = 'filename_changed'
38 message_images_changed = 'images_changed'
39 message_mode_about_to_change = 'mode_about_to_change'
40 message_mode_changed = 'mode_changed'
41 message_submodules_changed = 'message_submodules_changed'
42 message_refs_updated = 'message_refs_updated'
43 message_updated = 'updated'
44 message_worktree_changed = 'message_worktree_changed'
46 # States
47 mode_none = 'none' # Default: nothing's happened, do nothing
48 mode_worktree = 'worktree' # Comparing index to worktree
49 mode_diffstat = 'diffstat' # Showing a diffstat
50 mode_untracked = 'untracked' # Dealing with an untracked file
51 mode_index = 'index' # Comparing index to last commit
52 mode_amend = 'amend' # Amending a commit
54 # Modes where we can checkout files from the $head
55 modes_undoable = set((mode_amend, mode_index, mode_worktree))
57 # Modes where we can partially stage files
58 modes_stageable = set((mode_amend, mode_worktree, mode_untracked))
60 # Modes where we can partially unstage files
61 modes_unstageable = set((mode_amend, mode_index))
63 unstaged = property(lambda self: self.modified + self.unmerged + self.untracked)
64 """An aggregate of the modified, unmerged, and untracked file lists."""
66 def __init__(self, context, cwd=None):
67 """Interface to the main repository status"""
68 Observable.__init__(self)
70 self.context = context
71 self.git = context.git
72 self.cfg = context.cfg
73 self.selection = context.selection
75 self.initialized = False
76 self.annex = False
77 self.lfs = False
78 self.head = 'HEAD'
79 self.diff_text = ''
80 self.diff_type = Types.TEXT
81 self.file_type = Types.TEXT
82 self.mode = self.mode_none
83 self.filename = None
84 self.is_merging = False
85 self.is_rebasing = False
86 self.currentbranch = ''
87 self.directory = ''
88 self.project = ''
89 self.remotes = []
90 self.filter_paths = None
91 self.images = []
93 self.commitmsg = '' # current commit message
94 self._auto_commitmsg = '' # e.g. .git/MERGE_MSG
95 self._prev_commitmsg = '' # saved here when clobbered by .git/MERGE_MSG
97 self.modified = [] # modified, staged, untracked, unmerged paths
98 self.staged = []
99 self.untracked = []
100 self.unmerged = []
101 self.upstream_changed = [] # paths that've changed upstream
102 self.staged_deleted = set()
103 self.unstaged_deleted = set()
104 self.submodules = set()
105 self.submodules_list = []
107 self.ref_sort = 0 # (0: version, 1:reverse-chrono)
108 self.local_branches = []
109 self.remote_branches = []
110 self.tags = []
111 if cwd:
112 self.set_worktree(cwd)
114 def unstageable(self):
115 return self.mode in self.modes_unstageable
117 def amending(self):
118 return self.mode == self.mode_amend
120 def undoable(self):
121 """Whether we can checkout files from the $head."""
122 return self.mode in self.modes_undoable
124 def stageable(self):
125 """Whether staging should be allowed."""
126 return self.mode in self.modes_stageable
128 def all_branches(self):
129 return self.local_branches + self.remote_branches
131 def set_worktree(self, worktree):
132 self.git.set_worktree(worktree)
133 is_valid = self.git.is_valid()
134 if is_valid:
135 cwd = self.git.getcwd()
136 self.project = os.path.basename(cwd)
137 self.set_directory(cwd)
138 core.chdir(cwd)
139 self.update_config(reset=True)
140 self.notify_observers(self.message_worktree_changed)
141 return is_valid
143 def is_git_lfs_enabled(self):
144 """Return True if `git lfs install` has been run
146 We check for the existence of the "lfs" object-storea, and one of the
147 "git lfs install"-provided hooks. This allows us to detect when
148 "git lfs uninstall" has been run.
151 lfs_filter = self.cfg.get('filter.lfs.clean', default=False)
152 lfs_dir = lfs_filter and self.git.git_path('lfs')
153 lfs_hook = lfs_filter and self.cfg.hooks_path('post-merge')
154 return (
155 lfs_filter
156 and lfs_dir
157 and core.exists(lfs_dir)
158 and lfs_hook
159 and core.exists(lfs_hook)
162 def set_commitmsg(self, msg, notify=True):
163 self.commitmsg = msg
164 if notify:
165 self.notify_observers(self.message_commit_message_changed, msg)
167 def save_commitmsg(self, msg=None):
168 if msg is None:
169 msg = self.commitmsg
170 path = self.git.git_path('GIT_COLA_MSG')
171 try:
172 if not msg.endswith('\n'):
173 msg += '\n'
174 core.write(path, msg)
175 except (OSError, IOError):
176 pass
177 return path
179 def set_diff_text(self, txt):
180 """Update the text displayed in the diff editor"""
181 changed = txt != self.diff_text
182 self.diff_text = txt
183 self.notify_observers(self.message_diff_text_updated, txt)
184 if changed:
185 self.notify_observers(self.message_diff_text_changed)
187 def set_diff_type(self, diff_type): # text, image
188 """Set the diff type to either text or image"""
189 changed = diff_type != self.diff_type
190 self.diff_type = diff_type
191 if changed:
192 self.notify_observers(self.message_diff_type_changed, diff_type)
194 def set_file_type(self, file_type): # text, image
195 """Set the file type to either text or image"""
196 changed = file_type != self.file_type
197 self.file_type = file_type
198 if changed:
199 self.notify_observers(self.message_file_type_changed, file_type)
201 def set_images(self, images):
202 """Update the images shown in the preview pane"""
203 self.images = images
204 self.notify_observers(self.message_images_changed, images)
206 def set_directory(self, path):
207 self.directory = path
209 def set_filename(self, filename):
210 self.filename = filename
211 self.notify_observers(self.message_filename_changed, filename)
213 def set_mode(self, mode):
214 if self.amending():
215 if mode != self.mode_none:
216 return
217 if self.is_merging and mode == self.mode_amend:
218 mode = self.mode
219 if mode == self.mode_amend:
220 head = 'HEAD^'
221 else:
222 head = 'HEAD'
223 self.notify_observers(self.message_mode_about_to_change, mode)
224 self.head = head
225 self.mode = mode
226 self.notify_observers(self.message_mode_changed, mode)
228 def update_path_filter(self, filter_paths):
229 self.filter_paths = filter_paths
230 self.update_file_status()
232 def emit_about_to_update(self):
233 self.notify_observers(self.message_about_to_update)
235 def emit_updated(self):
236 self.notify_observers(self.message_updated)
238 def update_file_status(self, update_index=False):
239 self.emit_about_to_update()
240 self.update_files(update_index=update_index, emit=True)
242 def update_status(self, update_index=False):
243 # Give observers a chance to respond
244 self.emit_about_to_update()
245 self.initialized = True
246 self._update_merge_rebase_status()
247 self._update_files(update_index=update_index)
248 self._update_remotes()
249 self._update_branches_and_tags()
250 self._update_commitmsg()
251 self.update_config()
252 self.update_submodules_list()
253 self.emit_updated()
255 def update_config(self, emit=False, reset=False):
256 if reset:
257 self.cfg.reset()
258 self.annex = self.cfg.is_annex()
259 self.lfs = self.is_git_lfs_enabled()
260 if emit:
261 self.emit_updated()
263 def update_files(self, update_index=False, emit=False):
264 self._update_files(update_index=update_index)
265 if emit:
266 self.emit_updated()
268 def _update_files(self, update_index=False):
269 context = self.context
270 display_untracked = prefs.display_untracked(context)
271 state = gitcmds.worktree_state(
272 context,
273 head=self.head,
274 update_index=update_index,
275 display_untracked=display_untracked,
276 paths=self.filter_paths,
278 self.staged = state.get('staged', [])
279 self.modified = state.get('modified', [])
280 self.unmerged = state.get('unmerged', [])
281 self.untracked = state.get('untracked', [])
282 self.upstream_changed = state.get('upstream_changed', [])
283 self.staged_deleted = state.get('staged_deleted', set())
284 self.unstaged_deleted = state.get('unstaged_deleted', set())
285 self.submodules = state.get('submodules', set())
287 selection = self.selection
288 if self.is_empty():
289 selection.reset()
290 else:
291 selection.update(self)
292 if selection.is_empty():
293 self.set_diff_text('')
295 def is_empty(self):
296 return not (
297 bool(self.staged or self.modified or self.unmerged or self.untracked)
300 def is_empty_repository(self):
301 return not self.local_branches
303 def _update_remotes(self):
304 self.remotes = self.git.remote()[STDOUT].splitlines()
306 def _update_branches_and_tags(self):
307 context = self.context
308 sort_types = (
309 'version:refname',
310 '-committerdate',
312 sort_key = sort_types[self.ref_sort]
313 local_branches, remote_branches, tags = gitcmds.all_refs(
314 context, split=True, sort_key=sort_key
316 self.local_branches = local_branches
317 self.remote_branches = remote_branches
318 self.tags = tags
319 # Set these early since they are used to calculate 'upstream_changed'.
320 self.currentbranch = gitcmds.current_branch(self.context)
321 self.notify_observers(self.message_refs_updated)
323 def _update_merge_rebase_status(self):
324 merge_head = self.git.git_path('MERGE_HEAD')
325 rebase_merge = self.git.git_path('rebase-merge')
326 self.is_merging = merge_head and core.exists(merge_head)
327 self.is_rebasing = rebase_merge and core.exists(rebase_merge)
328 if self.is_merging and self.mode == self.mode_amend:
329 self.set_mode(self.mode_none)
331 def _update_commitmsg(self):
332 """Check for merge message files and update the commit message
334 The message is cleared when the merge completes
337 if self.amending():
338 return
339 # Check if there's a message file in .git/
340 context = self.context
341 merge_msg_path = gitcmds.merge_message_path(context)
342 if merge_msg_path:
343 msg = core.read(merge_msg_path)
344 if msg != self._auto_commitmsg:
345 self._auto_commitmsg = msg
346 self._prev_commitmsg = self.commitmsg
347 self.set_commitmsg(msg)
349 elif self._auto_commitmsg and self._auto_commitmsg == self.commitmsg:
350 self._auto_commitmsg = ''
351 self.set_commitmsg(self._prev_commitmsg)
353 def update_submodules_list(self):
354 self.submodules_list = gitcmds.list_submodule(self.context)
355 self.notify_observers(self.message_submodules_changed)
357 def update_remotes(self):
358 self._update_remotes()
359 self.update_refs()
361 def update_refs(self):
362 """Update tag and branch names"""
363 self.emit_about_to_update()
364 self._update_branches_and_tags()
365 self.emit_updated()
367 def delete_branch(self, branch):
368 status, out, err = self.git.branch(branch, D=True)
369 self.update_refs()
370 return status, out, err
372 def rename_branch(self, branch, new_branch):
373 status, out, err = self.git.branch(branch, new_branch, M=True)
374 self.update_refs()
375 return status, out, err
377 def remote_url(self, name, action):
378 push = action == 'PUSH'
379 return gitcmds.remote_url(self.context, name, push=push)
381 def fetch(self, remote, **opts):
382 result = run_remote_action(self.context, self.git.fetch, remote, **opts)
383 self.update_refs()
384 return result
386 def push(self, remote, remote_branch='', local_branch='', **opts):
387 # Swap the branches in push mode (reverse of fetch)
388 opts.update(dict(local_branch=remote_branch, remote_branch=local_branch))
389 result = run_remote_action(
390 self.context, self.git.push, remote, push=True, **opts
392 self.update_refs()
393 return result
395 def pull(self, remote, **opts):
396 result = run_remote_action(
397 self.context, self.git.pull, remote, pull=True, **opts
399 # Pull can result in merge conflicts
400 self.update_refs()
401 self.update_files(update_index=False, emit=True)
402 return result
404 def create_branch(self, name, base, track=False, force=False):
405 """Create a branch named 'name' from revision 'base'
407 Pass track=True to create a local tracking branch.
409 return self.git.branch(name, base, track=track, force=force)
411 def cherry_pick_list(self, revs):
412 """Cherry-picks each revision into the current branch.
413 Returns a list of command output strings (1 per cherry pick)"""
414 if not revs:
415 return []
416 outs = []
417 errs = []
418 status = 0
419 for rev in revs:
420 stat, out, err = self.git.cherry_pick(rev)
421 status = max(stat, status)
422 outs.append(out)
423 errs.append(err)
424 return (status, '\n'.join(outs), '\n'.join(errs))
426 def is_commit_published(self):
427 """Return True if the latest commit exists in any remote branch"""
428 return bool(self.git.branch(r=True, contains='HEAD')[STDOUT])
430 def untrack_paths(self, paths):
431 context = self.context
432 status, out, err = gitcmds.untrack_paths(context, paths)
433 self.update_file_status()
434 return status, out, err
436 def getcwd(self):
437 """If we've chosen a directory then use it, otherwise use current"""
438 if self.directory:
439 return self.directory
440 return core.getcwd()
442 def cycle_ref_sort(self):
443 """Choose the next ref sort type (version, reverse-chronological)"""
444 self.set_ref_sort(self.ref_sort + 1)
446 def set_ref_sort(self, raw_value):
447 value = raw_value % 2 # Currently two sort types
448 if value == self.ref_sort:
449 return
450 self.ref_sort = value
451 self.update_refs()
454 class Types(object):
455 """File types (used for image diff modes)"""
457 IMAGE = 'image'
458 TEXT = 'text'
461 # Helpers
462 # pylint: disable=too-many-arguments
463 def remote_args(
464 context,
465 remote,
466 local_branch='',
467 remote_branch='',
468 ff_only=False,
469 force=False,
470 no_ff=False,
471 tags=False,
472 rebase=False,
473 pull=False,
474 push=False,
475 set_upstream=False,
476 prune=False,
478 """Return arguments for git fetch/push/pull"""
480 args = [remote]
481 what = refspec_arg(local_branch, remote_branch, pull, push)
482 if what:
483 args.append(what)
485 kwargs = {
486 'verbose': True,
488 if pull:
489 if rebase:
490 kwargs['rebase'] = True
491 elif ff_only:
492 kwargs['ff_only'] = True
493 elif no_ff:
494 kwargs['no_ff'] = True
495 elif force:
496 # pylint: disable=simplifiable-if-statement
497 if push and version.check_git(context, 'force-with-lease'):
498 kwargs['force_with_lease'] = True
499 else:
500 kwargs['force'] = True
502 if push and set_upstream:
503 kwargs['set_upstream'] = True
504 if tags:
505 kwargs['tags'] = True
506 if prune:
507 kwargs['prune'] = True
509 return (args, kwargs)
512 def refspec(src, dst, push=False):
513 if push and src == dst:
514 spec = src
515 else:
516 spec = '%s:%s' % (src, dst)
517 return spec
520 def refspec_arg(local_branch, remote_branch, pull, push):
521 """Return the refspec for a fetch or pull command"""
522 if not pull and local_branch and remote_branch:
523 what = refspec(remote_branch, local_branch, push=push)
524 else:
525 what = local_branch or remote_branch or None
526 return what
529 def run_remote_action(context, action, remote, **kwargs):
530 args, kwargs = remote_args(context, remote, **kwargs)
531 return action(*args, **kwargs)