Merge pull request #206 from alam5131/master
[git-cola.git] / cola / cmds.py
blobf83eb4a7d8fc1b26024b5508052b597ebbbca92b
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 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_diff_text = ''
285 def do(self):
286 Command.do(self)
287 self.model.update_file_status()
290 class Commit(ResetMode):
291 """Attempt to create a new commit."""
293 SHORTCUT = 'Ctrl+Return'
295 def __init__(self, amend, msg):
296 ResetMode.__init__(self)
297 self.amend = amend
298 self.msg = msg
299 self.old_commitmsg = self.model.commitmsg
300 self.new_commitmsg = ''
302 def do(self):
303 tmpfile = utils.tmp_filename('commit-message')
304 status, out, err = self.model.commit_with_msg(self.msg, tmpfile,
305 amend=self.amend)
306 if status == 0:
307 ResetMode.do(self)
308 self.model.set_commitmsg(self.new_commitmsg)
309 msg = N_('Created commit: %s') % out
310 else:
311 msg = N_('Commit failed: %s') % out
312 Interaction.log_status(status, msg, err)
314 return status, out, err
317 class Ignore(Command):
318 """Add files to .gitignore"""
320 def __init__(self, filenames):
321 Command.__init__(self)
322 self.filenames = filenames
324 def do(self):
325 new_additions = ''
326 for fname in self.filenames:
327 new_additions = new_additions + fname + '\n'
328 for_status = new_additions
329 if new_additions:
330 if core.exists('.gitignore'):
331 current_list = core.read('.gitignore')
332 new_additions = new_additions + current_list
333 core.write('.gitignore', new_additions)
334 Interaction.log_status(
335 0, 'Added to .gitignore:\n%s' % for_status, '')
336 self.model.update_file_status()
339 class Delete(Command):
340 """Delete files."""
342 def __init__(self, filenames):
343 Command.__init__(self)
344 self.filenames = filenames
345 # We could git-hash-object stuff and provide undo-ability
346 # as an option. Heh.
347 def do(self):
348 rescan = False
349 for filename in self.filenames:
350 if filename:
351 try:
352 os.remove(filename)
353 rescan=True
354 except:
355 Interaction.information(
356 N_('Error'),
357 N_('Deleting "%s" failed') % filename)
358 if rescan:
359 self.model.update_file_status()
362 class DeleteBranch(Command):
363 """Delete a git branch."""
365 def __init__(self, branch):
366 Command.__init__(self)
367 self.branch = branch
369 def do(self):
370 status, out, err = self.model.delete_branch(self.branch)
371 Interaction.log_status(status, out, err)
374 class DeleteRemoteBranch(Command):
375 """Delete a remote git branch."""
377 def __init__(self, remote, branch):
378 Command.__init__(self)
379 self.remote = remote
380 self.branch = branch
382 def do(self):
383 status, out, err = self.model.git.push(self.remote, self.branch,
384 delete=True)
385 Interaction.log_status(status, out, err)
386 self.model.update_status()
388 if status == 0:
389 Interaction.information(
390 N_('Remote Branch Deleted'),
391 N_('"%(branch)s" has been deleted from "%(remote)s".')
392 % dict(branch=self.branch, remote=self.remote))
393 else:
394 command = 'git push'
395 message = (N_('"%(command)s" returned exit status %(status)d') %
396 dict(command=command, status=status))
398 Interaction.critical(N_('Error Deleting Remote Branch'),
399 message, out + err)
403 class Diff(Command):
404 """Perform a diff and set the model's current text."""
406 def __init__(self, filenames, cached=False):
407 Command.__init__(self)
408 # Guard against the list of files being empty
409 if not filenames:
410 return
411 opts = {}
412 if cached:
413 opts['ref'] = self.model.head
414 self.new_filename = filenames[0]
415 self.old_filename = self.model.filename
416 self.new_mode = self.model.mode_worktree
417 self.new_diff_text = gitcmds.diff_helper(filename=self.new_filename,
418 cached=cached, **opts)
421 class Diffstat(Command):
422 """Perform a diffstat and set the model's diff text."""
424 def __init__(self):
425 Command.__init__(self)
426 diff = self.model.git.diff(self.model.head,
427 unified=_config.get('diff.context', 3),
428 no_ext_diff=True,
429 no_color=True,
430 M=True,
431 stat=True)[STDOUT]
432 self.new_diff_text = diff
433 self.new_mode = self.model.mode_worktree
436 class DiffStaged(Diff):
437 """Perform a staged diff on a file."""
439 def __init__(self, filenames):
440 Diff.__init__(self, filenames, cached=True)
441 self.new_mode = self.model.mode_index
444 class DiffStagedSummary(Command):
446 def __init__(self):
447 Command.__init__(self)
448 diff = self.model.git.diff(self.model.head,
449 cached=True,
450 no_color=True,
451 no_ext_diff=True,
452 patch_with_stat=True,
453 M=True)[STDOUT]
454 self.new_diff_text = diff
455 self.new_mode = self.model.mode_index
458 class Difftool(Command):
459 """Run git-difftool limited by path."""
461 def __init__(self, staged, filenames):
462 Command.__init__(self)
463 self.staged = staged
464 self.filenames = filenames
466 def do(self):
467 difftool.launch_with_head(self.filenames,
468 self.staged, self.model.head)
471 class Edit(Command):
472 """Edit a file using the configured gui.editor."""
473 SHORTCUT = 'Ctrl+E'
475 @staticmethod
476 def name():
477 return N_('Edit')
479 def __init__(self, filenames, line_number=None):
480 Command.__init__(self)
481 self.filenames = filenames
482 self.line_number = line_number
484 def do(self):
485 if not self.filenames:
486 return
487 filename = self.filenames[0]
488 if not core.exists(filename):
489 return
490 editor = prefs.editor()
491 opts = []
493 if self.line_number is None:
494 opts = self.filenames
495 else:
496 # Single-file w/ line-numbers (likely from grep)
497 editor_opts = {
498 '*vim*': ['+'+self.line_number, filename],
499 '*emacs*': ['+'+self.line_number, filename],
500 '*textpad*': ['%s(%s,0)' % (filename, self.line_number)],
501 '*notepad++*': ['-n'+self.line_number, filename],
504 opts = self.filenames
505 for pattern, opt in editor_opts.items():
506 if fnmatch(editor, pattern):
507 opts = opt
508 break
510 try:
511 core.fork(utils.shell_split(editor) + opts)
512 except Exception as e:
513 message = (N_('Cannot exec "%s": please configure your editor') %
514 editor)
515 Interaction.critical(N_('Error Editing File'),
516 message, str(e))
519 class FormatPatch(Command):
520 """Output a patch series given all revisions and a selected subset."""
522 def __init__(self, to_export, revs):
523 Command.__init__(self)
524 self.to_export = to_export
525 self.revs = revs
527 def do(self):
528 status, out, err = gitcmds.format_patchsets(self.to_export, self.revs)
529 Interaction.log_status(status, out, err)
532 class LaunchDifftool(BaseCommand):
534 SHORTCUT = 'Ctrl+D'
536 @staticmethod
537 def name():
538 return N_('Launch Diff Tool')
540 def __init__(self):
541 BaseCommand.__init__(self)
543 def do(self):
544 s = selection.selection()
545 if s.unmerged:
546 paths = s.unmerged
547 if utils.is_win32():
548 core.fork(['git', 'mergetool', '--no-prompt', '--'] + paths)
549 else:
550 core.fork(['xterm', '-e',
551 'git', 'mergetool', '--no-prompt', '--'] + paths)
552 else:
553 difftool.run()
556 class LaunchEditor(Edit):
557 SHORTCUT = 'Ctrl+E'
559 @staticmethod
560 def name():
561 return N_('Launch Editor')
563 def __init__(self):
564 s = selection.selection()
565 allfiles = s.staged + s.unmerged + s.modified + s.untracked
566 Edit.__init__(self, allfiles)
569 class LoadCommitMessageFromFile(Command):
570 """Loads a commit message from a path."""
572 def __init__(self, path):
573 Command.__init__(self)
574 self.undoable = True
575 self.path = path
576 self.old_commitmsg = self.model.commitmsg
577 self.old_directory = self.model.directory
579 def do(self):
580 path = self.path
581 if not path or not core.isfile(path):
582 raise UsageError(N_('Error: Cannot find commit template'),
583 N_('%s: No such file or directory.') % path)
584 self.model.set_directory(os.path.dirname(path))
585 self.model.set_commitmsg(core.read(path))
587 def undo(self):
588 self.model.set_commitmsg(self.old_commitmsg)
589 self.model.set_directory(self.old_directory)
592 class LoadCommitMessageFromTemplate(LoadCommitMessageFromFile):
593 """Loads the commit message template specified by commit.template."""
595 def __init__(self):
596 template = _config.get('commit.template')
597 LoadCommitMessageFromFile.__init__(self, template)
599 def do(self):
600 if self.path is None:
601 raise UsageError(
602 N_('Error: Unconfigured commit template'),
603 N_('A commit template has not been configured.\n'
604 'Use "git config" to define "commit.template"\n'
605 'so that it points to a commit template.'))
606 return LoadCommitMessageFromFile.do(self)
610 class LoadCommitMessageFromSHA1(Command):
611 """Load a previous commit message"""
613 def __init__(self, sha1, prefix=''):
614 Command.__init__(self)
615 self.sha1 = sha1
616 self.old_commitmsg = self.model.commitmsg
617 self.new_commitmsg = prefix + self.model.prev_commitmsg(sha1)
618 self.undoable = True
620 def do(self):
621 self.model.set_commitmsg(self.new_commitmsg)
623 def undo(self):
624 self.model.set_commitmsg(self.old_commitmsg)
627 class LoadFixupMessage(LoadCommitMessageFromSHA1):
628 """Load a fixup message"""
630 def __init__(self, sha1):
631 LoadCommitMessageFromSHA1.__init__(self, sha1, prefix='fixup! ')
634 class Merge(Command):
635 def __init__(self, revision, no_commit, squash):
636 Command.__init__(self)
637 self.revision = revision
638 self.no_commit = no_commit
639 self.squash = squash
641 def do(self):
642 squash = self.squash
643 revision = self.revision
644 no_commit = self.no_commit
645 msg = gitcmds.merge_message(revision)
647 status, out, err = self.model.git.merge('-m', msg,
648 revision,
649 no_commit=no_commit,
650 squash=squash)
652 Interaction.log_status(status, out, err)
653 self.model.update_status()
656 class OpenDefaultApp(BaseCommand):
657 """Open a file using the OS default."""
658 SHORTCUT = 'Space'
660 @staticmethod
661 def name():
662 return N_('Open Using Default Application')
664 def __init__(self, filenames):
665 BaseCommand.__init__(self)
666 if utils.is_darwin():
667 launcher = 'open'
668 else:
669 launcher = 'xdg-open'
670 self.launcher = launcher
671 self.filenames = filenames
673 def do(self):
674 if not self.filenames:
675 return
676 core.fork([self.launcher] + self.filenames)
679 class OpenParentDir(OpenDefaultApp):
680 """Open parent directories using the OS default."""
681 SHORTCUT = 'Shift+Space'
683 @staticmethod
684 def name():
685 return N_('Open Parent Directory')
687 def __init__(self, filenames):
688 OpenDefaultApp.__init__(self, filenames)
690 def do(self):
691 if not self.filenames:
692 return
693 dirs = set(map(os.path.dirname, self.filenames))
694 core.fork([self.launcher] + dirs)
697 class OpenRepo(Command):
698 """Launches git-cola on a repo."""
700 def __init__(self, repo_path):
701 Command.__init__(self)
702 self.repo_path = repo_path
704 def do(self):
705 self.model.set_directory(self.repo_path)
706 core.fork([sys.executable, sys.argv[0], '--repo', self.repo_path])
709 class Clone(Command):
710 """Clones a repository and optionally spawns a new cola session."""
712 def __init__(self, url, new_directory, spawn=True):
713 Command.__init__(self)
714 self.url = url
715 self.new_directory = new_directory
716 self.spawn = spawn
718 def do(self):
719 status, out, err = self.model.git.clone(self.url, self.new_directory)
720 if status != 0:
721 Interaction.information(
722 N_('Error: could not clone "%s"') % self.url,
723 (N_('git clone returned exit code %s') % status) +
724 ((out+err) and ('\n\n' + out + err) or ''))
725 return False
726 if self.spawn:
727 core.fork([sys.executable, sys.argv[0],
728 '--repo', self.new_directory])
729 return True
732 class GitXBaseContext(object):
734 def __init__(self, **kwargs):
735 self.extras = kwargs
737 def __enter__(self):
738 compat.setenv('GIT_SEQUENCE_EDITOR',
739 resources.share('bin', 'git-xbase'))
740 for var, value in self.extras.items():
741 compat.setenv(var, value)
742 return self
744 def __exit__(self, exc_type, exc_val, exc_tb):
745 compat.unsetenv('GIT_SEQUENCE_EDITOR')
746 for var in self.extras:
747 compat.unsetenv(var)
750 class Rebase(Command):
752 def __init__(self, branch):
753 Command.__init__(self)
754 self.branch = branch
756 def do(self):
757 branch = self.branch
758 if not branch:
759 return
760 status = 1
761 out = ''
762 err = ''
763 with GitXBaseContext(
764 GIT_EDITOR=prefs.editor(),
765 GIT_XBASE_TITLE=N_('Rebase onto %s') % branch,
766 GIT_XBASE_ACTION=N_('Rebase')):
767 status, out, err = self.model.git.rebase(branch,
768 interactive=True,
769 autosquash=True)
770 Interaction.log_status(status, out, err)
771 self.model.update_status()
772 return status, out, err
775 class RebaseEditTodo(Command):
777 def do(self):
778 with GitXBaseContext(
779 GIT_XBASE_TITLE=N_('Edit Rebase'),
780 GIT_XBASE_ACTION=N_('Save')):
781 status, out, err = self.model.git.rebase(edit_todo=True)
782 Interaction.log_status(status, out, err)
783 self.model.update_status()
786 class RebaseContinue(Command):
788 def do(self):
789 status, out, err = self.model.git.rebase('--continue')
790 Interaction.log_status(status, out, err)
791 self.model.update_status()
794 class RebaseSkip(Command):
796 def do(self):
797 status, out, err = self.model.git.rebase(skip=True)
798 Interaction.log_status(status, out, err)
799 self.model.update_status()
802 class RebaseAbort(Command):
804 def do(self):
805 status, out, err = self.model.git.rebase(abort=True)
806 Interaction.log_status(status, out, err)
807 self.model.update_status()
810 class Rescan(Command):
811 """Rescan for changes"""
813 def do(self):
814 self.model.update_status()
817 class Refresh(Command):
818 """Update refs and refresh the index"""
820 SHORTCUT = 'Ctrl+R'
822 @staticmethod
823 def name():
824 return N_('Refresh')
826 def do(self):
827 self.model.update_status(update_index=True)
830 class RunConfigAction(Command):
831 """Run a user-configured action, typically from the "Tools" menu"""
833 def __init__(self, action_name):
834 Command.__init__(self)
835 self.action_name = action_name
836 self.model = main.model()
838 def do(self):
839 for env in ('FILENAME', 'REVISION', 'ARGS'):
840 try:
841 compat.unsetenv(env)
842 except KeyError:
843 pass
844 rev = None
845 args = None
846 opts = _config.get_guitool_opts(self.action_name)
847 cmd = opts.get('cmd')
848 if 'title' not in opts:
849 opts['title'] = cmd
851 if 'prompt' not in opts or opts.get('prompt') is True:
852 prompt = N_('Run "%s"?') % cmd
853 opts['prompt'] = prompt
855 if opts.get('needsfile'):
856 filename = selection.filename()
857 if not filename:
858 Interaction.information(
859 N_('Please select a file'),
860 N_('"%s" requires a selected file.') % cmd)
861 return False
862 compat.setenv('FILENAME', filename)
864 if opts.get('revprompt') or opts.get('argprompt'):
865 while True:
866 ok = Interaction.confirm_config_action(cmd, opts)
867 if not ok:
868 return False
869 rev = opts.get('revision')
870 args = opts.get('args')
871 if opts.get('revprompt') and not rev:
872 title = N_('Invalid Revision')
873 msg = N_('The revision expression cannot be empty.')
874 Interaction.critical(title, msg)
875 continue
876 break
878 elif opts.get('confirm'):
879 title = os.path.expandvars(opts.get('title'))
880 prompt = os.path.expandvars(opts.get('prompt'))
881 if Interaction.question(title, prompt):
882 return
883 if rev:
884 compat.setenv('REVISION', rev)
885 if args:
886 compat.setenv('ARGS', args)
887 title = os.path.expandvars(cmd)
888 Interaction.log(N_('Running command: %s') % title)
889 cmd = ['sh', '-c', cmd]
891 if opts.get('noconsole'):
892 status, out, err = core.run_command(cmd)
893 else:
894 status, out, err = Interaction.run_command(title, cmd)
896 Interaction.log_status(status,
897 out and (N_('Output: %s') % out) or '',
898 err and (N_('Errors: %s') % err) or '')
900 if not opts.get('norescan'):
901 self.model.update_status()
902 return status
905 class SetDiffText(Command):
907 def __init__(self, text):
908 Command.__init__(self)
909 self.undoable = True
910 self.new_diff_text = text
913 class ShowUntracked(Command):
914 """Show an untracked file."""
916 def __init__(self, filenames):
917 Command.__init__(self)
918 self.filenames = filenames
919 self.new_mode = self.model.mode_untracked
920 self.new_diff_text = ''
921 if filenames:
922 self.new_diff_text = self.diff_text_for(filenames[0])
924 def diff_text_for(self, filename):
925 size = _config.get('cola.readsize', 1024 * 2)
926 try:
927 result = core.read(filename, size=size)
928 except:
929 result = ''
931 if len(result) == size:
932 result += '...'
933 return result
936 class SignOff(Command):
937 SHORTCUT = 'Ctrl+I'
939 @staticmethod
940 def name():
941 return N_('Sign Off')
943 def __init__(self):
944 Command.__init__(self)
945 self.undoable = True
946 self.old_commitmsg = self.model.commitmsg
948 def do(self):
949 signoff = self.signoff()
950 if signoff in self.model.commitmsg:
951 return
952 self.model.set_commitmsg(self.model.commitmsg + '\n' + signoff)
954 def undo(self):
955 self.model.set_commitmsg(self.old_commitmsg)
957 def signoff(self):
958 try:
959 import pwd
960 user = pwd.getpwuid(os.getuid()).pw_name
961 except ImportError:
962 user = os.getenv('USER', N_('unknown'))
964 name = _config.get('user.name', user)
965 email = _config.get('user.email', '%s@%s' % (user, core.node()))
966 return '\nSigned-off-by: %s <%s>' % (name, email)
969 class Stage(Command):
970 """Stage a set of paths."""
971 SHORTCUT = 'Ctrl+S'
973 @staticmethod
974 def name():
975 return N_('Stage')
977 def __init__(self, paths):
978 Command.__init__(self)
979 self.paths = paths
981 def do(self):
982 msg = N_('Staging: %s') % (', '.join(self.paths))
983 Interaction.log(msg)
984 # Prevent external updates while we are staging files.
985 # We update file stats at the end of this operation
986 # so there's no harm in ignoring updates from other threads
987 # (e.g. inotify).
988 with CommandDisabled(UpdateFileStatus):
989 self.model.stage_paths(self.paths)
992 class StageModified(Stage):
993 """Stage all modified files."""
995 SHORTCUT = 'Ctrl+S'
997 @staticmethod
998 def name():
999 return N_('Stage Modified')
1001 def __init__(self):
1002 Stage.__init__(self, None)
1003 self.paths = self.model.modified
1006 class StageUnmerged(Stage):
1007 """Stage all modified files."""
1009 SHORTCUT = 'Ctrl+S'
1011 @staticmethod
1012 def name():
1013 return N_('Stage Unmerged')
1015 def __init__(self):
1016 Stage.__init__(self, None)
1017 self.paths = self.model.unmerged
1020 class StageUntracked(Stage):
1021 """Stage all untracked files."""
1023 SHORTCUT = 'Ctrl+S'
1025 @staticmethod
1026 def name():
1027 return N_('Stage Untracked')
1029 def __init__(self):
1030 Stage.__init__(self, None)
1031 self.paths = self.model.untracked
1034 class Tag(Command):
1035 """Create a tag object."""
1037 def __init__(self, name, revision, sign=False, message=''):
1038 Command.__init__(self)
1039 self._name = name
1040 self._message = message
1041 self._revision = revision
1042 self._sign = sign
1044 def do(self):
1045 log_msg = (N_('Tagging "%(revision)s" as "%(name)s"') %
1046 dict(revision=self._revision, name=self._name))
1047 opts = {}
1048 if self._message:
1049 opts['F'] = utils.tmp_filename('tag-message')
1050 core.write(opts['F'], self._message)
1052 if self._sign:
1053 log_msg += ' (%s)' % N_('GPG-signed')
1054 opts['s'] = True
1055 status, output, err = self.model.git.tag(self._name,
1056 self._revision, **opts)
1057 else:
1058 opts['a'] = bool(self._message)
1059 status, output, err = self.model.git.tag(self._name,
1060 self._revision, **opts)
1061 if 'F' in opts:
1062 os.unlink(opts['F'])
1064 if output:
1065 log_msg += '\n' + (N_('Output: %s') % output)
1067 Interaction.log_status(status, log_msg, err)
1068 if status == 0:
1069 self.model.update_status()
1072 class Unstage(Command):
1073 """Unstage a set of paths."""
1075 SHORTCUT = 'Ctrl+S'
1077 @staticmethod
1078 def name():
1079 return N_('Unstage')
1081 def __init__(self, paths):
1082 Command.__init__(self)
1083 self.paths = paths
1085 def do(self):
1086 msg = N_('Unstaging: %s') % (', '.join(self.paths))
1087 Interaction.log(msg)
1088 with CommandDisabled(UpdateFileStatus):
1089 self.model.unstage_paths(self.paths)
1092 class UnstageAll(Command):
1093 """Unstage all files; resets the index."""
1095 def do(self):
1096 self.model.unstage_all()
1099 class UnstageSelected(Unstage):
1100 """Unstage selected files."""
1102 def __init__(self):
1103 Unstage.__init__(self, selection.selection_model().staged)
1106 class Untrack(Command):
1107 """Unstage a set of paths."""
1109 def __init__(self, paths):
1110 Command.__init__(self)
1111 self.paths = paths
1113 def do(self):
1114 msg = N_('Untracking: %s') % (', '.join(self.paths))
1115 Interaction.log(msg)
1116 with CommandDisabled(UpdateFileStatus):
1117 status, out, err = self.model.untrack_paths(self.paths)
1118 Interaction.log_status(status, out, err)
1121 class UntrackedSummary(Command):
1122 """List possible .gitignore rules as the diff text."""
1124 def __init__(self):
1125 Command.__init__(self)
1126 untracked = self.model.untracked
1127 suffix = len(untracked) > 1 and 's' or ''
1128 io = StringIO()
1129 io.write('# %s untracked file%s\n' % (len(untracked), suffix))
1130 if untracked:
1131 io.write('# possible .gitignore rule%s:\n' % suffix)
1132 for u in untracked:
1133 io.write('/'+core.encode(u)+'\n')
1134 self.new_diff_text = core.decode(io.getvalue())
1135 self.new_mode = self.model.mode_untracked
1138 class UpdateFileStatus(Command):
1139 """Rescans for changes."""
1141 def do(self):
1142 self.model.update_file_status()
1145 class VisualizeAll(Command):
1146 """Visualize all branches."""
1148 def do(self):
1149 browser = utils.shell_split(prefs.history_browser())
1150 core.fork(browser + ['--all'])
1153 class VisualizeCurrent(Command):
1154 """Visualize all branches."""
1156 def do(self):
1157 browser = utils.shell_split(prefs.history_browser())
1158 core.fork(browser + [self.model.currentbranch])
1161 class VisualizePaths(Command):
1162 """Path-limited visualization."""
1164 def __init__(self, paths):
1165 Command.__init__(self)
1166 browser = utils.shell_split(prefs.history_browser())
1167 if paths:
1168 self.argv = browser + paths
1169 else:
1170 self.argv = browser
1172 def do(self):
1173 core.fork(self.argv)
1176 class VisualizeRevision(Command):
1177 """Visualize a specific revision."""
1179 def __init__(self, revision, paths=None):
1180 Command.__init__(self)
1181 self.revision = revision
1182 self.paths = paths
1184 def do(self):
1185 argv = utils.shell_split(prefs.history_browser())
1186 if self.revision:
1187 argv.append(self.revision)
1188 if self.paths:
1189 argv.append('--')
1190 argv.extend(self.paths)
1192 try:
1193 core.fork(argv)
1194 except Exception as e:
1195 _, details = utils.format_exception(e)
1196 title = N_('Error Launching History Browser')
1197 msg = (N_('Cannot exec "%s": please configure a history browser') %
1198 ' '.join(argv))
1199 Interaction.critical(title, message=msg, details=details)
1202 def run(cls, *args, **opts):
1204 Returns a callback that runs a command
1206 If the caller of run() provides args or opts then those are
1207 used instead of the ones provided by the invoker of the callback.
1210 def runner(*local_args, **local_opts):
1211 if args or opts:
1212 do(cls, *args, **opts)
1213 else:
1214 do(cls, *local_args, **local_opts)
1216 return runner
1219 class CommandDisabled(object):
1221 """Context manager to temporarily disable a command from running"""
1222 def __init__(self, cmdclass):
1223 self.cmdclass = cmdclass
1225 def __enter__(self):
1226 self.cmdclass.DISABLED = True
1227 return self
1229 def __exit__(self, exc_type, exc_val, exc_tb):
1230 self.cmdclass.DISABLED = False
1233 def do(cls, *args, **opts):
1234 """Run a command in-place"""
1235 return do_cmd(cls(*args, **opts))
1238 def do_cmd(cmd):
1239 if hasattr(cmd, 'DISABLED') and cmd.DISABLED:
1240 return None
1241 try:
1242 return cmd.do()
1243 except StandardError, e:
1244 msg, details = utils.format_exception(e)
1245 Interaction.critical(N_('Error'), message=msg, details=details)
1246 return None