doc: fix release notes typo
[git-cola.git] / cola / cmds.py
blobdfdc0cd96ffeaf818f2b2386d19cf6a622bfb2dd
1 import os
2 import sys
3 import platform
4 from fnmatch import fnmatch
6 from cStringIO import StringIO
8 import cola
9 from cola import compat
10 from cola import core
11 from cola import errors
12 from cola import gitcfg
13 from cola import gitcmds
14 from cola import utils
15 from cola import difftool
16 from cola import resources
17 from cola.compat import set
18 from cola.diffparse import DiffParser
19 from cola.git import STDOUT
20 from cola.i18n import N_
21 from cola.interaction import Interaction
22 from cola.models import prefs
23 from cola.models import selection
25 _notifier = cola.notifier()
26 _config = gitcfg.instance()
29 class BaseCommand(object):
30 """Base class for all commands; provides the command pattern"""
32 DISABLED = False
34 def __init__(self):
35 self.undoable = False
37 def is_undoable(self):
38 """Can this be undone?"""
39 return self.undoable
41 @staticmethod
42 def name(cls):
43 return 'Unknown'
45 def do(self):
46 raise NotImplementedError('%s.do() is unimplemented' % self.__class__.__name__)
48 def undo(self):
49 raise NotImplementedError('%s.undo() is unimplemented' % self.__class__.__name__)
52 class Command(BaseCommand):
53 """Base class for commands that modify the main model"""
55 def __init__(self):
56 """Initialize the command and stash away values for use in do()"""
57 # These are commonly used so let's make it easier to write new commands.
58 BaseCommand.__init__(self)
59 self.model = cola.model()
61 self.old_diff_text = self.model.diff_text
62 self.old_filename = self.model.filename
63 self.old_mode = self.model.mode
64 self.old_head = self.model.head
66 self.new_diff_text = self.old_diff_text
67 self.new_filename = self.old_filename
68 self.new_head = self.old_head
69 self.new_mode = self.old_mode
71 def do(self):
72 """Perform the operation."""
73 self.model.set_filename(self.new_filename)
74 self.model.set_head(self.new_head)
75 self.model.set_mode(self.new_mode)
76 self.model.set_diff_text(self.new_diff_text)
78 def undo(self):
79 """Undo the operation."""
80 self.model.set_diff_text(self.old_diff_text)
81 self.model.set_filename(self.old_filename)
82 self.model.set_head(self.old_head)
83 self.model.set_mode(self.old_mode)
86 class AmendMode(Command):
87 """Try to amend a commit."""
89 SHORTCUT = 'Ctrl+M'
91 LAST_MESSAGE = None
93 @staticmethod
94 def name():
95 return N_('Amend')
97 def __init__(self, amend):
98 Command.__init__(self)
99 self.undoable = True
100 self.skip = False
101 self.amending = amend
102 self.old_commitmsg = self.model.commitmsg
104 if self.amending:
105 self.new_mode = self.model.mode_amend
106 self.new_head = 'HEAD^'
107 self.new_commitmsg = self.model.prev_commitmsg()
108 AmendMode.LAST_MESSAGE = self.model.commitmsg
109 return
110 # else, amend unchecked, regular commit
111 self.new_mode = self.model.mode_none
112 self.new_head = 'HEAD'
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 core.exists(self.model.git.git_path('MERGE_HEAD')):
127 self.skip = True
128 _notifier.broadcast(_notifier.AMEND, False)
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 _notifier.broadcast(_notifier.AMEND, self.amending)
136 self.model.set_commitmsg(self.new_commitmsg)
137 Command.do(self)
138 self.model.update_file_status()
140 def undo(self):
141 if self.skip:
142 return
143 self.model.set_commitmsg(self.old_commitmsg)
144 Command.undo(self)
145 self.model.update_file_status()
148 class ApplyDiffSelection(Command):
150 def __init__(self, staged, selected, offset, selection, apply_to_worktree):
151 Command.__init__(self)
152 self.staged = staged
153 self.selected = selected
154 self.offset = offset
155 self.selection = selection
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,
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 patches.sort()
178 self.patches = patches
180 def do(self):
181 diff_text = ''
182 num_patches = len(self.patches)
183 orig_head = self.model.git.rev_parse('HEAD')[STDOUT]
185 for idx, patch in enumerate(self.patches):
186 status, out, err = self.model.git.am(patch)
187 # Log the git-am command
188 Interaction.log_status(status, out, err)
190 if num_patches > 1:
191 diff = self.model.git.diff('HEAD^!', stat=True)[STDOUT]
192 diff_text += (N_('PATCH %(current)d/%(count)d') %
193 dict(current=idx+1, count=num_patches))
194 diff_text += ' - %s:\n%s\n\n' % (os.path.basename(patch), diff)
196 diff_text += N_('Summary:') + '\n'
197 diff_text += self.model.git.diff(orig_head, stat=True)[STDOUT]
199 # Display a diffstat
200 self.model.set_diff_text(diff_text)
201 self.model.update_file_status()
203 basenames = '\n'.join([os.path.basename(p) for p in self.patches])
204 Interaction.information(
205 N_('Patch(es) Applied'),
206 (N_('%d patch(es) applied.') + '\n\n%s') %
207 (len(self.patches), basenames))
210 class Archive(BaseCommand):
212 def __init__(self, ref, fmt, prefix, filename):
213 BaseCommand.__init__(self)
214 self.ref = ref
215 self.fmt = fmt
216 self.prefix = prefix
217 self.filename = filename
219 def do(self):
220 fp = core.xopen(self.filename, 'wb')
221 cmd = ['git', 'archive', '--format='+self.fmt]
222 if self.fmt in ('tgz', 'tar.gz'):
223 cmd.append('-9')
224 if self.prefix:
225 cmd.append('--prefix=' + self.prefix)
226 cmd.append(self.ref)
227 proc = core.start_command(cmd, stdout=fp)
228 out, err = proc.communicate()
229 fp.close()
230 status = proc.returncode
231 Interaction.log_status(status, out or '', err or '')
234 class Checkout(Command):
236 A command object for git-checkout.
238 'argv' is handed off directly to git.
242 def __init__(self, argv, checkout_branch=False):
243 Command.__init__(self)
244 self.argv = argv
245 self.checkout_branch = checkout_branch
246 self.new_diff_text = ''
248 def do(self):
249 status, out, err = self.model.git.checkout(*self.argv)
250 Interaction.log_status(status, out, err)
251 if self.checkout_branch:
252 self.model.update_status()
253 else:
254 self.model.update_file_status()
257 class CheckoutBranch(Checkout):
258 """Checkout a branch."""
260 def __init__(self, branch):
261 args = [branch]
262 Checkout.__init__(self, args, checkout_branch=True)
265 class CherryPick(Command):
266 """Cherry pick commits into the current branch."""
268 def __init__(self, commits):
269 Command.__init__(self)
270 self.commits = commits
272 def do(self):
273 self.model.cherry_pick_list(self.commits)
274 self.model.update_file_status()
277 class ResetMode(Command):
278 """Reset the mode and clear the model's diff text."""
280 def __init__(self):
281 Command.__init__(self)
282 self.new_mode = self.model.mode_none
283 self.new_head = 'HEAD'
284 self.new_diff_text = ''
286 def do(self):
287 Command.do(self)
288 self.model.update_file_status()
291 class Commit(ResetMode):
292 """Attempt to create a new commit."""
294 SHORTCUT = 'Ctrl+Return'
296 def __init__(self, amend, msg):
297 ResetMode.__init__(self)
298 self.amend = amend
299 self.msg = msg
300 self.old_commitmsg = self.model.commitmsg
301 self.new_commitmsg = ''
303 def do(self):
304 tmpfile = utils.tmp_filename('commit-message')
305 status, out, err = self.model.commit_with_msg(self.msg, tmpfile,
306 amend=self.amend)
307 if status == 0:
308 ResetMode.do(self)
309 self.model.set_commitmsg(self.new_commitmsg)
310 msg = N_('Created commit: %s') % out
311 else:
312 msg = N_('Commit failed: %s') % out
313 Interaction.log_status(status, msg, err)
315 return status, out, err
318 class Ignore(Command):
319 """Add files to .gitignore"""
321 def __init__(self, filenames):
322 Command.__init__(self)
323 self.filenames = filenames
325 def do(self):
326 new_additions = ''
327 for fname in self.filenames:
328 new_additions = new_additions + fname + '\n'
329 for_status = new_additions
330 if new_additions:
331 if core.exists('.gitignore'):
332 current_list = core.read('.gitignore')
333 new_additions = new_additions + current_list
334 core.write('.gitignore', new_additions)
335 Interaction.log_status(
336 0, 'Added to .gitignore:\n%s' % for_status, '')
337 self.model.update_file_status()
340 class Delete(Command):
341 """Delete files."""
343 def __init__(self, filenames):
344 Command.__init__(self)
345 self.filenames = filenames
346 # We could git-hash-object stuff and provide undo-ability
347 # as an option. Heh.
348 def do(self):
349 rescan = False
350 for filename in self.filenames:
351 if filename:
352 try:
353 os.remove(filename)
354 rescan=True
355 except:
356 Interaction.information(
357 N_('Error'),
358 N_('Deleting "%s" failed') % filename)
359 if rescan:
360 self.model.update_file_status()
363 class DeleteBranch(Command):
364 """Delete a git branch."""
366 def __init__(self, branch):
367 Command.__init__(self)
368 self.branch = branch
370 def do(self):
371 status, out, err = self.model.delete_branch(self.branch)
372 Interaction.log_status(status, out, err)
375 class DeleteRemoteBranch(Command):
376 """Delete a remote git branch."""
378 def __init__(self, remote, branch):
379 Command.__init__(self)
380 self.remote = remote
381 self.branch = branch
383 def do(self):
384 status, out, err = self.model.git.push(self.remote, self.branch,
385 delete=True)
386 Interaction.log_status(status, out, err)
387 self.model.update_status()
389 if status == 0:
390 Interaction.information(
391 N_('Remote Branch Deleted'),
392 N_('"%(branch)s" has been deleted from "%(remote)s".')
393 % dict(branch=self.branch, remote=self.remote))
394 else:
395 command = 'git push'
396 message = (N_('"%(command)s" returned exit status %(status)d') %
397 dict(command=command, status=status))
399 Interaction.critical(N_('Error Deleting Remote Branch'),
400 message, out + err)
404 class Diff(Command):
405 """Perform a diff and set the model's current text."""
407 def __init__(self, filenames, cached=False):
408 Command.__init__(self)
409 # Guard against the list of files being empty
410 if not filenames:
411 return
412 opts = {}
413 if cached:
414 opts['ref'] = self.model.head
415 self.new_filename = filenames[0]
416 self.old_filename = self.model.filename
417 self.new_mode = self.model.mode_worktree
418 self.new_diff_text = gitcmds.diff_helper(filename=self.new_filename,
419 cached=cached, **opts)
422 class Diffstat(Command):
423 """Perform a diffstat and set the model's diff text."""
425 def __init__(self):
426 Command.__init__(self)
427 diff = self.model.git.diff(self.model.head,
428 unified=_config.get('diff.context', 3),
429 no_ext_diff=True,
430 no_color=True,
431 M=True,
432 stat=True)[STDOUT]
433 self.new_diff_text = diff
434 self.new_mode = self.model.mode_worktree
437 class DiffStaged(Diff):
438 """Perform a staged diff on a file."""
440 def __init__(self, filenames):
441 Diff.__init__(self, filenames, cached=True)
442 self.new_mode = self.model.mode_index
445 class DiffStagedSummary(Command):
447 def __init__(self):
448 Command.__init__(self)
449 diff = self.model.git.diff(self.model.head,
450 cached=True,
451 no_color=True,
452 no_ext_diff=True,
453 patch_with_stat=True,
454 M=True)[STDOUT]
455 self.new_diff_text = diff
456 self.new_mode = self.model.mode_index
459 class Difftool(Command):
460 """Run git-difftool limited by path."""
462 def __init__(self, staged, filenames):
463 Command.__init__(self)
464 self.staged = staged
465 self.filenames = filenames
467 def do(self):
468 difftool.launch_with_head(self.filenames,
469 self.staged, self.model.head)
472 class Edit(Command):
473 """Edit a file using the configured gui.editor."""
474 SHORTCUT = 'Ctrl+E'
476 @staticmethod
477 def name():
478 return N_('Edit')
480 def __init__(self, filenames, line_number=None):
481 Command.__init__(self)
482 self.filenames = filenames
483 self.line_number = line_number
485 def do(self):
486 if not self.filenames:
487 return
488 filename = self.filenames[0]
489 if not core.exists(filename):
490 return
491 editor = prefs.editor()
492 opts = []
494 if self.line_number is None:
495 opts = self.filenames
496 else:
497 # Single-file w/ line-numbers (likely from grep)
498 editor_opts = {
499 '*vim*': ['+'+self.line_number, filename],
500 '*emacs*': ['+'+self.line_number, filename],
501 '*textpad*': ['%s(%s,0)' % (filename, self.line_number)],
502 '*notepad++*': ['-n'+self.line_number, filename],
505 opts = self.filenames
506 for pattern, opt in editor_opts.items():
507 if fnmatch(editor, pattern):
508 opts = opt
509 break
511 try:
512 core.fork(utils.shell_split(editor) + opts)
513 except Exception as e:
514 message = (N_('Cannot exec "%s": please configure your editor') %
515 editor)
516 Interaction.critical(N_('Error Editing File'),
517 message, str(e))
520 class FormatPatch(Command):
521 """Output a patch series given all revisions and a selected subset."""
523 def __init__(self, to_export, revs):
524 Command.__init__(self)
525 self.to_export = to_export
526 self.revs = revs
528 def do(self):
529 status, out, err = gitcmds.format_patchsets(self.to_export, self.revs)
530 Interaction.log_status(status, out, err)
533 class LaunchDifftool(BaseCommand):
535 SHORTCUT = 'Ctrl+D'
537 @staticmethod
538 def name():
539 return N_('Launch Diff Tool')
541 def __init__(self):
542 BaseCommand.__init__(self)
544 def do(self):
545 s = cola.selection()
546 if s.unmerged:
547 paths = s.unmerged
548 if utils.is_win32():
549 core.fork(['git', 'mergetool', '--no-prompt', '--'] + paths)
550 else:
551 core.fork(['xterm', '-e',
552 'git', 'mergetool', '--no-prompt', '--'] + paths)
553 else:
554 difftool.run()
557 class LaunchEditor(Edit):
558 SHORTCUT = 'Ctrl+E'
560 @staticmethod
561 def name():
562 return N_('Launch Editor')
564 def __init__(self):
565 s = cola.selection()
566 allfiles = s.staged + s.unmerged + s.modified + s.untracked
567 Edit.__init__(self, allfiles)
570 class LoadCommitMessageFromFile(Command):
571 """Loads a commit message from a path."""
573 def __init__(self, path):
574 Command.__init__(self)
575 self.undoable = True
576 self.path = path
577 self.old_commitmsg = self.model.commitmsg
578 self.old_directory = self.model.directory
580 def do(self):
581 path = self.path
582 if not path or not core.isfile(path):
583 raise errors.UsageError(N_('Error: Cannot find commit template'),
584 N_('%s: No such file or directory.') % path)
585 self.model.set_directory(os.path.dirname(path))
586 self.model.set_commitmsg(core.read(path))
588 def undo(self):
589 self.model.set_commitmsg(self.old_commitmsg)
590 self.model.set_directory(self.old_directory)
593 class LoadCommitMessageFromTemplate(LoadCommitMessageFromFile):
594 """Loads the commit message template specified by commit.template."""
596 def __init__(self):
597 template = _config.get('commit.template')
598 LoadCommitMessageFromFile.__init__(self, template)
600 def do(self):
601 if self.path is None:
602 raise errors.UsageError(
603 N_('Error: Unconfigured commit template'),
604 N_('A commit template has not been configured.\n'
605 'Use "git config" to define "commit.template"\n'
606 'so that it points to a commit template.'))
607 return LoadCommitMessageFromFile.do(self)
611 class LoadCommitMessageFromSHA1(Command):
612 """Load a previous commit message"""
614 def __init__(self, sha1, prefix=''):
615 Command.__init__(self)
616 self.sha1 = sha1
617 self.old_commitmsg = self.model.commitmsg
618 self.new_commitmsg = prefix + self.model.prev_commitmsg(sha1)
619 self.undoable = True
621 def do(self):
622 self.model.set_commitmsg(self.new_commitmsg)
624 def undo(self):
625 self.model.set_commitmsg(self.old_commitmsg)
628 class LoadFixupMessage(LoadCommitMessageFromSHA1):
629 """Load a fixup message"""
631 def __init__(self, sha1):
632 LoadCommitMessageFromSHA1.__init__(self, sha1, prefix='fixup! ')
635 class Merge(Command):
636 def __init__(self, revision, no_commit, squash):
637 Command.__init__(self)
638 self.revision = revision
639 self.no_commit = no_commit
640 self.squash = squash
642 def do(self):
643 squash = self.squash
644 revision = self.revision
645 no_commit = self.no_commit
646 msg = gitcmds.merge_message(revision)
648 status, out, err = self.model.git.merge('-m', msg,
649 revision,
650 no_commit=no_commit,
651 squash=squash)
653 Interaction.log_status(status, out, err)
654 self.model.update_status()
657 class OpenDefaultApp(BaseCommand):
658 """Open a file using the OS default."""
659 SHORTCUT = 'Space'
661 @staticmethod
662 def name():
663 return N_('Open Using Default Application')
665 def __init__(self, filenames):
666 BaseCommand.__init__(self)
667 if utils.is_darwin():
668 launcher = 'open'
669 else:
670 launcher = 'xdg-open'
671 self.launcher = launcher
672 self.filenames = filenames
674 def do(self):
675 if not self.filenames:
676 return
677 core.fork([self.launcher] + self.filenames)
680 class OpenParentDir(OpenDefaultApp):
681 """Open parent directories using the OS default."""
682 SHORTCUT = 'Shift+Space'
684 @staticmethod
685 def name():
686 return N_('Open Parent Directory')
688 def __init__(self, filenames):
689 OpenDefaultApp.__init__(self, filenames)
691 def do(self):
692 if not self.filenames:
693 return
694 dirs = set(map(os.path.dirname, self.filenames))
695 core.fork([self.launcher] + dirs)
698 class OpenRepo(Command):
699 """Launches git-cola on a repo."""
701 def __init__(self, repo_path):
702 Command.__init__(self)
703 self.repo_path = repo_path
705 def do(self):
706 self.model.set_directory(self.repo_path)
707 core.fork([sys.executable, sys.argv[0], '--repo', self.repo_path])
710 class Clone(Command):
711 """Clones a repository and optionally spawns a new cola session."""
713 def __init__(self, url, new_directory, spawn=True):
714 Command.__init__(self)
715 self.url = url
716 self.new_directory = new_directory
717 self.spawn = spawn
719 def do(self):
720 status, out, err = self.model.git.clone(self.url, self.new_directory)
721 if status != 0:
722 Interaction.information(
723 N_('Error: could not clone "%s"') % self.url,
724 (N_('git clone returned exit code %s') % status) +
725 ((out+err) and ('\n\n' + out + err) or ''))
726 return False
727 if self.spawn:
728 core.fork([sys.executable, sys.argv[0],
729 '--repo', self.new_directory])
730 return True
733 class GitXBaseContext(object):
735 def __init__(self, **kwargs):
736 self.extras = kwargs
738 def __enter__(self):
739 compat.setenv('GIT_SEQUENCE_EDITOR',
740 resources.share('bin', 'git-xbase'))
741 for var, value in self.extras.items():
742 compat.setenv(var, value)
743 return self
745 def __exit__(self, exc_type, exc_val, exc_tb):
746 compat.unsetenv('GIT_SEQUENCE_EDITOR')
747 for var in self.extras:
748 compat.unsetenv(var)
751 class Rebase(Command):
753 def __init__(self, branch):
754 Command.__init__(self)
755 self.branch = branch
757 def do(self):
758 branch = self.branch
759 if not branch:
760 return
761 status = 1
762 out = ''
763 err = ''
764 with GitXBaseContext(
765 GIT_EDITOR=prefs.editor(),
766 GIT_XBASE_TITLE=N_('Rebase onto %s') % branch,
767 GIT_XBASE_ACTION=N_('Rebase')):
768 status, out, err = self.model.git.rebase(branch,
769 interactive=True,
770 autosquash=True)
771 Interaction.log_status(status, out, err)
772 self.model.update_status()
773 return status, out, err
776 class RebaseEditTodo(Command):
778 def do(self):
779 with GitXBaseContext(
780 GIT_XBASE_TITLE=N_('Edit Rebase'),
781 GIT_XBASE_ACTION=N_('Save')):
782 status, out, err = self.model.git.rebase(edit_todo=True)
783 Interaction.log_status(status, out, err)
784 self.model.update_status()
787 class RebaseContinue(Command):
789 def do(self):
790 status, out, err = self.model.git.rebase('--continue')
791 Interaction.log_status(status, out, err)
792 self.model.update_status()
795 class RebaseSkip(Command):
797 def do(self):
798 status, out, err = self.model.git.rebase(skip=True)
799 Interaction.log_status(status, out, err)
800 self.model.update_status()
803 class RebaseAbort(Command):
805 def do(self):
806 status, out, err = self.model.git.rebase(abort=True)
807 Interaction.log_status(status, out, err)
808 self.model.update_status()
811 class Rescan(Command):
812 """Rescan for changes"""
814 def do(self):
815 self.model.update_status()
818 class Refresh(Command):
819 """Update refs and refresh the index"""
821 SHORTCUT = 'Ctrl+R'
823 @staticmethod
824 def name():
825 return N_('Refresh')
827 def do(self):
828 self.model.update_status(update_index=True)
831 class RunConfigAction(Command):
832 """Run a user-configured action, typically from the "Tools" menu"""
834 def __init__(self, action_name):
835 Command.__init__(self)
836 self.action_name = action_name
837 self.model = cola.model()
839 def do(self):
840 for env in ('FILENAME', 'REVISION', 'ARGS'):
841 try:
842 compat.unsetenv(env)
843 except KeyError:
844 pass
845 rev = None
846 args = None
847 opts = _config.get_guitool_opts(self.action_name)
848 cmd = opts.get('cmd')
849 if 'title' not in opts:
850 opts['title'] = cmd
852 if 'prompt' not in opts or opts.get('prompt') is True:
853 prompt = N_('Run "%s"?') % cmd
854 opts['prompt'] = prompt
856 if opts.get('needsfile'):
857 filename = selection.filename()
858 if not filename:
859 Interaction.information(
860 N_('Please select a file'),
861 N_('"%s" requires a selected file.') % cmd)
862 return False
863 compat.setenv('FILENAME', filename)
865 if opts.get('revprompt') or opts.get('argprompt'):
866 while True:
867 ok = Interaction.confirm_config_action(cmd, opts)
868 if not ok:
869 return False
870 rev = opts.get('revision')
871 args = opts.get('args')
872 if opts.get('revprompt') and not rev:
873 title = N_('Invalid Revision')
874 msg = N_('The revision expression cannot be empty.')
875 Interaction.critical(title, msg)
876 continue
877 break
879 elif opts.get('confirm'):
880 title = os.path.expandvars(opts.get('title'))
881 prompt = os.path.expandvars(opts.get('prompt'))
882 if Interaction.question(title, prompt):
883 return
884 if rev:
885 compat.setenv('REVISION', rev)
886 if args:
887 compat.setenv('ARGS', args)
888 title = os.path.expandvars(cmd)
889 Interaction.log(N_('Running command: %s') % title)
890 cmd = ['sh', '-c', cmd]
892 if opts.get('noconsole'):
893 status, out, err = core.run_command(cmd)
894 else:
895 status, out, err = Interaction.run_command(title, cmd)
897 Interaction.log_status(status,
898 out and (N_('Output: %s') % out) or '',
899 err and (N_('Errors: %s') % err) or '')
901 if not opts.get('norescan'):
902 self.model.update_status()
903 return status
906 class SetDiffText(Command):
908 def __init__(self, text):
909 Command.__init__(self)
910 self.undoable = True
911 self.new_diff_text = text
914 class ShowUntracked(Command):
915 """Show an untracked file."""
917 def __init__(self, filenames):
918 Command.__init__(self)
919 self.filenames = filenames
920 self.new_mode = self.model.mode_untracked
921 self.new_diff_text = ''
922 if filenames:
923 self.new_diff_text = self.diff_text_for(filenames[0])
925 def diff_text_for(self, filename):
926 size = _config.get('cola.readsize', 1024 * 2)
927 try:
928 result = core.read(filename, size=size)
929 except:
930 result = ''
932 if len(result) == size:
933 result += '...'
934 return result
937 class SignOff(Command):
938 SHORTCUT = 'Ctrl+I'
940 @staticmethod
941 def name():
942 return N_('Sign Off')
944 def __init__(self):
945 Command.__init__(self)
946 self.undoable = True
947 self.old_commitmsg = self.model.commitmsg
949 def do(self):
950 signoff = self.signoff()
951 if signoff in self.model.commitmsg:
952 return
953 self.model.set_commitmsg(self.model.commitmsg + '\n' + signoff)
955 def undo(self):
956 self.model.set_commitmsg(self.old_commitmsg)
958 def signoff(self):
959 try:
960 import pwd
961 user = pwd.getpwuid(os.getuid()).pw_name
962 except ImportError:
963 user = os.getenv('USER', N_('unknown'))
965 name = _config.get('user.name', user)
966 email = _config.get('user.email', '%s@%s' % (user, platform.node()))
967 return '\nSigned-off-by: %s <%s>' % (name, email)
970 class Stage(Command):
971 """Stage a set of paths."""
972 SHORTCUT = 'Ctrl+S'
974 @staticmethod
975 def name():
976 return N_('Stage')
978 def __init__(self, paths):
979 Command.__init__(self)
980 self.paths = paths
982 def do(self):
983 msg = N_('Staging: %s') % (', '.join(self.paths))
984 Interaction.log(msg)
985 # Prevent external updates while we are staging files.
986 # We update file stats at the end of this operation
987 # so there's no harm in ignoring updates from other threads
988 # (e.g. inotify).
989 with CommandDisabled(UpdateFileStatus):
990 self.model.stage_paths(self.paths)
993 class StageModified(Stage):
994 """Stage all modified files."""
996 SHORTCUT = 'Ctrl+S'
998 @staticmethod
999 def name():
1000 return N_('Stage Modified')
1002 def __init__(self):
1003 Stage.__init__(self, None)
1004 self.paths = self.model.modified
1007 class StageUnmerged(Stage):
1008 """Stage all modified files."""
1010 SHORTCUT = 'Ctrl+S'
1012 @staticmethod
1013 def name():
1014 return N_('Stage Unmerged')
1016 def __init__(self):
1017 Stage.__init__(self, None)
1018 self.paths = self.model.unmerged
1021 class StageUntracked(Stage):
1022 """Stage all untracked files."""
1024 SHORTCUT = 'Ctrl+S'
1026 @staticmethod
1027 def name():
1028 return N_('Stage Untracked')
1030 def __init__(self):
1031 Stage.__init__(self, None)
1032 self.paths = self.model.untracked
1035 class Tag(Command):
1036 """Create a tag object."""
1038 def __init__(self, name, revision, sign=False, message=''):
1039 Command.__init__(self)
1040 self._name = name
1041 self._message = message
1042 self._revision = revision
1043 self._sign = sign
1045 def do(self):
1046 log_msg = (N_('Tagging "%(revision)s" as "%(name)s"') %
1047 dict(revision=self._revision, name=self._name))
1048 opts = {}
1049 if self._message:
1050 opts['F'] = utils.tmp_filename('tag-message')
1051 core.write(opts['F'], self._message)
1053 if self._sign:
1054 log_msg += ' (%s)' % N_('GPG-signed')
1055 opts['s'] = True
1056 status, output, err = self.model.git.tag(self._name,
1057 self._revision, **opts)
1058 else:
1059 opts['a'] = bool(self._message)
1060 status, output, err = self.model.git.tag(self._name,
1061 self._revision, **opts)
1062 if 'F' in opts:
1063 os.unlink(opts['F'])
1065 if output:
1066 log_msg += '\n' + (N_('Output: %s') % output)
1068 Interaction.log_status(status, log_msg, err)
1069 if status == 0:
1070 self.model.update_status()
1073 class Unstage(Command):
1074 """Unstage a set of paths."""
1076 SHORTCUT = 'Ctrl+S'
1078 @staticmethod
1079 def name():
1080 return N_('Unstage')
1082 def __init__(self, paths):
1083 Command.__init__(self)
1084 self.paths = paths
1086 def do(self):
1087 msg = N_('Unstaging: %s') % (', '.join(self.paths))
1088 Interaction.log(msg)
1089 with CommandDisabled(UpdateFileStatus):
1090 self.model.unstage_paths(self.paths)
1093 class UnstageAll(Command):
1094 """Unstage all files; resets the index."""
1096 def do(self):
1097 self.model.unstage_all()
1100 class UnstageSelected(Unstage):
1101 """Unstage selected files."""
1103 def __init__(self):
1104 Unstage.__init__(self, cola.selection_model().staged)
1107 class Untrack(Command):
1108 """Unstage a set of paths."""
1110 def __init__(self, paths):
1111 Command.__init__(self)
1112 self.paths = paths
1114 def do(self):
1115 msg = N_('Untracking: %s') % (', '.join(self.paths))
1116 Interaction.log(msg)
1117 with CommandDisabled(UpdateFileStatus):
1118 status, out, err = self.model.untrack_paths(self.paths)
1119 Interaction.log_status(status, out, err)
1122 class UntrackedSummary(Command):
1123 """List possible .gitignore rules as the diff text."""
1125 def __init__(self):
1126 Command.__init__(self)
1127 untracked = self.model.untracked
1128 suffix = len(untracked) > 1 and 's' or ''
1129 io = StringIO()
1130 io.write('# %s untracked file%s\n' % (len(untracked), suffix))
1131 if untracked:
1132 io.write('# possible .gitignore rule%s:\n' % suffix)
1133 for u in untracked:
1134 io.write('/'+core.encode(u)+'\n')
1135 self.new_diff_text = core.decode(io.getvalue())
1136 self.new_mode = self.model.mode_untracked
1139 class UpdateFileStatus(Command):
1140 """Rescans for changes."""
1142 def do(self):
1143 self.model.update_file_status()
1146 class VisualizeAll(Command):
1147 """Visualize all branches."""
1149 def do(self):
1150 browser = utils.shell_split(prefs.history_browser())
1151 core.fork(browser + ['--all'])
1154 class VisualizeCurrent(Command):
1155 """Visualize all branches."""
1157 def do(self):
1158 browser = utils.shell_split(prefs.history_browser())
1159 core.fork(browser + [self.model.currentbranch])
1162 class VisualizePaths(Command):
1163 """Path-limited visualization."""
1165 def __init__(self, paths):
1166 Command.__init__(self)
1167 browser = utils.shell_split(prefs.history_browser())
1168 if paths:
1169 self.argv = browser + paths
1170 else:
1171 self.argv = browser
1173 def do(self):
1174 core.fork(self.argv)
1177 class VisualizeRevision(Command):
1178 """Visualize a specific revision."""
1180 def __init__(self, revision, paths=None):
1181 Command.__init__(self)
1182 self.revision = revision
1183 self.paths = paths
1185 def do(self):
1186 argv = utils.shell_split(prefs.history_browser())
1187 if self.revision:
1188 argv.append(self.revision)
1189 if self.paths:
1190 argv.append('--')
1191 argv.extend(self.paths)
1193 try:
1194 core.fork(argv)
1195 except Exception as e:
1196 _, details = utils.format_exception(e)
1197 title = N_('Error Launching History Browser')
1198 msg = (N_('Cannot exec "%s": please configure a history browser') %
1199 ' '.join(argv))
1200 Interaction.critical(title, message=msg, details=details)
1203 def run(cls, *args, **opts):
1205 Returns a callback that runs a command
1207 If the caller of run() provides args or opts then those are
1208 used instead of the ones provided by the invoker of the callback.
1211 def runner(*local_args, **local_opts):
1212 if args or opts:
1213 do(cls, *args, **opts)
1214 else:
1215 do(cls, *local_args, **local_opts)
1217 return runner
1220 class CommandDisabled(object):
1222 """Context manager to temporarily disable a command from running"""
1223 def __init__(self, cmdclass):
1224 self.cmdclass = cmdclass
1226 def __enter__(self):
1227 self.cmdclass.DISABLED = True
1228 return self
1230 def __exit__(self, exc_type, exc_val, exc_tb):
1231 self.cmdclass.DISABLED = False
1234 def do(cls, *args, **opts):
1235 """Run a command in-place"""
1236 return do_cmd(cls(*args, **opts))
1239 def do_cmd(cmd):
1240 if hasattr(cmd, 'DISABLED') and cmd.DISABLED:
1241 return None
1242 try:
1243 return cmd.do()
1244 except StandardError, e:
1245 msg, details = utils.format_exception(e)
1246 Interaction.critical(N_('Error'), message=msg, details=details)
1247 return None