cmds: properly support xfce4-terminal and gnome-terminal
[git-cola.git] / cola / cmds.py
blob7c48cf239592dc6357a8efa4a8211b76692d5d0c
1 from __future__ import division, absolute_import, unicode_literals
3 import os
4 import re
5 import subprocess
6 import sys
7 from fnmatch import fnmatch
8 from io import StringIO
10 try:
11 from send2trash import send2trash
12 except ImportError:
13 send2trash = None
15 from cola import compat
16 from cola import core
17 from cola import fsmonitor
18 from cola import gitcfg
19 from cola import gitcmds
20 from cola import icons
21 from cola import utils
22 from cola import difftool
23 from cola import resources
24 from cola.diffparse import DiffParser
25 from cola.git import STDOUT
26 from cola.i18n import N_
27 from cola.interaction import Interaction
28 from cola.models import main
29 from cola.models import prefs
30 from cola.models import selection
33 class UsageError(Exception):
34 """Exception class for usage errors."""
35 def __init__(self, title, message):
36 Exception.__init__(self, message)
37 self.title = title
38 self.msg = message
41 class BaseCommand(object):
42 """Base class for all commands; provides the command pattern"""
44 DISABLED = False
46 def __init__(self):
47 self.undoable = False
49 def is_undoable(self):
50 """Can this be undone?"""
51 return self.undoable
53 @staticmethod
54 def name():
55 return 'Unknown'
57 def do(self):
58 pass
60 def undo(self):
61 pass
64 class ConfirmAction(BaseCommand):
66 def __init__(self):
67 BaseCommand.__init__(self)
69 def ok_to_run(self):
70 return True
72 def confirm(self):
73 return True
75 def action(self):
76 return (-1, '', '')
78 def ok(self, status):
79 return status == 0
81 def success(self):
82 pass
84 def fail(self, status, out, err):
85 title = msg = self.error_message()
86 details = self.error_details() or out + err
87 Interaction.critical(title, message=msg, details=details)
89 def error_message(self):
90 return ''
92 def error_details(self):
93 return ''
95 def do(self):
96 status = -1
97 out = err = ''
98 ok = self.ok_to_run() and self.confirm()
99 if ok:
100 status, out, err = self.action()
101 if self.ok(status):
102 self.success()
103 else:
104 self.fail(status, out, err)
106 return ok, status, out, err
109 class ModelCommand(BaseCommand):
110 """Commands that manipulate the main models"""
112 def __init__(self):
113 BaseCommand.__init__(self)
114 self.model = main.model()
117 class Command(ModelCommand):
118 """Base class for commands that modify the main model"""
120 def __init__(self):
121 """Initialize the command and stash away values for use in do()"""
122 # These are commonly used so let's make it easier to write new commands.
123 ModelCommand.__init__(self)
125 self.old_diff_text = self.model.diff_text
126 self.old_filename = self.model.filename
127 self.old_mode = self.model.mode
129 self.new_diff_text = self.old_diff_text
130 self.new_filename = self.old_filename
131 self.new_mode = self.old_mode
133 def do(self):
134 """Perform the operation."""
135 self.model.set_filename(self.new_filename)
136 self.model.set_mode(self.new_mode)
137 self.model.set_diff_text(self.new_diff_text)
139 def undo(self):
140 """Undo the operation."""
141 self.model.set_diff_text(self.old_diff_text)
142 self.model.set_filename(self.old_filename)
143 self.model.set_mode(self.old_mode)
146 class AmendMode(Command):
147 """Try to amend a commit."""
149 LAST_MESSAGE = None
151 @staticmethod
152 def name():
153 return N_('Amend')
155 def __init__(self, amend):
156 Command.__init__(self)
157 self.undoable = True
158 self.skip = False
159 self.amending = amend
160 self.old_commitmsg = self.model.commitmsg
161 self.old_mode = self.model.mode
163 if self.amending:
164 self.new_mode = self.model.mode_amend
165 self.new_commitmsg = self.model.prev_commitmsg()
166 AmendMode.LAST_MESSAGE = self.model.commitmsg
167 return
168 # else, amend unchecked, regular commit
169 self.new_mode = self.model.mode_none
170 self.new_diff_text = ''
171 self.new_commitmsg = self.model.commitmsg
172 # If we're going back into new-commit-mode then search the
173 # undo stack for a previous amend-commit-mode and grab the
174 # commit message at that point in time.
175 if AmendMode.LAST_MESSAGE is not None:
176 self.new_commitmsg = AmendMode.LAST_MESSAGE
177 AmendMode.LAST_MESSAGE = None
179 def do(self):
180 """Leave/enter amend mode."""
181 """Attempt to enter amend mode. Do not allow this when merging."""
182 if self.amending:
183 if self.model.is_merging:
184 self.skip = True
185 self.model.set_mode(self.old_mode)
186 Interaction.information(
187 N_('Cannot Amend'),
188 N_('You are in the middle of a merge.\n'
189 'Cannot amend while merging.'))
190 return
191 self.skip = False
192 Command.do(self)
193 self.model.set_commitmsg(self.new_commitmsg)
194 self.model.update_file_status()
196 def undo(self):
197 if self.skip:
198 return
199 self.model.set_commitmsg(self.old_commitmsg)
200 Command.undo(self)
201 self.model.update_file_status()
204 class ApplyDiffSelection(Command):
206 def __init__(self, first_line_idx, last_line_idx, has_selection,
207 reverse, apply_to_worktree):
208 Command.__init__(self)
209 self.first_line_idx = first_line_idx
210 self.last_line_idx = last_line_idx
211 self.has_selection = has_selection
212 self.reverse = reverse
213 self.apply_to_worktree = apply_to_worktree
215 def do(self):
216 parser = DiffParser(self.model.filename, self.model.diff_text)
217 if self.has_selection:
218 patch = parser.generate_patch(self.first_line_idx,
219 self.last_line_idx,
220 reverse=self.reverse)
221 else:
222 patch = parser.generate_hunk_patch(self.first_line_idx,
223 reverse=self.reverse)
224 if patch is None:
225 return
227 cfg = gitcfg.current()
228 tmp_path = utils.tmp_filename('patch')
229 try:
230 core.write(tmp_path, patch,
231 encoding=cfg.file_encoding(self.model.filename))
232 if self.apply_to_worktree:
233 status, out, err = self.model.apply_diff_to_worktree(tmp_path)
234 else:
235 status, out, err = self.model.apply_diff(tmp_path)
236 finally:
237 os.unlink(tmp_path)
238 Interaction.log_status(status, out, err)
239 self.model.update_file_status(update_index=True)
242 class ApplyPatches(Command):
244 def __init__(self, patches):
245 Command.__init__(self)
246 self.patches = patches
248 def do(self):
249 diff_text = ''
250 num_patches = len(self.patches)
251 orig_head = self.model.git.rev_parse('HEAD')[STDOUT]
253 for idx, patch in enumerate(self.patches):
254 status, out, err = self.model.git.am(patch)
255 # Log the git-am command
256 Interaction.log_status(status, out, err)
258 if num_patches > 1:
259 diff = self.model.git.diff('HEAD^!', stat=True)[STDOUT]
260 diff_text += (N_('PATCH %(current)d/%(count)d') %
261 dict(current=idx+1, count=num_patches))
262 diff_text += ' - %s:\n%s\n\n' % (os.path.basename(patch), diff)
264 diff_text += N_('Summary:') + '\n'
265 diff_text += self.model.git.diff(orig_head, stat=True)[STDOUT]
267 # Display a diffstat
268 self.model.set_diff_text(diff_text)
269 self.model.update_file_status()
271 basenames = '\n'.join([os.path.basename(p) for p in self.patches])
272 Interaction.information(
273 N_('Patch(es) Applied'),
274 (N_('%d patch(es) applied.') + '\n\n%s') %
275 (len(self.patches), basenames))
278 class Archive(BaseCommand):
280 def __init__(self, ref, fmt, prefix, filename):
281 BaseCommand.__init__(self)
282 self.ref = ref
283 self.fmt = fmt
284 self.prefix = prefix
285 self.filename = filename
287 def do(self):
288 fp = core.xopen(self.filename, 'wb')
289 cmd = ['git', 'archive', '--format='+self.fmt]
290 if self.fmt in ('tgz', 'tar.gz'):
291 cmd.append('-9')
292 if self.prefix:
293 cmd.append('--prefix=' + self.prefix)
294 cmd.append(self.ref)
295 proc = core.start_command(cmd, stdout=fp)
296 out, err = proc.communicate()
297 fp.close()
298 status = proc.returncode
299 Interaction.log_status(status, out or '', err or '')
302 class Checkout(Command):
304 A command object for git-checkout.
306 'argv' is handed off directly to git.
310 def __init__(self, argv, checkout_branch=False):
311 Command.__init__(self)
312 self.argv = argv
313 self.checkout_branch = checkout_branch
314 self.new_diff_text = ''
316 def do(self):
317 status, out, err = self.model.git.checkout(*self.argv)
318 Interaction.log_status(status, out, err)
319 if self.checkout_branch:
320 self.model.update_status()
321 else:
322 self.model.update_file_status()
325 class BlamePaths(Command):
326 """Blame view for paths."""
328 def __init__(self, paths):
329 Command.__init__(self)
330 viewer = utils.shell_split(prefs.blame_viewer())
331 self.argv = viewer + list(paths)
333 def do(self):
334 try:
335 core.fork(self.argv)
336 except Exception as e:
337 _, details = utils.format_exception(e)
338 title = N_('Error Launching Blame Viewer')
339 msg = (N_('Cannot exec "%s": please configure a blame viewer') %
340 ' '.join(self.argv))
341 Interaction.critical(title, message=msg, details=details)
344 class CheckoutBranch(Checkout):
345 """Checkout a branch."""
347 def __init__(self, branch):
348 args = [branch]
349 Checkout.__init__(self, args, checkout_branch=True)
352 class CherryPick(Command):
353 """Cherry pick commits into the current branch."""
355 def __init__(self, commits):
356 Command.__init__(self)
357 self.commits = commits
359 def do(self):
360 self.model.cherry_pick_list(self.commits)
361 self.model.update_file_status()
364 class ResetMode(Command):
365 """Reset the mode and clear the model's diff text."""
367 def __init__(self):
368 Command.__init__(self)
369 self.new_mode = self.model.mode_none
370 self.new_diff_text = ''
372 def do(self):
373 Command.do(self)
374 self.model.update_file_status()
377 class Commit(ResetMode):
378 """Attempt to create a new commit."""
380 def __init__(self, amend, msg, sign, no_verify=False):
381 ResetMode.__init__(self)
382 self.amend = amend
383 self.msg = msg
384 self.sign = sign
385 self.no_verify = no_verify
386 self.old_commitmsg = self.model.commitmsg
387 self.new_commitmsg = ''
389 def do(self):
390 # Create the commit message file
391 comment_char = prefs.comment_char()
392 msg = self.strip_comments(self.msg, comment_char=comment_char)
393 tmpfile = utils.tmp_filename('commit-message')
394 try:
395 core.write(tmpfile, msg)
397 # Run 'git commit'
398 status, out, err = self.model.git.commit(F=tmpfile,
399 v=True,
400 gpg_sign=self.sign,
401 amend=self.amend,
402 no_verify=self.no_verify)
403 finally:
404 core.unlink(tmpfile)
406 if status == 0:
407 ResetMode.do(self)
408 self.model.set_commitmsg(self.new_commitmsg)
409 msg = N_('Created commit: %s') % out
410 else:
411 msg = N_('Commit failed: %s') % out
412 Interaction.log_status(status, msg, err)
414 return status, out, err
416 @staticmethod
417 def strip_comments(msg, comment_char='#'):
418 # Strip off comments
419 message_lines = [line for line in msg.split('\n')
420 if not line.startswith(comment_char)]
421 msg = '\n'.join(message_lines)
422 if not msg.endswith('\n'):
423 msg += '\n'
425 return msg
428 class Ignore(Command):
429 """Add files to .gitignore"""
431 def __init__(self, filenames):
432 Command.__init__(self)
433 self.filenames = list(filenames)
435 def do(self):
436 if not self.filenames:
437 return
438 new_additions = '\n'.join(self.filenames) + '\n'
439 for_status = new_additions
440 if core.exists('.gitignore'):
441 current_list = core.read('.gitignore')
442 new_additions = current_list.rstrip() + '\n' + new_additions
443 core.write('.gitignore', new_additions)
444 Interaction.log_status(0, 'Added to .gitignore:\n%s' % for_status, '')
445 self.model.update_file_status()
448 def file_summary(files):
449 txt = subprocess.list2cmdline(files)
450 if len(txt) > 768:
451 txt = txt[:768].rstrip() + '...'
452 return txt
455 class RemoteCommand(ConfirmAction):
457 def __init__(self, remote):
458 ConfirmAction.__init__(self)
459 self.model = main.model()
460 self.remote = remote
462 def success(self):
463 self.model.update_remotes()
466 class RemoteAdd(RemoteCommand):
468 def __init__(self, remote, url):
469 RemoteCommand.__init__(self, remote)
470 self.url = url
472 def action(self):
473 git = self.model.git
474 return git.remote('add', self.remote, self.url)
476 def error_message(self):
477 return N_('Error creating remote "%s"') % self.remote
480 class RemoteRemove(RemoteCommand):
482 def confirm(self):
483 title = N_('Delete Remote')
484 question = N_('Delete remote?')
485 info = N_('Delete remote "%s"') % self.remote
486 ok_btn = N_('Delete')
487 return Interaction.confirm(title, question, info, ok_btn)
489 def action(self):
490 git = self.model.git
491 return git.remote('rm', self.remote)
493 def error_message(self):
494 return N_('Error deleting remote "%s"') % self.remote
497 class RemoteRename(RemoteCommand):
499 def __init__(self, remote, new_remote):
500 RemoteCommand.__init__(self, remote)
501 self.new_remote = new_remote
503 def confirm(self):
504 title = N_('Rename Remote')
505 question = N_('Rename remote?')
506 info = (N_('Rename remote "%(current)s" to "%(new)s"?') %
507 dict(current=self.remote, new=self.new_remote))
508 ok_btn = N_('Rename')
509 return Interaction.confirm(title, question, info, ok_btn)
511 def action(self):
512 git = self.model.git
513 return git.remote('rename', self.remote, self.new_remote)
516 class RemoveFromSettings(ConfirmAction):
518 def __init__(self, settings, repo, icon=None):
519 ConfirmAction.__init__(self)
520 self.settings = settings
521 self.repo = repo
522 self.icon = icon
524 def success(self):
525 self.settings.save()
528 class RemoveBookmark(RemoveFromSettings):
530 def confirm(self):
531 repo = self.repo
532 title = msg = N_('Delete Bookmark?')
533 info = N_('%s will be removed from your bookmarks.') % repo
534 ok_text = N_('Delete Bookmark')
535 return Interaction.confirm(title, msg, info, ok_text, icon=self.icon)
537 def action(self):
538 self.settings.remove_bookmark(self.repo)
539 return (0, '', '')
542 class RemoveRecent(RemoveFromSettings):
544 def confirm(self):
545 repo = self.repo
546 title = msg = N_('Remove %s from the recent list?') % repo
547 info = N_('%s will be removed from your recent repositories.') % repo
548 ok_text = N_('Remove')
549 return Interaction.confirm(title, msg, info, ok_text, icon=self.icon)
551 def action(self):
552 self.settings.remove_recent(self.repo)
553 return (0, '', '')
556 class RemoveFiles(Command):
557 """Removes files."""
559 def __init__(self, remover, filenames):
560 Command.__init__(self)
561 if remover is None:
562 remover = os.remove
563 self.remover = remover
564 self.filenames = filenames
565 # We could git-hash-object stuff and provide undo-ability
566 # as an option. Heh.
568 def do(self):
569 files = self.filenames
570 if not files:
571 return
573 rescan = False
574 bad_filenames = []
575 remove = self.remover
576 for filename in files:
577 if filename:
578 try:
579 remove(filename)
580 rescan=True
581 except:
582 bad_filenames.append(filename)
584 if bad_filenames:
585 Interaction.information(
586 N_('Error'),
587 N_('Deleting "%s" failed') % file_summary(files))
589 if rescan:
590 self.model.update_file_status()
593 class Delete(RemoveFiles):
594 """Delete files."""
596 def __init__(self, filenames):
597 RemoveFiles.__init__(self, os.remove, filenames)
599 def do(self):
600 files = self.filenames
601 if not files:
602 return
604 title = N_('Delete Files?')
605 msg = N_('The following files will be deleted:') + '\n\n'
606 msg += file_summary(files)
607 info_txt = N_('Delete %d file(s)?') % len(files)
608 ok_txt = N_('Delete Files')
610 if not Interaction.confirm(title, msg, info_txt, ok_txt,
611 default=True, icon=icons.remove()):
612 return
614 return RemoveFiles.do(self)
617 class MoveToTrash(RemoveFiles):
618 """Move files to the trash using send2trash"""
620 AVAILABLE = send2trash is not None
622 def __init__(self, filenames):
623 RemoveFiles.__init__(self, send2trash, filenames)
626 class DeleteBranch(Command):
627 """Delete a git branch."""
629 def __init__(self, branch):
630 Command.__init__(self)
631 self.branch = branch
633 def do(self):
634 status, out, err = self.model.delete_branch(self.branch)
635 Interaction.log_status(status, out, err)
638 class RenameBranch(Command):
639 """Rename a git branch."""
641 def __init__(self, branch, new_branch):
642 Command.__init__(self)
643 self.branch = branch
644 self.new_branch = new_branch
646 def do(self):
647 status, out, err = self.model.rename_branch(self.branch, self.new_branch)
648 Interaction.log_status(status, out, err)
650 class DeleteRemoteBranch(Command):
651 """Delete a remote git branch."""
653 def __init__(self, remote, branch):
654 Command.__init__(self)
655 self.remote = remote
656 self.branch = branch
658 def do(self):
659 status, out, err = self.model.git.push(self.remote, self.branch,
660 delete=True)
661 Interaction.log_status(status, out, err)
662 self.model.update_status()
664 if status == 0:
665 Interaction.information(
666 N_('Remote Branch Deleted'),
667 N_('"%(branch)s" has been deleted from "%(remote)s".')
668 % dict(branch=self.branch, remote=self.remote))
669 else:
670 command = 'git push'
671 message = (N_('"%(command)s" returned exit status %(status)d') %
672 dict(command=command, status=status))
674 Interaction.critical(N_('Error Deleting Remote Branch'),
675 message, out + err)
679 class Diff(Command):
680 """Perform a diff and set the model's current text."""
682 def __init__(self, filename, cached=False, deleted=False):
683 Command.__init__(self)
684 opts = {}
685 if cached:
686 opts['ref'] = self.model.head
687 self.new_filename = filename
688 self.new_mode = self.model.mode_worktree
689 self.new_diff_text = gitcmds.diff_helper(filename=filename,
690 cached=cached,
691 deleted=deleted,
692 **opts)
695 class Diffstat(Command):
696 """Perform a diffstat and set the model's diff text."""
698 def __init__(self):
699 Command.__init__(self)
700 cfg = gitcfg.current()
701 diff_context = cfg.get('diff.context', 3)
702 diff = self.model.git.diff(self.model.head,
703 unified=diff_context,
704 no_ext_diff=True,
705 no_color=True,
706 M=True,
707 stat=True)[STDOUT]
708 self.new_diff_text = diff
709 self.new_mode = self.model.mode_worktree
712 class DiffStaged(Diff):
713 """Perform a staged diff on a file."""
715 def __init__(self, filename, deleted=None):
716 Diff.__init__(self, filename, cached=True, deleted=deleted)
717 self.new_mode = self.model.mode_index
720 class DiffStagedSummary(Command):
722 def __init__(self):
723 Command.__init__(self)
724 diff = self.model.git.diff(self.model.head,
725 cached=True,
726 no_color=True,
727 no_ext_diff=True,
728 patch_with_stat=True,
729 M=True)[STDOUT]
730 self.new_diff_text = diff
731 self.new_mode = self.model.mode_index
734 class Difftool(Command):
735 """Run git-difftool limited by path."""
737 def __init__(self, staged, filenames):
738 Command.__init__(self)
739 self.staged = staged
740 self.filenames = filenames
742 def do(self):
743 difftool.launch_with_head(self.filenames,
744 self.staged, self.model.head)
747 class Edit(Command):
748 """Edit a file using the configured gui.editor."""
750 @staticmethod
751 def name():
752 return N_('Launch Editor')
754 def __init__(self, filenames, line_number=None):
755 Command.__init__(self)
756 self.filenames = filenames
757 self.line_number = line_number
759 def do(self):
760 if not self.filenames:
761 return
762 filename = self.filenames[0]
763 if not core.exists(filename):
764 return
765 editor = prefs.editor()
766 opts = []
768 if self.line_number is None:
769 opts = self.filenames
770 else:
771 # Single-file w/ line-numbers (likely from grep)
772 editor_opts = {
773 '*vim*': ['+'+self.line_number, filename],
774 '*emacs*': ['+'+self.line_number, filename],
775 '*textpad*': ['%s(%s,0)' % (filename, self.line_number)],
776 '*notepad++*': ['-n'+self.line_number, filename],
779 opts = self.filenames
780 for pattern, opt in editor_opts.items():
781 if fnmatch(editor, pattern):
782 opts = opt
783 break
785 try:
786 core.fork(utils.shell_split(editor) + opts)
787 except Exception as e:
788 message = (N_('Cannot exec "%s": please configure your editor')
789 % editor)
790 details = core.decode(e.strerror)
791 Interaction.critical(N_('Error Editing File'), message, details)
794 class FormatPatch(Command):
795 """Output a patch series given all revisions and a selected subset."""
797 def __init__(self, to_export, revs):
798 Command.__init__(self)
799 self.to_export = list(to_export)
800 self.revs = list(revs)
802 def do(self):
803 status, out, err = gitcmds.format_patchsets(self.to_export, self.revs)
804 Interaction.log_status(status, out, err)
807 class LaunchDifftool(BaseCommand):
809 @staticmethod
810 def name():
811 return N_('Launch Diff Tool')
813 def __init__(self):
814 BaseCommand.__init__(self)
816 def do(self):
817 s = selection.selection()
818 if s.unmerged:
819 paths = s.unmerged
820 if utils.is_win32():
821 core.fork(['git', 'mergetool', '--no-prompt', '--'] + paths)
822 else:
823 cfg = gitcfg.current()
824 cmd = cfg.terminal()
825 argv = utils.shell_split(cmd)
826 mergetool = ['git', 'mergetool', '--no-prompt', '--']
827 mergetool.extend(paths)
828 needs_shellquote = set(['gnome-terminal', 'xfce4-terminal'])
829 if os.path.basename(argv[0]) in needs_shellquote:
830 argv.append(subprocess.list2cmdline(mergetool))
831 else:
832 argv.extend(mergetool)
833 core.fork(argv)
834 else:
835 difftool.run()
838 class LaunchTerminal(BaseCommand):
840 @staticmethod
841 def name():
842 return N_('Launch Terminal')
844 def __init__(self, path):
845 BaseCommand.__init__(self)
846 self.path = path
848 def do(self):
849 cfg = gitcfg.current()
850 cmd = cfg.terminal()
851 argv = utils.shell_split(cmd)
852 argv.append(os.getenv('SHELL', '/bin/sh'))
853 core.fork(argv, cwd=self.path)
856 class LaunchEditor(Edit):
858 @staticmethod
859 def name():
860 return N_('Launch Editor')
862 def __init__(self):
863 s = selection.selection()
864 allfiles = s.staged + s.unmerged + s.modified + s.untracked
865 Edit.__init__(self, allfiles)
868 class LoadCommitMessageFromFile(Command):
869 """Loads a commit message from a path."""
871 def __init__(self, path):
872 Command.__init__(self)
873 self.undoable = True
874 self.path = path
875 self.old_commitmsg = self.model.commitmsg
876 self.old_directory = self.model.directory
878 def do(self):
879 path = self.path
880 if not path or not core.isfile(path):
881 raise UsageError(N_('Error: Cannot find commit template'),
882 N_('%s: No such file or directory.') % path)
883 self.model.set_directory(os.path.dirname(path))
884 self.model.set_commitmsg(core.read(path))
886 def undo(self):
887 self.model.set_commitmsg(self.old_commitmsg)
888 self.model.set_directory(self.old_directory)
891 class LoadCommitMessageFromTemplate(LoadCommitMessageFromFile):
892 """Loads the commit message template specified by commit.template."""
894 def __init__(self):
895 cfg = gitcfg.current()
896 template = cfg.get('commit.template')
897 LoadCommitMessageFromFile.__init__(self, template)
899 def do(self):
900 if self.path is None:
901 raise UsageError(
902 N_('Error: Unconfigured commit template'),
903 N_('A commit template has not been configured.\n'
904 'Use "git config" to define "commit.template"\n'
905 'so that it points to a commit template.'))
906 return LoadCommitMessageFromFile.do(self)
910 class LoadCommitMessageFromSHA1(Command):
911 """Load a previous commit message"""
913 def __init__(self, sha1, prefix=''):
914 Command.__init__(self)
915 self.sha1 = sha1
916 self.old_commitmsg = self.model.commitmsg
917 self.new_commitmsg = prefix + self.model.prev_commitmsg(sha1)
918 self.undoable = True
920 def do(self):
921 self.model.set_commitmsg(self.new_commitmsg)
923 def undo(self):
924 self.model.set_commitmsg(self.old_commitmsg)
927 class LoadFixupMessage(LoadCommitMessageFromSHA1):
928 """Load a fixup message"""
930 def __init__(self, sha1):
931 LoadCommitMessageFromSHA1.__init__(self, sha1, prefix='fixup! ')
934 class Merge(Command):
935 """Merge commits"""
937 def __init__(self, revision, no_commit, squash, no_ff, sign):
938 Command.__init__(self)
939 self.revision = revision
940 self.no_ff = no_ff
941 self.no_commit = no_commit
942 self.squash = squash
943 self.sign = sign
945 def do(self):
946 squash = self.squash
947 revision = self.revision
948 no_ff = self.no_ff
949 no_commit = self.no_commit
950 sign = self.sign
952 status, out, err = self.model.git.merge(revision,
953 gpg_sign=sign,
954 no_ff=no_ff,
955 no_commit=no_commit,
956 squash=squash)
957 Interaction.log_status(status, out, err)
958 self.model.update_status()
961 class OpenDefaultApp(BaseCommand):
962 """Open a file using the OS default."""
964 @staticmethod
965 def name():
966 return N_('Open Using Default Application')
968 def __init__(self, filenames):
969 BaseCommand.__init__(self)
970 if utils.is_darwin():
971 launcher = 'open'
972 else:
973 launcher = 'xdg-open'
974 self.launcher = launcher
975 self.filenames = filenames
977 def do(self):
978 if not self.filenames:
979 return
980 core.fork([self.launcher] + self.filenames)
983 class OpenParentDir(OpenDefaultApp):
984 """Open parent directories using the OS default."""
986 @staticmethod
987 def name():
988 return N_('Open Parent Directory')
990 def __init__(self, filenames):
991 OpenDefaultApp.__init__(self, filenames)
993 def do(self):
994 if not self.filenames:
995 return
996 dirs = list(set(map(os.path.dirname, self.filenames)))
997 core.fork([self.launcher] + dirs)
1000 class OpenNewRepo(Command):
1001 """Launches git-cola on a repo."""
1003 def __init__(self, repo_path):
1004 Command.__init__(self)
1005 self.repo_path = repo_path
1007 def do(self):
1008 self.model.set_directory(self.repo_path)
1009 core.fork([sys.executable, sys.argv[0], '--repo', self.repo_path])
1012 class OpenRepo(Command):
1013 def __init__(self, repo_path):
1014 Command.__init__(self)
1015 self.repo_path = repo_path
1017 def do(self):
1018 git = self.model.git
1019 old_worktree = git.worktree()
1020 if not self.model.set_worktree(self.repo_path):
1021 self.model.set_worktree(old_worktree)
1022 return
1023 new_worktree = git.worktree()
1024 core.chdir(new_worktree)
1025 self.model.set_directory(self.repo_path)
1026 cfg = gitcfg.current()
1027 cfg.reset()
1028 fsmonitor.instance().stop()
1029 fsmonitor.instance().start()
1030 self.model.update_status()
1033 class Clone(Command):
1034 """Clones a repository and optionally spawns a new cola session."""
1036 def __init__(self, url, new_directory, spawn=True):
1037 Command.__init__(self)
1038 self.url = url
1039 self.new_directory = new_directory
1040 self.spawn = spawn
1042 self.ok = False
1043 self.error_message = ''
1044 self.error_details = ''
1046 def do(self):
1047 status, out, err = self.model.git.clone(self.url, self.new_directory)
1048 self.ok = status == 0
1050 if self.ok:
1051 if self.spawn:
1052 core.fork([sys.executable, sys.argv[0],
1053 '--repo', self.new_directory])
1054 else:
1055 self.error_message = N_('Error: could not clone "%s"') % self.url
1056 self.error_details = (
1057 (N_('git clone returned exit code %s') % status) +
1058 ((out+err) and ('\n\n' + out + err) or ''))
1060 return self
1063 def unix_path(path, is_win32=utils.is_win32):
1064 """Git for Windows requires unix paths, so force them here
1066 unix_path = path
1067 if is_win32():
1068 first = path[0]
1069 second = path[1]
1070 if second == ':': # sanity check, this better be a Windows-style path
1071 unix_path = '/' + first + path[2:].replace('\\', '/')
1073 return unix_path
1076 class GitXBaseContext(object):
1078 def __init__(self, **kwargs):
1079 self.env = {'GIT_EDITOR': prefs.editor()}
1080 self.env.update(kwargs)
1082 def __enter__(self):
1083 compat.setenv('GIT_SEQUENCE_EDITOR',
1084 unix_path(resources.share('bin', 'git-xbase')))
1085 for var, value in self.env.items():
1086 compat.setenv(var, value)
1087 return self
1089 def __exit__(self, exc_type, exc_val, exc_tb):
1090 compat.unsetenv('GIT_SEQUENCE_EDITOR')
1091 for var in self.env:
1092 compat.unsetenv(var)
1095 class Rebase(Command):
1097 def __init__(self,
1098 upstream=None, branch=None, capture_output=True, **kwargs):
1099 """Start an interactive rebase session
1101 :param upstream: upstream branch
1102 :param branch: optional branch to checkout
1103 :param capture_output: whether to capture stdout and stderr
1104 :param kwargs: forwarded directly to `git.rebase()`
1107 Command.__init__(self)
1109 self.upstream = upstream
1110 self.branch = branch
1111 self.capture_output = capture_output
1112 self.kwargs = kwargs
1114 def prepare_arguments(self):
1115 args = []
1116 kwargs = {}
1118 if self.capture_output:
1119 kwargs['_stderr'] = None
1120 kwargs['_stdout'] = None
1122 # Rebase actions must be the only option specified
1123 for action in ('continue', 'abort', 'skip', 'edit_todo'):
1124 if self.kwargs.get(action, False):
1125 kwargs[action] = self.kwargs[action]
1126 return args, kwargs
1128 kwargs['interactive'] = True
1129 kwargs['autosquash'] = self.kwargs.get('autosquash', True)
1130 kwargs.update(self.kwargs)
1132 if self.upstream:
1133 args.append(self.upstream)
1134 if self.branch:
1135 args.append(self.branch)
1137 return args, kwargs
1139 def do(self):
1140 (status, out, err) = (1, '', '')
1141 args, kwargs = self.prepare_arguments()
1142 upstream_title = self.upstream or '@{upstream}'
1143 with GitXBaseContext(
1144 GIT_XBASE_TITLE=N_('Rebase onto %s') % upstream_title,
1145 GIT_XBASE_ACTION=N_('Rebase')):
1146 status, out, err = self.model.git.rebase(*args, **kwargs)
1147 Interaction.log_status(status, out, err)
1148 self.model.update_status()
1149 return status, out, err
1152 class RebaseEditTodo(Command):
1154 def do(self):
1155 (status, out, err) = (1, '', '')
1156 with GitXBaseContext(
1157 GIT_XBASE_TITLE=N_('Edit Rebase'),
1158 GIT_XBASE_ACTION=N_('Save')):
1159 status, out, err = self.model.git.rebase(edit_todo=True)
1160 Interaction.log_status(status, out, err)
1161 self.model.update_status()
1162 return status, out, err
1165 class RebaseContinue(Command):
1167 def do(self):
1168 (status, out, err) = (1, '', '')
1169 with GitXBaseContext(
1170 GIT_XBASE_TITLE=N_('Rebase'),
1171 GIT_XBASE_ACTION=N_('Rebase')):
1172 status, out, err = self.model.git.rebase('--continue')
1173 Interaction.log_status(status, out, err)
1174 self.model.update_status()
1175 return status, out, err
1178 class RebaseSkip(Command):
1180 def do(self):
1181 (status, out, err) = (1, '', '')
1182 with GitXBaseContext(
1183 GIT_XBASE_TITLE=N_('Rebase'),
1184 GIT_XBASE_ACTION=N_('Rebase')):
1185 status, out, err = self.model.git.rebase(skip=True)
1186 Interaction.log_status(status, out, err)
1187 self.model.update_status()
1188 return status, out, err
1191 class RebaseAbort(Command):
1193 def do(self):
1194 status, out, err = self.model.git.rebase(abort=True)
1195 Interaction.log_status(status, out, err)
1196 self.model.update_status()
1199 class Rescan(Command):
1200 """Rescan for changes"""
1202 def do(self):
1203 self.model.update_status()
1206 class Refresh(Command):
1207 """Update refs and refresh the index"""
1209 @staticmethod
1210 def name():
1211 return N_('Refresh')
1213 def do(self):
1214 self.model.update_status(update_index=True)
1215 fsmonitor.instance().refresh()
1218 class RevertEditsCommand(ConfirmAction):
1220 def __init__(self):
1221 ConfirmAction.__init__(self)
1222 self.model = main.model()
1223 self.icon = icons.undo()
1225 def ok_to_run(self):
1226 return self.model.undoable()
1228 def checkout_from_head(self):
1229 return False
1231 def checkout_args(self):
1232 args = []
1233 s = selection.selection()
1234 if self.checkout_from_head():
1235 args.append(self.model.head)
1236 args.append('--')
1238 if s.staged:
1239 items = s.staged
1240 else:
1241 items = s.modified
1242 args.extend(items)
1244 return args
1246 def action(self):
1247 git = self.model.git
1248 checkout_args = self.checkout_args()
1249 return git.checkout(*checkout_args)
1251 def success(self):
1252 self.model.update_file_status()
1255 class RevertUnstagedEdits(RevertEditsCommand):
1257 @staticmethod
1258 def name():
1259 return N_('Revert Unstaged Edits...')
1261 def checkout_from_head(self):
1262 # If we are amending and a modified file is selected
1263 # then we should include "HEAD^" on the command-line.
1264 selected = selection.selection()
1265 return not selected.staged and self.model.amending()
1267 def confirm(self):
1268 title = N_('Revert Unstaged Changes?')
1269 text = N_('This operation drops unstaged changes.\n'
1270 'These changes cannot be recovered.')
1271 info = N_('Revert the unstaged changes?')
1272 ok_text = N_('Revert Unstaged Changes')
1273 return Interaction.confirm(title, text, info, ok_text,
1274 default=True, icon=self.icon)
1277 class RevertUncommittedEdits(RevertEditsCommand):
1279 @staticmethod
1280 def name():
1281 return N_('Revert Uncommitted Edits...')
1283 def checkout_from_head(self):
1284 return True
1286 def confirm(self):
1287 title = N_('Revert Uncommitted Changes?')
1288 text = N_('This operation drops uncommitted changes.\n'
1289 'These changes cannot be recovered.')
1290 info = N_('Revert the uncommitted changes?')
1291 ok_text = N_('Revert Uncommitted Changes')
1292 return Interaction.confirm(title, text, info, ok_text,
1293 default=True, icon=self.icon)
1296 class RunConfigAction(Command):
1297 """Run a user-configured action, typically from the "Tools" menu"""
1299 def __init__(self, action_name):
1300 Command.__init__(self)
1301 self.action_name = action_name
1302 self.model = main.model()
1304 def do(self):
1305 for env in ('FILENAME', 'REVISION', 'ARGS'):
1306 try:
1307 compat.unsetenv(env)
1308 except KeyError:
1309 pass
1310 rev = None
1311 args = None
1312 cfg = gitcfg.current()
1313 opts = cfg.get_guitool_opts(self.action_name)
1314 cmd = opts.get('cmd')
1315 if 'title' not in opts:
1316 opts['title'] = cmd
1318 if 'prompt' not in opts or opts.get('prompt') is True:
1319 prompt = N_('Run "%s"?') % cmd
1320 opts['prompt'] = prompt
1322 if opts.get('needsfile'):
1323 filename = selection.filename()
1324 if not filename:
1325 Interaction.information(
1326 N_('Please select a file'),
1327 N_('"%s" requires a selected file.') % cmd)
1328 return False
1329 compat.setenv('FILENAME', filename)
1331 if opts.get('revprompt') or opts.get('argprompt'):
1332 while True:
1333 ok = Interaction.confirm_config_action(cmd, opts)
1334 if not ok:
1335 return False
1336 rev = opts.get('revision')
1337 args = opts.get('args')
1338 if opts.get('revprompt') and not rev:
1339 title = N_('Invalid Revision')
1340 msg = N_('The revision expression cannot be empty.')
1341 Interaction.critical(title, msg)
1342 continue
1343 break
1345 elif opts.get('confirm'):
1346 title = os.path.expandvars(opts.get('title'))
1347 prompt = os.path.expandvars(opts.get('prompt'))
1348 if Interaction.question(title, prompt):
1349 return
1350 if rev:
1351 compat.setenv('REVISION', rev)
1352 if args:
1353 compat.setenv('ARGS', args)
1354 title = os.path.expandvars(cmd)
1355 Interaction.log(N_('Running command: %s') % title)
1356 cmd = ['sh', '-c', cmd]
1358 if opts.get('background'):
1359 core.fork(cmd)
1360 status, out, err = (0, '', '')
1361 elif opts.get('noconsole'):
1362 status, out, err = core.run_command(cmd)
1363 else:
1364 status, out, err = Interaction.run_command(title, cmd)
1366 Interaction.log_status(status,
1367 out and (N_('Output: %s') % out) or '',
1368 err and (N_('Errors: %s') % err) or '')
1370 if not opts.get('background') and not opts.get('norescan'):
1371 self.model.update_status()
1372 return status
1375 class SetDefaultRepo(Command):
1377 def __init__(self, repo):
1378 Command.__init__(self)
1379 self.repo = repo
1381 def do(self):
1382 gitcfg.current().set_user('cola.defaultrepo', self.repo)
1385 class SetDiffText(Command):
1387 def __init__(self, text):
1388 Command.__init__(self)
1389 self.undoable = True
1390 self.new_diff_text = text
1393 class ShowUntracked(Command):
1394 """Show an untracked file."""
1396 def __init__(self, filename):
1397 Command.__init__(self)
1398 self.new_filename = filename
1399 self.new_mode = self.model.mode_untracked
1400 self.new_diff_text = self.diff_text_for(filename)
1402 def diff_text_for(self, filename):
1403 cfg = gitcfg.current()
1404 size = cfg.get('cola.readsize', 1024 * 2)
1405 try:
1406 result = core.read(filename, size=size,
1407 encoding='utf-8', errors='ignore')
1408 except:
1409 result = ''
1411 if len(result) == size:
1412 result += '...'
1413 return result
1416 class SignOff(Command):
1418 @staticmethod
1419 def name():
1420 return N_('Sign Off')
1422 def __init__(self):
1423 Command.__init__(self)
1424 self.undoable = True
1425 self.old_commitmsg = self.model.commitmsg
1427 def do(self):
1428 signoff = self.signoff()
1429 if signoff in self.model.commitmsg:
1430 return
1431 self.model.set_commitmsg(self.model.commitmsg + '\n' + signoff)
1433 def undo(self):
1434 self.model.set_commitmsg(self.old_commitmsg)
1436 def signoff(self):
1437 try:
1438 import pwd
1439 user = pwd.getpwuid(os.getuid()).pw_name
1440 except ImportError:
1441 user = os.getenv('USER', N_('unknown'))
1443 cfg = gitcfg.current()
1444 name = cfg.get('user.name', user)
1445 email = cfg.get('user.email', '%s@%s' % (user, core.node()))
1446 return '\nSigned-off-by: %s <%s>' % (name, email)
1449 def check_conflicts(unmerged):
1450 """Check paths for conflicts
1452 Conflicting files can be filtered out one-by-one.
1455 if prefs.check_conflicts():
1456 unmerged = [path for path in unmerged if is_conflict_free(path)]
1457 return unmerged
1460 def is_conflict_free(path):
1461 """Return True if `path` contains no conflict markers
1463 rgx = re.compile(r'^(<<<<<<<|\|\|\|\|\|\|\||>>>>>>>) ')
1464 try:
1465 with core.xopen(path, 'r') as f:
1466 for line in f:
1467 line = core.decode(line, errors='ignore')
1468 if rgx.match(line):
1469 if should_stage_conflicts(path):
1470 return True
1471 else:
1472 return False
1473 except IOError:
1474 # We can't read this file ~ we may be staging a removal
1475 pass
1476 return True
1479 def should_stage_conflicts(path):
1480 """Inform the user that a file contains merge conflicts
1482 Return `True` if we should stage the path nonetheless.
1485 title = msg = N_('Stage conflicts?')
1486 info = N_('%s appears to contain merge conflicts.\n\n'
1487 'You should probably skip this file.\n'
1488 'Stage it anyways?') % path
1489 ok_text = N_('Stage conflicts')
1490 cancel_text = N_('Skip')
1491 return Interaction.confirm(title, msg, info, ok_text,
1492 default=False, cancel_text=cancel_text)
1495 class Stage(Command):
1496 """Stage a set of paths."""
1498 @staticmethod
1499 def name():
1500 return N_('Stage')
1502 def __init__(self, paths):
1503 Command.__init__(self)
1504 self.paths = paths
1506 def do(self):
1507 msg = N_('Staging: %s') % (', '.join(self.paths))
1508 Interaction.log(msg)
1509 # Prevent external updates while we are staging files.
1510 # We update file stats at the end of this operation
1511 # so there's no harm in ignoring updates from other threads
1512 # (e.g. the file system change monitor).
1513 with CommandDisabled(UpdateFileStatus):
1514 self.model.stage_paths(self.paths)
1517 class StageCarefully(Stage):
1518 """Only stage when the path list is non-empty
1520 We use "git add -u -- <pathspec>" to stage, and it stages everything by
1521 default when no pathspec is specified, so this class ensures that paths
1522 are specified before calling git.
1524 When no paths are specified, the command does nothing.
1527 def __init__(self):
1528 Stage.__init__(self, None)
1529 self.init_paths()
1531 def init_paths(self):
1532 pass
1534 def ok_to_run(self):
1535 """Prevent catch-all "git add -u" from adding unmerged files"""
1536 return self.paths or not self.model.unmerged
1538 def do(self):
1539 if self.ok_to_run():
1540 Stage.do(self)
1543 class StageModified(StageCarefully):
1544 """Stage all modified files."""
1546 @staticmethod
1547 def name():
1548 return N_('Stage Modified')
1550 def init_paths(self):
1551 self.paths = self.model.modified
1554 class StageUnmerged(StageCarefully):
1555 """Stage unmerged files."""
1557 @staticmethod
1558 def name():
1559 return N_('Stage Unmerged')
1561 def init_paths(self):
1562 self.paths = check_conflicts(self.model.unmerged)
1565 class StageUntracked(StageCarefully):
1566 """Stage all untracked files."""
1568 @staticmethod
1569 def name():
1570 return N_('Stage Untracked')
1572 def init_paths(self):
1573 self.paths = self.model.untracked
1576 class StageOrUnstage(Command):
1577 """If the selection is staged, unstage it, otherwise stage"""
1579 @staticmethod
1580 def name():
1581 return N_('Stage / Unstage')
1583 def do(self):
1584 s = selection.selection()
1585 if s.staged:
1586 do(Unstage, s.staged)
1588 unstaged = []
1589 unmerged = check_conflicts(s.unmerged)
1590 if unmerged:
1591 unstaged.extend(unmerged)
1592 if s.modified:
1593 unstaged.extend(s.modified)
1594 if s.untracked:
1595 unstaged.extend(s.untracked)
1596 if unstaged:
1597 do(Stage, unstaged)
1600 class Tag(Command):
1601 """Create a tag object."""
1603 def __init__(self, name, revision, sign=False, message=''):
1604 Command.__init__(self)
1605 self._name = name
1606 self._message = message
1607 self._revision = revision
1608 self._sign = sign
1610 def do(self):
1611 log_msg = (N_('Tagging "%(revision)s" as "%(name)s"') %
1612 dict(revision=self._revision, name=self._name))
1613 opts = {}
1614 try:
1615 if self._message:
1616 opts['F'] = utils.tmp_filename('tag-message')
1617 core.write(opts['F'], self._message)
1619 if self._sign:
1620 log_msg += ' (%s)' % N_('GPG-signed')
1621 opts['s'] = True
1622 else:
1623 opts['a'] = bool(self._message)
1624 status, output, err = self.model.git.tag(self._name,
1625 self._revision, **opts)
1626 finally:
1627 if 'F' in opts:
1628 os.unlink(opts['F'])
1630 if output:
1631 log_msg += '\n' + (N_('Output: %s') % output)
1633 Interaction.log_status(status, log_msg, err)
1634 if status == 0:
1635 self.model.update_status()
1636 return (status, output, err)
1639 class Unstage(Command):
1640 """Unstage a set of paths."""
1642 @staticmethod
1643 def name():
1644 return N_('Unstage')
1646 def __init__(self, paths):
1647 Command.__init__(self)
1648 self.paths = paths
1650 def do(self):
1651 msg = N_('Unstaging: %s') % (', '.join(self.paths))
1652 Interaction.log(msg)
1653 with CommandDisabled(UpdateFileStatus):
1654 self.model.unstage_paths(self.paths)
1657 class UnstageAll(Command):
1658 """Unstage all files; resets the index."""
1660 def do(self):
1661 self.model.unstage_all()
1664 class UnstageSelected(Unstage):
1665 """Unstage selected files."""
1667 def __init__(self):
1668 Unstage.__init__(self, selection.selection_model().staged)
1671 class Untrack(Command):
1672 """Unstage a set of paths."""
1674 def __init__(self, paths):
1675 Command.__init__(self)
1676 self.paths = paths
1678 def do(self):
1679 msg = N_('Untracking: %s') % (', '.join(self.paths))
1680 Interaction.log(msg)
1681 with CommandDisabled(UpdateFileStatus):
1682 status, out, err = self.model.untrack_paths(self.paths)
1683 Interaction.log_status(status, out, err)
1686 class UntrackedSummary(Command):
1687 """List possible .gitignore rules as the diff text."""
1689 def __init__(self):
1690 Command.__init__(self)
1691 untracked = self.model.untracked
1692 suffix = len(untracked) > 1 and 's' or ''
1693 io = StringIO()
1694 io.write('# %s untracked file%s\n' % (len(untracked), suffix))
1695 if untracked:
1696 io.write('# possible .gitignore rule%s:\n' % suffix)
1697 for u in untracked:
1698 io.write('/'+u+'\n')
1699 self.new_diff_text = io.getvalue()
1700 self.new_mode = self.model.mode_untracked
1703 class UpdateFileStatus(Command):
1704 """Rescans for changes."""
1706 def do(self):
1707 self.model.update_file_status()
1710 class VisualizeAll(Command):
1711 """Visualize all branches."""
1713 def do(self):
1714 browser = utils.shell_split(prefs.history_browser())
1715 launch_history_browser(browser + ['--all'])
1718 class VisualizeCurrent(Command):
1719 """Visualize all branches."""
1721 def do(self):
1722 browser = utils.shell_split(prefs.history_browser())
1723 launch_history_browser(browser + [self.model.currentbranch])
1726 class VisualizePaths(Command):
1727 """Path-limited visualization."""
1729 def __init__(self, paths):
1730 Command.__init__(self)
1731 browser = utils.shell_split(prefs.history_browser())
1732 if paths:
1733 self.argv = browser + list(paths)
1734 else:
1735 self.argv = browser
1737 def do(self):
1738 launch_history_browser(self.argv)
1741 class VisualizeRevision(Command):
1742 """Visualize a specific revision."""
1744 def __init__(self, revision, paths=None):
1745 Command.__init__(self)
1746 self.revision = revision
1747 self.paths = paths
1749 def do(self):
1750 argv = utils.shell_split(prefs.history_browser())
1751 if self.revision:
1752 argv.append(self.revision)
1753 if self.paths:
1754 argv.append('--')
1755 argv.extend(self.paths)
1756 launch_history_browser(argv)
1759 def launch_history_browser(argv):
1760 try:
1761 core.fork(argv)
1762 except Exception as e:
1763 _, details = utils.format_exception(e)
1764 title = N_('Error Launching History Browser')
1765 msg = (N_('Cannot exec "%s": please configure a history browser') %
1766 ' '.join(argv))
1767 Interaction.critical(title, message=msg, details=details)
1770 def run(cls, *args, **opts):
1772 Returns a callback that runs a command
1774 If the caller of run() provides args or opts then those are
1775 used instead of the ones provided by the invoker of the callback.
1778 def runner(*local_args, **local_opts):
1779 if args or opts:
1780 do(cls, *args, **opts)
1781 else:
1782 do(cls, *local_args, **local_opts)
1784 return runner
1787 class CommandDisabled(object):
1789 """Context manager to temporarily disable a command from running"""
1790 def __init__(self, cmdclass):
1791 self.cmdclass = cmdclass
1793 def __enter__(self):
1794 self.cmdclass.DISABLED = True
1795 return self
1797 def __exit__(self, exc_type, exc_val, exc_tb):
1798 self.cmdclass.DISABLED = False
1801 def do(cls, *args, **opts):
1802 """Run a command in-place"""
1803 return do_cmd(cls(*args, **opts))
1806 def do_cmd(cmd):
1807 if hasattr(cmd, 'DISABLED') and cmd.DISABLED:
1808 return None
1809 try:
1810 return cmd.do()
1811 except Exception as e:
1812 msg, details = utils.format_exception(e)
1813 Interaction.critical(N_('Error'), message=msg, details=details)
1814 return None