1"""Editor commands"""
2from __future__ import division, absolute_import, unicode_literals
3import os
4import re
5import sys
6from fnmatch import fnmatch
7from io import StringIO
8
9try:
10    from send2trash import send2trash
11except ImportError:
12    send2trash = None
13
14from . import compat
15from . import core
16from . import gitcmds
17from . import icons
18from . import resources
19from . import textwrap
20from . import utils
21from . import version
22from .cmd import ContextCommand
23from .diffparse import DiffParser
24from .git import STDOUT
25from .git import EMPTY_TREE_OID
26from .git import MISSING_BLOB_OID
27from .i18n import N_
28from .interaction import Interaction
29from .models import main
30from .models import prefs
31
32
33class UsageError(Exception):
34    """Exception class for usage errors."""
35
36    def __init__(self, title, message):
37        Exception.__init__(self, message)
38        self.title = title
39        self.msg = message
40
41
42class EditModel(ContextCommand):
43    """Commands that mutate the main model diff data"""
44
45    UNDOABLE = True
46
47    def __init__(self, context):
48        """Common edit operations on the main model"""
49        super(EditModel, self).__init__(context)
50
51        self.old_diff_text = self.model.diff_text
52        self.old_filename = self.model.filename
53        self.old_mode = self.model.mode
54        self.old_diff_type = self.model.diff_type
55        self.old_file_type = self.model.file_type
56
57        self.new_diff_text = self.old_diff_text
58        self.new_filename = self.old_filename
59        self.new_mode = self.old_mode
60        self.new_diff_type = self.old_diff_type
61        self.new_file_type = self.old_file_type
62
63    def do(self):
64        """Perform the operation."""
65        self.model.set_filename(self.new_filename)
66        self.model.set_mode(self.new_mode)
67        self.model.set_diff_text(self.new_diff_text)
68        self.model.set_diff_type(self.new_diff_type)
69        self.model.set_file_type(self.new_file_type)
70
71    def undo(self):
72        """Undo the operation."""
73        self.model.set_filename(self.old_filename)
74        self.model.set_mode(self.old_mode)
75        self.model.set_diff_text(self.old_diff_text)
76        self.model.set_diff_type(self.old_diff_type)
77        self.model.set_file_type(self.old_file_type)
78
79
80class ConfirmAction(ContextCommand):
81    """Confirm an action before running it"""
82
83    # pylint: disable=no-self-use
84    def ok_to_run(self):
85        """Return True when the command is ok to run"""
86        return True
87
88    # pylint: disable=no-self-use
89    def confirm(self):
90        """Prompt for confirmation"""
91        return True
92
93    # pylint: disable=no-self-use
94    def action(self):
95        """Run the command and return (status, out, err)"""
96        return (-1, '', '')
97
98    # pylint: disable=no-self-use
99    def success(self):
100        """Callback run on success"""
101        return
102
103    # pylint: disable=no-self-use
104    def command(self):
105        """Command name, for error messages"""
106        return 'git'
107
108    # pylint: disable=no-self-use
109    def error_message(self):
110        """Command error message"""
111        return ''
112
113    def do(self):
114        """Prompt for confirmation before running a command"""
115        status = -1
116        out = err = ''
117        ok = self.ok_to_run() and self.confirm()
118        if ok:
119            status, out, err = self.action()
120            if status == 0:
121                self.success()
122            title = self.error_message()
123            cmd = self.command()
124            Interaction.command(title, cmd, status, out, err)
125
126        return ok, status, out, err
127
128
129class AbortMerge(ConfirmAction):
130    """Reset an in-progress merge back to HEAD"""
131
132    def confirm(self):
133        title = N_('Abort Merge...')
134        question = N_('Aborting the current merge?')
135        info = N_(
136            'Aborting the current merge will cause '
137            '*ALL* uncommitted changes to be lost.\n'
138            'Recovering uncommitted changes is not possible.'
139        )
140        ok_txt = N_('Abort Merge')
141        return Interaction.confirm(
142            title, question, info, ok_txt, default=False, icon=icons.undo()
143        )
144
145    def action(self):
146        status, out, err = gitcmds.abort_merge(self.context)
147        self.model.update_file_status()
148        return status, out, err
149
150    def success(self):
151        self.model.set_commitmsg('')
152
153    def error_message(self):
154        return N_('Error')
155
156    def command(self):
157        return 'git merge'
158
159
160class AmendMode(EditModel):
161    """Try to amend a commit."""
162
163    UNDOABLE = True
164    LAST_MESSAGE = None
165
166    @staticmethod
167    def name():
168        return N_('Amend')
169
170    def __init__(self, context, amend=True):
171        super(AmendMode, self).__init__(context)
172        self.skip = False
173        self.amending = amend
174        self.old_commitmsg = self.model.commitmsg
175        self.old_mode = self.model.mode
176
177        if self.amending:
178            self.new_mode = self.model.mode_amend
179            self.new_commitmsg = gitcmds.prev_commitmsg(context)
180            AmendMode.LAST_MESSAGE = self.model.commitmsg
181            return
182        # else, amend unchecked, regular commit
183        self.new_mode = self.model.mode_none
184        self.new_diff_text = ''
185        self.new_commitmsg = self.model.commitmsg
186        # If we're going back into new-commit-mode then search the
187        # undo stack for a previous amend-commit-mode and grab the
188        # commit message at that point in time.
189        if AmendMode.LAST_MESSAGE is not None:
190            self.new_commitmsg = AmendMode.LAST_MESSAGE
191            AmendMode.LAST_MESSAGE = None
192
193    def do(self):
194        """Leave/enter amend mode."""
195        # Attempt to enter amend mode.  Do not allow this when merging.
196        if self.amending:
197            if self.model.is_merging:
198                self.skip = True
199                self.model.set_mode(self.old_mode)
200                Interaction.information(
201                    N_('Cannot Amend'),
202                    N_(
203                        'You are in the middle of a merge.\n'
204                        'Cannot amend while merging.'
205                    ),
206                )
207                return
208        self.skip = False
209        super(AmendMode, self).do()
210        self.model.set_commitmsg(self.new_commitmsg)
211        self.model.update_file_status()
212
213    def undo(self):
214        if self.skip:
215            return
216        self.model.set_commitmsg(self.old_commitmsg)
217        super(AmendMode, self).undo()
218        self.model.update_file_status()
219
220
221class AnnexAdd(ContextCommand):
222    """Add to Git Annex"""
223
224    def __init__(self, context):
225        super(AnnexAdd, self).__init__(context)
226        self.filename = self.selection.filename()
227
228    def do(self):
229        status, out, err = self.git.annex('add', self.filename)
230        Interaction.command(N_('Error'), 'git annex add', status, out, err)
231        self.model.update_status()
232
233
234class AnnexInit(ContextCommand):
235    """Initialize Git Annex"""
236
237    def do(self):
238        status, out, err = self.git.annex('init')
239        Interaction.command(N_('Error'), 'git annex init', status, out, err)
240        self.model.cfg.reset()
241        self.model.emit_updated()
242
243
244class LFSTrack(ContextCommand):
245    """Add a file to git lfs"""
246
247    def __init__(self, context):
248        super(LFSTrack, self).__init__(context)
249        self.filename = self.selection.filename()
250        self.stage_cmd = Stage(context, [self.filename])
251
252    def do(self):
253        status, out, err = self.git.lfs('track', self.filename)
254        Interaction.command(N_('Error'), 'git lfs track', status, out, err)
255        if status == 0:
256            self.stage_cmd.do()
257
258
259class LFSInstall(ContextCommand):
260    """Initialize git lfs"""
261
262    def do(self):
263        status, out, err = self.git.lfs('install')
264        Interaction.command(N_('Error'), 'git lfs install', status, out, err)
265        self.model.update_config(reset=True, emit=True)
266
267
268class ApplyDiffSelection(ContextCommand):
269    """Apply the selected diff to the worktree or index"""
270
271    def __init__(
272        self,
273        context,
274        first_line_idx,
275        last_line_idx,
276        has_selection,
277        reverse,
278        apply_to_worktree,
279    ):
280        super(ApplyDiffSelection, self).__init__(context)
281        self.first_line_idx = first_line_idx
282        self.last_line_idx = last_line_idx
283        self.has_selection = has_selection
284        self.reverse = reverse
285        self.apply_to_worktree = apply_to_worktree
286
287    def do(self):
288        context = self.context
289        cfg = self.context.cfg
290        diff_text = self.model.diff_text
291
292        parser = DiffParser(self.model.filename, diff_text)
293        if self.has_selection:
294            patch = parser.generate_patch(
295                self.first_line_idx, self.last_line_idx, reverse=self.reverse
296            )
297        else:
298            patch = parser.generate_hunk_patch(
299                self.first_line_idx, reverse=self.reverse
300            )
301        if patch is None:
302            return
303
304        if isinstance(diff_text, core.UStr):
305            # original encoding must prevail
306            encoding = diff_text.encoding
307        else:
308            encoding = cfg.file_encoding(self.model.filename)
309
310        tmp_file = utils.tmp_filename('patch')
311        try:
312            core.write(tmp_file, patch, encoding=encoding)
313            if self.apply_to_worktree:
314                status, out, err = gitcmds.apply_diff_to_worktree(context, tmp_file)
315            else:
316                status, out, err = gitcmds.apply_diff(context, tmp_file)
317        finally:
318            core.unlink(tmp_file)
319
320        Interaction.log_status(status, out, err)
321        self.model.update_file_status(update_index=True)
322
323
324class ApplyPatches(ContextCommand):
325    """Apply patches using the "git am" command"""
326
327    def __init__(self, context, patches):
328        super(ApplyPatches, self).__init__(context)
329        self.patches = patches
330
331    def do(self):
332        status, out, err = self.git.am('-3', *self.patches)
333        Interaction.log_status(status, out, err)
334
335        # Display a diffstat
336        self.model.update_file_status()
337
338        patch_basenames = [os.path.basename(p) for p in self.patches]
339        if len(patch_basenames) > 25:
340            patch_basenames = patch_basenames[:25]
341            patch_basenames.append('...')
342
343        basenames = '\n'.join(patch_basenames)
344        Interaction.information(
345            N_('Patch(es) Applied'),
346            (N_('%d patch(es) applied.') + '\n\n%s') % (len(self.patches), basenames),
347        )
348
349
350class Archive(ContextCommand):
351    """"Export archives using the "git archive" command"""
352
353    def __init__(self, context, ref, fmt, prefix, filename):
354        super(Archive, self).__init__(context)
355        self.ref = ref
356        self.fmt = fmt
357        self.prefix = prefix
358        self.filename = filename
359
360    def do(self):
361        fp = core.xopen(self.filename, 'wb')
362        cmd = ['git', 'archive', '--format=' + self.fmt]
363        if self.fmt in ('tgz', 'tar.gz'):
364            cmd.append('-9')
365        if self.prefix:
366            cmd.append('--prefix=' + self.prefix)
367        cmd.append(self.ref)
368        proc = core.start_command(cmd, stdout=fp)
369        out, err = proc.communicate()
370        fp.close()
371        status = proc.returncode
372        Interaction.log_status(status, out or '', err or '')
373
374
375class Checkout(EditModel):
376    """A command object for git-checkout.
377
378    'argv' is handed off directly to git.
379
380    """
381
382    def __init__(self, context, argv, checkout_branch=False):
383        super(Checkout, self).__init__(context)
384        self.argv = argv
385        self.checkout_branch = checkout_branch
386        self.new_diff_text = ''
387        self.new_diff_type = main.Types.TEXT
388        self.new_file_type = main.Types.TEXT
389
390    def do(self):
391        super(Checkout, self).do()
392        status, out, err = self.git.checkout(*self.argv)
393        if self.checkout_branch:
394            self.model.update_status()
395        else:
396            self.model.update_file_status()
397        Interaction.command(N_('Error'), 'git checkout', status, out, err)
398
399
400class BlamePaths(ContextCommand):
401    """Blame view for paths."""
402
403    @staticmethod
404    def name():
405        return N_('Blame...')
406
407    def __init__(self, context, paths=None):
408        super(BlamePaths, self).__init__(context)
409        if not paths:
410            paths = context.selection.union()
411        viewer = utils.shell_split(prefs.blame_viewer(context))
412        self.argv = viewer + list(paths)
413
414    def do(self):
415        try:
416            core.fork(self.argv)
417        except OSError as e:
418            _, details = utils.format_exception(e)
419            title = N_('Error Launching Blame Viewer')
420            msg = N_('Cannot exec "%s": please configure a blame viewer') % ' '.join(
421                self.argv
422            )
423            Interaction.critical(title, message=msg, details=details)
424
425
426class CheckoutBranch(Checkout):
427    """Checkout a branch."""
428
429    def __init__(self, context, branch):
430        args = [branch]
431        super(CheckoutBranch, self).__init__(context, args, checkout_branch=True)
432
433
434class CherryPick(ContextCommand):
435    """Cherry pick commits into the current branch."""
436
437    def __init__(self, context, commits):
438        super(CherryPick, self).__init__(context)
439        self.commits = commits
440
441    def do(self):
442        self.model.cherry_pick_list(self.commits)
443        self.model.update_file_status()
444
445
446class Revert(ContextCommand):
447    """Cherry pick commits into the current branch."""
448
449    def __init__(self, context, oid):
450        super(Revert, self).__init__(context)
451        self.oid = oid
452
453    def do(self):
454        self.git.revert(self.oid, no_edit=True)
455        self.model.update_file_status()
456
457
458class ResetMode(EditModel):
459    """Reset the mode and clear the model's diff text."""
460
461    def __init__(self, context):
462        super(ResetMode, self).__init__(context)
463        self.new_mode = self.model.mode_none
464        self.new_diff_text = ''
465        self.new_diff_type = main.Types.TEXT
466        self.new_file_type = main.Types.TEXT
467        self.new_filename = ''
468
469    def do(self):
470        super(ResetMode, self).do()
471        self.model.update_file_status()
472
473
474class ResetCommand(ConfirmAction):
475    """Reset state using the "git reset" command"""
476
477    def __init__(self, context, ref):
478        super(ResetCommand, self).__init__(context)
479        self.ref = ref
480
481    def action(self):
482        return self.reset()
483
484    def command(self):
485        return 'git reset'
486
487    def error_message(self):
488        return N_('Error')
489
490    def success(self):
491        self.model.update_file_status()
492
493    def confirm(self):
494        raise NotImplementedError('confirm() must be overridden')
495
496    def reset(self):
497        raise NotImplementedError('reset() must be overridden')
498
499
500class ResetMixed(ResetCommand):
501    @staticmethod
502    def tooltip(ref):
503        tooltip = N_('The branch will be reset using "git reset --mixed %s"')
504        return tooltip % ref
505
506    def confirm(self):
507        title = N_('Reset Branch and Stage (Mixed)')
508        question = N_('Point the current branch head to a new commit?')
509        info = self.tooltip(self.ref)
510        ok_text = N_('Reset Branch')
511        return Interaction.confirm(title, question, info, ok_text)
512
513    def reset(self):
514        return self.git.reset(self.ref, '--', mixed=True)
515
516
517class ResetKeep(ResetCommand):
518    @staticmethod
519    def tooltip(ref):
520        tooltip = N_('The repository will be reset using "git reset --keep %s"')
521        return tooltip % ref
522
523    def confirm(self):
524        title = N_('Restore Worktree and Reset All (Keep Unstaged Changes)')
525        question = N_('Restore worktree, reset, and preserve unstaged edits?')
526        info = self.tooltip(self.ref)
527        ok_text = N_('Reset and Restore')
528        return Interaction.confirm(title, question, info, ok_text)
529
530    def reset(self):
531        return self.git.reset(self.ref, '--', keep=True)
532
533
534class ResetMerge(ResetCommand):
535    @staticmethod
536    def tooltip(ref):
537        tooltip = N_('The repository will be reset using "git reset --merge %s"')
538        return tooltip % ref
539
540    def confirm(self):
541        title = N_('Restore Worktree and Reset All (Merge)')
542        question = N_('Reset Worktree and Reset All?')
543        info = self.tooltip(self.ref)
544        ok_text = N_('Reset and Restore')
545        return Interaction.confirm(title, question, info, ok_text)
546
547    def reset(self):
548        return self.git.reset(self.ref, '--', merge=True)
549
550
551class ResetSoft(ResetCommand):
552    @staticmethod
553    def tooltip(ref):
554        tooltip = N_('The branch will be reset using "git reset --soft %s"')
555        return tooltip % ref
556
557    def confirm(self):
558        title = N_('Reset Branch (Soft)')
559        question = N_('Reset branch?')
560        info = self.tooltip(self.ref)
561        ok_text = N_('Reset Branch')
562        return Interaction.confirm(title, question, info, ok_text)
563
564    def reset(self):
565        return self.git.reset(self.ref, '--', soft=True)
566
567
568class ResetHard(ResetCommand):
569    @staticmethod
570    def tooltip(ref):
571        tooltip = N_('The repository will be reset using "git reset --hard %s"')
572        return tooltip % ref
573
574    def confirm(self):
575        title = N_('Restore Worktree and Reset All (Hard)')
576        question = N_('Restore Worktree and Reset All?')
577        info = self.tooltip(self.ref)
578        ok_text = N_('Reset and Restore')
579        return Interaction.confirm(title, question, info, ok_text)
580
581    def reset(self):
582        return self.git.reset(self.ref, '--', hard=True)
583
584
585class RestoreWorktree(ConfirmAction):
586    """Reset the worktree using the "git read-tree" command"""
587
588    @staticmethod
589    def tooltip(ref):
590        tooltip = N_(
591            'The worktree will be restored using "git read-tree --reset -u %s"'
592        )
593        return tooltip % ref
594
595    def __init__(self, context, ref):
596        super(RestoreWorktree, self).__init__(context)
597        self.ref = ref
598
599    def action(self):
600        return self.git.read_tree(self.ref, reset=True, u=True)
601
602    def command(self):
603        return 'git read-tree --reset -u %s' % self.ref
604
605    def error_message(self):
606        return N_('Error')
607
608    def success(self):
609        self.model.update_file_status()
610
611    def confirm(self):
612        title = N_('Restore Worktree')
613        question = N_('Restore Worktree to %s?') % self.ref
614        info = self.tooltip(self.ref)
615        ok_text = N_('Restore Worktree')
616        return Interaction.confirm(title, question, info, ok_text)
617
618
619class UndoLastCommit(ResetCommand):
620    """Undo the last commit"""
621
622    # NOTE: this is the similar to ResetSoft() with an additional check for
623    # published commits and different messages.
624    def __init__(self, context):
625        super(UndoLastCommit, self).__init__(context, 'HEAD^')
626
627    def confirm(self):
628        check_published = prefs.check_published_commits(self.context)
629        if check_published and self.model.is_commit_published():
630            return Interaction.confirm(
631                N_('Rewrite Published Commit?'),
632                N_(
633                    'This commit has already been published.\n'
634                    'This operation will rewrite published history.\n'
635                    'You probably don\'t want to do this.'
636                ),
637                N_('Undo the published commit?'),
638                N_('Undo Last Commit'),
639                default=False,
640                icon=icons.save(),
641            )
642
643        title = N_('Undo Last Commit')
644        question = N_('Undo last commit?')
645        info = N_('The branch will be reset using "git reset --soft %s"')
646        ok_text = N_('Undo Last Commit')
647        info_text = info % self.ref
648        return Interaction.confirm(title, question, info_text, ok_text)
649
650    def reset(self):
651        return self.git.reset('HEAD^', '--', soft=True)
652
653
654class Commit(ResetMode):
655    """Attempt to create a new commit."""
656
657    def __init__(self, context, amend, msg, sign, no_verify=False):
658        super(Commit, self).__init__(context)
659        self.amend = amend
660        self.msg = msg
661        self.sign = sign
662        self.no_verify = no_verify
663        self.old_commitmsg = self.model.commitmsg
664        self.new_commitmsg = ''
665
666    def do(self):
667        # Create the commit message file
668        context = self.context
669        comment_char = prefs.comment_char(context)
670        msg = self.strip_comments(self.msg, comment_char=comment_char)
671        tmp_file = utils.tmp_filename('commit-message')
672        try:
673            core.write(tmp_file, msg)
674            # Run 'git commit'
675            status, out, err = self.git.commit(
676                F=tmp_file,
677                v=True,
678                gpg_sign=self.sign,
679                amend=self.amend,
680                no_verify=self.no_verify,
681            )
682        finally:
683            core.unlink(tmp_file)
684        if status == 0:
685            super(Commit, self).do()
686            if context.cfg.get(prefs.AUTOTEMPLATE):
687                template_loader = LoadCommitMessageFromTemplate(context)
688                template_loader.do()
689            else:
690                self.model.set_commitmsg(self.new_commitmsg)
691
692        title = N_('Commit failed')
693        Interaction.command(title, 'git commit', status, out, err)
694
695        return status, out, err
696
697    @staticmethod
698    def strip_comments(msg, comment_char='#'):
699        # Strip off comments
700        message_lines = [
701            line for line in msg.split('\n') if not line.startswith(comment_char)
702        ]
703        msg = '\n'.join(message_lines)
704        if not msg.endswith('\n'):
705            msg += '\n'
706
707        return msg
708
709
710class CycleReferenceSort(ContextCommand):
711    """Choose the next reference sort type"""
712
713    def do(self):
714        self.model.cycle_ref_sort()
715
716
717class Ignore(ContextCommand):
718    """Add files to an exclusion file"""
719
720    def __init__(self, context, filenames, local=False):
721        super(Ignore, self).__init__(context)
722        self.filenames = list(filenames)
723        self.local = local
724
725    def do(self):
726        if not self.filenames:
727            return
728        new_additions = '\n'.join(self.filenames) + '\n'
729        for_status = new_additions
730        if self.local:
731            filename = os.path.join('.git', 'info', 'exclude')
732        else:
733            filename = '.gitignore'
734        if core.exists(filename):
735            current_list = core.read(filename)
736            new_additions = current_list.rstrip() + '\n' + new_additions
737        core.write(filename, new_additions)
738        Interaction.log_status(0, 'Added to %s:\n%s' % (filename, for_status), '')
739        self.model.update_file_status()
740
741
742def file_summary(files):
743    txt = core.list2cmdline(files)
744    if len(txt) > 768:
745        txt = txt[:768].rstrip() + '...'
746    wrap = textwrap.TextWrapper()
747    return '\n'.join(wrap.wrap(txt))
748
749
750class RemoteCommand(ConfirmAction):
751    def __init__(self, context, remote):
752        super(RemoteCommand, self).__init__(context)
753        self.remote = remote
754
755    def success(self):
756        self.cfg.reset()
757        self.model.update_remotes()
758
759
760class RemoteAdd(RemoteCommand):
761    def __init__(self, context, remote, url):
762        super(RemoteAdd, self).__init__(context, remote)
763        self.url = url
764
765    def action(self):
766        return self.git.remote('add', self.remote, self.url)
767
768    def error_message(self):
769        return N_('Error creating remote "%s"') % self.remote
770
771    def command(self):
772        return 'git remote add "%s" "%s"' % (self.remote, self.url)
773
774
775class RemoteRemove(RemoteCommand):
776    def confirm(self):
777        title = N_('Delete Remote')
778        question = N_('Delete remote?')
779        info = N_('Delete remote "%s"') % self.remote
780        ok_text = N_('Delete')
781        return Interaction.confirm(title, question, info, ok_text)
782
783    def action(self):
784        return self.git.remote('rm', self.remote)
785
786    def error_message(self):
787        return N_('Error deleting remote "%s"') % self.remote
788
789    def command(self):
790        return 'git remote rm "%s"' % self.remote
791
792
793class RemoteRename(RemoteCommand):
794    def __init__(self, context, remote, new_name):
795        super(RemoteRename, self).__init__(context, remote)
796        self.new_name = new_name
797
798    def confirm(self):
799        title = N_('Rename Remote')
800        text = N_('Rename remote "%(current)s" to "%(new)s"?') % dict(
801            current=self.remote, new=self.new_name
802        )
803        info_text = ''
804        ok_text = title
805        return Interaction.confirm(title, text, info_text, ok_text)
806
807    def action(self):
808        return self.git.remote('rename', self.remote, self.new_name)
809
810    def error_message(self):
811        return N_('Error renaming "%(name)s" to "%(new_name)s"') % dict(
812            name=self.remote, new_name=self.new_name
813        )
814
815    def command(self):
816        return 'git remote rename "%s" "%s"' % (self.remote, self.new_name)
817
818
819class RemoteSetURL(RemoteCommand):
820    def __init__(self, context, remote, url):
821        super(RemoteSetURL, self).__init__(context, remote)
822        self.url = url
823
824    def action(self):
825        return self.git.remote('set-url', self.remote, self.url)
826
827    def error_message(self):
828        return N_('Unable to set URL for "%(name)s" to "%(url)s"') % dict(
829            name=self.remote, url=self.url
830        )
831
832    def command(self):
833        return 'git remote set-url "%s" "%s"' % (self.remote, self.url)
834
835
836class RemoteEdit(ContextCommand):
837    """Combine RemoteRename and RemoteSetURL"""
838
839    def __init__(self, context, old_name, remote, url):
840        super(RemoteEdit, self).__init__(context)
841        self.rename = RemoteRename(context, old_name, remote)
842        self.set_url = RemoteSetURL(context, remote, url)
843
844    def do(self):
845        result = self.rename.do()
846        name_ok = result[0]
847        url_ok = False
848        if name_ok:
849            result = self.set_url.do()
850            url_ok = result[0]
851        return name_ok, url_ok
852
853
854class RemoveFromSettings(ConfirmAction):
855    def __init__(self, context, repo, entry, icon=None):
856        super(RemoveFromSettings, self).__init__(context)
857        self.context = context
858        self.repo = repo
859        self.entry = entry
860        self.icon = icon
861
862    def success(self):
863        self.context.settings.save()
864
865
866class RemoveBookmark(RemoveFromSettings):
867    def confirm(self):
868        entry = self.entry
869        title = msg = N_('Delete Bookmark?')
870        info = N_('%s will be removed from your bookmarks.') % entry
871        ok_text = N_('Delete Bookmark')
872        return Interaction.confirm(title, msg, info, ok_text, icon=self.icon)
873
874    def action(self):
875        self.context.settings.remove_bookmark(self.repo, self.entry)
876        return (0, '', '')
877
878
879class RemoveRecent(RemoveFromSettings):
880    def confirm(self):
881        repo = self.repo
882        title = msg = N_('Remove %s from the recent list?') % repo
883        info = N_('%s will be removed from your recent repositories.') % repo
884        ok_text = N_('Remove')
885        return Interaction.confirm(title, msg, info, ok_text, icon=self.icon)
886
887    def action(self):
888        self.context.settings.remove_recent(self.repo)
889        return (0, '', '')
890
891
892class RemoveFiles(ContextCommand):
893    """Removes files"""
894
895    def __init__(self, context, remover, filenames):
896        super(RemoveFiles, self).__init__(context)
897        if remover is None:
898            remover = os.remove
899        self.remover = remover
900        self.filenames = filenames
901        # We could git-hash-object stuff and provide undo-ability
902        # as an option.  Heh.
903
904    def do(self):
905        files = self.filenames
906        if not files:
907            return
908
909        rescan = False
910        bad_filenames = []
911        remove = self.remover
912        for filename in files:
913            if filename:
914                try:
915                    remove(filename)
916                    rescan = True
917                except OSError:
918                    bad_filenames.append(filename)
919
920        if bad_filenames:
921            Interaction.information(
922                N_('Error'), N_('Deleting "%s" failed') % file_summary(bad_filenames)
923            )
924
925        if rescan:
926            self.model.update_file_status()
927
928
929class Delete(RemoveFiles):
930    """Delete files."""
931
932    def __init__(self, context, filenames):
933        super(Delete, self).__init__(context, os.remove, filenames)
934
935    def do(self):
936        files = self.filenames
937        if not files:
938            return
939
940        title = N_('Delete Files?')
941        msg = N_('The following files will be deleted:') + '\n\n'
942        msg += file_summary(files)
943        info_txt = N_('Delete %d file(s)?') % len(files)
944        ok_txt = N_('Delete Files')
945
946        if Interaction.confirm(
947            title, msg, info_txt, ok_txt, default=True, icon=icons.remove()
948        ):
949            super(Delete, self).do()
950
951
952class MoveToTrash(RemoveFiles):
953    """Move files to the trash using send2trash"""
954
955    AVAILABLE = send2trash is not None
956
957    def __init__(self, context, filenames):
958        super(MoveToTrash, self).__init__(context, send2trash, filenames)
959
960
961class DeleteBranch(ConfirmAction):
962    """Delete a git branch."""
963
964    def __init__(self, context, branch):
965        super(DeleteBranch, self).__init__(context)
966        self.branch = branch
967
968    def confirm(self):
969        title = N_('Delete Branch')
970        question = N_('Delete branch "%s"?') % self.branch
971        info = N_('The branch will be no longer available.')
972        ok_txt = N_('Delete Branch')
973        return Interaction.confirm(
974            title, question, info, ok_txt, default=True, icon=icons.discard()
975        )
976
977    def action(self):
978        return self.model.delete_branch(self.branch)
979
980    def error_message(self):
981        return N_('Error deleting branch "%s"' % self.branch)
982
983    def command(self):
984        command = 'git branch -D %s'
985        return command % self.branch
986
987
988class Rename(ContextCommand):
989    """Rename a set of paths."""
990
991    def __init__(self, context, paths):
992        super(Rename, self).__init__(context)
993        self.paths = paths
994
995    def do(self):
996        msg = N_('Untracking: %s') % (', '.join(self.paths))
997        Interaction.log(msg)
998
999        for path in self.paths:
1000            ok = self.rename(path)
1001            if not ok:
1002                return
1003
1004        self.model.update_status()
1005
1006    def rename(self, path):
1007        git = self.git
1008        title = N_('Rename "%s"') % path
1009
1010        if os.path.isdir(path):
1011            base_path = os.path.dirname(path)
1012        else:
1013            base_path = path
1014        new_path = Interaction.save_as(base_path, title)
1015        if not new_path:
1016            return False
1017
1018        status, out, err = git.mv(path, new_path, force=True, verbose=True)
1019        Interaction.command(N_('Error'), 'git mv', status, out, err)
1020        return status == 0
1021
1022
1023class RenameBranch(ContextCommand):
1024    """Rename a git branch."""
1025
1026    def __init__(self, context, branch, new_branch):
1027        super(RenameBranch, self).__init__(context)
1028        self.branch = branch
1029        self.new_branch = new_branch
1030
1031    def do(self):
1032        branch = self.branch
1033        new_branch = self.new_branch
1034        status, out, err = self.model.rename_branch(branch, new_branch)
1035        Interaction.log_status(status, out, err)
1036
1037
1038class DeleteRemoteBranch(DeleteBranch):
1039    """Delete a remote git branch."""
1040
1041    def __init__(self, context, remote, branch):
1042        super(DeleteRemoteBranch, self).__init__(context, branch)
1043        self.remote = remote
1044
1045    def action(self):
1046        return self.git.push(self.remote, self.branch, delete=True)
1047
1048    def success(self):
1049        self.model.update_status()
1050        Interaction.information(
1051            N_('Remote Branch Deleted'),
1052            N_('"%(branch)s" has been deleted from "%(remote)s".')
1053            % dict(branch=self.branch, remote=self.remote),
1054        )
1055
1056    def error_message(self):
1057        return N_('Error Deleting Remote Branch')
1058
1059    def command(self):
1060        command = 'git push --delete %s %s'
1061        return command % (self.remote, self.branch)
1062
1063
1064def get_mode(model, staged, modified, unmerged, untracked):
1065    if staged:
1066        mode = model.mode_index
1067    elif modified or unmerged:
1068        mode = model.mode_worktree
1069    elif untracked:
1070        mode = model.mode_untracked
1071    else:
1072        mode = model.mode
1073    return mode
1074
1075
1076class DiffText(EditModel):
1077    """Set the diff type to text"""
1078
1079    def __init__(self, context):
1080        super(DiffText, self).__init__(context)
1081        self.new_file_type = main.Types.TEXT
1082        self.new_diff_type = main.Types.TEXT
1083
1084
1085class ToggleDiffType(ContextCommand):
1086    """Toggle the diff type between image and text"""
1087
1088    def __init__(self, context):
1089        super(ToggleDiffType, self).__init__(context)
1090        if self.model.diff_type == main.Types.IMAGE:
1091            self.new_diff_type = main.Types.TEXT
1092            self.new_value = False
1093        else:
1094            self.new_diff_type = main.Types.IMAGE
1095            self.new_value = True
1096
1097    def do(self):
1098        diff_type = self.new_diff_type
1099        value = self.new_value
1100
1101        self.model.set_diff_type(diff_type)
1102
1103        filename = self.model.filename
1104        _, ext = os.path.splitext(filename)
1105        if ext.startswith('.'):
1106            cfg = 'cola.imagediff' + ext
1107            self.cfg.set_repo(cfg, value)
1108
1109
1110class DiffImage(EditModel):
1111    def __init__(
1112        self, context, filename, deleted, staged, modified, unmerged, untracked
1113    ):
1114        super(DiffImage, self).__init__(context)
1115
1116        self.new_filename = filename
1117        self.new_diff_type = self.get_diff_type(filename)
1118        self.new_file_type = main.Types.IMAGE
1119        self.new_mode = get_mode(self.model, staged, modified, unmerged, untracked)
1120        self.staged = staged
1121        self.modified = modified
1122        self.unmerged = unmerged
1123        self.untracked = untracked
1124        self.deleted = deleted
1125        self.annex = self.cfg.is_annex()
1126
1127    def get_diff_type(self, filename):
1128        """Query the diff type to use based on cola.imagediff.<extension>"""
1129        _, ext = os.path.splitext(filename)
1130        if ext.startswith('.'):
1131            # Check eg. "cola.imagediff.svg" to see if we should imagediff.
1132            cfg = 'cola.imagediff' + ext
1133            if self.cfg.get(cfg, True):
1134                result = main.Types.IMAGE
1135            else:
1136                result = main.Types.TEXT
1137        else:
1138            result = main.Types.IMAGE
1139        return result
1140
1141    def do(self):
1142        filename = self.new_filename
1143
1144        if self.staged:
1145            images = self.staged_images()
1146        elif self.modified:
1147            images = self.modified_images()
1148        elif self.unmerged:
1149            images = self.unmerged_images()
1150        elif self.untracked:
1151            images = [(filename, False)]
1152        else:
1153            images = []
1154
1155        self.model.set_images(images)
1156        super(DiffImage, self).do()
1157
1158    def staged_images(self):
1159        context = self.context
1160        git = self.git
1161        head = self.model.head
1162        filename = self.new_filename
1163        annex = self.annex
1164
1165        images = []
1166        index = git.diff_index(head, '--', filename, cached=True)[STDOUT]
1167        if index:
1168            # Example:
1169            #  :100644 100644 fabadb8... 4866510... M      describe.c
1170            parts = index.split(' ')
1171            if len(parts) > 3:
1172                old_oid = parts[2]
1173                new_oid = parts[3]
1174
1175            if old_oid != MISSING_BLOB_OID:
1176                # First, check if we can get a pre-image from git-annex
1177                annex_image = None
1178                if annex:
1179                    annex_image = gitcmds.annex_path(context, head, filename)
1180                if annex_image:
1181                    images.append((annex_image, False))  # git annex HEAD
1182                else:
1183                    image = gitcmds.write_blob_path(context, head, old_oid, filename)
1184                    if image:
1185                        images.append((image, True))
1186
1187            if new_oid != MISSING_BLOB_OID:
1188                found_in_annex = False
1189                if annex and core.islink(filename):
1190                    status, out, _ = git.annex('status', '--', filename)
1191                    if status == 0:
1192                        details = out.split(' ')
1193                        if details and details[0] == 'A':  # newly added file
1194                            images.append((filename, False))
1195                            found_in_annex = True
1196
1197                if not found_in_annex:
1198                    image = gitcmds.write_blob(context, new_oid, filename)
1199                    if image:
1200                        images.append((image, True))
1201
1202        return images
1203
1204    def unmerged_images(self):
1205        context = self.context
1206        git = self.git
1207        head = self.model.head
1208        filename = self.new_filename
1209        annex = self.annex
1210
1211        candidate_merge_heads = ('HEAD', 'CHERRY_HEAD', 'MERGE_HEAD')
1212        merge_heads = [
1213            merge_head
1214            for merge_head in candidate_merge_heads
1215            if core.exists(git.git_path(merge_head))
1216        ]
1217
1218        if annex:  # Attempt to find files in git-annex
1219            annex_images = []
1220            for merge_head in merge_heads:
1221                image = gitcmds.annex_path(context, merge_head, filename)
1222                if image:
1223                    annex_images.append((image, False))
1224            if annex_images:
1225                annex_images.append((filename, False))
1226                return annex_images
1227
1228        # DIFF FORMAT FOR MERGES
1229        # "git-diff-tree", "git-diff-files" and "git-diff --raw"
1230        # can take -c or --cc option to generate diff output also
1231        # for merge commits. The output differs from the format
1232        # described above in the following way:
1233        #
1234        #  1. there is a colon for each parent
1235        #  2. there are more "src" modes and "src" sha1
1236        #  3. status is concatenated status characters for each parent
1237        #  4. no optional "score" number
1238        #  5. single path, only for "dst"
1239        # Example:
1240        #  ::100644 100644 100644 fabadb8... cc95eb0... 4866510... \
1241        #  MM      describe.c
1242        images = []
1243        index = git.diff_index(head, '--', filename, cached=True, cc=True)[STDOUT]
1244        if index:
1245            parts = index.split(' ')
1246            if len(parts) > 3:
1247                first_mode = parts[0]
1248                num_parents = first_mode.count(':')
1249                # colon for each parent, but for the index, the "parents"
1250                # are really entries in stages 1,2,3 (head, base, remote)
1251                # remote, base, head
1252                for i in range(num_parents):
1253                    offset = num_parents + i + 1
1254                    oid = parts[offset]
1255                    try:
1256                        merge_head = merge_heads[i]
1257                    except IndexError:
1258                        merge_head = 'HEAD'
1259                    if oid != MISSING_BLOB_OID:
1260                        image = gitcmds.write_blob_path(
1261                            context, merge_head, oid, filename
1262                        )
1263                        if image:
1264                            images.append((image, True))
1265
1266        images.append((filename, False))
1267        return images
1268
1269    def modified_images(self):
1270        context = self.context
1271        git = self.git
1272        head = self.model.head
1273        filename = self.new_filename
1274        annex = self.annex
1275
1276        images = []
1277        annex_image = None
1278        if annex:  # Check for a pre-image from git-annex
1279            annex_image = gitcmds.annex_path(context, head, filename)
1280        if annex_image:
1281            images.append((annex_image, False))  # git annex HEAD
1282        else:
1283            worktree = git.diff_files('--', filename)[STDOUT]
1284            parts = worktree.split(' ')
1285            if len(parts) > 3:
1286                oid = parts[2]
1287                if oid != MISSING_BLOB_OID:
1288                    image = gitcmds.write_blob_path(context, head, oid, filename)
1289                    if image:
1290                        images.append((image, True))  # HEAD
1291
1292        images.append((filename, False))  # worktree
1293        return images
1294
1295
1296class Diff(EditModel):
1297    """Perform a diff and set the model's current text."""
1298
1299    def __init__(self, context, filename, cached=False, deleted=False):
1300        super(Diff, self).__init__(context)
1301        opts = {}
1302        if cached:
1303            opts['ref'] = self.model.head
1304        self.new_filename = filename
1305        self.new_mode = self.model.mode_worktree
1306        self.new_diff_text = gitcmds.diff_helper(
1307            self.context, filename=filename, cached=cached, deleted=deleted, **opts
1308        )
1309
1310
1311class Diffstat(EditModel):
1312    """Perform a diffstat and set the model's diff text."""
1313
1314    def __init__(self, context):
1315        super(Diffstat, self).__init__(context)
1316        cfg = self.cfg
1317        diff_context = cfg.get('diff.context', 3)
1318        diff = self.git.diff(
1319            self.model.head,
1320            unified=diff_context,
1321            no_ext_diff=True,
1322            no_color=True,
1323            M=True,
1324            stat=True,
1325        )[STDOUT]
1326        self.new_diff_text = diff
1327        self.new_diff_type = main.Types.TEXT
1328        self.new_file_type = main.Types.TEXT
1329        self.new_mode = self.model.mode_diffstat
1330
1331
1332class DiffStaged(Diff):
1333    """Perform a staged diff on a file."""
1334
1335    def __init__(self, context, filename, deleted=None):
1336        super(DiffStaged, self).__init__(
1337            context, filename, cached=True, deleted=deleted
1338        )
1339        self.new_mode = self.model.mode_index
1340
1341
1342class DiffStagedSummary(EditModel):
1343    def __init__(self, context):
1344        super(DiffStagedSummary, self).__init__(context)
1345        diff = self.git.diff(
1346            self.model.head,
1347            cached=True,
1348            no_color=True,
1349            no_ext_diff=True,
1350            patch_with_stat=True,
1351            M=True,
1352        )[STDOUT]
1353        self.new_diff_text = diff
1354        self.new_diff_type = main.Types.TEXT
1355        self.new_file_type = main.Types.TEXT
1356        self.new_mode = self.model.mode_index
1357
1358
1359class Difftool(ContextCommand):
1360    """Run git-difftool limited by path."""
1361
1362    def __init__(self, context, staged, filenames):
1363        super(Difftool, self).__init__(context)
1364        self.staged = staged
1365        self.filenames = filenames
1366
1367    def do(self):
1368        difftool_launch_with_head(
1369            self.context, self.filenames, self.staged, self.model.head
1370        )
1371
1372
1373class Edit(ContextCommand):
1374    """Edit a file using the configured gui.editor."""
1375
1376    @staticmethod
1377    def name():
1378        return N_('Launch Editor')
1379
1380    def __init__(self, context, filenames, line_number=None, background_editor=False):
1381        super(Edit, self).__init__(context)
1382        self.filenames = filenames
1383        self.line_number = line_number
1384        self.background_editor = background_editor
1385
1386    def do(self):
1387        context = self.context
1388        if not self.filenames:
1389            return
1390        filename = self.filenames[0]
1391        if not core.exists(filename):
1392            return
1393        if self.background_editor:
1394            editor = prefs.background_editor(context)
1395        else:
1396            editor = prefs.editor(context)
1397        opts = []
1398
1399        if self.line_number is None:
1400            opts = self.filenames
1401        else:
1402            # Single-file w/ line-numbers (likely from grep)
1403            editor_opts = {
1404                '*vim*': [filename, '+%s' % self.line_number],
1405                '*emacs*': ['+%s' % self.line_number, filename],
1406                '*textpad*': ['%s(%s,0)' % (filename, self.line_number)],
1407                '*notepad++*': ['-n%s' % self.line_number, filename],
1408                '*subl*': ['%s:%s' % (filename, self.line_number)],
1409            }
1410
1411            opts = self.filenames
1412            for pattern, opt in editor_opts.items():
1413                if fnmatch(editor, pattern):
1414                    opts = opt
1415                    break
1416
1417        try:
1418            core.fork(utils.shell_split(editor) + opts)
1419        except (OSError, ValueError) as e:
1420            message = N_('Cannot exec "%s": please configure your editor') % editor
1421            _, details = utils.format_exception(e)
1422            Interaction.critical(N_('Error Editing File'), message, details)
1423
1424
1425class FormatPatch(ContextCommand):
1426    """Output a patch series given all revisions and a selected subset."""
1427
1428    def __init__(self, context, to_export, revs, output='patches'):
1429        super(FormatPatch, self).__init__(context)
1430        self.to_export = list(to_export)
1431        self.revs = list(revs)
1432        self.output = output
1433
1434    def do(self):
1435        context = self.context
1436        status, out, err = gitcmds.format_patchsets(
1437            context, self.to_export, self.revs, self.output
1438        )
1439        Interaction.log_status(status, out, err)
1440
1441
1442class LaunchDifftool(ContextCommand):
1443    @staticmethod
1444    def name():
1445        return N_('Launch Diff Tool')
1446
1447    def do(self):
1448        s = self.selection.selection()
1449        if s.unmerged:
1450            paths = s.unmerged
1451            if utils.is_win32():
1452                core.fork(['git', 'mergetool', '--no-prompt', '--'] + paths)
1453            else:
1454                cfg = self.cfg
1455                cmd = cfg.terminal()
1456                argv = utils.shell_split(cmd)
1457
1458                terminal = os.path.basename(argv[0])
1459                shellquote_terms = set(['xfce4-terminal'])
1460                shellquote_default = terminal in shellquote_terms
1461
1462                mergetool = ['git', 'mergetool', '--no-prompt', '--']
1463                mergetool.extend(paths)
1464                needs_shellquote = cfg.get(
1465                    'cola.terminalshellquote', shellquote_default
1466                )
1467
1468                if needs_shellquote:
1469                    argv.append(core.list2cmdline(mergetool))
1470                else:
1471                    argv.extend(mergetool)
1472
1473                core.fork(argv)
1474        else:
1475            difftool_run(self.context)
1476
1477
1478class LaunchTerminal(ContextCommand):
1479    @staticmethod
1480    def name():
1481        return N_('Launch Terminal')
1482
1483    @staticmethod
1484    def is_available(context):
1485        return context.cfg.terminal() is not None
1486
1487    def __init__(self, context, path):
1488        super(LaunchTerminal, self).__init__(context)
1489        self.path = path
1490
1491    def do(self):
1492        cmd = self.context.cfg.terminal()
1493        if cmd is None:
1494            return
1495        if utils.is_win32():
1496            argv = ['start', '', cmd, '--login']
1497            shell = True
1498        else:
1499            argv = utils.shell_split(cmd)
1500            argv.append(os.getenv('SHELL', '/bin/sh'))
1501            shell = False
1502        core.fork(argv, cwd=self.path, shell=shell)
1503
1504
1505class LaunchEditor(Edit):
1506    @staticmethod
1507    def name():
1508        return N_('Launch Editor')
1509
1510    def __init__(self, context):
1511        s = context.selection.selection()
1512        filenames = s.staged + s.unmerged + s.modified + s.untracked
1513        super(LaunchEditor, self).__init__(context, filenames, background_editor=True)
1514
1515
1516class LaunchEditorAtLine(LaunchEditor):
1517    """Launch an editor at the specified line"""
1518
1519    def __init__(self, context):
1520        super(LaunchEditorAtLine, self).__init__(context)
1521        self.line_number = context.selection.line_number
1522
1523
1524class LoadCommitMessageFromFile(ContextCommand):
1525    """Loads a commit message from a path."""
1526
1527    UNDOABLE = True
1528
1529    def __init__(self, context, path):
1530        super(LoadCommitMessageFromFile, self).__init__(context)
1531        self.path = path
1532        self.old_commitmsg = self.model.commitmsg
1533        self.old_directory = self.model.directory
1534
1535    def do(self):
1536        path = os.path.expanduser(self.path)
1537        if not path or not core.isfile(path):
1538            raise UsageError(
1539                N_('Error: Cannot find commit template'),
1540                N_('%s: No such file or directory.') % path,
1541            )
1542        self.model.set_directory(os.path.dirname(path))
1543        self.model.set_commitmsg(core.read(path))
1544
1545    def undo(self):
1546        self.model.set_commitmsg(self.old_commitmsg)
1547        self.model.set_directory(self.old_directory)
1548
1549
1550class LoadCommitMessageFromTemplate(LoadCommitMessageFromFile):
1551    """Loads the commit message template specified by commit.template."""
1552
1553    def __init__(self, context):
1554        cfg = context.cfg
1555        template = cfg.get('commit.template')
1556        super(LoadCommitMessageFromTemplate, self).__init__(context, template)
1557
1558    def do(self):
1559        if self.path is None:
1560            raise UsageError(
1561                N_('Error: Unconfigured commit template'),
1562                N_(
1563                    'A commit template has not been configured.\n'
1564                    'Use "git config" to define "commit.template"\n'
1565                    'so that it points to a commit template.'
1566                ),
1567            )
1568        return LoadCommitMessageFromFile.do(self)
1569
1570
1571class LoadCommitMessageFromOID(ContextCommand):
1572    """Load a previous commit message"""
1573
1574    UNDOABLE = True
1575
1576    def __init__(self, context, oid, prefix=''):
1577        super(LoadCommitMessageFromOID, self).__init__(context)
1578        self.oid = oid
1579        self.old_commitmsg = self.model.commitmsg
1580        self.new_commitmsg = prefix + gitcmds.prev_commitmsg(context, oid)
1581
1582    def do(self):
1583        self.model.set_commitmsg(self.new_commitmsg)
1584
1585    def undo(self):
1586        self.model.set_commitmsg(self.old_commitmsg)
1587
1588
1589class PrepareCommitMessageHook(ContextCommand):
1590    """Use the cola-prepare-commit-msg hook to prepare the commit message"""
1591
1592    UNDOABLE = True
1593
1594    def __init__(self, context):
1595        super(PrepareCommitMessageHook, self).__init__(context)
1596        self.old_commitmsg = self.model.commitmsg
1597
1598    def get_message(self):
1599
1600        title = N_('Error running prepare-commitmsg hook')
1601        hook = gitcmds.prepare_commit_message_hook(self.context)
1602
1603        if os.path.exists(hook):
1604            filename = self.model.save_commitmsg()
1605            status, out, err = core.run_command([hook, filename])
1606
1607            if status == 0:
1608                result = core.read(filename)
1609            else:
1610                result = self.old_commitmsg
1611                Interaction.command_error(title, hook, status, out, err)
1612        else:
1613            message = N_('A hook must be provided at "%s"') % hook
1614            Interaction.critical(title, message=message)
1615            result = self.old_commitmsg
1616
1617        return result
1618
1619    def do(self):
1620        msg = self.get_message()
1621        self.model.set_commitmsg(msg)
1622
1623    def undo(self):
1624        self.model.set_commitmsg(self.old_commitmsg)
1625
1626
1627class LoadFixupMessage(LoadCommitMessageFromOID):
1628    """Load a fixup message"""
1629
1630    def __init__(self, context, oid):
1631        super(LoadFixupMessage, self).__init__(context, oid, prefix='fixup! ')
1632        if self.new_commitmsg:
1633            self.new_commitmsg = self.new_commitmsg.splitlines()[0]
1634
1635
1636class Merge(ContextCommand):
1637    """Merge commits"""
1638
1639    def __init__(self, context, revision, no_commit, squash, no_ff, sign):
1640        super(Merge, self).__init__(context)
1641        self.revision = revision
1642        self.no_ff = no_ff
1643        self.no_commit = no_commit
1644        self.squash = squash
1645        self.sign = sign
1646
1647    def do(self):
1648        squash = self.squash
1649        revision = self.revision
1650        no_ff = self.no_ff
1651        no_commit = self.no_commit
1652        sign = self.sign
1653
1654        status, out, err = self.git.merge(
1655            revision, gpg_sign=sign, no_ff=no_ff, no_commit=no_commit, squash=squash
1656        )
1657        self.model.update_status()
1658        title = N_('Merge failed.  Conflict resolution is required.')
1659        Interaction.command(title, 'git merge', status, out, err)
1660
1661        return status, out, err
1662
1663
1664class OpenDefaultApp(ContextCommand):
1665    """Open a file using the OS default."""
1666
1667    @staticmethod
1668    def name():
1669        return N_('Open Using Default Application')
1670
1671    def __init__(self, context, filenames):
1672        super(OpenDefaultApp, self).__init__(context)
1673        if utils.is_darwin():
1674            launcher = 'open'
1675        else:
1676            launcher = 'xdg-open'
1677        self.launcher = launcher
1678        self.filenames = filenames
1679
1680    def do(self):
1681        if not self.filenames:
1682            return
1683        core.fork([self.launcher] + self.filenames)
1684
1685
1686class OpenParentDir(OpenDefaultApp):
1687    """Open parent directories using the OS default."""
1688
1689    @staticmethod
1690    def name():
1691        return N_('Open Parent Directory')
1692
1693    def __init__(self, context, filenames):
1694        OpenDefaultApp.__init__(self, context, filenames)
1695
1696    def do(self):
1697        if not self.filenames:
1698            return
1699        dirnames = list(set([os.path.dirname(x) for x in self.filenames]))
1700        # os.path.dirname() can return an empty string so we fallback to
1701        # the current directory
1702        dirs = [(dirname or core.getcwd()) for dirname in dirnames]
1703        core.fork([self.launcher] + dirs)
1704
1705
1706class OpenNewRepo(ContextCommand):
1707    """Launches git-cola on a repo."""
1708
1709    def __init__(self, context, repo_path):
1710        super(OpenNewRepo, self).__init__(context)
1711        self.repo_path = repo_path
1712
1713    def do(self):
1714        self.model.set_directory(self.repo_path)
1715        core.fork([sys.executable, sys.argv[0], '--repo', self.repo_path])
1716
1717
1718class OpenRepo(EditModel):
1719    def __init__(self, context, repo_path):
1720        super(OpenRepo, self).__init__(context)
1721        self.repo_path = repo_path
1722        self.new_mode = self.model.mode_none
1723        self.new_diff_text = ''
1724        self.new_diff_type = main.Types.TEXT
1725        self.new_file_type = main.Types.TEXT
1726        self.new_commitmsg = ''
1727        self.new_filename = ''
1728
1729    def do(self):
1730        old_repo = self.git.getcwd()
1731        if self.model.set_worktree(self.repo_path):
1732            self.fsmonitor.stop()
1733            self.fsmonitor.start()
1734            self.model.update_status()
1735            # Check if template should be loaded
1736            if self.context.cfg.get(prefs.AUTOTEMPLATE):
1737                template_loader = LoadCommitMessageFromTemplate(self.context)
1738                template_loader.do()
1739            else:
1740                self.model.set_commitmsg(self.new_commitmsg)
1741            settings = self.context.settings
1742            settings.load()
1743            settings.add_recent(self.repo_path, prefs.maxrecent(self.context))
1744            settings.save()
1745            super(OpenRepo, self).do()
1746        else:
1747            self.model.set_worktree(old_repo)
1748
1749
1750class OpenParentRepo(OpenRepo):
1751    def __init__(self, context):
1752        path = ''
1753        if version.check_git(context, 'show-superproject-working-tree'):
1754            status, out, _ = context.git.rev_parse(show_superproject_working_tree=True)
1755            if status == 0:
1756                path = out
1757        if not path:
1758            path = os.path.dirname(core.getcwd())
1759        super(OpenParentRepo, self).__init__(context, path)
1760
1761
1762class Clone(ContextCommand):
1763    """Clones a repository and optionally spawns a new cola session."""
1764
1765    def __init__(
1766        self, context, url, new_directory, submodules=False, shallow=False, spawn=True
1767    ):
1768        super(Clone, self).__init__(context)
1769        self.url = url
1770        self.new_directory = new_directory
1771        self.submodules = submodules
1772        self.shallow = shallow
1773        self.spawn = spawn
1774        self.status = -1
1775        self.out = ''
1776        self.err = ''
1777
1778    def do(self):
1779        kwargs = {}
1780        if self.shallow:
1781            kwargs['depth'] = 1
1782        recurse_submodules = self.submodules
1783        shallow_submodules = self.submodules and self.shallow
1784
1785        status, out, err = self.git.clone(
1786            self.url,
1787            self.new_directory,
1788            recurse_submodules=recurse_submodules,
1789            shallow_submodules=shallow_submodules,
1790            **kwargs
1791        )
1792
1793        self.status = status
1794        self.out = out
1795        self.err = err
1796        if status == 0 and self.spawn:
1797            executable = sys.executable
1798            core.fork([executable, sys.argv[0], '--repo', self.new_directory])
1799        return self
1800
1801
1802class NewBareRepo(ContextCommand):
1803    """Create a new shared bare repository"""
1804
1805    def __init__(self, context, path):
1806        super(NewBareRepo, self).__init__(context)
1807        self.path = path
1808
1809    def do(self):
1810        path = self.path
1811        status, out, err = self.git.init(path, bare=True, shared=True)
1812        Interaction.command(
1813            N_('Error'), 'git init --bare --shared "%s"' % path, status, out, err
1814        )
1815        return status == 0
1816
1817
1818def unix_path(path, is_win32=utils.is_win32):
1819    """Git for Windows requires unix paths, so force them here"""
1820    if is_win32():
1821        path = path.replace('\\', '/')
1822        first = path[0]
1823        second = path[1]
1824        if second == ':':  # sanity check, this better be a Windows-style path
1825            path = '/' + first + path[2:]
1826
1827    return path
1828
1829
1830def sequence_editor():
1831    """Set GIT_SEQUENCE_EDITOR for running git-cola-sequence-editor"""
1832    xbase = unix_path(resources.command('git-cola-sequence-editor'))
1833    if utils.is_win32():
1834        editor = core.list2cmdline([unix_path(sys.executable), xbase])
1835    else:
1836        editor = core.list2cmdline([xbase])
1837    return editor
1838
1839
1840class SequenceEditorEnvironment(object):
1841    """Set environment variables to enable git-cola-sequence-editor"""
1842
1843    def __init__(self, context, **kwargs):
1844        self.env = {
1845            'GIT_EDITOR': prefs.editor(context),
1846            'GIT_SEQUENCE_EDITOR': sequence_editor(),
1847            'GIT_COLA_SEQ_EDITOR_CANCEL_ACTION': 'save',
1848        }
1849        self.env.update(kwargs)
1850
1851    def __enter__(self):
1852        for var, value in self.env.items():
1853            compat.setenv(var, value)
1854        return self
1855
1856    def __exit__(self, exc_type, exc_val, exc_tb):
1857        for var in self.env:
1858            compat.unsetenv(var)
1859
1860
1861class Rebase(ContextCommand):
1862    def __init__(self, context, upstream=None, branch=None, **kwargs):
1863        """Start an interactive rebase session
1864
1865        :param upstream: upstream branch
1866        :param branch: optional branch to checkout
1867        :param kwargs: forwarded directly to `git.rebase()`
1868
1869        """
1870        super(Rebase, self).__init__(context)
1871
1872        self.upstream = upstream
1873        self.branch = branch
1874        self.kwargs = kwargs
1875
1876    def prepare_arguments(self, upstream):
1877        args = []
1878        kwargs = {}
1879
1880        # Rebase actions must be the only option specified
1881        for action in ('continue', 'abort', 'skip', 'edit_todo'):
1882            if self.kwargs.get(action, False):
1883                kwargs[action] = self.kwargs[action]
1884                return args, kwargs
1885
1886        kwargs['interactive'] = True
1887        kwargs['autosquash'] = self.kwargs.get('autosquash', True)
1888        kwargs.update(self.kwargs)
1889
1890        if upstream:
1891            args.append(upstream)
1892        if self.branch:
1893            args.append(self.branch)
1894
1895        return args, kwargs
1896
1897    def do(self):
1898        (status, out, err) = (1, '', '')
1899        context = self.context
1900        cfg = self.cfg
1901        model = self.model
1902
1903        if not cfg.get('rebase.autostash', False):
1904            if model.staged or model.unmerged or model.modified:
1905                Interaction.information(
1906                    N_('Unable to rebase'),
1907                    N_('You cannot rebase with uncommitted changes.'),
1908                )
1909                return status, out, err
1910
1911        upstream = self.upstream or Interaction.choose_ref(
1912            context,
1913            N_('Select New Upstream'),
1914            N_('Interactive Rebase'),
1915            default='@{upstream}',
1916        )
1917        if not upstream:
1918            return status, out, err
1919
1920        self.model.is_rebasing = True
1921        self.model.emit_updated()
1922
1923        args, kwargs = self.prepare_arguments(upstream)
1924        upstream_title = upstream or '@{upstream}'
1925        with SequenceEditorEnvironment(
1926            self.context,
1927            GIT_COLA_SEQ_EDITOR_TITLE=N_('Rebase onto %s') % upstream_title,
1928            GIT_COLA_SEQ_EDITOR_ACTION=N_('Rebase'),
1929        ):
1930            # TODO this blocks the user interface window for the duration
1931            # of git-cola-sequence-editor. We would need to implement
1932            # signals for QProcess and continue running the main thread.
1933            # Alternatively, we can hide the main window while rebasing.
1934            # That doesn't require as much effort.
1935            status, out, err = self.git.rebase(
1936                *args, _no_win32_startupinfo=True, **kwargs
1937            )
1938        self.model.update_status()
1939        if err.strip() != 'Nothing to do':
1940            title = N_('Rebase stopped')
1941            Interaction.command(title, 'git rebase', status, out, err)
1942        return status, out, err
1943
1944
1945class RebaseEditTodo(ContextCommand):
1946    def do(self):
1947        (status, out, err) = (1, '', '')
1948        with SequenceEditorEnvironment(
1949            self.context,
1950            GIT_COLA_SEQ_EDITOR_TITLE=N_('Edit Rebase'),
1951            GIT_COLA_SEQ_EDITOR_ACTION=N_('Save'),
1952        ):
1953            status, out, err = self.git.rebase(edit_todo=True)
1954        Interaction.log_status(status, out, err)
1955        self.model.update_status()
1956        return status, out, err
1957
1958
1959class RebaseContinue(ContextCommand):
1960    def do(self):
1961        (status, out, err) = (1, '', '')
1962        with SequenceEditorEnvironment(
1963            self.context,
1964            GIT_COLA_SEQ_EDITOR_TITLE=N_('Rebase'),
1965            GIT_COLA_SEQ_EDITOR_ACTION=N_('Rebase'),
1966        ):
1967            status, out, err = self.git.rebase('--continue')
1968        Interaction.log_status(status, out, err)
1969        self.model.update_status()
1970        return status, out, err
1971
1972
1973class RebaseSkip(ContextCommand):
1974    def do(self):
1975        (status, out, err) = (1, '', '')
1976        with SequenceEditorEnvironment(
1977            self.context,
1978            GIT_COLA_SEQ_EDITOR_TITLE=N_('Rebase'),
1979            GIT_COLA_SEQ_EDITOR_ACTION=N_('Rebase'),
1980        ):
1981            status, out, err = self.git.rebase(skip=True)
1982        Interaction.log_status(status, out, err)
1983        self.model.update_status()
1984        return status, out, err
1985
1986
1987class RebaseAbort(ContextCommand):
1988    def do(self):
1989        status, out, err = self.git.rebase(abort=True)
1990        Interaction.log_status(status, out, err)
1991        self.model.update_status()
1992
1993
1994class Rescan(ContextCommand):
1995    """Rescan for changes"""
1996
1997    def do(self):
1998        self.model.update_status()
1999
2000
2001class Refresh(ContextCommand):
2002    """Update refs, refresh the index, and update config"""
2003
2004    @staticmethod
2005    def name():
2006        return N_('Refresh')
2007
2008    def do(self):
2009        self.model.update_status(update_index=True)
2010        self.cfg.update()
2011        self.fsmonitor.refresh()
2012
2013
2014class RefreshConfig(ContextCommand):
2015    """Refresh the git config cache"""
2016
2017    def do(self):
2018        self.cfg.update()
2019
2020
2021class RevertEditsCommand(ConfirmAction):
2022    def __init__(self, context):
2023        super(RevertEditsCommand, self).__init__(context)
2024        self.icon = icons.undo()
2025
2026    def ok_to_run(self):
2027        return self.model.undoable()
2028
2029    # pylint: disable=no-self-use
2030    def checkout_from_head(self):
2031        return False
2032
2033    def checkout_args(self):
2034        args = []
2035        s = self.selection.selection()
2036        if self.checkout_from_head():
2037            args.append(self.model.head)
2038        args.append('--')
2039
2040        if s.staged:
2041            items = s.staged
2042        else:
2043            items = s.modified
2044        args.extend(items)
2045
2046        return args
2047
2048    def action(self):
2049        checkout_args = self.checkout_args()
2050        return self.git.checkout(*checkout_args)
2051
2052    def success(self):
2053        self.model.set_diff_type(main.Types.TEXT)
2054        self.model.update_file_status()
2055
2056
2057class RevertUnstagedEdits(RevertEditsCommand):
2058    @staticmethod
2059    def name():
2060        return N_('Revert Unstaged Edits...')
2061
2062    def checkout_from_head(self):
2063        # Being in amend mode should not affect the behavior of this command.
2064        # The only sensible thing to do is to checkout from the index.
2065        return False
2066
2067    def confirm(self):
2068        title = N_('Revert Unstaged Changes?')
2069        text = N_(
2070            'This operation removes unstaged edits from selected files.\n'
2071            'These changes cannot be recovered.'
2072        )
2073        info = N_('Revert the unstaged changes?')
2074        ok_text = N_('Revert Unstaged Changes')
2075        return Interaction.confirm(
2076            title, text, info, ok_text, default=True, icon=self.icon
2077        )
2078
2079
2080class RevertUncommittedEdits(RevertEditsCommand):
2081    @staticmethod
2082    def name():
2083        return N_('Revert Uncommitted Edits...')
2084
2085    def checkout_from_head(self):
2086        return True
2087
2088    def confirm(self):
2089        """Prompt for reverting changes"""
2090        title = N_('Revert Uncommitted Changes?')
2091        text = N_(
2092            'This operation removes uncommitted edits from selected files.\n'
2093            'These changes cannot be recovered.'
2094        )
2095        info = N_('Revert the uncommitted changes?')
2096        ok_text = N_('Revert Uncommitted Changes')
2097        return Interaction.confirm(
2098            title, text, info, ok_text, default=True, icon=self.icon
2099        )
2100
2101
2102class RunConfigAction(ContextCommand):
2103    """Run a user-configured action, typically from the "Tools" menu"""
2104
2105    def __init__(self, context, action_name):
2106        super(RunConfigAction, self).__init__(context)
2107        self.action_name = action_name
2108
2109    def do(self):
2110        """Run the user-configured action"""
2111        for env in ('ARGS', 'DIRNAME', 'FILENAME', 'REVISION'):
2112            try:
2113                compat.unsetenv(env)
2114            except KeyError:
2115                pass
2116        rev = None
2117        args = None
2118        context = self.context
2119        cfg = self.cfg
2120        opts = cfg.get_guitool_opts(self.action_name)
2121        cmd = opts.get('cmd')
2122        if 'title' not in opts:
2123            opts['title'] = cmd
2124
2125        if 'prompt' not in opts or opts.get('prompt') is True:
2126            prompt = N_('Run "%s"?') % cmd
2127            opts['prompt'] = prompt
2128
2129        if opts.get('needsfile'):
2130            filename = self.selection.filename()
2131            if not filename:
2132                Interaction.information(
2133                    N_('Please select a file'),
2134                    N_('"%s" requires a selected file.') % cmd,
2135                )
2136                return False
2137            dirname = utils.dirname(filename, current_dir='.')
2138            compat.setenv('FILENAME', filename)
2139            compat.setenv('DIRNAME', dirname)
2140
2141        if opts.get('revprompt') or opts.get('argprompt'):
2142            while True:
2143                ok = Interaction.confirm_config_action(context, cmd, opts)
2144                if not ok:
2145                    return False
2146                rev = opts.get('revision')
2147                args = opts.get('args')
2148                if opts.get('revprompt') and not rev:
2149                    title = N_('Invalid Revision')
2150                    msg = N_('The revision expression cannot be empty.')
2151                    Interaction.critical(title, msg)
2152                    continue
2153                break
2154
2155        elif opts.get('confirm'):
2156            title = os.path.expandvars(opts.get('title'))
2157            prompt = os.path.expandvars(opts.get('prompt'))
2158            if not Interaction.question(title, prompt):
2159                return False
2160        if rev:
2161            compat.setenv('REVISION', rev)
2162        if args:
2163            compat.setenv('ARGS', args)
2164        title = os.path.expandvars(cmd)
2165        Interaction.log(N_('Running command: %s') % title)
2166        cmd = ['sh', '-c', cmd]
2167
2168        if opts.get('background'):
2169            core.fork(cmd)
2170            status, out, err = (0, '', '')
2171        elif opts.get('noconsole'):
2172            status, out, err = core.run_command(cmd)
2173        else:
2174            status, out, err = Interaction.run_command(title, cmd)
2175
2176        if not opts.get('background') and not opts.get('norescan'):
2177            self.model.update_status()
2178
2179        title = N_('Error')
2180        Interaction.command(title, cmd, status, out, err)
2181
2182        return status == 0
2183
2184
2185class SetDefaultRepo(ContextCommand):
2186    """Set the default repository"""
2187
2188    def __init__(self, context, repo):
2189        super(SetDefaultRepo, self).__init__(context)
2190        self.repo = repo
2191
2192    def do(self):
2193        self.cfg.set_user('cola.defaultrepo', self.repo)
2194
2195
2196class SetDiffText(EditModel):
2197    """Set the diff text"""
2198
2199    UNDOABLE = True
2200
2201    def __init__(self, context, text):
2202        super(SetDiffText, self).__init__(context)
2203        self.new_diff_text = text
2204        self.new_diff_type = main.Types.TEXT
2205        self.new_file_type = main.Types.TEXT
2206
2207
2208class SetUpstreamBranch(ContextCommand):
2209    """Set the upstream branch"""
2210
2211    def __init__(self, context, branch, remote, remote_branch):
2212        super(SetUpstreamBranch, self).__init__(context)
2213        self.branch = branch
2214        self.remote = remote
2215        self.remote_branch = remote_branch
2216
2217    def do(self):
2218        cfg = self.cfg
2219        remote = self.remote
2220        branch = self.branch
2221        remote_branch = self.remote_branch
2222        cfg.set_repo('branch.%s.remote' % branch, remote)
2223        cfg.set_repo('branch.%s.merge' % branch, 'refs/heads/' + remote_branch)
2224
2225
2226def format_hex(data):
2227    """Translate binary data into a hex dump"""
2228    hexdigits = '0123456789ABCDEF'
2229    result = ''
2230    offset = 0
2231    byte_offset_to_int = compat.byte_offset_to_int_converter()
2232    while offset < len(data):
2233        result += '%04u |' % offset
2234        textpart = ''
2235        for i in range(0, 16):
2236            if i > 0 and i % 4 == 0:
2237                result += ' '
2238            if offset < len(data):
2239                v = byte_offset_to_int(data[offset])
2240                result += ' ' + hexdigits[v >> 4] + hexdigits[v & 0xF]
2241                textpart += chr(v) if 32 <= v < 127 else '.'
2242                offset += 1
2243            else:
2244                result += '   '
2245                textpart += ' '
2246        result += ' | ' + textpart + ' |\n'
2247
2248    return result
2249
2250
2251class ShowUntracked(EditModel):
2252    """Show an untracked file."""
2253
2254    def __init__(self, context, filename):
2255        super(ShowUntracked, self).__init__(context)
2256        self.new_filename = filename
2257        self.new_mode = self.model.mode_untracked
2258        self.new_diff_text = self.read(filename)
2259        self.new_diff_type = main.Types.TEXT
2260        self.new_file_type = main.Types.TEXT
2261
2262    def read(self, filename):
2263        """Read file contents"""
2264        cfg = self.cfg
2265        size = cfg.get('cola.readsize', 2048)
2266        try:
2267            result = core.read(filename, size=size, encoding='bytes')
2268        except (IOError, OSError):
2269            result = ''
2270
2271        truncated = len(result) == size
2272
2273        encoding = cfg.file_encoding(filename) or core.ENCODING
2274        try:
2275            text_result = core.decode_maybe(result, encoding)
2276        except UnicodeError:
2277            text_result = format_hex(result)
2278
2279        if truncated:
2280            text_result += '...'
2281        return text_result
2282
2283
2284class SignOff(ContextCommand):
2285    """Append a signoff to the commit message"""
2286
2287    UNDOABLE = True
2288
2289    @staticmethod
2290    def name():
2291        return N_('Sign Off')
2292
2293    def __init__(self, context):
2294        super(SignOff, self).__init__(context)
2295        self.old_commitmsg = self.model.commitmsg
2296
2297    def do(self):
2298        """Add a signoff to the commit message"""
2299        signoff = self.signoff()
2300        if signoff in self.model.commitmsg:
2301            return
2302        msg = self.model.commitmsg.rstrip()
2303        self.model.set_commitmsg(msg + '\n' + signoff)
2304
2305    def undo(self):
2306        """Restore the commit message"""
2307        self.model.set_commitmsg(self.old_commitmsg)
2308
2309    def signoff(self):
2310        """Generate the signoff string"""
2311        try:
2312            import pwd  # pylint: disable=all
2313
2314            user = pwd.getpwuid(os.getuid()).pw_name
2315        except ImportError:
2316            user = os.getenv('USER', N_('unknown'))
2317
2318        cfg = self.cfg
2319        name = cfg.get('user.name', user)
2320        email = cfg.get('user.email', '%s@%s' % (user, core.node()))
2321        return '\nSigned-off-by: %s <%s>' % (name, email)
2322
2323
2324def check_conflicts(context, unmerged):
2325    """Check paths for conflicts
2326
2327    Conflicting files can be filtered out one-by-one.
2328
2329    """
2330    if prefs.check_conflicts(context):
2331        unmerged = [path for path in unmerged if is_conflict_free(path)]
2332    return unmerged
2333
2334
2335def is_conflict_free(path):
2336    """Return True if `path` contains no conflict markers"""
2337    rgx = re.compile(r'^(<<<<<<<|\|\|\|\|\|\|\||>>>>>>>) ')
2338    try:
2339        with core.xopen(path, 'r') as f:
2340            for line in f:
2341                line = core.decode(line, errors='ignore')
2342                if rgx.match(line):
2343                    return should_stage_conflicts(path)
2344    except IOError:
2345        # We can't read this file ~ we may be staging a removal
2346        pass
2347    return True
2348
2349
2350def should_stage_conflicts(path):
2351    """Inform the user that a file contains merge conflicts
2352
2353    Return `True` if we should stage the path nonetheless.
2354
2355    """
2356    title = msg = N_('Stage conflicts?')
2357    info = (
2358        N_(
2359            '%s appears to contain merge conflicts.\n\n'
2360            'You should probably skip this file.\n'
2361            'Stage it anyways?'
2362        )
2363        % path
2364    )
2365    ok_text = N_('Stage conflicts')
2366    cancel_text = N_('Skip')
2367    return Interaction.confirm(
2368        title, msg, info, ok_text, default=False, cancel_text=cancel_text
2369    )
2370
2371
2372class Stage(ContextCommand):
2373    """Stage a set of paths."""
2374
2375    @staticmethod
2376    def name():
2377        return N_('Stage')
2378
2379    def __init__(self, context, paths):
2380        super(Stage, self).__init__(context)
2381        self.paths = paths
2382
2383    def do(self):
2384        msg = N_('Staging: %s') % (', '.join(self.paths))
2385        Interaction.log(msg)
2386        return self.stage_paths()
2387
2388    def stage_paths(self):
2389        """Stages add/removals to git."""
2390        context = self.context
2391        paths = self.paths
2392        if not paths:
2393            if self.model.cfg.get('cola.safemode', False):
2394                return (0, '', '')
2395            return self.stage_all()
2396
2397        add = []
2398        remove = []
2399
2400        for path in set(paths):
2401            if core.exists(path) or core.islink(path):
2402                if path.endswith('/'):
2403                    path = path.rstrip('/')
2404                add.append(path)
2405            else:
2406                remove.append(path)
2407
2408        self.model.emit_about_to_update()
2409
2410        # `git add -u` doesn't work on untracked files
2411        if add:
2412            status, out, err = gitcmds.add(context, add)
2413            Interaction.command(N_('Error'), 'git add', status, out, err)
2414
2415        # If a path doesn't exist then that means it should be removed
2416        # from the index.   We use `git add -u` for that.
2417        if remove:
2418            status, out, err = gitcmds.add(context, remove, u=True)
2419            Interaction.command(N_('Error'), 'git add -u', status, out, err)
2420
2421        self.model.update_files(emit=True)
2422        return status, out, err
2423
2424    def stage_all(self):
2425        """Stage all files"""
2426        status, out, err = self.git.add(v=True, u=True)
2427        Interaction.command(N_('Error'), 'git add -u', status, out, err)
2428        self.model.update_file_status()
2429        return (status, out, err)
2430
2431
2432class StageCarefully(Stage):
2433    """Only stage when the path list is non-empty
2434
2435    We use "git add -u -- <pathspec>" to stage, and it stages everything by
2436    default when no pathspec is specified, so this class ensures that paths
2437    are specified before calling git.
2438
2439    When no paths are specified, the command does nothing.
2440
2441    """
2442
2443    def __init__(self, context):
2444        super(StageCarefully, self).__init__(context, None)
2445        self.init_paths()
2446
2447    # pylint: disable=no-self-use
2448    def init_paths(self):
2449        """Initialize path data"""
2450        return
2451
2452    def ok_to_run(self):
2453        """Prevent catch-all "git add -u" from adding unmerged files"""
2454        return self.paths or not self.model.unmerged
2455
2456    def do(self):
2457        """Stage files when ok_to_run() return True"""
2458        if self.ok_to_run():
2459            return super(StageCarefully, self).do()
2460        return (0, '', '')
2461
2462
2463class StageModified(StageCarefully):
2464    """Stage all modified files."""
2465
2466    @staticmethod
2467    def name():
2468        return N_('Stage Modified')
2469
2470    def init_paths(self):
2471        self.paths = self.model.modified
2472
2473
2474class StageUnmerged(StageCarefully):
2475    """Stage unmerged files."""
2476
2477    @staticmethod
2478    def name():
2479        return N_('Stage Unmerged')
2480
2481    def init_paths(self):
2482        self.paths = check_conflicts(self.context, self.model.unmerged)
2483
2484
2485class StageUntracked(StageCarefully):
2486    """Stage all untracked files."""
2487
2488    @staticmethod
2489    def name():
2490        return N_('Stage Untracked')
2491
2492    def init_paths(self):
2493        self.paths = self.model.untracked
2494
2495
2496class StageModifiedAndUntracked(StageCarefully):
2497    """Stage all untracked files."""
2498
2499    @staticmethod
2500    def name():
2501        return N_('Stage Modified and Untracked')
2502
2503    def init_paths(self):
2504        self.paths = self.model.modified + self.model.untracked
2505
2506
2507class StageOrUnstageAll(ContextCommand):
2508    """If the selection is staged, unstage it, otherwise stage"""
2509    @staticmethod
2510    def name():
2511        return N_('Stage / Unstage All')
2512
2513    def do(self):
2514        if self.model.staged:
2515            do(Unstage, self.context, self.model.staged)
2516        else:
2517            if self.cfg.get('cola.safemode', False):
2518                unstaged = self.model.modified
2519            else:
2520                unstaged = self.model.modified + self.model.untracked
2521            do(Stage, self.context, unstaged)
2522
2523
2524class StageOrUnstage(ContextCommand):
2525    """If the selection is staged, unstage it, otherwise stage"""
2526
2527    @staticmethod
2528    def name():
2529        return N_('Stage / Unstage')
2530
2531    def do(self):
2532        s = self.selection.selection()
2533        if s.staged:
2534            do(Unstage, self.context, s.staged)
2535
2536        unstaged = []
2537        unmerged = check_conflicts(self.context, s.unmerged)
2538        if unmerged:
2539            unstaged.extend(unmerged)
2540        if s.modified:
2541            unstaged.extend(s.modified)
2542        if s.untracked:
2543            unstaged.extend(s.untracked)
2544        if unstaged:
2545            do(Stage, self.context, unstaged)
2546
2547
2548class Tag(ContextCommand):
2549    """Create a tag object."""
2550
2551    def __init__(self, context, name, revision, sign=False, message=''):
2552        super(Tag, self).__init__(context)
2553        self._name = name
2554        self._message = message
2555        self._revision = revision
2556        self._sign = sign
2557
2558    def do(self):
2559        result = False
2560        git = self.git
2561        revision = self._revision
2562        tag_name = self._name
2563        tag_message = self._message
2564
2565        if not revision:
2566            Interaction.critical(
2567                N_('Missing Revision'), N_('Please specify a revision to tag.')
2568            )
2569            return result
2570
2571        if not tag_name:
2572            Interaction.critical(
2573                N_('Missing Name'), N_('Please specify a name for the new tag.')
2574            )
2575            return result
2576
2577        title = N_('Missing Tag Message')
2578        message = N_('Tag-signing was requested but the tag message is empty.')
2579        info = N_(
2580            'An unsigned, lightweight tag will be created instead.\n'
2581            'Create an unsigned tag?'
2582        )
2583        ok_text = N_('Create Unsigned Tag')
2584        sign = self._sign
2585        if sign and not tag_message:
2586            # We require a message in order to sign the tag, so if they
2587            # choose to create an unsigned tag we have to clear the sign flag.
2588            if not Interaction.confirm(
2589                title, message, info, ok_text, default=False, icon=icons.save()
2590            ):
2591                return result
2592            sign = False
2593
2594        opts = {}
2595        tmp_file = None
2596        try:
2597            if tag_message:
2598                tmp_file = utils.tmp_filename('tag-message')
2599                opts['file'] = tmp_file
2600                core.write(tmp_file, tag_message)
2601
2602            if sign:
2603                opts['sign'] = True
2604            if tag_message:
2605                opts['annotate'] = True
2606            status, out, err = git.tag(tag_name, revision, **opts)
2607        finally:
2608            if tmp_file:
2609                core.unlink(tmp_file)
2610
2611        title = N_('Error: could not create tag "%s"') % tag_name
2612        Interaction.command(title, 'git tag', status, out, err)
2613
2614        if status == 0:
2615            result = True
2616            self.model.update_status()
2617            Interaction.information(
2618                N_('Tag Created'),
2619                N_('Created a new tag named "%s"') % tag_name,
2620                details=tag_message or None,
2621            )
2622
2623        return result
2624
2625
2626class Unstage(ContextCommand):
2627    """Unstage a set of paths."""
2628
2629    @staticmethod
2630    def name():
2631        return N_('Unstage')
2632
2633    def __init__(self, context, paths):
2634        super(Unstage, self).__init__(context)
2635        self.paths = paths
2636
2637    def do(self):
2638        """Unstage paths"""
2639        context = self.context
2640        head = self.model.head
2641        paths = self.paths
2642
2643        msg = N_('Unstaging: %s') % (', '.join(paths))
2644        Interaction.log(msg)
2645        if not paths:
2646            return unstage_all(context)
2647        status, out, err = gitcmds.unstage_paths(context, paths, head=head)
2648        Interaction.command(N_('Error'), 'git reset', status, out, err)
2649        self.model.update_file_status()
2650        return (status, out, err)
2651
2652
2653class UnstageAll(ContextCommand):
2654    """Unstage all files; resets the index."""
2655
2656    def do(self):
2657        return unstage_all(self.context)
2658
2659
2660def unstage_all(context):
2661    """Unstage all files, even while amending"""
2662    model = context.model
2663    git = context.git
2664    head = model.head
2665    status, out, err = git.reset(head, '--', '.')
2666    Interaction.command(N_('Error'), 'git reset', status, out, err)
2667    model.update_file_status()
2668    return (status, out, err)
2669
2670
2671class StageSelected(ContextCommand):
2672    """Stage selected files, or all files if no selection exists."""
2673
2674    def do(self):
2675        context = self.context
2676        paths = self.selection.unstaged
2677        if paths:
2678            do(Stage, context, paths)
2679        elif self.cfg.get('cola.safemode', False):
2680            do(StageModified, context)
2681
2682
2683class UnstageSelected(Unstage):
2684    """Unstage selected files."""
2685
2686    def __init__(self, context):
2687        staged = self.selection.staged
2688        super(UnstageSelected, self).__init__(context, staged)
2689
2690
2691class Untrack(ContextCommand):
2692    """Unstage a set of paths."""
2693
2694    def __init__(self, context, paths):
2695        super(Untrack, self).__init__(context)
2696        self.paths = paths
2697
2698    def do(self):
2699        msg = N_('Untracking: %s') % (', '.join(self.paths))
2700        Interaction.log(msg)
2701        status, out, err = self.model.untrack_paths(self.paths)
2702        Interaction.log_status(status, out, err)
2703
2704
2705class UntrackedSummary(EditModel):
2706    """List possible .gitignore rules as the diff text."""
2707
2708    def __init__(self, context):
2709        super(UntrackedSummary, self).__init__(context)
2710        untracked = self.model.untracked
2711        suffix = 's' if untracked else ''
2712        io = StringIO()
2713        io.write('# %s untracked file%s\n' % (len(untracked), suffix))
2714        if untracked:
2715            io.write('# possible .gitignore rule%s:\n' % suffix)
2716            for u in untracked:
2717                io.write('/' + u + '\n')
2718        self.new_diff_text = io.getvalue()
2719        self.new_diff_type = main.Types.TEXT
2720        self.new_file_type = main.Types.TEXT
2721        self.new_mode = self.model.mode_untracked
2722
2723
2724class VisualizeAll(ContextCommand):
2725    """Visualize all branches."""
2726
2727    def do(self):
2728        context = self.context
2729        browser = utils.shell_split(prefs.history_browser(context))
2730        launch_history_browser(browser + ['--all'])
2731
2732
2733class VisualizeCurrent(ContextCommand):
2734    """Visualize all branches."""
2735
2736    def do(self):
2737        context = self.context
2738        browser = utils.shell_split(prefs.history_browser(context))
2739        launch_history_browser(browser + [self.model.currentbranch] + ['--'])
2740
2741
2742class VisualizePaths(ContextCommand):
2743    """Path-limited visualization."""
2744
2745    def __init__(self, context, paths):
2746        super(VisualizePaths, self).__init__(context)
2747        context = self.context
2748        browser = utils.shell_split(prefs.history_browser(context))
2749        if paths:
2750            self.argv = browser + ['--'] + list(paths)
2751        else:
2752            self.argv = browser
2753
2754    def do(self):
2755        launch_history_browser(self.argv)
2756
2757
2758class VisualizeRevision(ContextCommand):
2759    """Visualize a specific revision."""
2760
2761    def __init__(self, context, revision, paths=None):
2762        super(VisualizeRevision, self).__init__(context)
2763        self.revision = revision
2764        self.paths = paths
2765
2766    def do(self):
2767        context = self.context
2768        argv = utils.shell_split(prefs.history_browser(context))
2769        if self.revision:
2770            argv.append(self.revision)
2771        if self.paths:
2772            argv.append('--')
2773            argv.extend(self.paths)
2774        launch_history_browser(argv)
2775
2776
2777class SubmoduleAdd(ConfirmAction):
2778    """Add specified submodules"""
2779
2780    def __init__(self, context, url, path, branch, depth, reference):
2781        super(SubmoduleAdd, self).__init__(context)
2782        self.url = url
2783        self.path = path
2784        self.branch = branch
2785        self.depth = depth
2786        self.reference = reference
2787
2788    def confirm(self):
2789        title = N_('Add Submodule...')
2790        question = N_('Add this submodule?')
2791        info = N_('The submodule will be added using\n' '"%s"' % self.command())
2792        ok_txt = N_('Add Submodule')
2793        return Interaction.confirm(title, question, info, ok_txt, icon=icons.ok())
2794
2795    def action(self):
2796        context = self.context
2797        args = self.get_args()
2798        return context.git.submodule('add', *args)
2799
2800    def success(self):
2801        self.model.update_file_status()
2802        self.model.update_submodules_list()
2803
2804    def error_message(self):
2805        return N_('Error updating submodule %s' % self.path)
2806
2807    def command(self):
2808        cmd = ['git', 'submodule', 'add']
2809        cmd.extend(self.get_args())
2810        return core.list2cmdline(cmd)
2811
2812    def get_args(self):
2813        args = []
2814        if self.branch:
2815            args.extend(['--branch', self.branch])
2816        if self.reference:
2817            args.extend(['--reference', self.reference])
2818        if self.depth:
2819            args.extend(['--depth', '%d' % self.depth])
2820        args.extend(['--', self.url])
2821        if self.path:
2822            args.append(self.path)
2823        return args
2824
2825
2826class SubmoduleUpdate(ConfirmAction):
2827    """Update specified submodule"""
2828
2829    def __init__(self, context, path):
2830        super(SubmoduleUpdate, self).__init__(context)
2831        self.path = path
2832
2833    def confirm(self):
2834        title = N_('Update Submodule...')
2835        question = N_('Update this submodule?')
2836        info = N_('The submodule will be updated using\n' '"%s"' % self.command())
2837        ok_txt = N_('Update Submodule')
2838        return Interaction.confirm(
2839            title, question, info, ok_txt, default=False, icon=icons.pull()
2840        )
2841
2842    def action(self):
2843        context = self.context
2844        args = self.get_args()
2845        return context.git.submodule(*args)
2846
2847    def success(self):
2848        self.model.update_file_status()
2849
2850    def error_message(self):
2851        return N_('Error updating submodule %s' % self.path)
2852
2853    def command(self):
2854        cmd = ['git', 'submodule']
2855        cmd.extend(self.get_args())
2856        return core.list2cmdline(cmd)
2857
2858    def get_args(self):
2859        cmd = ['update']
2860        if version.check_git(self.context, 'submodule-update-recursive'):
2861            cmd.append('--recursive')
2862        cmd.extend(['--', self.path])
2863        return cmd
2864
2865
2866class SubmodulesUpdate(ConfirmAction):
2867    """Update all submodules"""
2868
2869    def confirm(self):
2870        title = N_('Update submodules...')
2871        question = N_('Update all submodules?')
2872        info = N_('All submodules will be updated using\n' '"%s"' % self.command())
2873        ok_txt = N_('Update Submodules')
2874        return Interaction.confirm(
2875            title, question, info, ok_txt, default=False, icon=icons.pull()
2876        )
2877
2878    def action(self):
2879        context = self.context
2880        args = self.get_args()
2881        return context.git.submodule(*args)
2882
2883    def success(self):
2884        self.model.update_file_status()
2885
2886    def error_message(self):
2887        return N_('Error updating submodules')
2888
2889    def command(self):
2890        cmd = ['git', 'submodule']
2891        cmd.extend(self.get_args())
2892        return core.list2cmdline(cmd)
2893
2894    def get_args(self):
2895        cmd = ['update']
2896        if version.check_git(self.context, 'submodule-update-recursive'):
2897            cmd.append('--recursive')
2898        return cmd
2899
2900
2901def launch_history_browser(argv):
2902    """Launch the configured history browser"""
2903    try:
2904        core.fork(argv)
2905    except OSError as e:
2906        _, details = utils.format_exception(e)
2907        title = N_('Error Launching History Browser')
2908        msg = N_('Cannot exec "%s": please configure a history browser') % ' '.join(
2909            argv
2910        )
2911        Interaction.critical(title, message=msg, details=details)
2912
2913
2914def run(cls, *args, **opts):
2915    """
2916    Returns a callback that runs a command
2917
2918    If the caller of run() provides args or opts then those are
2919    used instead of the ones provided by the invoker of the callback.
2920
2921    """
2922
2923    def runner(*local_args, **local_opts):
2924        """Closure return by run() which runs the command"""
2925        if args or opts:
2926            do(cls, *args, **opts)
2927        else:
2928            do(cls, *local_args, **local_opts)
2929
2930    return runner
2931
2932
2933def do(cls, *args, **opts):
2934    """Run a command in-place"""
2935    try:
2936        cmd = cls(*args, **opts)
2937        return cmd.do()
2938    except Exception as e:  # pylint: disable=broad-except
2939        msg, details = utils.format_exception(e)
2940        if hasattr(cls, '__name__'):
2941            msg = '%s exception:\n%s' % (cls.__name__, msg)
2942        Interaction.critical(N_('Error'), message=msg, details=details)
2943        return None
2944
2945
2946def difftool_run(context):
2947    """Start a default difftool session"""
2948    selection = context.selection
2949    files = selection.group()
2950    if not files:
2951        return
2952    s = selection.selection()
2953    head = context.model.head
2954    difftool_launch_with_head(context, files, bool(s.staged), head)
2955
2956
2957def difftool_launch_with_head(context, filenames, staged, head):
2958    """Launch difftool against the provided head"""
2959    if head == 'HEAD':
2960        left = None
2961    else:
2962        left = head
2963    difftool_launch(context, left=left, staged=staged, paths=filenames)
2964
2965
2966def difftool_launch(
2967    context,
2968    left=None,
2969    right=None,
2970    paths=None,
2971    staged=False,
2972    dir_diff=False,
2973    left_take_magic=False,
2974    left_take_parent=False,
2975):
2976    """Launches 'git difftool' with given parameters
2977
2978    :param left: first argument to difftool
2979    :param right: second argument to difftool_args
2980    :param paths: paths to diff
2981    :param staged: activate `git difftool --staged`
2982    :param dir_diff: activate `git difftool --dir-diff`
2983    :param left_take_magic: whether to append the magic ^! diff expression
2984    :param left_take_parent: whether to append the first-parent ~ for diffing
2985
2986    """
2987
2988    difftool_args = ['git', 'difftool', '--no-prompt']
2989    if staged:
2990        difftool_args.append('--cached')
2991    if dir_diff:
2992        difftool_args.append('--dir-diff')
2993
2994    if left:
2995        if left_take_parent or left_take_magic:
2996            suffix = '^!' if left_take_magic else '~'
2997            # Check root commit (no parents and thus cannot execute '~')
2998            git = context.git
2999            status, out, err = git.rev_list(left, parents=True, n=1)
3000            Interaction.log_status(status, out, err)
3001            if status:
3002                raise OSError('git rev-list command failed')
3003
3004            if len(out.split()) >= 2:
3005                # Commit has a parent, so we can take its child as requested
3006                left += suffix
3007            else:
3008                # No parent, assume it's the root commit, so we have to diff
3009                # against the empty tree.
3010                left = EMPTY_TREE_OID
3011                if not right and left_take_magic:
3012                    right = left
3013        difftool_args.append(left)
3014
3015    if right:
3016        difftool_args.append(right)
3017
3018    if paths:
3019        difftool_args.append('--')
3020        difftool_args.extend(paths)
3021
3022    runtask = context.runtask
3023    if runtask:
3024        Interaction.async_command(N_('Difftool'), difftool_args, runtask)
3025    else:
3026        core.fork(difftool_args)
3027