1# Copyright (C) 2007-2018 David Aguilar
2"""This module provides the central cola model.
3"""
4from __future__ import division, absolute_import, unicode_literals
5
6import os
7
8from .. import core
9from .. import gitcmds
10from .. import version
11from ..git import STDOUT
12from ..observable import Observable
13from . import prefs
14
15
16def create(context):
17    """Create the repository status model"""
18    return MainModel(context)
19
20
21# pylint: disable=too-many-public-methods
22class MainModel(Observable):
23    """Repository status model"""
24
25    # TODO this class can probably be split apart into a DiffModel,
26    # CommitMessageModel, StatusModel, and an AppStatusStateMachine.
27
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'
45
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
53
54    # Modes where we can checkout files from the $head
55    modes_undoable = set((mode_amend, mode_index, mode_worktree))
56
57    # Modes where we can partially stage files
58    modes_stageable = set((mode_amend, mode_worktree, mode_untracked))
59
60    # Modes where we can partially unstage files
61    modes_unstageable = set((mode_amend, mode_index))
62
63    unstaged = property(lambda self: self.modified + self.unmerged + self.untracked)
64    """An aggregate of the modified, unmerged, and untracked file lists."""
65
66    def __init__(self, context, cwd=None):
67        """Interface to the main repository status"""
68        Observable.__init__(self)
69
70        self.context = context
71        self.git = context.git
72        self.cfg = context.cfg
73        self.selection = context.selection
74
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 = []
92
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
96
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 = []
106
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)
113
114    def unstageable(self):
115        return self.mode in self.modes_unstageable
116
117    def amending(self):
118        return self.mode == self.mode_amend
119
120    def undoable(self):
121        """Whether we can checkout files from the $head."""
122        return self.mode in self.modes_undoable
123
124    def stageable(self):
125        """Whether staging should be allowed."""
126        return self.mode in self.modes_stageable
127
128    def all_branches(self):
129        return self.local_branches + self.remote_branches
130
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
142
143    def is_git_lfs_enabled(self):
144        """Return True if `git lfs install` has been run
145
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.
149
150        """
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)
160        )
161
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)
166
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
178
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)
186
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)
193
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)
200
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)
205
206    def set_directory(self, path):
207        self.directory = path
208
209    def set_filename(self, filename):
210        self.filename = filename
211        self.notify_observers(self.message_filename_changed, filename)
212
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)
227
228    def update_path_filter(self, filter_paths):
229        self.filter_paths = filter_paths
230        self.update_file_status()
231
232    def emit_about_to_update(self):
233        self.notify_observers(self.message_about_to_update)
234
235    def emit_updated(self):
236        self.notify_observers(self.message_updated)
237
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)
241
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()
254
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()
262
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()
267
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,
277        )
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())
286
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('')
294
295    def is_empty(self):
296        return not (
297            bool(self.staged or self.modified or self.unmerged or self.untracked)
298        )
299
300    def is_empty_repository(self):
301        return not self.local_branches
302
303    def _update_remotes(self):
304        self.remotes = self.git.remote()[STDOUT].splitlines()
305
306    def _update_branches_and_tags(self):
307        context = self.context
308        sort_types = (
309            'version:refname',
310            '-committerdate',
311        )
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
315        )
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)
322
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)
330
331    def _update_commitmsg(self):
332        """Check for merge message files and update the commit message
333
334        The message is cleared when the merge completes
335
336        """
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)
348
349        elif self._auto_commitmsg and self._auto_commitmsg == self.commitmsg:
350            self._auto_commitmsg = ''
351            self.set_commitmsg(self._prev_commitmsg)
352
353    def update_submodules_list(self):
354        self.submodules_list = gitcmds.list_submodule(self.context)
355        self.notify_observers(self.message_submodules_changed)
356
357    def update_remotes(self):
358        self._update_remotes()
359        self.update_refs()
360
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()
366
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
371
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
376
377    def remote_url(self, name, action):
378        push = action == 'PUSH'
379        return gitcmds.remote_url(self.context, name, push=push)
380
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
385
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
391        )
392        self.update_refs()
393        return result
394
395    def pull(self, remote, **opts):
396        result = run_remote_action(
397            self.context, self.git.pull, remote, pull=True, **opts
398        )
399        # Pull can result in merge conflicts
400        self.update_refs()
401        self.update_files(update_index=False, emit=True)
402        return result
403
404    def create_branch(self, name, base, track=False, force=False):
405        """Create a branch named 'name' from revision 'base'
406
407        Pass track=True to create a local tracking branch.
408        """
409        return self.git.branch(name, base, track=track, force=force)
410
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))
425
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])
429
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
435
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()
441
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)
445
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()
452
453
454class Types(object):
455    """File types (used for image diff modes)"""
456
457    IMAGE = 'image'
458    TEXT = 'text'
459
460
461# Helpers
462# pylint: disable=too-many-arguments
463def 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,
477):
478    """Return arguments for git fetch/push/pull"""
479
480    args = [remote]
481    what = refspec_arg(local_branch, remote_branch, pull, push)
482    if what:
483        args.append(what)
484
485    kwargs = {
486        'verbose': True,
487    }
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
501
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
508
509    return (args, kwargs)
510
511
512def 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
518
519
520def 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
527
528
529def run_remote_action(context, action, remote, **kwargs):
530    args, kwargs = remote_args(context, remote, **kwargs)
531    return action(*args, **kwargs)
532