README.md: title-case Git
[git-cola.git] / cola / cmds.py
blobb9ac92b0f6fb277a9ccb7b65db901607533e0bc3
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 if not self.filenames:
355 return
356 new_additions = '\n'.join(self.filenames) + '\n'
357 for_status = new_additions
358 if core.exists('.gitignore'):
359 current_list = core.read('.gitignore')
360 new_additions = current_list.rstrip() + '\n' + new_additions
361 core.write('.gitignore', new_additions)
362 Interaction.log_status(0, 'Added to .gitignore:\n%s' % for_status, '')
363 self.model.update_file_status()
366 class Delete(Command):
367 """Delete files."""
369 def __init__(self, filenames):
370 Command.__init__(self)
371 self.filenames = filenames
372 # We could git-hash-object stuff and provide undo-ability
373 # as an option. Heh.
374 def do(self):
375 rescan = False
376 for filename in self.filenames:
377 if filename:
378 try:
379 os.remove(filename)
380 rescan=True
381 except:
382 Interaction.information(
383 N_('Error'),
384 N_('Deleting "%s" failed') % filename)
385 if rescan:
386 self.model.update_file_status()
389 class DeleteBranch(Command):
390 """Delete a git branch."""
392 def __init__(self, branch):
393 Command.__init__(self)
394 self.branch = branch
396 def do(self):
397 status, out, err = self.model.delete_branch(self.branch)
398 Interaction.log_status(status, out, err)
401 class DeleteRemoteBranch(Command):
402 """Delete a remote git branch."""
404 def __init__(self, remote, branch):
405 Command.__init__(self)
406 self.remote = remote
407 self.branch = branch
409 def do(self):
410 status, out, err = self.model.git.push(self.remote, self.branch,
411 delete=True)
412 Interaction.log_status(status, out, err)
413 self.model.update_status()
415 if status == 0:
416 Interaction.information(
417 N_('Remote Branch Deleted'),
418 N_('"%(branch)s" has been deleted from "%(remote)s".')
419 % dict(branch=self.branch, remote=self.remote))
420 else:
421 command = 'git push'
422 message = (N_('"%(command)s" returned exit status %(status)d') %
423 dict(command=command, status=status))
425 Interaction.critical(N_('Error Deleting Remote Branch'),
426 message, out + err)
430 class Diff(Command):
431 """Perform a diff and set the model's current text."""
433 def __init__(self, filenames, cached=False):
434 Command.__init__(self)
435 # Guard against the list of files being empty
436 if not filenames:
437 return
438 opts = {}
439 if cached:
440 opts['ref'] = self.model.head
441 self.new_filename = filenames[0]
442 self.old_filename = self.model.filename
443 self.new_mode = self.model.mode_worktree
444 self.new_diff_text = gitcmds.diff_helper(filename=self.new_filename,
445 cached=cached, **opts)
448 class Diffstat(Command):
449 """Perform a diffstat and set the model's diff text."""
451 def __init__(self):
452 Command.__init__(self)
453 diff = self.model.git.diff(self.model.head,
454 unified=_config.get('diff.context', 3),
455 no_ext_diff=True,
456 no_color=True,
457 M=True,
458 stat=True)[STDOUT]
459 self.new_diff_text = diff
460 self.new_mode = self.model.mode_worktree
463 class DiffStaged(Diff):
464 """Perform a staged diff on a file."""
466 def __init__(self, filenames):
467 Diff.__init__(self, filenames, cached=True)
468 self.new_mode = self.model.mode_index
471 class DiffStagedSummary(Command):
473 def __init__(self):
474 Command.__init__(self)
475 diff = self.model.git.diff(self.model.head,
476 cached=True,
477 no_color=True,
478 no_ext_diff=True,
479 patch_with_stat=True,
480 M=True)[STDOUT]
481 self.new_diff_text = diff
482 self.new_mode = self.model.mode_index
485 class Difftool(Command):
486 """Run git-difftool limited by path."""
488 def __init__(self, staged, filenames):
489 Command.__init__(self)
490 self.staged = staged
491 self.filenames = filenames
493 def do(self):
494 difftool.launch_with_head(self.filenames,
495 self.staged, self.model.head)
498 class Edit(Command):
499 """Edit a file using the configured gui.editor."""
500 SHORTCUT = 'Ctrl+E'
502 @staticmethod
503 def name():
504 return N_('Edit')
506 def __init__(self, filenames, line_number=None):
507 Command.__init__(self)
508 self.filenames = filenames
509 self.line_number = line_number
511 def do(self):
512 if not self.filenames:
513 return
514 filename = self.filenames[0]
515 if not core.exists(filename):
516 return
517 editor = prefs.editor()
518 opts = []
520 if self.line_number is None:
521 opts = self.filenames
522 else:
523 # Single-file w/ line-numbers (likely from grep)
524 editor_opts = {
525 '*vim*': ['+'+self.line_number, filename],
526 '*emacs*': ['+'+self.line_number, filename],
527 '*textpad*': ['%s(%s,0)' % (filename, self.line_number)],
528 '*notepad++*': ['-n'+self.line_number, filename],
531 opts = self.filenames
532 for pattern, opt in editor_opts.items():
533 if fnmatch(editor, pattern):
534 opts = opt
535 break
537 try:
538 core.fork(utils.shell_split(editor) + opts)
539 except Exception as e:
540 message = (N_('Cannot exec "%s": please configure your editor') %
541 editor)
542 Interaction.critical(N_('Error Editing File'),
543 message, str(e))
546 class FormatPatch(Command):
547 """Output a patch series given all revisions and a selected subset."""
549 def __init__(self, to_export, revs):
550 Command.__init__(self)
551 self.to_export = to_export
552 self.revs = revs
554 def do(self):
555 status, out, err = gitcmds.format_patchsets(self.to_export, self.revs)
556 Interaction.log_status(status, out, err)
559 class LaunchDifftool(BaseCommand):
561 SHORTCUT = 'Ctrl+D'
563 @staticmethod
564 def name():
565 return N_('Launch Diff Tool')
567 def __init__(self):
568 BaseCommand.__init__(self)
570 def do(self):
571 s = selection.selection()
572 if s.unmerged:
573 paths = s.unmerged
574 if utils.is_win32():
575 core.fork(['git', 'mergetool', '--no-prompt', '--'] + paths)
576 else:
577 core.fork(['xterm', '-e',
578 'git', 'mergetool', '--no-prompt', '--'] + paths)
579 else:
580 difftool.run()
583 class LaunchTerminal(BaseCommand):
585 SHORTCUT = 'Ctrl+t'
587 @staticmethod
588 def name():
589 return N_('Launch Terminal')
591 def __init__(self, path):
592 self.path = path
594 def do(self):
595 cmd = _config.get('cola.terminal', 'xterm -e $SHELL')
596 cmd = os.path.expandvars(cmd)
597 argv = utils.shell_split(cmd)
598 core.fork(argv, cwd=self.path)
601 class LaunchEditor(Edit):
602 SHORTCUT = 'Ctrl+E'
604 @staticmethod
605 def name():
606 return N_('Launch Editor')
608 def __init__(self):
609 s = selection.selection()
610 allfiles = s.staged + s.unmerged + s.modified + s.untracked
611 Edit.__init__(self, allfiles)
614 class LoadCommitMessageFromFile(Command):
615 """Loads a commit message from a path."""
617 def __init__(self, path):
618 Command.__init__(self)
619 self.undoable = True
620 self.path = path
621 self.old_commitmsg = self.model.commitmsg
622 self.old_directory = self.model.directory
624 def do(self):
625 path = self.path
626 if not path or not core.isfile(path):
627 raise UsageError(N_('Error: Cannot find commit template'),
628 N_('%s: No such file or directory.') % path)
629 self.model.set_directory(os.path.dirname(path))
630 self.model.set_commitmsg(core.read(path))
632 def undo(self):
633 self.model.set_commitmsg(self.old_commitmsg)
634 self.model.set_directory(self.old_directory)
637 class LoadCommitMessageFromTemplate(LoadCommitMessageFromFile):
638 """Loads the commit message template specified by commit.template."""
640 def __init__(self):
641 template = _config.get('commit.template')
642 LoadCommitMessageFromFile.__init__(self, template)
644 def do(self):
645 if self.path is None:
646 raise UsageError(
647 N_('Error: Unconfigured commit template'),
648 N_('A commit template has not been configured.\n'
649 'Use "git config" to define "commit.template"\n'
650 'so that it points to a commit template.'))
651 return LoadCommitMessageFromFile.do(self)
655 class LoadCommitMessageFromSHA1(Command):
656 """Load a previous commit message"""
658 def __init__(self, sha1, prefix=''):
659 Command.__init__(self)
660 self.sha1 = sha1
661 self.old_commitmsg = self.model.commitmsg
662 self.new_commitmsg = prefix + self.model.prev_commitmsg(sha1)
663 self.undoable = True
665 def do(self):
666 self.model.set_commitmsg(self.new_commitmsg)
668 def undo(self):
669 self.model.set_commitmsg(self.old_commitmsg)
672 class LoadFixupMessage(LoadCommitMessageFromSHA1):
673 """Load a fixup message"""
675 def __init__(self, sha1):
676 LoadCommitMessageFromSHA1.__init__(self, sha1, prefix='fixup! ')
679 class Merge(Command):
680 def __init__(self, revision, no_commit, squash):
681 Command.__init__(self)
682 self.revision = revision
683 self.no_commit = no_commit
684 self.squash = squash
686 def do(self):
687 squash = self.squash
688 revision = self.revision
689 no_commit = self.no_commit
690 msg = gitcmds.merge_message(revision)
692 status, out, err = self.model.git.merge('-m', msg,
693 revision,
694 no_commit=no_commit,
695 squash=squash)
697 Interaction.log_status(status, out, err)
698 self.model.update_status()
701 class OpenDefaultApp(BaseCommand):
702 """Open a file using the OS default."""
703 SHORTCUT = 'Space'
705 @staticmethod
706 def name():
707 return N_('Open Using Default Application')
709 def __init__(self, filenames):
710 BaseCommand.__init__(self)
711 if utils.is_darwin():
712 launcher = 'open'
713 else:
714 launcher = 'xdg-open'
715 self.launcher = launcher
716 self.filenames = filenames
718 def do(self):
719 if not self.filenames:
720 return
721 core.fork([self.launcher] + self.filenames)
724 class OpenParentDir(OpenDefaultApp):
725 """Open parent directories using the OS default."""
726 SHORTCUT = 'Shift+Space'
728 @staticmethod
729 def name():
730 return N_('Open Parent Directory')
732 def __init__(self, filenames):
733 OpenDefaultApp.__init__(self, filenames)
735 def do(self):
736 if not self.filenames:
737 return
738 dirs = list(set(map(os.path.dirname, self.filenames)))
739 core.fork([self.launcher] + dirs)
742 class OpenNewRepo(Command):
743 """Launches git-cola on a repo."""
745 def __init__(self, repo_path):
746 Command.__init__(self)
747 self.repo_path = repo_path
749 def do(self):
750 self.model.set_directory(self.repo_path)
751 core.fork([sys.executable, sys.argv[0], '--repo', self.repo_path])
754 class OpenRepo(Command):
755 def __init__(self, repo_path):
756 Command.__init__(self)
757 self.repo_path = repo_path
759 def do(self):
760 git = self.model.git
761 old_worktree = git.worktree()
762 if not self.model.set_worktree(self.repo_path):
763 self.model.set_worktree(old_worktree)
764 return
765 new_worktree = git.worktree()
766 core.chdir(new_worktree)
767 self.model.set_directory(self.repo_path)
768 _config.reset()
769 self.model.update_status()
772 class Clone(Command):
773 """Clones a repository and optionally spawns a new cola session."""
775 def __init__(self, url, new_directory, spawn=True):
776 Command.__init__(self)
777 self.url = url
778 self.new_directory = new_directory
779 self.spawn = spawn
781 def do(self):
782 status, out, err = self.model.git.clone(self.url, self.new_directory)
783 if status != 0:
784 Interaction.information(
785 N_('Error: could not clone "%s"') % self.url,
786 (N_('git clone returned exit code %s') % status) +
787 ((out+err) and ('\n\n' + out + err) or ''))
788 return False
789 if self.spawn:
790 core.fork([sys.executable, sys.argv[0],
791 '--repo', self.new_directory])
792 return True
795 class GitXBaseContext(object):
797 def __init__(self, **kwargs):
798 self.extras = kwargs
800 def __enter__(self):
801 compat.setenv('GIT_SEQUENCE_EDITOR',
802 resources.share('bin', 'git-xbase'))
803 for var, value in self.extras.items():
804 compat.setenv(var, value)
805 return self
807 def __exit__(self, exc_type, exc_val, exc_tb):
808 compat.unsetenv('GIT_SEQUENCE_EDITOR')
809 for var in self.extras:
810 compat.unsetenv(var)
813 class Rebase(Command):
815 def __init__(self, branch, capture_output=True):
816 Command.__init__(self)
817 self.branch = branch
818 self.capture_output = capture_output
820 def do(self):
821 branch = self.branch
822 if not branch:
823 return
824 status = 1
825 out = ''
826 err = ''
827 extra = {}
828 if self.capture_output:
829 extra['_stderr'] = None
830 extra['_stdout'] = None
831 with GitXBaseContext(
832 GIT_EDITOR=prefs.editor(),
833 GIT_XBASE_TITLE=N_('Rebase onto %s') % branch,
834 GIT_XBASE_ACTION=N_('Rebase')):
835 status, out, err = self.model.git.rebase(branch,
836 interactive=True,
837 autosquash=True,
838 **extra)
839 Interaction.log_status(status, out, err)
840 self.model.update_status()
841 return status, out, err
844 class RebaseEditTodo(Command):
846 def do(self):
847 with GitXBaseContext(
848 GIT_XBASE_TITLE=N_('Edit Rebase'),
849 GIT_XBASE_ACTION=N_('Save')):
850 status, out, err = self.model.git.rebase(edit_todo=True)
851 Interaction.log_status(status, out, err)
852 self.model.update_status()
855 class RebaseContinue(Command):
857 def do(self):
858 status, out, err = self.model.git.rebase('--continue')
859 Interaction.log_status(status, out, err)
860 self.model.update_status()
863 class RebaseSkip(Command):
865 def do(self):
866 status, out, err = self.model.git.rebase(skip=True)
867 Interaction.log_status(status, out, err)
868 self.model.update_status()
871 class RebaseAbort(Command):
873 def do(self):
874 status, out, err = self.model.git.rebase(abort=True)
875 Interaction.log_status(status, out, err)
876 self.model.update_status()
879 class Rescan(Command):
880 """Rescan for changes"""
882 def do(self):
883 self.model.update_status()
886 class Refresh(Command):
887 """Update refs and refresh the index"""
889 SHORTCUT = 'Ctrl+R'
891 @staticmethod
892 def name():
893 return N_('Refresh')
895 def do(self):
896 self.model.update_status(update_index=True)
899 class RunConfigAction(Command):
900 """Run a user-configured action, typically from the "Tools" menu"""
902 def __init__(self, action_name):
903 Command.__init__(self)
904 self.action_name = action_name
905 self.model = main.model()
907 def do(self):
908 for env in ('FILENAME', 'REVISION', 'ARGS'):
909 try:
910 compat.unsetenv(env)
911 except KeyError:
912 pass
913 rev = None
914 args = None
915 opts = _config.get_guitool_opts(self.action_name)
916 cmd = opts.get('cmd')
917 if 'title' not in opts:
918 opts['title'] = cmd
920 if 'prompt' not in opts or opts.get('prompt') is True:
921 prompt = N_('Run "%s"?') % cmd
922 opts['prompt'] = prompt
924 if opts.get('needsfile'):
925 filename = selection.filename()
926 if not filename:
927 Interaction.information(
928 N_('Please select a file'),
929 N_('"%s" requires a selected file.') % cmd)
930 return False
931 compat.setenv('FILENAME', filename)
933 if opts.get('revprompt') or opts.get('argprompt'):
934 while True:
935 ok = Interaction.confirm_config_action(cmd, opts)
936 if not ok:
937 return False
938 rev = opts.get('revision')
939 args = opts.get('args')
940 if opts.get('revprompt') and not rev:
941 title = N_('Invalid Revision')
942 msg = N_('The revision expression cannot be empty.')
943 Interaction.critical(title, msg)
944 continue
945 break
947 elif opts.get('confirm'):
948 title = os.path.expandvars(opts.get('title'))
949 prompt = os.path.expandvars(opts.get('prompt'))
950 if Interaction.question(title, prompt):
951 return
952 if rev:
953 compat.setenv('REVISION', rev)
954 if args:
955 compat.setenv('ARGS', args)
956 title = os.path.expandvars(cmd)
957 Interaction.log(N_('Running command: %s') % title)
958 cmd = ['sh', '-c', cmd]
960 if opts.get('noconsole'):
961 status, out, err = core.run_command(cmd)
962 else:
963 status, out, err = Interaction.run_command(title, cmd)
965 Interaction.log_status(status,
966 out and (N_('Output: %s') % out) or '',
967 err and (N_('Errors: %s') % err) or '')
969 if not opts.get('norescan'):
970 self.model.update_status()
971 return status
974 class SetDiffText(Command):
976 def __init__(self, text):
977 Command.__init__(self)
978 self.undoable = True
979 self.new_diff_text = text
982 class ShowUntracked(Command):
983 """Show an untracked file."""
985 def __init__(self, filenames):
986 Command.__init__(self)
987 self.filenames = filenames
988 self.new_mode = self.model.mode_untracked
989 self.new_diff_text = ''
990 if filenames:
991 self.new_diff_text = self.diff_text_for(filenames[0])
993 def diff_text_for(self, filename):
994 size = _config.get('cola.readsize', 1024 * 2)
995 try:
996 result = core.read(filename, size=size)
997 except:
998 result = ''
1000 if len(result) == size:
1001 result += '...'
1002 return result
1005 class SignOff(Command):
1006 SHORTCUT = 'Ctrl+I'
1008 @staticmethod
1009 def name():
1010 return N_('Sign Off')
1012 def __init__(self):
1013 Command.__init__(self)
1014 self.undoable = True
1015 self.old_commitmsg = self.model.commitmsg
1017 def do(self):
1018 signoff = self.signoff()
1019 if signoff in self.model.commitmsg:
1020 return
1021 self.model.set_commitmsg(self.model.commitmsg + '\n' + signoff)
1023 def undo(self):
1024 self.model.set_commitmsg(self.old_commitmsg)
1026 def signoff(self):
1027 try:
1028 import pwd
1029 user = pwd.getpwuid(os.getuid()).pw_name
1030 except ImportError:
1031 user = os.getenv('USER', N_('unknown'))
1033 name = _config.get('user.name', user)
1034 email = _config.get('user.email', '%s@%s' % (user, core.node()))
1035 return '\nSigned-off-by: %s <%s>' % (name, email)
1038 class Stage(Command):
1039 """Stage a set of paths."""
1040 SHORTCUT = 'Ctrl+S'
1042 @staticmethod
1043 def name():
1044 return N_('Stage')
1046 def __init__(self, paths):
1047 Command.__init__(self)
1048 self.paths = paths
1050 def do(self):
1051 msg = N_('Staging: %s') % (', '.join(self.paths))
1052 Interaction.log(msg)
1053 # Prevent external updates while we are staging files.
1054 # We update file stats at the end of this operation
1055 # so there's no harm in ignoring updates from other threads
1056 # (e.g. inotify).
1057 with CommandDisabled(UpdateFileStatus):
1058 self.model.stage_paths(self.paths)
1061 class StageModified(Stage):
1062 """Stage all modified files."""
1064 SHORTCUT = 'Ctrl+S'
1066 @staticmethod
1067 def name():
1068 return N_('Stage Modified')
1070 def __init__(self):
1071 Stage.__init__(self, None)
1072 self.paths = self.model.modified
1075 class StageUnmerged(Stage):
1076 """Stage all modified files."""
1078 SHORTCUT = 'Ctrl+S'
1080 @staticmethod
1081 def name():
1082 return N_('Stage Unmerged')
1084 def __init__(self):
1085 Stage.__init__(self, None)
1086 self.paths = self.model.unmerged
1089 class StageUntracked(Stage):
1090 """Stage all untracked files."""
1092 SHORTCUT = 'Ctrl+S'
1094 @staticmethod
1095 def name():
1096 return N_('Stage Untracked')
1098 def __init__(self):
1099 Stage.__init__(self, None)
1100 self.paths = self.model.untracked
1103 class Tag(Command):
1104 """Create a tag object."""
1106 def __init__(self, name, revision, sign=False, message=''):
1107 Command.__init__(self)
1108 self._name = name
1109 self._message = message
1110 self._revision = revision
1111 self._sign = sign
1113 def do(self):
1114 log_msg = (N_('Tagging "%(revision)s" as "%(name)s"') %
1115 dict(revision=self._revision, name=self._name))
1116 opts = {}
1117 if self._message:
1118 opts['F'] = utils.tmp_filename('tag-message')
1119 core.write(opts['F'], self._message)
1121 if self._sign:
1122 log_msg += ' (%s)' % N_('GPG-signed')
1123 opts['s'] = True
1124 status, output, err = self.model.git.tag(self._name,
1125 self._revision, **opts)
1126 else:
1127 opts['a'] = bool(self._message)
1128 status, output, err = self.model.git.tag(self._name,
1129 self._revision, **opts)
1130 if 'F' in opts:
1131 os.unlink(opts['F'])
1133 if output:
1134 log_msg += '\n' + (N_('Output: %s') % output)
1136 Interaction.log_status(status, log_msg, err)
1137 if status == 0:
1138 self.model.update_status()
1141 class Unstage(Command):
1142 """Unstage a set of paths."""
1144 SHORTCUT = 'Ctrl+S'
1146 @staticmethod
1147 def name():
1148 return N_('Unstage')
1150 def __init__(self, paths):
1151 Command.__init__(self)
1152 self.paths = paths
1154 def do(self):
1155 msg = N_('Unstaging: %s') % (', '.join(self.paths))
1156 Interaction.log(msg)
1157 with CommandDisabled(UpdateFileStatus):
1158 self.model.unstage_paths(self.paths)
1161 class UnstageAll(Command):
1162 """Unstage all files; resets the index."""
1164 def do(self):
1165 self.model.unstage_all()
1168 class UnstageSelected(Unstage):
1169 """Unstage selected files."""
1171 def __init__(self):
1172 Unstage.__init__(self, selection.selection_model().staged)
1175 class Untrack(Command):
1176 """Unstage a set of paths."""
1178 def __init__(self, paths):
1179 Command.__init__(self)
1180 self.paths = paths
1182 def do(self):
1183 msg = N_('Untracking: %s') % (', '.join(self.paths))
1184 Interaction.log(msg)
1185 with CommandDisabled(UpdateFileStatus):
1186 status, out, err = self.model.untrack_paths(self.paths)
1187 Interaction.log_status(status, out, err)
1190 class UntrackedSummary(Command):
1191 """List possible .gitignore rules as the diff text."""
1193 def __init__(self):
1194 Command.__init__(self)
1195 untracked = self.model.untracked
1196 suffix = len(untracked) > 1 and 's' or ''
1197 io = StringIO()
1198 io.write('# %s untracked file%s\n' % (len(untracked), suffix))
1199 if untracked:
1200 io.write('# possible .gitignore rule%s:\n' % suffix)
1201 for u in untracked:
1202 io.write('/'+core.encode(u)+'\n')
1203 self.new_diff_text = core.decode(io.getvalue())
1204 self.new_mode = self.model.mode_untracked
1207 class UpdateFileStatus(Command):
1208 """Rescans for changes."""
1210 def do(self):
1211 self.model.update_file_status()
1214 class VisualizeAll(Command):
1215 """Visualize all branches."""
1217 def do(self):
1218 browser = utils.shell_split(prefs.history_browser())
1219 launch_history_browser(browser + ['--all'])
1222 class VisualizeCurrent(Command):
1223 """Visualize all branches."""
1225 def do(self):
1226 browser = utils.shell_split(prefs.history_browser())
1227 launch_history_browser(browser + [self.model.currentbranch])
1230 class VisualizePaths(Command):
1231 """Path-limited visualization."""
1233 def __init__(self, paths):
1234 Command.__init__(self)
1235 browser = utils.shell_split(prefs.history_browser())
1236 if paths:
1237 self.argv = browser + paths
1238 else:
1239 self.argv = browser
1241 def do(self):
1242 launch_history_browser(self.argv)
1245 class VisualizeRevision(Command):
1246 """Visualize a specific revision."""
1248 def __init__(self, revision, paths=None):
1249 Command.__init__(self)
1250 self.revision = revision
1251 self.paths = paths
1253 def do(self):
1254 argv = utils.shell_split(prefs.history_browser())
1255 if self.revision:
1256 argv.append(self.revision)
1257 if self.paths:
1258 argv.append('--')
1259 argv.extend(self.paths)
1260 launch_history_browser(argv)
1263 def launch_history_browser(argv):
1264 try:
1265 core.fork(argv)
1266 except Exception as e:
1267 _, details = utils.format_exception(e)
1268 title = N_('Error Launching History Browser')
1269 msg = (N_('Cannot exec "%s": please configure a history browser') %
1270 ' '.join(argv))
1271 Interaction.critical(title, message=msg, details=details)
1274 def run(cls, *args, **opts):
1276 Returns a callback that runs a command
1278 If the caller of run() provides args or opts then those are
1279 used instead of the ones provided by the invoker of the callback.
1282 def runner(*local_args, **local_opts):
1283 if args or opts:
1284 do(cls, *args, **opts)
1285 else:
1286 do(cls, *local_args, **local_opts)
1288 return runner
1291 class CommandDisabled(object):
1293 """Context manager to temporarily disable a command from running"""
1294 def __init__(self, cmdclass):
1295 self.cmdclass = cmdclass
1297 def __enter__(self):
1298 self.cmdclass.DISABLED = True
1299 return self
1301 def __exit__(self, exc_type, exc_val, exc_tb):
1302 self.cmdclass.DISABLED = False
1305 def do(cls, *args, **opts):
1306 """Run a command in-place"""
1307 return do_cmd(cls(*args, **opts))
1310 def do_cmd(cmd):
1311 if hasattr(cmd, 'DISABLED') and cmd.DISABLED:
1312 return None
1313 try:
1314 return cmd.do()
1315 except StandardError, e:
1316 msg, details = utils.format_exception(e)
1317 Interaction.critical(N_('Error'), message=msg, details=details)
1318 return None