cmds: add a command for launching a terminal
[git-cola.git] / cola / cmds.py
blob88e2e2afbc95844102b1e127a21e3be8702ef3c9
1 import os
2 import sys
3 from fnmatch import fnmatch
5 from cStringIO import StringIO
7 from cola import compat
8 from cola import core
9 from cola import gitcfg
10 from cola import gitcmds
11 from cola import utils
12 from cola import difftool
13 from cola import resources
14 from cola.compat import set
15 from cola.diffparse import DiffParser
16 from cola.git import STDOUT
17 from cola.i18n import N_
18 from cola.interaction import Interaction
19 from cola.models import main
20 from cola.models import prefs
21 from cola.models import selection
23 _config = gitcfg.instance()
26 class UsageError(StandardError):
27 """Exception class for usage errors."""
28 def __init__(self, title, message):
29 StandardError.__init__(self, message)
30 self.title = title
31 self.msg = message
34 class BaseCommand(object):
35 """Base class for all commands; provides the command pattern"""
37 DISABLED = False
39 def __init__(self):
40 self.undoable = False
42 def is_undoable(self):
43 """Can this be undone?"""
44 return self.undoable
46 @staticmethod
47 def name(cls):
48 return 'Unknown'
50 def do(self):
51 raise NotImplementedError('%s.do() is unimplemented' % self.__class__.__name__)
53 def undo(self):
54 raise NotImplementedError('%s.undo() is unimplemented' % self.__class__.__name__)
57 class Command(BaseCommand):
58 """Base class for commands that modify the main model"""
60 def __init__(self):
61 """Initialize the command and stash away values for use in do()"""
62 # These are commonly used so let's make it easier to write new commands.
63 BaseCommand.__init__(self)
64 self.model = main.model()
66 self.old_diff_text = self.model.diff_text
67 self.old_filename = self.model.filename
68 self.old_mode = self.model.mode
70 self.new_diff_text = self.old_diff_text
71 self.new_filename = self.old_filename
72 self.new_mode = self.old_mode
74 def do(self):
75 """Perform the operation."""
76 self.model.set_filename(self.new_filename)
77 self.model.set_mode(self.new_mode)
78 self.model.set_diff_text(self.new_diff_text)
80 def undo(self):
81 """Undo the operation."""
82 self.model.set_diff_text(self.old_diff_text)
83 self.model.set_filename(self.old_filename)
84 self.model.set_mode(self.old_mode)
87 class AmendMode(Command):
88 """Try to amend a commit."""
90 SHORTCUT = 'Ctrl+M'
92 LAST_MESSAGE = None
94 @staticmethod
95 def name():
96 return N_('Amend')
98 def __init__(self, amend):
99 Command.__init__(self)
100 self.undoable = True
101 self.skip = False
102 self.amending = amend
103 self.old_commitmsg = self.model.commitmsg
104 self.old_mode = self.model.mode
106 if self.amending:
107 self.new_mode = self.model.mode_amend
108 self.new_commitmsg = self.model.prev_commitmsg()
109 AmendMode.LAST_MESSAGE = self.model.commitmsg
110 return
111 # else, amend unchecked, regular commit
112 self.new_mode = self.model.mode_none
113 self.new_diff_text = ''
114 self.new_commitmsg = self.model.commitmsg
115 # If we're going back into new-commit-mode then search the
116 # undo stack for a previous amend-commit-mode and grab the
117 # commit message at that point in time.
118 if AmendMode.LAST_MESSAGE is not None:
119 self.new_commitmsg = AmendMode.LAST_MESSAGE
120 AmendMode.LAST_MESSAGE = None
122 def do(self):
123 """Leave/enter amend mode."""
124 """Attempt to enter amend mode. Do not allow this when merging."""
125 if self.amending:
126 if self.model.is_merging:
127 self.skip = True
128 self.model.set_mode(self.old_mode)
129 Interaction.information(
130 N_('Cannot Amend'),
131 N_('You are in the middle of a merge.\n'
132 'Cannot amend while merging.'))
133 return
134 self.skip = False
135 Command.do(self)
136 self.model.set_commitmsg(self.new_commitmsg)
137 self.model.update_file_status()
139 def undo(self):
140 if self.skip:
141 return
142 self.model.set_commitmsg(self.old_commitmsg)
143 Command.undo(self)
144 self.model.update_file_status()
147 class ApplyDiffSelection(Command):
149 def __init__(self, staged, selected, offset, selection_text,
150 apply_to_worktree):
151 Command.__init__(self)
152 self.staged = staged
153 self.selected = selected
154 self.offset = offset
155 self.selection_text = selection_text
156 self.apply_to_worktree = apply_to_worktree
158 def do(self):
159 # The normal worktree vs index scenario
160 parser = DiffParser(self.model,
161 filename=self.model.filename,
162 cached=self.staged,
163 reverse=self.apply_to_worktree)
164 status, out, err = \
165 parser.process_diff_selection(self.selected,
166 self.offset,
167 self.selection_text,
168 apply_to_worktree=self.apply_to_worktree)
169 Interaction.log_status(status, out, err)
170 self.model.update_file_status(update_index=True)
173 class ApplyPatches(Command):
175 def __init__(self, patches):
176 Command.__init__(self)
177 self.patches = patches
179 def do(self):
180 diff_text = ''
181 num_patches = len(self.patches)
182 orig_head = self.model.git.rev_parse('HEAD')[STDOUT]
184 for idx, patch in enumerate(self.patches):
185 status, out, err = self.model.git.am(patch)
186 # Log the git-am command
187 Interaction.log_status(status, out, err)
189 if num_patches > 1:
190 diff = self.model.git.diff('HEAD^!', stat=True)[STDOUT]
191 diff_text += (N_('PATCH %(current)d/%(count)d') %
192 dict(current=idx+1, count=num_patches))
193 diff_text += ' - %s:\n%s\n\n' % (os.path.basename(patch), diff)
195 diff_text += N_('Summary:') + '\n'
196 diff_text += self.model.git.diff(orig_head, stat=True)[STDOUT]
198 # Display a diffstat
199 self.model.set_diff_text(diff_text)
200 self.model.update_file_status()
202 basenames = '\n'.join([os.path.basename(p) for p in self.patches])
203 Interaction.information(
204 N_('Patch(es) Applied'),
205 (N_('%d patch(es) applied.') + '\n\n%s') %
206 (len(self.patches), basenames))
209 class Archive(BaseCommand):
211 def __init__(self, ref, fmt, prefix, filename):
212 BaseCommand.__init__(self)
213 self.ref = ref
214 self.fmt = fmt
215 self.prefix = prefix
216 self.filename = filename
218 def do(self):
219 fp = core.xopen(self.filename, 'wb')
220 cmd = ['git', 'archive', '--format='+self.fmt]
221 if self.fmt in ('tgz', 'tar.gz'):
222 cmd.append('-9')
223 if self.prefix:
224 cmd.append('--prefix=' + self.prefix)
225 cmd.append(self.ref)
226 proc = core.start_command(cmd, stdout=fp)
227 out, err = proc.communicate()
228 fp.close()
229 status = proc.returncode
230 Interaction.log_status(status, out or '', err or '')
233 class Checkout(Command):
235 A command object for git-checkout.
237 'argv' is handed off directly to git.
241 def __init__(self, argv, checkout_branch=False):
242 Command.__init__(self)
243 self.argv = argv
244 self.checkout_branch = checkout_branch
245 self.new_diff_text = ''
247 def do(self):
248 status, out, err = self.model.git.checkout(*self.argv)
249 Interaction.log_status(status, out, err)
250 if self.checkout_branch:
251 self.model.update_status()
252 else:
253 self.model.update_file_status()
256 class CheckoutBranch(Checkout):
257 """Checkout a branch."""
259 def __init__(self, branch):
260 args = [branch]
261 Checkout.__init__(self, args, checkout_branch=True)
264 class CherryPick(Command):
265 """Cherry pick commits into the current branch."""
267 def __init__(self, commits):
268 Command.__init__(self)
269 self.commits = commits
271 def do(self):
272 self.model.cherry_pick_list(self.commits)
273 self.model.update_file_status()
276 class ResetMode(Command):
277 """Reset the mode and clear the model's diff text."""
279 def __init__(self):
280 Command.__init__(self)
281 self.new_mode = self.model.mode_none
282 self.new_diff_text = ''
284 def do(self):
285 Command.do(self)
286 self.model.update_file_status()
289 class RevertUnstagedEdits(Command):
291 SHORTCUT = 'Ctrl+U'
293 def do(self):
294 if not self.model.undoable():
295 return
296 s = selection.selection()
297 if s.staged:
298 items_to_undo = s.staged
299 else:
300 items_to_undo = s.modified
301 if items_to_undo:
302 if not Interaction.confirm(N_('Revert Unstaged Changes?'),
303 N_('This operation drops unstaged changes.\n'
304 'These changes cannot be recovered.'),
305 N_('Revert the unstaged changes?'),
306 N_('Revert Unstaged Changes'),
307 default=True,
308 icon=resources.icon('undo.svg')):
309 return
310 args = []
311 if not s.staged and self.model.amending():
312 args.append(self.model.head)
313 do(Checkout, args + ['--'] + items_to_undo)
314 else:
315 msg = N_('No files selected for checkout from HEAD.')
316 Interaction.log(msg)
319 class Commit(ResetMode):
320 """Attempt to create a new commit."""
322 SHORTCUT = 'Ctrl+Return'
324 def __init__(self, amend, msg):
325 ResetMode.__init__(self)
326 self.amend = amend
327 self.msg = msg
328 self.old_commitmsg = self.model.commitmsg
329 self.new_commitmsg = ''
331 def do(self):
332 tmpfile = utils.tmp_filename('commit-message')
333 status, out, err = self.model.commit_with_msg(self.msg, tmpfile,
334 amend=self.amend)
335 if status == 0:
336 ResetMode.do(self)
337 self.model.set_commitmsg(self.new_commitmsg)
338 msg = N_('Created commit: %s') % out
339 else:
340 msg = N_('Commit failed: %s') % out
341 Interaction.log_status(status, msg, err)
343 return status, out, err
346 class Ignore(Command):
347 """Add files to .gitignore"""
349 def __init__(self, filenames):
350 Command.__init__(self)
351 self.filenames = filenames
353 def do(self):
354 new_additions = ''
355 for fname in self.filenames:
356 new_additions = new_additions + fname + '\n'
357 for_status = new_additions
358 if new_additions:
359 if core.exists('.gitignore'):
360 current_list = core.read('.gitignore')
361 new_additions = new_additions + current_list
362 core.write('.gitignore', new_additions)
363 Interaction.log_status(
364 0, 'Added to .gitignore:\n%s' % for_status, '')
365 self.model.update_file_status()
368 class Delete(Command):
369 """Delete files."""
371 def __init__(self, filenames):
372 Command.__init__(self)
373 self.filenames = filenames
374 # We could git-hash-object stuff and provide undo-ability
375 # as an option. Heh.
376 def do(self):
377 rescan = False
378 for filename in self.filenames:
379 if filename:
380 try:
381 os.remove(filename)
382 rescan=True
383 except:
384 Interaction.information(
385 N_('Error'),
386 N_('Deleting "%s" failed') % filename)
387 if rescan:
388 self.model.update_file_status()
391 class DeleteBranch(Command):
392 """Delete a git branch."""
394 def __init__(self, branch):
395 Command.__init__(self)
396 self.branch = branch
398 def do(self):
399 status, out, err = self.model.delete_branch(self.branch)
400 Interaction.log_status(status, out, err)
403 class DeleteRemoteBranch(Command):
404 """Delete a remote git branch."""
406 def __init__(self, remote, branch):
407 Command.__init__(self)
408 self.remote = remote
409 self.branch = branch
411 def do(self):
412 status, out, err = self.model.git.push(self.remote, self.branch,
413 delete=True)
414 Interaction.log_status(status, out, err)
415 self.model.update_status()
417 if status == 0:
418 Interaction.information(
419 N_('Remote Branch Deleted'),
420 N_('"%(branch)s" has been deleted from "%(remote)s".')
421 % dict(branch=self.branch, remote=self.remote))
422 else:
423 command = 'git push'
424 message = (N_('"%(command)s" returned exit status %(status)d') %
425 dict(command=command, status=status))
427 Interaction.critical(N_('Error Deleting Remote Branch'),
428 message, out + err)
432 class Diff(Command):
433 """Perform a diff and set the model's current text."""
435 def __init__(self, filenames, cached=False):
436 Command.__init__(self)
437 # Guard against the list of files being empty
438 if not filenames:
439 return
440 opts = {}
441 if cached:
442 opts['ref'] = self.model.head
443 self.new_filename = filenames[0]
444 self.old_filename = self.model.filename
445 self.new_mode = self.model.mode_worktree
446 self.new_diff_text = gitcmds.diff_helper(filename=self.new_filename,
447 cached=cached, **opts)
450 class Diffstat(Command):
451 """Perform a diffstat and set the model's diff text."""
453 def __init__(self):
454 Command.__init__(self)
455 diff = self.model.git.diff(self.model.head,
456 unified=_config.get('diff.context', 3),
457 no_ext_diff=True,
458 no_color=True,
459 M=True,
460 stat=True)[STDOUT]
461 self.new_diff_text = diff
462 self.new_mode = self.model.mode_worktree
465 class DiffStaged(Diff):
466 """Perform a staged diff on a file."""
468 def __init__(self, filenames):
469 Diff.__init__(self, filenames, cached=True)
470 self.new_mode = self.model.mode_index
473 class DiffStagedSummary(Command):
475 def __init__(self):
476 Command.__init__(self)
477 diff = self.model.git.diff(self.model.head,
478 cached=True,
479 no_color=True,
480 no_ext_diff=True,
481 patch_with_stat=True,
482 M=True)[STDOUT]
483 self.new_diff_text = diff
484 self.new_mode = self.model.mode_index
487 class Difftool(Command):
488 """Run git-difftool limited by path."""
490 def __init__(self, staged, filenames):
491 Command.__init__(self)
492 self.staged = staged
493 self.filenames = filenames
495 def do(self):
496 difftool.launch_with_head(self.filenames,
497 self.staged, self.model.head)
500 class Edit(Command):
501 """Edit a file using the configured gui.editor."""
502 SHORTCUT = 'Ctrl+E'
504 @staticmethod
505 def name():
506 return N_('Edit')
508 def __init__(self, filenames, line_number=None):
509 Command.__init__(self)
510 self.filenames = filenames
511 self.line_number = line_number
513 def do(self):
514 if not self.filenames:
515 return
516 filename = self.filenames[0]
517 if not core.exists(filename):
518 return
519 editor = prefs.editor()
520 opts = []
522 if self.line_number is None:
523 opts = self.filenames
524 else:
525 # Single-file w/ line-numbers (likely from grep)
526 editor_opts = {
527 '*vim*': ['+'+self.line_number, filename],
528 '*emacs*': ['+'+self.line_number, filename],
529 '*textpad*': ['%s(%s,0)' % (filename, self.line_number)],
530 '*notepad++*': ['-n'+self.line_number, filename],
533 opts = self.filenames
534 for pattern, opt in editor_opts.items():
535 if fnmatch(editor, pattern):
536 opts = opt
537 break
539 try:
540 core.fork(utils.shell_split(editor) + opts)
541 except Exception as e:
542 message = (N_('Cannot exec "%s": please configure your editor') %
543 editor)
544 Interaction.critical(N_('Error Editing File'),
545 message, str(e))
548 class FormatPatch(Command):
549 """Output a patch series given all revisions and a selected subset."""
551 def __init__(self, to_export, revs):
552 Command.__init__(self)
553 self.to_export = to_export
554 self.revs = revs
556 def do(self):
557 status, out, err = gitcmds.format_patchsets(self.to_export, self.revs)
558 Interaction.log_status(status, out, err)
561 class LaunchDifftool(BaseCommand):
563 SHORTCUT = 'Ctrl+D'
565 @staticmethod
566 def name():
567 return N_('Launch Diff Tool')
569 def __init__(self):
570 BaseCommand.__init__(self)
572 def do(self):
573 s = selection.selection()
574 if s.unmerged:
575 paths = s.unmerged
576 if utils.is_win32():
577 core.fork(['git', 'mergetool', '--no-prompt', '--'] + paths)
578 else:
579 core.fork(['xterm', '-e',
580 'git', 'mergetool', '--no-prompt', '--'] + paths)
581 else:
582 difftool.run()
585 class LaunchTerminal(BaseCommand):
587 SHORTCUT = 'Ctrl+t'
589 @staticmethod
590 def name():
591 return N_('Launch Terminal')
593 def __init__(self, path):
594 self.path = path
596 def do(self):
597 cmd = _config.get('cola.terminal', 'xterm -e $SHELL')
598 cmd = os.path.expandvars(cmd)
599 argv = utils.shell_split(cmd)
600 core.fork(argv, cwd=self.path)
603 class LaunchEditor(Edit):
604 SHORTCUT = 'Ctrl+E'
606 @staticmethod
607 def name():
608 return N_('Launch Editor')
610 def __init__(self):
611 s = selection.selection()
612 allfiles = s.staged + s.unmerged + s.modified + s.untracked
613 Edit.__init__(self, allfiles)
616 class LoadCommitMessageFromFile(Command):
617 """Loads a commit message from a path."""
619 def __init__(self, path):
620 Command.__init__(self)
621 self.undoable = True
622 self.path = path
623 self.old_commitmsg = self.model.commitmsg
624 self.old_directory = self.model.directory
626 def do(self):
627 path = self.path
628 if not path or not core.isfile(path):
629 raise UsageError(N_('Error: Cannot find commit template'),
630 N_('%s: No such file or directory.') % path)
631 self.model.set_directory(os.path.dirname(path))
632 self.model.set_commitmsg(core.read(path))
634 def undo(self):
635 self.model.set_commitmsg(self.old_commitmsg)
636 self.model.set_directory(self.old_directory)
639 class LoadCommitMessageFromTemplate(LoadCommitMessageFromFile):
640 """Loads the commit message template specified by commit.template."""
642 def __init__(self):
643 template = _config.get('commit.template')
644 LoadCommitMessageFromFile.__init__(self, template)
646 def do(self):
647 if self.path is None:
648 raise UsageError(
649 N_('Error: Unconfigured commit template'),
650 N_('A commit template has not been configured.\n'
651 'Use "git config" to define "commit.template"\n'
652 'so that it points to a commit template.'))
653 return LoadCommitMessageFromFile.do(self)
657 class LoadCommitMessageFromSHA1(Command):
658 """Load a previous commit message"""
660 def __init__(self, sha1, prefix=''):
661 Command.__init__(self)
662 self.sha1 = sha1
663 self.old_commitmsg = self.model.commitmsg
664 self.new_commitmsg = prefix + self.model.prev_commitmsg(sha1)
665 self.undoable = True
667 def do(self):
668 self.model.set_commitmsg(self.new_commitmsg)
670 def undo(self):
671 self.model.set_commitmsg(self.old_commitmsg)
674 class LoadFixupMessage(LoadCommitMessageFromSHA1):
675 """Load a fixup message"""
677 def __init__(self, sha1):
678 LoadCommitMessageFromSHA1.__init__(self, sha1, prefix='fixup! ')
681 class Merge(Command):
682 def __init__(self, revision, no_commit, squash):
683 Command.__init__(self)
684 self.revision = revision
685 self.no_commit = no_commit
686 self.squash = squash
688 def do(self):
689 squash = self.squash
690 revision = self.revision
691 no_commit = self.no_commit
692 msg = gitcmds.merge_message(revision)
694 status, out, err = self.model.git.merge('-m', msg,
695 revision,
696 no_commit=no_commit,
697 squash=squash)
699 Interaction.log_status(status, out, err)
700 self.model.update_status()
703 class OpenDefaultApp(BaseCommand):
704 """Open a file using the OS default."""
705 SHORTCUT = 'Space'
707 @staticmethod
708 def name():
709 return N_('Open Using Default Application')
711 def __init__(self, filenames):
712 BaseCommand.__init__(self)
713 if utils.is_darwin():
714 launcher = 'open'
715 else:
716 launcher = 'xdg-open'
717 self.launcher = launcher
718 self.filenames = filenames
720 def do(self):
721 if not self.filenames:
722 return
723 core.fork([self.launcher] + self.filenames)
726 class OpenParentDir(OpenDefaultApp):
727 """Open parent directories using the OS default."""
728 SHORTCUT = 'Shift+Space'
730 @staticmethod
731 def name():
732 return N_('Open Parent Directory')
734 def __init__(self, filenames):
735 OpenDefaultApp.__init__(self, filenames)
737 def do(self):
738 if not self.filenames:
739 return
740 dirs = set(map(os.path.dirname, self.filenames))
741 core.fork([self.launcher] + dirs)
744 class OpenNewRepo(Command):
745 """Launches git-cola on a repo."""
747 def __init__(self, repo_path):
748 Command.__init__(self)
749 self.repo_path = repo_path
751 def do(self):
752 self.model.set_directory(self.repo_path)
753 core.fork([sys.executable, sys.argv[0], '--repo', self.repo_path])
756 class OpenRepo(Command):
757 def __init__(self, repo_path):
758 Command.__init__(self)
759 self.repo_path = repo_path
761 def do(self):
762 git = self.model.git
763 old_worktree = git.worktree()
764 if not self.model.set_worktree(self.repo_path):
765 self.model.set_worktree(old_worktree)
766 return
767 new_worktree = git.worktree()
768 core.chdir(new_worktree)
769 self.model.set_directory(self.repo_path)
770 _config.reset()
771 self.model.update_status()
774 class Clone(Command):
775 """Clones a repository and optionally spawns a new cola session."""
777 def __init__(self, url, new_directory, spawn=True):
778 Command.__init__(self)
779 self.url = url
780 self.new_directory = new_directory
781 self.spawn = spawn
783 def do(self):
784 status, out, err = self.model.git.clone(self.url, self.new_directory)
785 if status != 0:
786 Interaction.information(
787 N_('Error: could not clone "%s"') % self.url,
788 (N_('git clone returned exit code %s') % status) +
789 ((out+err) and ('\n\n' + out + err) or ''))
790 return False
791 if self.spawn:
792 core.fork([sys.executable, sys.argv[0],
793 '--repo', self.new_directory])
794 return True
797 class GitXBaseContext(object):
799 def __init__(self, **kwargs):
800 self.extras = kwargs
802 def __enter__(self):
803 compat.setenv('GIT_SEQUENCE_EDITOR',
804 resources.share('bin', 'git-xbase'))
805 for var, value in self.extras.items():
806 compat.setenv(var, value)
807 return self
809 def __exit__(self, exc_type, exc_val, exc_tb):
810 compat.unsetenv('GIT_SEQUENCE_EDITOR')
811 for var in self.extras:
812 compat.unsetenv(var)
815 class Rebase(Command):
817 def __init__(self, branch, capture_output=True):
818 Command.__init__(self)
819 self.branch = branch
820 self.capture_output = capture_output
822 def do(self):
823 branch = self.branch
824 if not branch:
825 return
826 status = 1
827 out = ''
828 err = ''
829 extra = {}
830 if self.capture_output:
831 extra['_stderr'] = None
832 extra['_stdout'] = None
833 with GitXBaseContext(
834 GIT_EDITOR=prefs.editor(),
835 GIT_XBASE_TITLE=N_('Rebase onto %s') % branch,
836 GIT_XBASE_ACTION=N_('Rebase')):
837 status, out, err = self.model.git.rebase(branch,
838 interactive=True,
839 autosquash=True,
840 **extra)
841 Interaction.log_status(status, out, err)
842 self.model.update_status()
843 return status, out, err
846 class RebaseEditTodo(Command):
848 def do(self):
849 with GitXBaseContext(
850 GIT_XBASE_TITLE=N_('Edit Rebase'),
851 GIT_XBASE_ACTION=N_('Save')):
852 status, out, err = self.model.git.rebase(edit_todo=True)
853 Interaction.log_status(status, out, err)
854 self.model.update_status()
857 class RebaseContinue(Command):
859 def do(self):
860 status, out, err = self.model.git.rebase('--continue')
861 Interaction.log_status(status, out, err)
862 self.model.update_status()
865 class RebaseSkip(Command):
867 def do(self):
868 status, out, err = self.model.git.rebase(skip=True)
869 Interaction.log_status(status, out, err)
870 self.model.update_status()
873 class RebaseAbort(Command):
875 def do(self):
876 status, out, err = self.model.git.rebase(abort=True)
877 Interaction.log_status(status, out, err)
878 self.model.update_status()
881 class Rescan(Command):
882 """Rescan for changes"""
884 def do(self):
885 self.model.update_status()
888 class Refresh(Command):
889 """Update refs and refresh the index"""
891 SHORTCUT = 'Ctrl+R'
893 @staticmethod
894 def name():
895 return N_('Refresh')
897 def do(self):
898 self.model.update_status(update_index=True)
901 class RunConfigAction(Command):
902 """Run a user-configured action, typically from the "Tools" menu"""
904 def __init__(self, action_name):
905 Command.__init__(self)
906 self.action_name = action_name
907 self.model = main.model()
909 def do(self):
910 for env in ('FILENAME', 'REVISION', 'ARGS'):
911 try:
912 compat.unsetenv(env)
913 except KeyError:
914 pass
915 rev = None
916 args = None
917 opts = _config.get_guitool_opts(self.action_name)
918 cmd = opts.get('cmd')
919 if 'title' not in opts:
920 opts['title'] = cmd
922 if 'prompt' not in opts or opts.get('prompt') is True:
923 prompt = N_('Run "%s"?') % cmd
924 opts['prompt'] = prompt
926 if opts.get('needsfile'):
927 filename = selection.filename()
928 if not filename:
929 Interaction.information(
930 N_('Please select a file'),
931 N_('"%s" requires a selected file.') % cmd)
932 return False
933 compat.setenv('FILENAME', filename)
935 if opts.get('revprompt') or opts.get('argprompt'):
936 while True:
937 ok = Interaction.confirm_config_action(cmd, opts)
938 if not ok:
939 return False
940 rev = opts.get('revision')
941 args = opts.get('args')
942 if opts.get('revprompt') and not rev:
943 title = N_('Invalid Revision')
944 msg = N_('The revision expression cannot be empty.')
945 Interaction.critical(title, msg)
946 continue
947 break
949 elif opts.get('confirm'):
950 title = os.path.expandvars(opts.get('title'))
951 prompt = os.path.expandvars(opts.get('prompt'))
952 if Interaction.question(title, prompt):
953 return
954 if rev:
955 compat.setenv('REVISION', rev)
956 if args:
957 compat.setenv('ARGS', args)
958 title = os.path.expandvars(cmd)
959 Interaction.log(N_('Running command: %s') % title)
960 cmd = ['sh', '-c', cmd]
962 if opts.get('noconsole'):
963 status, out, err = core.run_command(cmd)
964 else:
965 status, out, err = Interaction.run_command(title, cmd)
967 Interaction.log_status(status,
968 out and (N_('Output: %s') % out) or '',
969 err and (N_('Errors: %s') % err) or '')
971 if not opts.get('norescan'):
972 self.model.update_status()
973 return status
976 class SetDiffText(Command):
978 def __init__(self, text):
979 Command.__init__(self)
980 self.undoable = True
981 self.new_diff_text = text
984 class ShowUntracked(Command):
985 """Show an untracked file."""
987 def __init__(self, filenames):
988 Command.__init__(self)
989 self.filenames = filenames
990 self.new_mode = self.model.mode_untracked
991 self.new_diff_text = ''
992 if filenames:
993 self.new_diff_text = self.diff_text_for(filenames[0])
995 def diff_text_for(self, filename):
996 size = _config.get('cola.readsize', 1024 * 2)
997 try:
998 result = core.read(filename, size=size)
999 except:
1000 result = ''
1002 if len(result) == size:
1003 result += '...'
1004 return result
1007 class SignOff(Command):
1008 SHORTCUT = 'Ctrl+I'
1010 @staticmethod
1011 def name():
1012 return N_('Sign Off')
1014 def __init__(self):
1015 Command.__init__(self)
1016 self.undoable = True
1017 self.old_commitmsg = self.model.commitmsg
1019 def do(self):
1020 signoff = self.signoff()
1021 if signoff in self.model.commitmsg:
1022 return
1023 self.model.set_commitmsg(self.model.commitmsg + '\n' + signoff)
1025 def undo(self):
1026 self.model.set_commitmsg(self.old_commitmsg)
1028 def signoff(self):
1029 try:
1030 import pwd
1031 user = pwd.getpwuid(os.getuid()).pw_name
1032 except ImportError:
1033 user = os.getenv('USER', N_('unknown'))
1035 name = _config.get('user.name', user)
1036 email = _config.get('user.email', '%s@%s' % (user, core.node()))
1037 return '\nSigned-off-by: %s <%s>' % (name, email)
1040 class Stage(Command):
1041 """Stage a set of paths."""
1042 SHORTCUT = 'Ctrl+S'
1044 @staticmethod
1045 def name():
1046 return N_('Stage')
1048 def __init__(self, paths):
1049 Command.__init__(self)
1050 self.paths = paths
1052 def do(self):
1053 msg = N_('Staging: %s') % (', '.join(self.paths))
1054 Interaction.log(msg)
1055 # Prevent external updates while we are staging files.
1056 # We update file stats at the end of this operation
1057 # so there's no harm in ignoring updates from other threads
1058 # (e.g. inotify).
1059 with CommandDisabled(UpdateFileStatus):
1060 self.model.stage_paths(self.paths)
1063 class StageModified(Stage):
1064 """Stage all modified files."""
1066 SHORTCUT = 'Ctrl+S'
1068 @staticmethod
1069 def name():
1070 return N_('Stage Modified')
1072 def __init__(self):
1073 Stage.__init__(self, None)
1074 self.paths = self.model.modified
1077 class StageUnmerged(Stage):
1078 """Stage all modified files."""
1080 SHORTCUT = 'Ctrl+S'
1082 @staticmethod
1083 def name():
1084 return N_('Stage Unmerged')
1086 def __init__(self):
1087 Stage.__init__(self, None)
1088 self.paths = self.model.unmerged
1091 class StageUntracked(Stage):
1092 """Stage all untracked files."""
1094 SHORTCUT = 'Ctrl+S'
1096 @staticmethod
1097 def name():
1098 return N_('Stage Untracked')
1100 def __init__(self):
1101 Stage.__init__(self, None)
1102 self.paths = self.model.untracked
1105 class Tag(Command):
1106 """Create a tag object."""
1108 def __init__(self, name, revision, sign=False, message=''):
1109 Command.__init__(self)
1110 self._name = name
1111 self._message = message
1112 self._revision = revision
1113 self._sign = sign
1115 def do(self):
1116 log_msg = (N_('Tagging "%(revision)s" as "%(name)s"') %
1117 dict(revision=self._revision, name=self._name))
1118 opts = {}
1119 if self._message:
1120 opts['F'] = utils.tmp_filename('tag-message')
1121 core.write(opts['F'], self._message)
1123 if self._sign:
1124 log_msg += ' (%s)' % N_('GPG-signed')
1125 opts['s'] = True
1126 status, output, err = self.model.git.tag(self._name,
1127 self._revision, **opts)
1128 else:
1129 opts['a'] = bool(self._message)
1130 status, output, err = self.model.git.tag(self._name,
1131 self._revision, **opts)
1132 if 'F' in opts:
1133 os.unlink(opts['F'])
1135 if output:
1136 log_msg += '\n' + (N_('Output: %s') % output)
1138 Interaction.log_status(status, log_msg, err)
1139 if status == 0:
1140 self.model.update_status()
1143 class Unstage(Command):
1144 """Unstage a set of paths."""
1146 SHORTCUT = 'Ctrl+S'
1148 @staticmethod
1149 def name():
1150 return N_('Unstage')
1152 def __init__(self, paths):
1153 Command.__init__(self)
1154 self.paths = paths
1156 def do(self):
1157 msg = N_('Unstaging: %s') % (', '.join(self.paths))
1158 Interaction.log(msg)
1159 with CommandDisabled(UpdateFileStatus):
1160 self.model.unstage_paths(self.paths)
1163 class UnstageAll(Command):
1164 """Unstage all files; resets the index."""
1166 def do(self):
1167 self.model.unstage_all()
1170 class UnstageSelected(Unstage):
1171 """Unstage selected files."""
1173 def __init__(self):
1174 Unstage.__init__(self, selection.selection_model().staged)
1177 class Untrack(Command):
1178 """Unstage a set of paths."""
1180 def __init__(self, paths):
1181 Command.__init__(self)
1182 self.paths = paths
1184 def do(self):
1185 msg = N_('Untracking: %s') % (', '.join(self.paths))
1186 Interaction.log(msg)
1187 with CommandDisabled(UpdateFileStatus):
1188 status, out, err = self.model.untrack_paths(self.paths)
1189 Interaction.log_status(status, out, err)
1192 class UntrackedSummary(Command):
1193 """List possible .gitignore rules as the diff text."""
1195 def __init__(self):
1196 Command.__init__(self)
1197 untracked = self.model.untracked
1198 suffix = len(untracked) > 1 and 's' or ''
1199 io = StringIO()
1200 io.write('# %s untracked file%s\n' % (len(untracked), suffix))
1201 if untracked:
1202 io.write('# possible .gitignore rule%s:\n' % suffix)
1203 for u in untracked:
1204 io.write('/'+core.encode(u)+'\n')
1205 self.new_diff_text = core.decode(io.getvalue())
1206 self.new_mode = self.model.mode_untracked
1209 class UpdateFileStatus(Command):
1210 """Rescans for changes."""
1212 def do(self):
1213 self.model.update_file_status()
1216 class VisualizeAll(Command):
1217 """Visualize all branches."""
1219 def do(self):
1220 browser = utils.shell_split(prefs.history_browser())
1221 core.fork(browser + ['--all'])
1224 class VisualizeCurrent(Command):
1225 """Visualize all branches."""
1227 def do(self):
1228 browser = utils.shell_split(prefs.history_browser())
1229 core.fork(browser + [self.model.currentbranch])
1232 class VisualizePaths(Command):
1233 """Path-limited visualization."""
1235 def __init__(self, paths):
1236 Command.__init__(self)
1237 browser = utils.shell_split(prefs.history_browser())
1238 if paths:
1239 self.argv = browser + paths
1240 else:
1241 self.argv = browser
1243 def do(self):
1244 core.fork(self.argv)
1247 class VisualizeRevision(Command):
1248 """Visualize a specific revision."""
1250 def __init__(self, revision, paths=None):
1251 Command.__init__(self)
1252 self.revision = revision
1253 self.paths = paths
1255 def do(self):
1256 argv = utils.shell_split(prefs.history_browser())
1257 if self.revision:
1258 argv.append(self.revision)
1259 if self.paths:
1260 argv.append('--')
1261 argv.extend(self.paths)
1263 try:
1264 core.fork(argv)
1265 except Exception as e:
1266 _, details = utils.format_exception(e)
1267 title = N_('Error Launching History Browser')
1268 msg = (N_('Cannot exec "%s": please configure a history browser') %
1269 ' '.join(argv))
1270 Interaction.critical(title, message=msg, details=details)
1273 def run(cls, *args, **opts):
1275 Returns a callback that runs a command
1277 If the caller of run() provides args or opts then those are
1278 used instead of the ones provided by the invoker of the callback.
1281 def runner(*local_args, **local_opts):
1282 if args or opts:
1283 do(cls, *args, **opts)
1284 else:
1285 do(cls, *local_args, **local_opts)
1287 return runner
1290 class CommandDisabled(object):
1292 """Context manager to temporarily disable a command from running"""
1293 def __init__(self, cmdclass):
1294 self.cmdclass = cmdclass
1296 def __enter__(self):
1297 self.cmdclass.DISABLED = True
1298 return self
1300 def __exit__(self, exc_type, exc_val, exc_tb):
1301 self.cmdclass.DISABLED = False
1304 def do(cls, *args, **opts):
1305 """Run a command in-place"""
1306 return do_cmd(cls(*args, **opts))
1309 def do_cmd(cmd):
1310 if hasattr(cmd, 'DISABLED') and cmd.DISABLED:
1311 return None
1312 try:
1313 return cmd.do()
1314 except StandardError, e:
1315 msg, details = utils.format_exception(e)
1316 Interaction.critical(N_('Error'), message=msg, details=details)
1317 return None