compat: rename putenv() to setenv()
[git-cola.git] / cola / cmds.py
blob7c875f242cec2e75a2698120e20b286941a31fda
1 import os
2 import sys
3 import platform
4 import traceback
5 from fnmatch import fnmatch
7 from cStringIO import StringIO
9 from PyQt4 import QtCore
10 from PyQt4.QtCore import SIGNAL
12 import cola
13 from cola import compat
14 from cola import core
15 from cola import errors
16 from cola import gitcfg
17 from cola import gitcmds
18 from cola import utils
19 from cola import difftool
20 from cola.compat import set
21 from cola.diffparse import DiffParser
22 from cola.i18n import N_
23 from cola.interaction import Interaction
24 from cola.models import selection
26 _notifier = cola.notifier()
27 _config = gitcfg.instance()
30 class BaseCommand(object):
31 """Base class for all commands; provides the command pattern"""
33 DISABLED = False
35 def __init__(self):
36 self.undoable = False
38 def is_undoable(self):
39 """Can this be undone?"""
40 return self.undoable
42 @staticmethod
43 def name(cls):
44 return 'Unknown'
46 def prepare(self):
47 """Prepare to run the command.
49 This is performed in a separate thread before do()
50 is invoked.
52 """
53 pass
55 def do(self):
56 raise NotImplementedError('%s.do() is unimplemented' % self.__class__.__name__)
58 def undo(self):
59 raise NotImplementedError('%s.undo() is unimplemented' % self.__class__.__name__)
62 class Command(BaseCommand):
63 """Base class for commands that modify the main model"""
65 def __init__(self):
66 """Initialize the command and stash away values for use in do()"""
67 # These are commonly used so let's make it easier to write new commands.
68 BaseCommand.__init__(self)
69 self.model = cola.model()
71 self.old_diff_text = self.model.diff_text
72 self.old_filename = self.model.filename
73 self.old_mode = self.model.mode
74 self.old_head = self.model.head
76 self.new_diff_text = self.old_diff_text
77 self.new_filename = self.old_filename
78 self.new_head = self.old_head
79 self.new_mode = self.old_mode
81 def do(self):
82 """Perform the operation."""
83 self.model.set_filename(self.new_filename)
84 self.model.set_head(self.new_head)
85 self.model.set_mode(self.new_mode)
86 self.model.set_diff_text(self.new_diff_text)
88 def undo(self):
89 """Undo the operation."""
90 self.model.set_diff_text(self.old_diff_text)
91 self.model.set_filename(self.old_filename)
92 self.model.set_head(self.old_head)
93 self.model.set_mode(self.old_mode)
96 class AmendMode(Command):
97 """Try to amend a commit."""
99 SHORTCUT = 'Ctrl+M'
101 LAST_MESSAGE = None
103 @staticmethod
104 def name():
105 return N_('Amend')
107 def __init__(self, amend):
108 Command.__init__(self)
109 self.undoable = True
110 self.skip = False
111 self.amending = amend
112 self.old_commitmsg = self.model.commitmsg
114 if self.amending:
115 self.new_mode = self.model.mode_amend
116 self.new_head = 'HEAD^'
117 self.new_commitmsg = self.model.prev_commitmsg()
118 AmendMode.LAST_MESSAGE = self.model.commitmsg
119 return
120 # else, amend unchecked, regular commit
121 self.new_mode = self.model.mode_none
122 self.new_head = 'HEAD'
123 self.new_diff_text = ''
124 self.new_commitmsg = self.model.commitmsg
125 # If we're going back into new-commit-mode then search the
126 # undo stack for a previous amend-commit-mode and grab the
127 # commit message at that point in time.
128 if AmendMode.LAST_MESSAGE is not None:
129 self.new_commitmsg = AmendMode.LAST_MESSAGE
130 AmendMode.LAST_MESSAGE = None
132 def do(self):
133 """Leave/enter amend mode."""
134 """Attempt to enter amend mode. Do not allow this when merging."""
135 if self.amending:
136 if os.path.exists(self.model.git.git_path('MERGE_HEAD')):
137 self.skip = True
138 _notifier.broadcast(_notifier.AMEND, False)
139 Interaction.information(
140 N_('Cannot Amend'),
141 N_('You are in the middle of a merge.\n'
142 'Cannot amend while merging.'))
143 return
144 self.skip = False
145 _notifier.broadcast(_notifier.AMEND, self.amending)
146 self.model.set_commitmsg(self.new_commitmsg)
147 Command.do(self)
148 self.model.update_file_status()
150 def undo(self):
151 if self.skip:
152 return
153 self.model.set_commitmsg(self.old_commitmsg)
154 Command.undo(self)
155 self.model.update_file_status()
158 class ApplyDiffSelection(Command):
160 def __init__(self, staged, selected, offset, selection, apply_to_worktree):
161 Command.__init__(self)
162 self.staged = staged
163 self.selected = selected
164 self.offset = offset
165 self.selection = selection
166 self.apply_to_worktree = apply_to_worktree
168 def do(self):
169 # The normal worktree vs index scenario
170 parser = DiffParser(self.model,
171 filename=self.model.filename,
172 cached=self.staged,
173 reverse=self.apply_to_worktree)
174 status, output = \
175 parser.process_diff_selection(self.selected,
176 self.offset,
177 self.selection,
178 apply_to_worktree=self.apply_to_worktree)
179 Interaction.log_status(status, output, '')
180 self.model.update_file_status(update_index=True)
183 class ApplyPatches(Command):
185 def __init__(self, patches):
186 Command.__init__(self)
187 patches.sort()
188 self.patches = patches
190 def do(self):
191 diff_text = ''
192 num_patches = len(self.patches)
193 orig_head = self.model.git.rev_parse('HEAD')
195 for idx, patch in enumerate(self.patches):
196 status, output = self.model.git.am(patch,
197 with_status=True,
198 with_stderr=True)
199 # Log the git-am command
200 Interaction.log_status(status, output, '')
202 if num_patches > 1:
203 diff = self.model.git.diff('HEAD^!', stat=True)
204 diff_text += (N_('PATCH %(current)d/%(count)d') %
205 dict(current=idx+1, count=num_patches))
206 diff_text += ' - %s:\n%s\n\n' % (os.path.basename(patch), diff)
208 diff_text += N_('Summary:') + '\n'
209 diff_text += self.model.git.diff(orig_head, stat=True)
211 # Display a diffstat
212 self.model.set_diff_text(diff_text)
213 self.model.update_file_status()
215 basenames = '\n'.join([os.path.basename(p) for p in self.patches])
216 Interaction.information(
217 N_('Patch(es) Applied'),
218 (N_('%d patch(es) applied.') + '\n\n%s') %
219 (len(self.patches), basenames))
222 class Archive(BaseCommand):
224 def __init__(self, ref, fmt, prefix, filename):
225 BaseCommand.__init__(self)
226 self.ref = ref
227 self.fmt = fmt
228 self.prefix = prefix
229 self.filename = filename
231 def do(self):
232 fp = open(core.encode(self.filename), 'wb')
233 cmd = ['git', 'archive', '--format='+self.fmt]
234 if self.fmt in ('tgz', 'tar.gz'):
235 cmd.append('-9')
236 if self.prefix:
237 cmd.append('--prefix=' + self.prefix)
238 cmd.append(self.ref)
239 proc = utils.start_command(cmd, stdout=fp)
240 out, err = proc.communicate()
241 fp.close()
242 status = proc.returncode
243 Interaction.log_status(status, out or '', err or '')
246 class Checkout(Command):
248 A command object for git-checkout.
250 'argv' is handed off directly to git.
254 def __init__(self, argv, checkout_branch=False):
255 Command.__init__(self)
256 self.argv = argv
257 self.checkout_branch = checkout_branch
258 self.new_diff_text = ''
260 def do(self):
261 status, output = self.model.git.checkout(with_stderr=True,
262 with_status=True, *self.argv)
263 Interaction.log_status(status, output, '')
264 if self.checkout_branch:
265 self.model.update_status()
266 else:
267 self.model.update_file_status()
270 class CheckoutBranch(Checkout):
271 """Checkout a branch."""
273 def __init__(self, branch):
274 args = [branch]
275 Checkout.__init__(self, args, checkout_branch=True)
278 class CherryPick(Command):
279 """Cherry pick commits into the current branch."""
281 def __init__(self, commits):
282 Command.__init__(self)
283 self.commits = commits
285 def do(self):
286 self.model.cherry_pick_list(self.commits)
287 self.model.update_file_status()
290 class ResetMode(Command):
291 """Reset the mode and clear the model's diff text."""
293 def __init__(self):
294 Command.__init__(self)
295 self.new_mode = self.model.mode_none
296 self.new_head = 'HEAD'
297 self.new_diff_text = ''
299 def do(self):
300 Command.do(self)
301 self.model.update_file_status()
304 class Commit(ResetMode):
305 """Attempt to create a new commit."""
307 SHORTCUT = 'Ctrl+Return'
309 def __init__(self, amend, msg):
310 ResetMode.__init__(self)
311 self.amend = amend
312 self.msg = core.encode(msg)
313 self.old_commitmsg = self.model.commitmsg
314 self.new_commitmsg = ''
316 def do(self):
317 tmpfile = utils.tmp_filename('commit-message')
318 status, output = self.model.commit_with_msg(self.msg, tmpfile, amend=self.amend)
319 if status == 0:
320 ResetMode.do(self)
321 self.model.set_commitmsg(self.new_commitmsg)
322 msg = N_('Created commit: %s') % output
323 else:
324 msg = N_('Commit failed: %s') % output
325 Interaction.log_status(status, msg, '')
327 return status, output
330 class Ignore(Command):
331 """Add files to .gitignore"""
333 def __init__(self, filenames):
334 Command.__init__(self)
335 self.filenames = filenames
337 def do(self):
338 new_additions = ''
339 for fname in self.filenames:
340 new_additions = new_additions + fname + '\n'
341 for_status = new_additions
342 if new_additions:
343 if os.path.exists('.gitignore'):
344 current_list = utils.slurp('.gitignore')
345 new_additions = new_additions + current_list
346 utils.write('.gitignore', new_additions)
347 Interaction.log_status(
348 0, 'Added to .gitignore:\n%s' % for_status, '')
349 self.model.update_file_status()
352 class Delete(Command):
353 """Delete files."""
355 def __init__(self, filenames):
356 Command.__init__(self)
357 self.filenames = filenames
358 # We could git-hash-object stuff and provide undo-ability
359 # as an option. Heh.
360 def do(self):
361 rescan = False
362 for filename in self.filenames:
363 if filename:
364 try:
365 os.remove(filename)
366 rescan=True
367 except:
368 Interaction.information(
369 N_('Error'),
370 N_('Deleting "%s" failed') % filename)
371 if rescan:
372 self.model.update_file_status()
375 class DeleteBranch(Command):
376 """Delete a git branch."""
378 def __init__(self, branch):
379 Command.__init__(self)
380 self.branch = branch
382 def do(self):
383 status, output = self.model.delete_branch(self.branch)
384 Interaction.log_status(status, output)
387 class DeleteRemoteBranch(Command):
388 """Delete a remote git branch."""
390 def __init__(self, remote, branch):
391 Command.__init__(self)
392 self.remote = remote
393 self.branch = branch
395 def do(self):
396 status, output = self.model.git.push(self.remote, self.branch,
397 delete=True,
398 with_status=True,
399 with_stderr=True)
400 self.model.update_status()
402 Interaction.log_status(status, output)
404 if status == 0:
405 Interaction.information(
406 N_('Remote Branch Deleted'),
407 N_('"%(branch)s" has been deleted from "%(remote)s".')
408 % dict(branch=self.branch, remote=self.remote))
409 else:
410 command = 'git push'
411 message = (N_('"%(command)s" returned exit status %(status)d') %
412 dict(command=command, status=status))
414 Interaction.critical(N_('Error Deleting Remote Branch'),
415 message, output)
419 class Diff(Command):
420 """Perform a diff and set the model's current text."""
422 def __init__(self, filenames, cached=False):
423 Command.__init__(self)
424 # Guard against the list of files being empty
425 if not filenames:
426 return
427 opts = {}
428 if cached:
429 opts['ref'] = self.model.head
430 self.new_filename = filenames[0]
431 self.old_filename = self.model.filename
432 self.new_mode = self.model.mode_worktree
433 self.new_diff_text = gitcmds.diff_helper(filename=self.new_filename,
434 cached=cached, **opts)
437 class Diffstat(Command):
438 """Perform a diffstat and set the model's diff text."""
440 def __init__(self):
441 Command.__init__(self)
442 diff = self.model.git.diff(self.model.head,
443 unified=_config.get('diff.context', 3),
444 no_ext_diff=True,
445 no_color=True,
446 M=True,
447 stat=True)
448 self.new_diff_text = core.decode(diff)
449 self.new_mode = self.model.mode_worktree
452 class DiffStaged(Diff):
453 """Perform a staged diff on a file."""
455 def __init__(self, filenames):
456 Diff.__init__(self, filenames, cached=True)
457 self.new_mode = self.model.mode_index
460 class DiffStagedSummary(Command):
462 def __init__(self):
463 Command.__init__(self)
464 diff = self.model.git.diff(self.model.head,
465 cached=True,
466 no_color=True,
467 no_ext_diff=True,
468 patch_with_stat=True,
469 M=True)
470 self.new_diff_text = core.decode(diff)
471 self.new_mode = self.model.mode_index
474 class Difftool(Command):
475 """Run git-difftool limited by path."""
477 def __init__(self, staged, filenames):
478 Command.__init__(self)
479 self.staged = staged
480 self.filenames = filenames
482 def do(self):
483 difftool.launch_with_head(self.filenames,
484 self.staged, self.model.head)
487 class Edit(Command):
488 """Edit a file using the configured gui.editor."""
489 SHORTCUT = 'Ctrl+E'
491 @staticmethod
492 def name():
493 return N_('Edit')
495 def __init__(self, filenames, line_number=None):
496 Command.__init__(self)
497 self.filenames = filenames
498 self.line_number = line_number
500 def do(self):
501 if not self.filenames:
502 return
503 filename = self.filenames[0]
504 if not os.path.exists(filename):
505 return
506 editor = self.model.editor()
507 opts = []
509 if self.line_number is None:
510 opts = self.filenames
511 else:
512 # Single-file w/ line-numbers (likely from grep)
513 editor_opts = {
514 '*vim*': ['+'+self.line_number, filename],
515 '*emacs*': ['+'+self.line_number, filename],
516 '*textpad*': ['%s(%s,0)' % (filename, self.line_number)],
517 '*notepad++*': ['-n'+self.line_number, filename],
520 opts = self.filenames
521 for pattern, opt in editor_opts.items():
522 if fnmatch(editor, pattern):
523 opts = opt
524 break
526 try:
527 utils.fork(utils.shell_split(editor) + opts)
528 except Exception as e:
529 message = (N_('Cannot exec "%s": please configure your editor') %
530 editor)
531 Interaction.critical(N_('Error Editing File'),
532 message, str(e))
535 class FormatPatch(Command):
536 """Output a patch series given all revisions and a selected subset."""
538 def __init__(self, to_export, revs):
539 Command.__init__(self)
540 self.to_export = to_export
541 self.revs = revs
543 def do(self):
544 status, output = gitcmds.format_patchsets(self.to_export, self.revs)
545 Interaction.log_status(status, output, '')
548 class LaunchDifftool(BaseCommand):
550 SHORTCUT = 'Ctrl+D'
552 @staticmethod
553 def name():
554 return N_('Launch Diff Tool')
556 def __init__(self):
557 BaseCommand.__init__(self)
559 def do(self):
560 s = cola.selection()
561 if s.unmerged:
562 paths = s.unmerged
563 if utils.is_win32():
564 utils.fork(['git', 'mergetool', '--no-prompt', '--'] + paths)
565 else:
566 utils.fork(['xterm', '-e',
567 'git', 'mergetool', '--no-prompt', '--'] + paths)
568 else:
569 difftool.run()
572 class LaunchEditor(Edit):
573 SHORTCUT = 'Ctrl+E'
575 @staticmethod
576 def name():
577 return N_('Launch Editor')
579 def __init__(self):
580 s = cola.selection()
581 allfiles = s.staged + s.unmerged + s.modified + s.untracked
582 Edit.__init__(self, allfiles)
585 class LoadCommitMessage(Command):
586 """Loads a commit message from a path."""
588 def __init__(self, path):
589 Command.__init__(self)
590 self.undoable = True
591 self.path = path
592 self.old_commitmsg = self.model.commitmsg
593 self.old_directory = self.model.directory
595 def do(self):
596 path = self.path
597 if not path or not os.path.isfile(path):
598 raise errors.UsageError(N_('Error: Cannot find commit template'),
599 N_('%s: No such file or directory.') % path)
600 self.model.set_directory(os.path.dirname(path))
601 self.model.set_commitmsg(utils.slurp(path))
603 def undo(self):
604 self.model.set_commitmsg(self.old_commitmsg)
605 self.model.set_directory(self.old_directory)
608 class LoadCommitTemplate(LoadCommitMessage):
609 """Loads the commit message template specified by commit.template."""
610 def __init__(self):
611 LoadCommitMessage.__init__(self, _config.get('commit.template'))
613 def do(self):
614 if self.path is None:
615 raise errors.UsageError(
616 N_('Error: Unconfigured commit template'),
617 N_('A commit template has not been configured.\n'
618 'Use "git config" to define "commit.template"\n'
619 'so that it points to a commit template.'))
620 return LoadCommitMessage.do(self)
623 class LoadPreviousMessage(Command):
624 """Try to amend a commit."""
625 def __init__(self, sha1):
626 Command.__init__(self)
627 self.sha1 = sha1
628 self.old_commitmsg = self.model.commitmsg
629 self.new_commitmsg = self.model.prev_commitmsg(sha1)
630 self.undoable = True
632 def do(self):
633 self.model.set_commitmsg(self.new_commitmsg)
635 def undo(self):
636 self.model.set_commitmsg(self.old_commitmsg)
639 class Merge(Command):
640 def __init__(self, revision, no_commit, squash):
641 Command.__init__(self)
642 self.revision = revision
643 self.no_commit = no_commit
644 self.squash = squash
646 def do(self):
647 squash = self.squash
648 revision = self.revision
649 no_commit = self.no_commit
650 msg = gitcmds.merge_message(revision)
652 status, output = self.model.git.merge('-m', msg,
653 revision,
654 no_commit=no_commit,
655 squash=squash,
656 with_stderr=True,
657 with_status=True)
659 Interaction.log_status(status, output, '')
660 self.model.update_status()
663 class OpenDefaultApp(BaseCommand):
664 """Open a file using the OS default."""
665 SHORTCUT = 'Space'
667 @staticmethod
668 def name():
669 return N_('Open Using Default Application')
671 def __init__(self, filenames):
672 BaseCommand.__init__(self)
673 if utils.is_darwin():
674 launcher = 'open'
675 else:
676 launcher = 'xdg-open'
677 self.launcher = launcher
678 self.filenames = filenames
680 def do(self):
681 if not self.filenames:
682 return
683 utils.fork([self.launcher] + self.filenames)
686 class OpenParentDir(OpenDefaultApp):
687 """Open parent directories using the OS default."""
688 SHORTCUT = 'Shift+Space'
690 @staticmethod
691 def name():
692 return N_('Open Parent Directory')
694 def __init__(self, filenames):
695 OpenDefaultApp.__init__(self, filenames)
697 def do(self):
698 if not self.filenames:
699 return
700 dirs = set(map(os.path.dirname, self.filenames))
701 utils.fork([self.launcher] + dirs)
704 class OpenRepo(Command):
705 """Launches git-cola on a repo."""
707 def __init__(self, repo_path):
708 Command.__init__(self)
709 self.repo_path = repo_path
711 def do(self):
712 self.model.set_directory(self.repo_path)
713 utils.fork([sys.executable, sys.argv[0], '--repo', self.repo_path])
716 class Clone(Command):
717 """Clones a repository and optionally spawns a new cola session."""
719 def __init__(self, url, new_directory, spawn=True):
720 Command.__init__(self)
721 self.url = url
722 self.new_directory = new_directory
723 self.spawn = spawn
725 def do(self):
726 status, out = self.model.git.clone(self.url,
727 self.new_directory,
728 with_stderr=True,
729 with_status=True)
730 if status != 0:
731 Interaction.information(
732 N_('Error: could not clone "%s"') % self.url,
733 (N_('git clone returned exit code %s') % status) +
734 (out and ('\n' + out) or ''))
735 return False
736 if self.spawn:
737 utils.fork([sys.executable, sys.argv[0],
738 '--repo', self.new_directory])
739 return True
742 class Rescan(Command):
743 """Rescan for changes"""
745 def do(self):
746 self.model.update_status()
749 class Refresh(Command):
750 """Update refs and refresh the index"""
752 SHORTCUT = 'Ctrl+R'
754 @staticmethod
755 def name():
756 return N_('Refresh')
758 def do(self):
759 self.model.update_status(update_index=True)
762 class RunConfigAction(Command):
763 """Run a user-configured action, typically from the "Tools" menu"""
765 def __init__(self, action_name):
766 Command.__init__(self)
767 self.action_name = action_name
768 self.model = cola.model()
770 def do(self):
771 for env in ('FILENAME', 'REVISION', 'ARGS'):
772 try:
773 compat.unsetenv(env)
774 except KeyError:
775 pass
776 rev = None
777 args = None
778 opts = _config.get_guitool_opts(self.action_name)
779 cmd = opts.get('cmd')
780 if 'title' not in opts:
781 opts['title'] = cmd
783 if 'prompt' not in opts or opts.get('prompt') is True:
784 prompt = N_('Run "%s"?') % cmd
785 opts['prompt'] = prompt
787 if opts.get('needsfile'):
788 filename = selection.filename()
789 if not filename:
790 Interaction.information(
791 N_('Please select a file'),
792 N_('"%s" requires a selected file.') % cmd)
793 return False
794 compat.setenv('FILENAME', filename)
796 if opts.get('revprompt') or opts.get('argprompt'):
797 while True:
798 ok = Interaction.confirm_config_action(cmd, opts)
799 if not ok:
800 return False
801 rev = opts.get('revision')
802 args = opts.get('args')
803 if opts.get('revprompt') and not rev:
804 title = N_('Invalid Revision')
805 msg = N_('The revision expression cannot be empty.')
806 Interaction.critical(title, msg)
807 continue
808 break
810 elif opts.get('confirm'):
811 title = os.path.expandvars(opts.get('title'))
812 prompt = os.path.expandvars(opts.get('prompt'))
813 if Interaction.question(title, prompt):
814 return
815 if rev:
816 compat.setenv('REVISION', rev)
817 if args:
818 compat.setenv('ARGS', args)
819 title = os.path.expandvars(cmd)
820 Interaction.log(N_('Running command: %s') % title)
821 cmd = ['sh', '-c', cmd]
823 if opts.get('noconsole'):
824 status, out, err = utils.run_command(cmd)
825 else:
826 status, out, err = Interaction.run_command(title, cmd)
828 Interaction.log_status(status,
829 out and (N_('Output: %s') % out) or '',
830 err and (N_('Errors: %s') % err) or '')
832 if not opts.get('norescan'):
833 self.model.update_status()
834 return status
837 class SetDiffText(Command):
839 def __init__(self, text):
840 Command.__init__(self)
841 self.undoable = True
842 self.new_diff_text = text
845 class ShowUntracked(Command):
846 """Show an untracked file."""
848 def __init__(self, filenames):
849 Command.__init__(self)
850 self.filenames = filenames
851 self.new_mode = self.model.mode_untracked
852 self.new_diff_text = ''
854 def prepare(self):
855 filenames = self.filenames
856 if filenames:
857 self.new_diff_text = self.diff_text_for(filenames[0])
859 def diff_text_for(self, filename):
860 size = _config.get('cola.readsize', 1024 * 2)
861 try:
862 result = utils.slurp(filename, size=size)
863 except:
864 result = ''
866 if len(result) == size:
867 result += '...'
868 return result
871 class SignOff(Command):
872 SHORTCUT = 'Ctrl+I'
874 @staticmethod
875 def name():
876 return N_('Sign Off')
878 def __init__(self):
879 Command.__init__(self)
880 self.undoable = True
881 self.old_commitmsg = self.model.commitmsg
883 def do(self):
884 signoff = self.signoff()
885 if signoff in self.model.commitmsg:
886 return
887 self.model.set_commitmsg(self.model.commitmsg + '\n' + signoff)
889 def undo(self):
890 self.model.set_commitmsg(self.old_commitmsg)
892 def signoff(self):
893 try:
894 import pwd
895 user = pwd.getpwuid(os.getuid()).pw_name
896 except ImportError:
897 user = os.getenv('USER', N_('unknown'))
899 name = _config.get('user.name', user)
900 email = _config.get('user.email', '%s@%s' % (user, platform.node()))
901 return '\nSigned-off-by: %s <%s>' % (name, email)
904 class Stage(Command):
905 """Stage a set of paths."""
906 SHORTCUT = 'Ctrl+S'
908 @staticmethod
909 def name():
910 return N_('Stage')
912 def __init__(self, paths):
913 Command.__init__(self)
914 self.paths = paths
916 def do(self):
917 msg = N_('Staging: %s') % (', '.join(self.paths))
918 Interaction.log(msg)
919 # Prevent external updates while we are staging files.
920 # We update file stats at the end of this operation
921 # so there's no harm in ignoring updates from other threads
922 # (e.g. inotify).
923 with CommandDisabled(UpdateFileStatus):
924 self.model.stage_paths(self.paths)
927 class StageModified(Stage):
928 """Stage all modified files."""
930 SHORTCUT = 'Ctrl+S'
932 @staticmethod
933 def name():
934 return N_('Stage Modified')
936 def __init__(self):
937 Stage.__init__(self, None)
938 self.paths = self.model.modified
941 class StageUnmerged(Stage):
942 """Stage all modified files."""
944 SHORTCUT = 'Ctrl+S'
946 @staticmethod
947 def name():
948 return N_('Stage Unmerged')
950 def __init__(self):
951 Stage.__init__(self, None)
952 self.paths = self.model.unmerged
955 class StageUntracked(Stage):
956 """Stage all untracked files."""
958 SHORTCUT = 'Ctrl+S'
960 @staticmethod
961 def name():
962 return N_('Stage Untracked')
964 def __init__(self):
965 Stage.__init__(self, None)
966 self.paths = self.model.untracked
969 class Tag(Command):
970 """Create a tag object."""
972 def __init__(self, name, revision, sign=False, message=''):
973 Command.__init__(self)
974 self._name = name
975 self._message = core.encode(message)
976 self._revision = revision
977 self._sign = sign
979 def do(self):
980 log_msg = (N_('Tagging "%(revision)s" as "%(name)s"') %
981 dict(revision=self._revision, name=self._name))
982 opts = {}
983 if self._message:
984 opts['F'] = utils.tmp_filename('tag-message')
985 utils.write(opts['F'], self._message)
987 if self._sign:
988 log_msg += ' (%s)' % N_('GPG-signed')
989 opts['s'] = True
990 status, output = self.model.git.tag(self._name,
991 self._revision,
992 with_status=True,
993 with_stderr=True,
994 **opts)
995 else:
996 opts['a'] = bool(self._message)
997 status, output = self.model.git.tag(self._name,
998 self._revision,
999 with_status=True,
1000 with_stderr=True,
1001 **opts)
1002 if 'F' in opts:
1003 os.unlink(opts['F'])
1005 if output:
1006 log_msg += '\n' + N_('Output: %s') % output
1008 Interaction.log_status(status, log_msg, '')
1009 if status == 0:
1010 self.model.update_status()
1013 class Unstage(Command):
1014 """Unstage a set of paths."""
1016 SHORTCUT = 'Ctrl+S'
1018 @staticmethod
1019 def name():
1020 return N_('Unstage')
1022 def __init__(self, paths):
1023 Command.__init__(self)
1024 self.paths = paths
1026 def do(self):
1027 msg = N_('Unstaging: %s') % (', '.join(self.paths))
1028 Interaction.log(msg)
1029 with CommandDisabled(UpdateFileStatus):
1030 self.model.unstage_paths(self.paths)
1033 class UnstageAll(Command):
1034 """Unstage all files; resets the index."""
1036 def do(self):
1037 self.model.unstage_all()
1040 class UnstageSelected(Unstage):
1041 """Unstage selected files."""
1043 def __init__(self):
1044 Unstage.__init__(self, cola.selection_model().staged)
1047 class Untrack(Command):
1048 """Unstage a set of paths."""
1050 def __init__(self, paths):
1051 Command.__init__(self)
1052 self.paths = paths
1054 def do(self):
1055 msg = N_('Untracking: %s') % (', '.join(self.paths))
1056 Interaction.log(msg)
1057 with CommandDisabled(UpdateFileStatus):
1058 status, out = self.model.untrack_paths(self.paths)
1059 Interaction.log_status(status, out, '')
1062 class UntrackedSummary(Command):
1063 """List possible .gitignore rules as the diff text."""
1065 def __init__(self):
1066 Command.__init__(self)
1067 untracked = self.model.untracked
1068 suffix = len(untracked) > 1 and 's' or ''
1069 io = StringIO()
1070 io.write('# %s untracked file%s\n' % (len(untracked), suffix))
1071 if untracked:
1072 io.write('# possible .gitignore rule%s:\n' % suffix)
1073 for u in untracked:
1074 io.write('/'+core.encode(u)+'\n')
1075 self.new_diff_text = core.decode(io.getvalue())
1076 self.new_mode = self.model.mode_untracked
1079 class UpdateFileStatus(Command):
1080 """Rescans for changes."""
1082 def do(self):
1083 self.model.update_file_status()
1086 class VisualizeAll(Command):
1087 """Visualize all branches."""
1089 def do(self):
1090 browser = utils.shell_split(self.model.history_browser())
1091 utils.fork(browser + ['--all'])
1094 class VisualizeCurrent(Command):
1095 """Visualize all branches."""
1097 def do(self):
1098 browser = utils.shell_split(self.model.history_browser())
1099 utils.fork(browser + [self.model.currentbranch])
1102 class VisualizePaths(Command):
1103 """Path-limited visualization."""
1105 def __init__(self, paths):
1106 Command.__init__(self)
1107 browser = utils.shell_split(self.model.history_browser())
1108 if paths:
1109 self.argv = browser + paths
1110 else:
1111 self.argv = browser
1113 def do(self):
1114 utils.fork(self.argv)
1117 class VisualizeRevision(Command):
1118 """Visualize a specific revision."""
1120 def __init__(self, revision, paths=None):
1121 Command.__init__(self)
1122 self.revision = revision
1123 self.paths = paths
1125 def do(self):
1126 argv = utils.shell_split(self.model.history_browser())
1127 if self.revision:
1128 argv.append(self.revision)
1129 if self.paths:
1130 argv.append('--')
1131 argv.extend(self.paths)
1133 try:
1134 utils.fork(argv)
1135 except Exception as e:
1136 _, details = utils.format_exception(e)
1137 title = N_('Error Launching History Browser')
1138 msg = (N_('Cannot exec "%s": please configure a history browser') %
1139 ' '.join(argv))
1140 Interaction.critical(title, message=msg, details=details)
1143 def run(cls, *args, **opts):
1145 Returns a callback that runs a command
1147 If the caller of run() provides args or opts then those are
1148 used instead of the ones provided by the invoker of the callback.
1151 def runner(*local_args, **local_opts):
1152 if args or opts:
1153 do(cls, *args, **opts)
1154 else:
1155 do(cls, *local_args, **local_opts)
1157 return runner
1160 class CommandDisabled(object):
1162 """Context manager to temporarily disable a command from running"""
1163 def __init__(self, cmdclass):
1164 self.cmdclass = cmdclass
1166 def __enter__(self):
1167 self.cmdclass.DISABLED = True
1168 return self
1170 def __exit__(self, exc_type, exc_val, exc_tb):
1171 self.cmdclass.DISABLED = False
1174 def do(cls, *args, **opts):
1175 """Run a command in-place"""
1176 return do_cmd(cls(*args, **opts))
1179 def do_cmd(cmd):
1180 if hasattr(cmd, 'DISABLED') and cmd.DISABLED:
1181 return None
1182 try:
1183 return cmd.do()
1184 except StandardError, e:
1185 msg, details = utils.format_exception(e)
1186 Interaction.critical(N_('Error'), message=msg, details=details)
1187 return None
1190 def bg(parent, cls, *args, **opts):
1192 Returns a callback that runs a command
1194 If the caller of run() provides args or opts then those are
1195 used instead of the ones provided by the invoker of the callback.
1198 def runner(*local_args, **local_opts):
1199 if args or opts:
1200 background(parent, cls, *args, **opts)
1201 else:
1202 background(parent, cls, *local_args, **local_opts)
1204 return runner
1207 def background(parent, cls, *args, **opts):
1208 cmd = cls(*args, **opts)
1209 task = AsyncCommand(parent, cmd)
1210 QtCore.QThreadPool.globalInstance().start(task)
1213 class AsyncCommand(QtCore.QRunnable):
1215 # Holds a reference to background tasks to avoid PyQt4 segfaults
1216 INSTANCES = set()
1218 def __init__(self, parent, cmd):
1219 QtCore.QRunnable.__init__(self)
1220 self.__class__.INSTANCES.add(self)
1221 self.cmd = cmd
1222 self.qobj = QtCore.QObject(parent)
1223 self.qobj.connect(self.qobj, SIGNAL('command_ready'), self.do)
1225 def run(self):
1226 # background thread
1227 self.cmd.prepare()
1228 self.qobj.emit(SIGNAL('command_ready'))
1230 def do(self):
1231 # main thread
1232 do_cmd(self.cmd)
1233 self.__class__.INSTANCES.remove(self)