core: honor core.commentChar in commit messages and rebase todos
[git-cola.git] / cola / cmds.py
blob4082f98999ef3218dea503d8c92ea025ec40f985
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 gitcfg
18 from cola import gitcmds
19 from cola import inotify
20 from cola import utils
21 from cola import difftool
22 from cola import resources
23 from cola.diffparse import DiffParser
24 from cola.git import STDOUT
25 from cola.i18n import N_
26 from cola.interaction import Interaction
27 from cola.models import main
28 from cola.models import prefs
29 from cola.models import selection
32 class UsageError(Exception):
33 """Exception class for usage errors."""
34 def __init__(self, title, message):
35 Exception.__init__(self, message)
36 self.title = title
37 self.msg = message
40 class BaseCommand(object):
41 """Base class for all commands; provides the command pattern"""
43 DISABLED = False
45 def __init__(self):
46 self.undoable = False
48 def is_undoable(self):
49 """Can this be undone?"""
50 return self.undoable
52 @staticmethod
53 def name():
54 return 'Unknown'
56 def do(self):
57 pass
59 def undo(self):
60 pass
63 class ConfirmAction(BaseCommand):
65 def __init__(self):
66 BaseCommand.__init__(self)
68 def ok_to_run(self):
69 return True
71 def confirm(self):
72 return True
74 def action(self):
75 return (-1, '', '')
77 def ok(self, status):
78 return status == 0
80 def success(self):
81 pass
83 def fail(self, status, out, err):
84 title = msg = self.error_message()
85 details = self.error_details() or out + err
86 Interaction.critical(title, message=msg, details=details)
88 def error_message(self):
89 return ''
91 def error_details(self):
92 return ''
94 def do(self):
95 status = -1
96 out = err = ''
97 ok = self.ok_to_run() and self.confirm()
98 if ok:
99 status, out, err = self.action()
100 if self.ok(status):
101 self.success()
102 else:
103 self.fail(status, out, err)
105 return ok, status, out, err
108 class ModelCommand(BaseCommand):
109 """Commands that manipulate the main models"""
111 def __init__(self):
112 BaseCommand.__init__(self)
113 self.model = main.model()
116 class Command(ModelCommand):
117 """Base class for commands that modify the main model"""
119 def __init__(self):
120 """Initialize the command and stash away values for use in do()"""
121 # These are commonly used so let's make it easier to write new commands.
122 ModelCommand.__init__(self)
124 self.old_diff_text = self.model.diff_text
125 self.old_filename = self.model.filename
126 self.old_mode = self.model.mode
128 self.new_diff_text = self.old_diff_text
129 self.new_filename = self.old_filename
130 self.new_mode = self.old_mode
132 def do(self):
133 """Perform the operation."""
134 self.model.set_filename(self.new_filename)
135 self.model.set_mode(self.new_mode)
136 self.model.set_diff_text(self.new_diff_text)
138 def undo(self):
139 """Undo the operation."""
140 self.model.set_diff_text(self.old_diff_text)
141 self.model.set_filename(self.old_filename)
142 self.model.set_mode(self.old_mode)
145 class AmendMode(Command):
146 """Try to amend a commit."""
148 SHORTCUT = 'Ctrl+M'
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 CheckoutBranch(Checkout):
326 """Checkout a branch."""
328 def __init__(self, branch):
329 args = [branch]
330 Checkout.__init__(self, args, checkout_branch=True)
333 class CherryPick(Command):
334 """Cherry pick commits into the current branch."""
336 def __init__(self, commits):
337 Command.__init__(self)
338 self.commits = commits
340 def do(self):
341 self.model.cherry_pick_list(self.commits)
342 self.model.update_file_status()
345 class ResetMode(Command):
346 """Reset the mode and clear the model's diff text."""
348 def __init__(self):
349 Command.__init__(self)
350 self.new_mode = self.model.mode_none
351 self.new_diff_text = ''
353 def do(self):
354 Command.do(self)
355 self.model.update_file_status()
358 class Commit(ResetMode):
359 """Attempt to create a new commit."""
361 SHORTCUT = 'Ctrl+Return'
363 def __init__(self, amend, msg, sign, no_verify=False):
364 ResetMode.__init__(self)
365 self.amend = amend
366 self.msg = msg
367 self.sign = sign
368 self.no_verify = no_verify
369 self.old_commitmsg = self.model.commitmsg
370 self.new_commitmsg = ''
372 def do(self):
373 # Create the commit message file
374 comment_char = prefs.comment_char()
375 msg = self.strip_comments(self.msg, comment_char=comment_char)
376 tmpfile = utils.tmp_filename('commit-message')
377 try:
378 core.write(tmpfile, msg)
380 # Run 'git commit'
381 status, out, err = self.model.git.commit(F=tmpfile,
382 v=True,
383 gpg_sign=self.sign,
384 amend=self.amend,
385 no_verify=self.no_verify)
386 finally:
387 core.unlink(tmpfile)
389 if status == 0:
390 ResetMode.do(self)
391 self.model.set_commitmsg(self.new_commitmsg)
392 msg = N_('Created commit: %s') % out
393 else:
394 msg = N_('Commit failed: %s') % out
395 Interaction.log_status(status, msg, err)
397 return status, out, err
399 @staticmethod
400 def strip_comments(msg, comment_char='#'):
401 # Strip off comments
402 message_lines = [line for line in msg.split('\n')
403 if not line.startswith(comment_char)]
404 msg = '\n'.join(message_lines)
405 if not msg.endswith('\n'):
406 msg += '\n'
408 return msg
411 class Ignore(Command):
412 """Add files to .gitignore"""
414 def __init__(self, filenames):
415 Command.__init__(self)
416 self.filenames = list(filenames)
418 def do(self):
419 if not self.filenames:
420 return
421 new_additions = '\n'.join(self.filenames) + '\n'
422 for_status = new_additions
423 if core.exists('.gitignore'):
424 current_list = core.read('.gitignore')
425 new_additions = current_list.rstrip() + '\n' + new_additions
426 core.write('.gitignore', new_additions)
427 Interaction.log_status(0, 'Added to .gitignore:\n%s' % for_status, '')
428 self.model.update_file_status()
431 def file_summary(files):
432 txt = subprocess.list2cmdline(files)
433 if len(txt) > 768:
434 txt = txt[:768].rstrip() + '...'
435 return txt
438 class RemoteCommand(ConfirmAction):
440 def __init__(self, remote):
441 ConfirmAction.__init__(self)
442 self.model = main.model()
443 self.remote = remote
445 def success(self):
446 self.model.update_remotes()
449 class RemoteAdd(RemoteCommand):
451 def __init__(self, remote, url):
452 RemoteCommand.__init__(self, remote)
453 self.url = url
455 def action(self):
456 git = self.model.git
457 return git.remote('add', self.remote, self.url)
459 def error_message(self):
460 return N_('Error creating remote "%s"') % self.remote
463 class RemoteRemove(RemoteCommand):
465 def confirm(self):
466 title = N_('Delete Remote')
467 question = N_('Delete remote?')
468 info = N_('Delete remote "%s"') % self.remote
469 ok_btn = N_('Delete')
470 return Interaction.confirm(title, question, info, ok_btn)
472 def action(self):
473 git = self.model.git
474 return git.remote('rm', self.remote)
476 def error_message(self):
477 return N_('Error deleting remote "%s"') % self.remote
480 class RemoteRename(RemoteCommand):
482 def __init__(self, remote, new_remote):
483 RemoteCommand.__init__(self, remote)
484 self.new_remote = new_remote
486 def confirm(self):
487 title = N_('Rename Remote')
488 question = N_('Rename remote?')
489 info = (N_('Rename remote "%(current)s" to "%(new)s"?') %
490 dict(current=self.remote, new=self.new_remote))
491 ok_btn = N_('Rename')
492 return Interaction.confirm(title, question, info, ok_btn)
494 def action(self):
495 git = self.model.git
496 return git.remote('rename', self.remote, self.new_remote)
499 class RemoveFromSettings(ConfirmAction):
501 def __init__(self, settings, repo, icon=None):
502 ConfirmAction.__init__(self)
503 self.settings = settings
504 self.repo = repo
505 self.icon = icon
507 def success(self):
508 self.settings.save()
511 class RemoveBookmark(RemoveFromSettings):
513 def confirm(self):
514 repo = self.repo
515 title = msg = N_('Delete Bookmark?')
516 info = N_('%s will be removed from your bookmarks.') % repo
517 ok_text = N_('Delete Bookmark')
518 return Interaction.confirm(title, msg, info, ok_text, icon=self.icon)
520 def action(self):
521 self.settings.remove_bookmark(self.repo)
522 return (0, '', '')
525 class RemoveRecent(RemoveFromSettings):
527 def confirm(self):
528 repo = self.repo
529 title = msg = N_('Remove %s from the recent list?') % repo
530 info = N_('%s will be removed from your recent repositories.') % repo
531 ok_text = N_('Remove')
532 return Interaction.confirm(title, msg, info, ok_text, icon=self.icon)
534 def action(self):
535 self.settings.remove_recent(self.repo)
536 return (0, '', '')
539 class RemoveFiles(Command):
540 """Removes files."""
542 def __init__(self, remover, filenames):
543 Command.__init__(self)
544 if remover is None:
545 remover = os.remove
546 self.remover = remover
547 self.filenames = filenames
548 # We could git-hash-object stuff and provide undo-ability
549 # as an option. Heh.
551 def do(self):
552 files = self.filenames
553 if not files:
554 return
556 rescan = False
557 bad_filenames = []
558 remove = self.remover
559 for filename in files:
560 if filename:
561 try:
562 remove(filename)
563 rescan=True
564 except:
565 bad_filenames.append(filename)
567 if bad_filenames:
568 Interaction.information(
569 N_('Error'),
570 N_('Deleting "%s" failed') % file_summary(files))
572 if rescan:
573 self.model.update_file_status()
576 class Delete(RemoveFiles):
577 """Delete files."""
579 SHORTCUT = 'Ctrl+Shift+Backspace'
580 ALT_SHORTCUT = 'Ctrl+Backspace'
582 def __init__(self, filenames):
583 RemoveFiles.__init__(self, os.remove, filenames)
585 def do(self):
586 files = self.filenames
587 if not files:
588 return
590 title = N_('Delete Files?')
591 msg = N_('The following files will be deleted:') + '\n\n'
592 msg += file_summary(files)
593 info_txt = N_('Delete %d file(s)?') % len(files)
594 ok_txt = N_('Delete Files')
596 if not Interaction.confirm(title, msg, info_txt, ok_txt,
597 default=True,
598 icon=resources.icon('remove.svg')):
599 return
601 return RemoveFiles.do(self)
604 class MoveToTrash(RemoveFiles):
605 """Move files to the trash using send2trash"""
607 SHORTCUT = 'Ctrl+Backspace'
608 AVAILABLE = send2trash is not None
610 def __init__(self, filenames):
611 RemoveFiles.__init__(self, send2trash, filenames)
614 class DeleteBranch(Command):
615 """Delete a git branch."""
617 def __init__(self, branch):
618 Command.__init__(self)
619 self.branch = branch
621 def do(self):
622 status, out, err = self.model.delete_branch(self.branch)
623 Interaction.log_status(status, out, err)
626 class RenameBranch(Command):
627 """Rename a git branch."""
629 def __init__(self, branch, new_branch):
630 Command.__init__(self)
631 self.branch = branch
632 self.new_branch = new_branch
634 def do(self):
635 status, out, err = self.model.rename_branch(self.branch, self.new_branch)
636 Interaction.log_status(status, out, err)
638 class DeleteRemoteBranch(Command):
639 """Delete a remote git branch."""
641 def __init__(self, remote, branch):
642 Command.__init__(self)
643 self.remote = remote
644 self.branch = branch
646 def do(self):
647 status, out, err = self.model.git.push(self.remote, self.branch,
648 delete=True)
649 Interaction.log_status(status, out, err)
650 self.model.update_status()
652 if status == 0:
653 Interaction.information(
654 N_('Remote Branch Deleted'),
655 N_('"%(branch)s" has been deleted from "%(remote)s".')
656 % dict(branch=self.branch, remote=self.remote))
657 else:
658 command = 'git push'
659 message = (N_('"%(command)s" returned exit status %(status)d') %
660 dict(command=command, status=status))
662 Interaction.critical(N_('Error Deleting Remote Branch'),
663 message, out + err)
667 class Diff(Command):
668 """Perform a diff and set the model's current text."""
670 def __init__(self, filename, cached=False, deleted=False):
671 Command.__init__(self)
672 opts = {}
673 if cached:
674 opts['ref'] = self.model.head
675 self.new_filename = filename
676 self.new_mode = self.model.mode_worktree
677 self.new_diff_text = gitcmds.diff_helper(filename=filename,
678 cached=cached,
679 deleted=deleted,
680 **opts)
683 class Diffstat(Command):
684 """Perform a diffstat and set the model's diff text."""
686 def __init__(self):
687 Command.__init__(self)
688 cfg = gitcfg.current()
689 diff_context = cfg.get('diff.context', 3)
690 diff = self.model.git.diff(self.model.head,
691 unified=diff_context,
692 no_ext_diff=True,
693 no_color=True,
694 M=True,
695 stat=True)[STDOUT]
696 self.new_diff_text = diff
697 self.new_mode = self.model.mode_worktree
700 class DiffStaged(Diff):
701 """Perform a staged diff on a file."""
703 def __init__(self, filename, deleted=None):
704 Diff.__init__(self, filename, cached=True, deleted=deleted)
705 self.new_mode = self.model.mode_index
708 class DiffStagedSummary(Command):
710 def __init__(self):
711 Command.__init__(self)
712 diff = self.model.git.diff(self.model.head,
713 cached=True,
714 no_color=True,
715 no_ext_diff=True,
716 patch_with_stat=True,
717 M=True)[STDOUT]
718 self.new_diff_text = diff
719 self.new_mode = self.model.mode_index
722 class Difftool(Command):
723 """Run git-difftool limited by path."""
725 def __init__(self, staged, filenames):
726 Command.__init__(self)
727 self.staged = staged
728 self.filenames = filenames
730 def do(self):
731 difftool.launch_with_head(self.filenames,
732 self.staged, self.model.head)
735 class Edit(Command):
736 """Edit a file using the configured gui.editor."""
737 SHORTCUT = 'Ctrl+E'
739 @staticmethod
740 def name():
741 return N_('Launch Editor')
743 def __init__(self, filenames, line_number=None):
744 Command.__init__(self)
745 self.filenames = filenames
746 self.line_number = line_number
748 def do(self):
749 if not self.filenames:
750 return
751 filename = self.filenames[0]
752 if not core.exists(filename):
753 return
754 editor = prefs.editor()
755 opts = []
757 if self.line_number is None:
758 opts = self.filenames
759 else:
760 # Single-file w/ line-numbers (likely from grep)
761 editor_opts = {
762 '*vim*': ['+'+self.line_number, filename],
763 '*emacs*': ['+'+self.line_number, filename],
764 '*textpad*': ['%s(%s,0)' % (filename, self.line_number)],
765 '*notepad++*': ['-n'+self.line_number, filename],
768 opts = self.filenames
769 for pattern, opt in editor_opts.items():
770 if fnmatch(editor, pattern):
771 opts = opt
772 break
774 try:
775 core.fork(utils.shell_split(editor) + opts)
776 except Exception as e:
777 message = (N_('Cannot exec "%s": please configure your editor')
778 % editor)
779 details = core.decode(e.strerror)
780 Interaction.critical(N_('Error Editing File'), message, details)
783 class FormatPatch(Command):
784 """Output a patch series given all revisions and a selected subset."""
786 def __init__(self, to_export, revs):
787 Command.__init__(self)
788 self.to_export = list(to_export)
789 self.revs = list(revs)
791 def do(self):
792 status, out, err = gitcmds.format_patchsets(self.to_export, self.revs)
793 Interaction.log_status(status, out, err)
796 class LaunchDifftool(BaseCommand):
798 SHORTCUT = 'Ctrl+D'
800 @staticmethod
801 def name():
802 return N_('Launch Diff Tool')
804 def __init__(self):
805 BaseCommand.__init__(self)
807 def do(self):
808 s = selection.selection()
809 if s.unmerged:
810 paths = s.unmerged
811 if utils.is_win32():
812 core.fork(['git', 'mergetool', '--no-prompt', '--'] + paths)
813 else:
814 cfg = gitcfg.current()
815 cmd = cfg.terminal()
816 argv = utils.shell_split(cmd)
817 argv.extend(['git', 'mergetool', '--no-prompt', '--'])
818 argv.extend(paths)
819 core.fork(argv)
820 else:
821 difftool.run()
824 class LaunchTerminal(BaseCommand):
826 SHORTCUT = 'Ctrl+Shift+T'
828 @staticmethod
829 def name():
830 return N_('Launch Terminal')
832 def __init__(self, path):
833 BaseCommand.__init__(self)
834 self.path = path
836 def do(self):
837 cfg = gitcfg.current()
838 cmd = cfg.terminal()
839 argv = utils.shell_split(cmd)
840 argv.append(os.getenv('SHELL', '/bin/sh'))
841 core.fork(argv, cwd=self.path)
844 class LaunchEditor(Edit):
845 SHORTCUT = 'Ctrl+E'
847 @staticmethod
848 def name():
849 return N_('Launch Editor')
851 def __init__(self):
852 s = selection.selection()
853 allfiles = s.staged + s.unmerged + s.modified + s.untracked
854 Edit.__init__(self, allfiles)
857 class LoadCommitMessageFromFile(Command):
858 """Loads a commit message from a path."""
860 def __init__(self, path):
861 Command.__init__(self)
862 self.undoable = True
863 self.path = path
864 self.old_commitmsg = self.model.commitmsg
865 self.old_directory = self.model.directory
867 def do(self):
868 path = self.path
869 if not path or not core.isfile(path):
870 raise UsageError(N_('Error: Cannot find commit template'),
871 N_('%s: No such file or directory.') % path)
872 self.model.set_directory(os.path.dirname(path))
873 self.model.set_commitmsg(core.read(path))
875 def undo(self):
876 self.model.set_commitmsg(self.old_commitmsg)
877 self.model.set_directory(self.old_directory)
880 class LoadCommitMessageFromTemplate(LoadCommitMessageFromFile):
881 """Loads the commit message template specified by commit.template."""
883 def __init__(self):
884 cfg = gitcfg.current()
885 template = cfg.get('commit.template')
886 LoadCommitMessageFromFile.__init__(self, template)
888 def do(self):
889 if self.path is None:
890 raise UsageError(
891 N_('Error: Unconfigured commit template'),
892 N_('A commit template has not been configured.\n'
893 'Use "git config" to define "commit.template"\n'
894 'so that it points to a commit template.'))
895 return LoadCommitMessageFromFile.do(self)
899 class LoadCommitMessageFromSHA1(Command):
900 """Load a previous commit message"""
902 def __init__(self, sha1, prefix=''):
903 Command.__init__(self)
904 self.sha1 = sha1
905 self.old_commitmsg = self.model.commitmsg
906 self.new_commitmsg = prefix + self.model.prev_commitmsg(sha1)
907 self.undoable = True
909 def do(self):
910 self.model.set_commitmsg(self.new_commitmsg)
912 def undo(self):
913 self.model.set_commitmsg(self.old_commitmsg)
916 class LoadFixupMessage(LoadCommitMessageFromSHA1):
917 """Load a fixup message"""
919 def __init__(self, sha1):
920 LoadCommitMessageFromSHA1.__init__(self, sha1, prefix='fixup! ')
923 class Merge(Command):
924 """Merge commits"""
926 def __init__(self, revision, no_commit, squash, noff, sign):
927 Command.__init__(self)
928 self.revision = revision
929 self.no_ff = noff
930 self.no_commit = no_commit
931 self.squash = squash
932 self.sign = sign
934 def do(self):
935 squash = self.squash
936 revision = self.revision
937 no_ff = self.no_ff
938 no_commit = self.no_commit
939 sign = self.sign
940 msg = gitcmds.merge_message(revision)
942 status, out, err = self.model.git.merge('-m', msg, revision,
943 gpg_sign=sign,
944 no_ff=no_ff,
945 no_commit=no_commit,
946 squash=squash)
947 Interaction.log_status(status, out, err)
948 self.model.update_status()
951 class OpenDefaultApp(BaseCommand):
952 """Open a file using the OS default."""
953 SHORTCUT = 'Space'
955 @staticmethod
956 def name():
957 return N_('Open Using Default Application')
959 def __init__(self, filenames):
960 BaseCommand.__init__(self)
961 if utils.is_darwin():
962 launcher = 'open'
963 else:
964 launcher = 'xdg-open'
965 self.launcher = launcher
966 self.filenames = filenames
968 def do(self):
969 if not self.filenames:
970 return
971 core.fork([self.launcher] + self.filenames)
974 class OpenParentDir(OpenDefaultApp):
975 """Open parent directories using the OS default."""
976 SHORTCUT = 'Shift+Space'
978 @staticmethod
979 def name():
980 return N_('Open Parent Directory')
982 def __init__(self, filenames):
983 OpenDefaultApp.__init__(self, filenames)
985 def do(self):
986 if not self.filenames:
987 return
988 dirs = list(set(map(os.path.dirname, self.filenames)))
989 core.fork([self.launcher] + dirs)
992 class OpenNewRepo(Command):
993 """Launches git-cola on a repo."""
995 def __init__(self, repo_path):
996 Command.__init__(self)
997 self.repo_path = repo_path
999 def do(self):
1000 self.model.set_directory(self.repo_path)
1001 core.fork([sys.executable, sys.argv[0], '--repo', self.repo_path])
1004 class OpenRepo(Command):
1005 def __init__(self, repo_path):
1006 Command.__init__(self)
1007 self.repo_path = repo_path
1009 def do(self):
1010 git = self.model.git
1011 old_worktree = git.worktree()
1012 if not self.model.set_worktree(self.repo_path):
1013 self.model.set_worktree(old_worktree)
1014 return
1015 new_worktree = git.worktree()
1016 core.chdir(new_worktree)
1017 self.model.set_directory(self.repo_path)
1018 cfg = gitcfg.current()
1019 cfg.reset()
1020 inotify.stop()
1021 inotify.start()
1022 self.model.update_status()
1025 class Clone(Command):
1026 """Clones a repository and optionally spawns a new cola session."""
1028 def __init__(self, url, new_directory, spawn=True):
1029 Command.__init__(self)
1030 self.url = url
1031 self.new_directory = new_directory
1032 self.spawn = spawn
1034 self.ok = False
1035 self.error_message = ''
1036 self.error_details = ''
1038 def do(self):
1039 status, out, err = self.model.git.clone(self.url, self.new_directory)
1040 self.ok = status == 0
1042 if self.ok:
1043 if self.spawn:
1044 core.fork([sys.executable, sys.argv[0],
1045 '--repo', self.new_directory])
1046 else:
1047 self.error_message = N_('Error: could not clone "%s"') % self.url
1048 self.error_details = (
1049 (N_('git clone returned exit code %s') % status) +
1050 ((out+err) and ('\n\n' + out + err) or ''))
1052 return self
1055 def unix_path(path, is_win32=utils.is_win32):
1056 """Git for Windows requires unix paths, so force them here
1058 unix_path = path
1059 if is_win32():
1060 first = path[0]
1061 second = path[1]
1062 if second == ':': # sanity check, this better be a Windows-style path
1063 unix_path = '/' + first + path[2:].replace('\\', '/')
1065 return unix_path
1068 class GitXBaseContext(object):
1070 def __init__(self, **kwargs):
1071 self.env = {'GIT_EDITOR': prefs.editor()}
1072 self.env.update(kwargs)
1074 def __enter__(self):
1075 compat.setenv('GIT_SEQUENCE_EDITOR',
1076 unix_path(resources.share('bin', 'git-xbase')))
1077 for var, value in self.env.items():
1078 compat.setenv(var, value)
1079 return self
1081 def __exit__(self, exc_type, exc_val, exc_tb):
1082 compat.unsetenv('GIT_SEQUENCE_EDITOR')
1083 for var in self.env:
1084 compat.unsetenv(var)
1087 class Rebase(Command):
1089 def __init__(self,
1090 upstream=None, branch=None, capture_output=True, **kwargs):
1091 """Start an interactive rebase session
1093 :param upstream: upstream branch
1094 :param branch: optional branch to checkout
1095 :param capture_output: whether to capture stdout and stderr
1096 :param kwargs: forwarded directly to `git.rebase()`
1099 Command.__init__(self)
1101 self.upstream = upstream
1102 self.branch = branch
1103 self.capture_output = capture_output
1104 self.kwargs = kwargs
1106 def prepare_arguments(self):
1107 args = []
1108 kwargs = {}
1110 if self.capture_output:
1111 kwargs['_stderr'] = None
1112 kwargs['_stdout'] = None
1114 # Rebase actions must be the only option specified
1115 for action in ('continue', 'abort', 'skip', 'edit_todo'):
1116 if self.kwargs.get(action, False):
1117 kwargs[action] = self.kwargs[action]
1118 return args, kwargs
1120 kwargs['interactive'] = True
1121 kwargs['autosquash'] = self.kwargs.get('autosquash', True)
1122 kwargs.update(self.kwargs)
1124 if self.upstream:
1125 args.append(self.upstream)
1126 if self.branch:
1127 args.append(self.branch)
1129 return args, kwargs
1131 def do(self):
1132 (status, out, err) = (1, '', '')
1133 args, kwargs = self.prepare_arguments()
1134 upstream_title = self.upstream or '@{upstream}'
1135 with GitXBaseContext(
1136 GIT_XBASE_TITLE=N_('Rebase onto %s') % upstream_title,
1137 GIT_XBASE_ACTION=N_('Rebase')):
1138 status, out, err = self.model.git.rebase(*args, **kwargs)
1139 Interaction.log_status(status, out, err)
1140 self.model.update_status()
1141 return status, out, err
1144 class RebaseEditTodo(Command):
1146 def do(self):
1147 (status, out, err) = (1, '', '')
1148 with GitXBaseContext(
1149 GIT_XBASE_TITLE=N_('Edit Rebase'),
1150 GIT_XBASE_ACTION=N_('Save')):
1151 status, out, err = self.model.git.rebase(edit_todo=True)
1152 Interaction.log_status(status, out, err)
1153 self.model.update_status()
1154 return status, out, err
1157 class RebaseContinue(Command):
1159 def do(self):
1160 (status, out, err) = (1, '', '')
1161 with GitXBaseContext(
1162 GIT_XBASE_TITLE=N_('Rebase'),
1163 GIT_XBASE_ACTION=N_('Rebase')):
1164 status, out, err = self.model.git.rebase('--continue')
1165 Interaction.log_status(status, out, err)
1166 self.model.update_status()
1167 return status, out, err
1170 class RebaseSkip(Command):
1172 def do(self):
1173 (status, out, err) = (1, '', '')
1174 with GitXBaseContext(
1175 GIT_XBASE_TITLE=N_('Rebase'),
1176 GIT_XBASE_ACTION=N_('Rebase')):
1177 status, out, err = self.model.git.rebase(skip=True)
1178 Interaction.log_status(status, out, err)
1179 self.model.update_status()
1180 return status, out, err
1183 class RebaseAbort(Command):
1185 def do(self):
1186 status, out, err = self.model.git.rebase(abort=True)
1187 Interaction.log_status(status, out, err)
1188 self.model.update_status()
1191 class Rescan(Command):
1192 """Rescan for changes"""
1194 def do(self):
1195 self.model.update_status()
1198 class Refresh(Command):
1199 """Update refs and refresh the index"""
1201 SHORTCUTS = ('Ctrl+R', 'F5')
1203 @staticmethod
1204 def name():
1205 return N_('Refresh')
1207 def do(self):
1208 self.model.update_status(update_index=True)
1211 class RevertEditsCommand(ConfirmAction):
1213 def __init__(self):
1214 ConfirmAction.__init__(self)
1215 self.model = main.model()
1216 self.icon = resources.icon('edit-undo.svg')
1218 def ok_to_run(self):
1219 return self.model.undoable()
1221 def checkout_from_head(self):
1222 return False
1224 def checkout_args(self):
1225 args = []
1226 s = selection.selection()
1227 if self.checkout_from_head():
1228 args.append(self.model.head)
1229 args.append('--')
1231 if s.staged:
1232 items = s.staged
1233 else:
1234 items = s.modified
1235 args.extend(items)
1237 return args
1239 def action(self):
1240 git = self.model.git
1241 checkout_args = self.checkout_args()
1242 return git.checkout(*checkout_args)
1244 def success(self):
1245 self.model.update_file_status()
1248 class RevertUnstagedEdits(RevertEditsCommand):
1250 SHORTCUT = 'Ctrl+U'
1252 @staticmethod
1253 def name():
1254 return N_('Revert Unstaged Edits...')
1256 def checkout_from_head(self):
1257 # If we are amending and a modified file is selected
1258 # then we should include "HEAD^" on the command-line.
1259 selected = selection.selection()
1260 return not selected.staged and self.model.amending()
1262 def confirm(self):
1263 title = N_('Revert Unstaged Changes?')
1264 text = N_('This operation drops unstaged changes.\n'
1265 'These changes cannot be recovered.')
1266 info = N_('Revert the unstaged changes?')
1267 ok_text = N_('Revert Unstaged Changes')
1268 return Interaction.confirm(title, text, info, ok_text,
1269 default=True, icon=self.icon)
1272 class RevertUncommittedEdits(RevertEditsCommand):
1274 SHORTCUT = 'Ctrl+Z'
1276 @staticmethod
1277 def name():
1278 return N_('Revert Uncommitted Edits...')
1280 def checkout_from_head(self):
1281 return True
1283 def confirm(self):
1284 title = N_('Revert Uncommitted Changes?')
1285 text = N_('This operation drops uncommitted changes.\n'
1286 'These changes cannot be recovered.')
1287 info = N_('Revert the uncommitted changes?')
1288 ok_text = N_('Revert Uncommitted Changes')
1289 return Interaction.confirm(title, text, info, ok_text,
1290 default=True, icon=self.icon)
1293 class RunConfigAction(Command):
1294 """Run a user-configured action, typically from the "Tools" menu"""
1296 def __init__(self, action_name):
1297 Command.__init__(self)
1298 self.action_name = action_name
1299 self.model = main.model()
1301 def do(self):
1302 for env in ('FILENAME', 'REVISION', 'ARGS'):
1303 try:
1304 compat.unsetenv(env)
1305 except KeyError:
1306 pass
1307 rev = None
1308 args = None
1309 cfg = gitcfg.current()
1310 opts = cfg.get_guitool_opts(self.action_name)
1311 cmd = opts.get('cmd')
1312 if 'title' not in opts:
1313 opts['title'] = cmd
1315 if 'prompt' not in opts or opts.get('prompt') is True:
1316 prompt = N_('Run "%s"?') % cmd
1317 opts['prompt'] = prompt
1319 if opts.get('needsfile'):
1320 filename = selection.filename()
1321 if not filename:
1322 Interaction.information(
1323 N_('Please select a file'),
1324 N_('"%s" requires a selected file.') % cmd)
1325 return False
1326 compat.setenv('FILENAME', filename)
1328 if opts.get('revprompt') or opts.get('argprompt'):
1329 while True:
1330 ok = Interaction.confirm_config_action(cmd, opts)
1331 if not ok:
1332 return False
1333 rev = opts.get('revision')
1334 args = opts.get('args')
1335 if opts.get('revprompt') and not rev:
1336 title = N_('Invalid Revision')
1337 msg = N_('The revision expression cannot be empty.')
1338 Interaction.critical(title, msg)
1339 continue
1340 break
1342 elif opts.get('confirm'):
1343 title = os.path.expandvars(opts.get('title'))
1344 prompt = os.path.expandvars(opts.get('prompt'))
1345 if Interaction.question(title, prompt):
1346 return
1347 if rev:
1348 compat.setenv('REVISION', rev)
1349 if args:
1350 compat.setenv('ARGS', args)
1351 title = os.path.expandvars(cmd)
1352 Interaction.log(N_('Running command: %s') % title)
1353 cmd = ['sh', '-c', cmd]
1355 if opts.get('background'):
1356 core.fork(cmd)
1357 status, out, err = (0, '', '')
1358 elif opts.get('noconsole'):
1359 status, out, err = core.run_command(cmd)
1360 else:
1361 status, out, err = Interaction.run_command(title, cmd)
1363 Interaction.log_status(status,
1364 out and (N_('Output: %s') % out) or '',
1365 err and (N_('Errors: %s') % err) or '')
1367 if not opts.get('background') and not opts.get('norescan'):
1368 self.model.update_status()
1369 return status
1372 class SetDiffText(Command):
1374 def __init__(self, text):
1375 Command.__init__(self)
1376 self.undoable = True
1377 self.new_diff_text = text
1380 class ShowUntracked(Command):
1381 """Show an untracked file."""
1383 def __init__(self, filename):
1384 Command.__init__(self)
1385 self.new_filename = filename
1386 self.new_mode = self.model.mode_untracked
1387 self.new_diff_text = self.diff_text_for(filename)
1389 def diff_text_for(self, filename):
1390 cfg = gitcfg.current()
1391 size = cfg.get('cola.readsize', 1024 * 2)
1392 try:
1393 result = core.read(filename, size=size,
1394 encoding='utf-8', errors='ignore')
1395 except:
1396 result = ''
1398 if len(result) == size:
1399 result += '...'
1400 return result
1403 class SignOff(Command):
1404 SHORTCUT = 'Ctrl+I'
1406 @staticmethod
1407 def name():
1408 return N_('Sign Off')
1410 def __init__(self):
1411 Command.__init__(self)
1412 self.undoable = True
1413 self.old_commitmsg = self.model.commitmsg
1415 def do(self):
1416 signoff = self.signoff()
1417 if signoff in self.model.commitmsg:
1418 return
1419 self.model.set_commitmsg(self.model.commitmsg + '\n' + signoff)
1421 def undo(self):
1422 self.model.set_commitmsg(self.old_commitmsg)
1424 def signoff(self):
1425 try:
1426 import pwd
1427 user = pwd.getpwuid(os.getuid()).pw_name
1428 except ImportError:
1429 user = os.getenv('USER', N_('unknown'))
1431 cfg = gitcfg.current()
1432 name = cfg.get('user.name', user)
1433 email = cfg.get('user.email', '%s@%s' % (user, core.node()))
1434 return '\nSigned-off-by: %s <%s>' % (name, email)
1437 def check_conflicts(unmerged):
1438 """Check paths for conflicts
1440 Conflicting files can be filtered out one-by-one.
1443 if prefs.check_conflicts():
1444 unmerged = [path for path in unmerged if is_conflict_free(path)]
1445 return unmerged
1448 def is_conflict_free(path):
1449 """Return True if `path` contains no conflict markers
1451 rgx = re.compile(r'^(<<<<<<<|\|\|\|\|\|\|\||>>>>>>>) ')
1452 try:
1453 with core.xopen(path, 'r') as f:
1454 for line in f:
1455 line = core.decode(line, errors='ignore')
1456 if rgx.match(line):
1457 if should_stage_conflicts(path):
1458 return True
1459 else:
1460 return False
1461 except IOError:
1462 # We can't read this file ~ we may be staging a removal
1463 pass
1464 return True
1467 def should_stage_conflicts(path):
1468 """Inform the user that a file contains merge conflicts
1470 Return `True` if we should stage the path nonetheless.
1473 title = msg = N_('Stage conflicts?')
1474 info = N_('%s appears to contain merge conflicts.\n\n'
1475 'You should probably skip this file.\n'
1476 'Stage it anyways?') % path
1477 ok_text = N_('Stage conflicts')
1478 cancel_text = N_('Skip')
1479 return Interaction.confirm(title, msg, info, ok_text,
1480 default=False, cancel_text=cancel_text)
1483 class Stage(Command):
1484 """Stage a set of paths."""
1485 SHORTCUT = 'Ctrl+S'
1487 @staticmethod
1488 def name():
1489 return N_('Stage')
1491 def __init__(self, paths):
1492 Command.__init__(self)
1493 self.paths = paths
1495 def do(self):
1496 msg = N_('Staging: %s') % (', '.join(self.paths))
1497 Interaction.log(msg)
1498 # Prevent external updates while we are staging files.
1499 # We update file stats at the end of this operation
1500 # so there's no harm in ignoring updates from other threads
1501 # (e.g. inotify).
1502 with CommandDisabled(UpdateFileStatus):
1503 self.model.stage_paths(self.paths)
1506 class StageCarefully(Stage):
1507 """Only stage when the path list is non-empty
1509 We use "git add -u -- <pathspec>" to stage, and it stages everything by
1510 default when no pathspec is specified, so this class ensures that paths
1511 are specified before calling git.
1513 When no paths are specified, the command does nothing.
1516 def __init__(self):
1517 Stage.__init__(self, None)
1518 self.init_paths()
1520 def init_paths(self):
1521 pass
1523 def ok_to_run(self):
1524 """Prevent catch-all "git add -u" from adding unmerged files"""
1525 return self.paths or not self.model.unmerged
1527 def do(self):
1528 if self.ok_to_run():
1529 Stage.do(self)
1532 class StageModified(StageCarefully):
1533 """Stage all modified files."""
1535 SHORTCUT = 'Ctrl+S'
1537 @staticmethod
1538 def name():
1539 return N_('Stage Modified')
1541 def init_paths(self):
1542 self.paths = self.model.modified
1545 class StageUnmerged(StageCarefully):
1546 """Stage unmerged files."""
1548 SHORTCUT = 'Ctrl+S'
1550 @staticmethod
1551 def name():
1552 return N_('Stage Unmerged')
1554 def init_paths(self):
1555 self.paths = check_conflicts(self.model.unmerged)
1558 class StageUntracked(StageCarefully):
1559 """Stage all untracked files."""
1561 SHORTCUT = 'Ctrl+S'
1563 @staticmethod
1564 def name():
1565 return N_('Stage Untracked')
1567 def init_paths(self):
1568 self.paths = self.model.untracked
1571 class StageOrUnstage(Command):
1572 """If the selection is staged, unstage it, otherwise stage"""
1574 SHORTCUT = 'Ctrl+S'
1576 @staticmethod
1577 def name():
1578 return N_('Stage / Unstage')
1580 def do(self):
1581 s = selection.selection()
1582 if s.staged:
1583 do(Unstage, s.staged)
1585 unstaged = []
1586 unmerged = check_conflicts(s.unmerged)
1587 if unmerged:
1588 unstaged.extend(unmerged)
1589 if s.modified:
1590 unstaged.extend(s.modified)
1591 if s.untracked:
1592 unstaged.extend(s.untracked)
1593 if unstaged:
1594 do(Stage, unstaged)
1597 class Tag(Command):
1598 """Create a tag object."""
1600 def __init__(self, name, revision, sign=False, message=''):
1601 Command.__init__(self)
1602 self._name = name
1603 self._message = message
1604 self._revision = revision
1605 self._sign = sign
1607 def do(self):
1608 log_msg = (N_('Tagging "%(revision)s" as "%(name)s"') %
1609 dict(revision=self._revision, name=self._name))
1610 opts = {}
1611 try:
1612 if self._message:
1613 opts['F'] = utils.tmp_filename('tag-message')
1614 core.write(opts['F'], self._message)
1616 if self._sign:
1617 log_msg += ' (%s)' % N_('GPG-signed')
1618 opts['s'] = True
1619 else:
1620 opts['a'] = bool(self._message)
1621 status, output, err = self.model.git.tag(self._name,
1622 self._revision, **opts)
1623 finally:
1624 if 'F' in opts:
1625 os.unlink(opts['F'])
1627 if output:
1628 log_msg += '\n' + (N_('Output: %s') % output)
1630 Interaction.log_status(status, log_msg, err)
1631 if status == 0:
1632 self.model.update_status()
1633 return (status, output, err)
1636 class Unstage(Command):
1637 """Unstage a set of paths."""
1639 SHORTCUT = 'Ctrl+S'
1641 @staticmethod
1642 def name():
1643 return N_('Unstage')
1645 def __init__(self, paths):
1646 Command.__init__(self)
1647 self.paths = paths
1649 def do(self):
1650 msg = N_('Unstaging: %s') % (', '.join(self.paths))
1651 Interaction.log(msg)
1652 with CommandDisabled(UpdateFileStatus):
1653 self.model.unstage_paths(self.paths)
1656 class UnstageAll(Command):
1657 """Unstage all files; resets the index."""
1659 def do(self):
1660 self.model.unstage_all()
1663 class UnstageSelected(Unstage):
1664 """Unstage selected files."""
1666 def __init__(self):
1667 Unstage.__init__(self, selection.selection_model().staged)
1670 class Untrack(Command):
1671 """Unstage a set of paths."""
1673 def __init__(self, paths):
1674 Command.__init__(self)
1675 self.paths = paths
1677 def do(self):
1678 msg = N_('Untracking: %s') % (', '.join(self.paths))
1679 Interaction.log(msg)
1680 with CommandDisabled(UpdateFileStatus):
1681 status, out, err = self.model.untrack_paths(self.paths)
1682 Interaction.log_status(status, out, err)
1685 class UntrackedSummary(Command):
1686 """List possible .gitignore rules as the diff text."""
1688 def __init__(self):
1689 Command.__init__(self)
1690 untracked = self.model.untracked
1691 suffix = len(untracked) > 1 and 's' or ''
1692 io = StringIO()
1693 io.write('# %s untracked file%s\n' % (len(untracked), suffix))
1694 if untracked:
1695 io.write('# possible .gitignore rule%s:\n' % suffix)
1696 for u in untracked:
1697 io.write('/'+u+'\n')
1698 self.new_diff_text = io.getvalue()
1699 self.new_mode = self.model.mode_untracked
1702 class UpdateFileStatus(Command):
1703 """Rescans for changes."""
1705 def do(self):
1706 self.model.update_file_status()
1709 class VisualizeAll(Command):
1710 """Visualize all branches."""
1712 def do(self):
1713 browser = utils.shell_split(prefs.history_browser())
1714 launch_history_browser(browser + ['--all'])
1717 class VisualizeCurrent(Command):
1718 """Visualize all branches."""
1720 def do(self):
1721 browser = utils.shell_split(prefs.history_browser())
1722 launch_history_browser(browser + [self.model.currentbranch])
1725 class VisualizePaths(Command):
1726 """Path-limited visualization."""
1728 def __init__(self, paths):
1729 Command.__init__(self)
1730 browser = utils.shell_split(prefs.history_browser())
1731 if paths:
1732 self.argv = browser + list(paths)
1733 else:
1734 self.argv = browser
1736 def do(self):
1737 launch_history_browser(self.argv)
1740 class VisualizeRevision(Command):
1741 """Visualize a specific revision."""
1743 def __init__(self, revision, paths=None):
1744 Command.__init__(self)
1745 self.revision = revision
1746 self.paths = paths
1748 def do(self):
1749 argv = utils.shell_split(prefs.history_browser())
1750 if self.revision:
1751 argv.append(self.revision)
1752 if self.paths:
1753 argv.append('--')
1754 argv.extend(self.paths)
1755 launch_history_browser(argv)
1758 def launch_history_browser(argv):
1759 try:
1760 core.fork(argv)
1761 except Exception as e:
1762 _, details = utils.format_exception(e)
1763 title = N_('Error Launching History Browser')
1764 msg = (N_('Cannot exec "%s": please configure a history browser') %
1765 ' '.join(argv))
1766 Interaction.critical(title, message=msg, details=details)
1769 def run(cls, *args, **opts):
1771 Returns a callback that runs a command
1773 If the caller of run() provides args or opts then those are
1774 used instead of the ones provided by the invoker of the callback.
1777 def runner(*local_args, **local_opts):
1778 if args or opts:
1779 do(cls, *args, **opts)
1780 else:
1781 do(cls, *local_args, **local_opts)
1783 return runner
1786 class CommandDisabled(object):
1788 """Context manager to temporarily disable a command from running"""
1789 def __init__(self, cmdclass):
1790 self.cmdclass = cmdclass
1792 def __enter__(self):
1793 self.cmdclass.DISABLED = True
1794 return self
1796 def __exit__(self, exc_type, exc_val, exc_tb):
1797 self.cmdclass.DISABLED = False
1800 def do(cls, *args, **opts):
1801 """Run a command in-place"""
1802 return do_cmd(cls(*args, **opts))
1805 def do_cmd(cmd):
1806 if hasattr(cmd, 'DISABLED') and cmd.DISABLED:
1807 return None
1808 try:
1809 return cmd.do()
1810 except Exception as e:
1811 msg, details = utils.format_exception(e)
1812 Interaction.critical(N_('Error'), message=msg, details=details)
1813 return None