refactor: eliminate `cola.prefs` package
[git-cola.git] / cola / cmds.py
blobf9135cd6fbf2f80a3595bd298adfd6fcf62e6a4a
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.basecmd import BaseCommand
18 from cola.compat import set
19 from cola.diffparse import DiffParser
20 from cola.git import STDOUT
21 from cola.i18n import N_
22 from cola.interaction import Interaction
23 from cola.models import prefs
24 from cola.models import selection
26 _notifier = cola.notifier()
27 _config = gitcfg.instance()
30 class Command(BaseCommand):
31 """Base class for commands that modify the main model"""
33 def __init__(self):
34 """Initialize the command and stash away values for use in do()"""
35 # These are commonly used so let's make it easier to write new commands.
36 BaseCommand.__init__(self)
37 self.model = cola.model()
39 self.old_diff_text = self.model.diff_text
40 self.old_filename = self.model.filename
41 self.old_mode = self.model.mode
42 self.old_head = self.model.head
44 self.new_diff_text = self.old_diff_text
45 self.new_filename = self.old_filename
46 self.new_head = self.old_head
47 self.new_mode = self.old_mode
49 def do(self):
50 """Perform the operation."""
51 self.model.set_filename(self.new_filename)
52 self.model.set_head(self.new_head)
53 self.model.set_mode(self.new_mode)
54 self.model.set_diff_text(self.new_diff_text)
56 def undo(self):
57 """Undo the operation."""
58 self.model.set_diff_text(self.old_diff_text)
59 self.model.set_filename(self.old_filename)
60 self.model.set_head(self.old_head)
61 self.model.set_mode(self.old_mode)
64 class AmendMode(Command):
65 """Try to amend a commit."""
67 SHORTCUT = 'Ctrl+M'
69 LAST_MESSAGE = None
71 @staticmethod
72 def name():
73 return N_('Amend')
75 def __init__(self, amend):
76 Command.__init__(self)
77 self.undoable = True
78 self.skip = False
79 self.amending = amend
80 self.old_commitmsg = self.model.commitmsg
82 if self.amending:
83 self.new_mode = self.model.mode_amend
84 self.new_head = 'HEAD^'
85 self.new_commitmsg = self.model.prev_commitmsg()
86 AmendMode.LAST_MESSAGE = self.model.commitmsg
87 return
88 # else, amend unchecked, regular commit
89 self.new_mode = self.model.mode_none
90 self.new_head = 'HEAD'
91 self.new_diff_text = ''
92 self.new_commitmsg = self.model.commitmsg
93 # If we're going back into new-commit-mode then search the
94 # undo stack for a previous amend-commit-mode and grab the
95 # commit message at that point in time.
96 if AmendMode.LAST_MESSAGE is not None:
97 self.new_commitmsg = AmendMode.LAST_MESSAGE
98 AmendMode.LAST_MESSAGE = None
100 def do(self):
101 """Leave/enter amend mode."""
102 """Attempt to enter amend mode. Do not allow this when merging."""
103 if self.amending:
104 if core.exists(self.model.git.git_path('MERGE_HEAD')):
105 self.skip = True
106 _notifier.broadcast(_notifier.AMEND, False)
107 Interaction.information(
108 N_('Cannot Amend'),
109 N_('You are in the middle of a merge.\n'
110 'Cannot amend while merging.'))
111 return
112 self.skip = False
113 _notifier.broadcast(_notifier.AMEND, self.amending)
114 self.model.set_commitmsg(self.new_commitmsg)
115 Command.do(self)
116 self.model.update_file_status()
118 def undo(self):
119 if self.skip:
120 return
121 self.model.set_commitmsg(self.old_commitmsg)
122 Command.undo(self)
123 self.model.update_file_status()
126 class ApplyDiffSelection(Command):
128 def __init__(self, staged, selected, offset, selection, apply_to_worktree):
129 Command.__init__(self)
130 self.staged = staged
131 self.selected = selected
132 self.offset = offset
133 self.selection = selection
134 self.apply_to_worktree = apply_to_worktree
136 def do(self):
137 # The normal worktree vs index scenario
138 parser = DiffParser(self.model,
139 filename=self.model.filename,
140 cached=self.staged,
141 reverse=self.apply_to_worktree)
142 status, out, err = \
143 parser.process_diff_selection(self.selected,
144 self.offset,
145 self.selection,
146 apply_to_worktree=self.apply_to_worktree)
147 Interaction.log_status(status, out, err)
148 self.model.update_file_status(update_index=True)
151 class ApplyPatches(Command):
153 def __init__(self, patches):
154 Command.__init__(self)
155 patches.sort()
156 self.patches = patches
158 def do(self):
159 diff_text = ''
160 num_patches = len(self.patches)
161 orig_head = self.model.git.rev_parse('HEAD')[STDOUT]
163 for idx, patch in enumerate(self.patches):
164 status, out, err = self.model.git.am(patch)
165 # Log the git-am command
166 Interaction.log_status(status, out, err)
168 if num_patches > 1:
169 diff = self.model.git.diff('HEAD^!', stat=True)[STDOUT]
170 diff_text += (N_('PATCH %(current)d/%(count)d') %
171 dict(current=idx+1, count=num_patches))
172 diff_text += ' - %s:\n%s\n\n' % (os.path.basename(patch), diff)
174 diff_text += N_('Summary:') + '\n'
175 diff_text += self.model.git.diff(orig_head, stat=True)[STDOUT]
177 # Display a diffstat
178 self.model.set_diff_text(diff_text)
179 self.model.update_file_status()
181 basenames = '\n'.join([os.path.basename(p) for p in self.patches])
182 Interaction.information(
183 N_('Patch(es) Applied'),
184 (N_('%d patch(es) applied.') + '\n\n%s') %
185 (len(self.patches), basenames))
188 class Archive(BaseCommand):
190 def __init__(self, ref, fmt, prefix, filename):
191 BaseCommand.__init__(self)
192 self.ref = ref
193 self.fmt = fmt
194 self.prefix = prefix
195 self.filename = filename
197 def do(self):
198 fp = core.xopen(self.filename, 'wb')
199 cmd = ['git', 'archive', '--format='+self.fmt]
200 if self.fmt in ('tgz', 'tar.gz'):
201 cmd.append('-9')
202 if self.prefix:
203 cmd.append('--prefix=' + self.prefix)
204 cmd.append(self.ref)
205 proc = utils.start_command(cmd, stdout=fp)
206 out, err = proc.communicate()
207 fp.close()
208 status = proc.returncode
209 Interaction.log_status(status, out or '', err or '')
212 class Checkout(Command):
214 A command object for git-checkout.
216 'argv' is handed off directly to git.
220 def __init__(self, argv, checkout_branch=False):
221 Command.__init__(self)
222 self.argv = argv
223 self.checkout_branch = checkout_branch
224 self.new_diff_text = ''
226 def do(self):
227 status, out, err = self.model.git.checkout(*self.argv)
228 Interaction.log_status(status, out, err)
229 if self.checkout_branch:
230 self.model.update_status()
231 else:
232 self.model.update_file_status()
235 class CheckoutBranch(Checkout):
236 """Checkout a branch."""
238 def __init__(self, branch):
239 args = [branch]
240 Checkout.__init__(self, args, checkout_branch=True)
243 class CherryPick(Command):
244 """Cherry pick commits into the current branch."""
246 def __init__(self, commits):
247 Command.__init__(self)
248 self.commits = commits
250 def do(self):
251 self.model.cherry_pick_list(self.commits)
252 self.model.update_file_status()
255 class ResetMode(Command):
256 """Reset the mode and clear the model's diff text."""
258 def __init__(self):
259 Command.__init__(self)
260 self.new_mode = self.model.mode_none
261 self.new_head = 'HEAD'
262 self.new_diff_text = ''
264 def do(self):
265 Command.do(self)
266 self.model.update_file_status()
269 class Commit(ResetMode):
270 """Attempt to create a new commit."""
272 SHORTCUT = 'Ctrl+Return'
274 def __init__(self, amend, msg):
275 ResetMode.__init__(self)
276 self.amend = amend
277 self.msg = msg
278 self.old_commitmsg = self.model.commitmsg
279 self.new_commitmsg = ''
281 def do(self):
282 tmpfile = utils.tmp_filename('commit-message')
283 status, out, err = self.model.commit_with_msg(self.msg, tmpfile,
284 amend=self.amend)
285 if status == 0:
286 ResetMode.do(self)
287 self.model.set_commitmsg(self.new_commitmsg)
288 msg = N_('Created commit: %s') % out
289 else:
290 msg = N_('Commit failed: %s') % out
291 Interaction.log_status(status, msg, err)
293 return status, out, err
296 class Ignore(Command):
297 """Add files to .gitignore"""
299 def __init__(self, filenames):
300 Command.__init__(self)
301 self.filenames = filenames
303 def do(self):
304 new_additions = ''
305 for fname in self.filenames:
306 new_additions = new_additions + fname + '\n'
307 for_status = new_additions
308 if new_additions:
309 if core.exists('.gitignore'):
310 current_list = core.read('.gitignore')
311 new_additions = new_additions + current_list
312 core.write('.gitignore', new_additions)
313 Interaction.log_status(
314 0, 'Added to .gitignore:\n%s' % for_status, '')
315 self.model.update_file_status()
318 class Delete(Command):
319 """Delete files."""
321 def __init__(self, filenames):
322 Command.__init__(self)
323 self.filenames = filenames
324 # We could git-hash-object stuff and provide undo-ability
325 # as an option. Heh.
326 def do(self):
327 rescan = False
328 for filename in self.filenames:
329 if filename:
330 try:
331 os.remove(filename)
332 rescan=True
333 except:
334 Interaction.information(
335 N_('Error'),
336 N_('Deleting "%s" failed') % filename)
337 if rescan:
338 self.model.update_file_status()
341 class DeleteBranch(Command):
342 """Delete a git branch."""
344 def __init__(self, branch):
345 Command.__init__(self)
346 self.branch = branch
348 def do(self):
349 status, out, err = self.model.delete_branch(self.branch)
350 Interaction.log_status(status, out, err)
353 class DeleteRemoteBranch(Command):
354 """Delete a remote git branch."""
356 def __init__(self, remote, branch):
357 Command.__init__(self)
358 self.remote = remote
359 self.branch = branch
361 def do(self):
362 status, out, err = self.model.git.push(self.remote, self.branch,
363 delete=True)
364 Interaction.log_status(status, out, err)
365 self.model.update_status()
367 if status == 0:
368 Interaction.information(
369 N_('Remote Branch Deleted'),
370 N_('"%(branch)s" has been deleted from "%(remote)s".')
371 % dict(branch=self.branch, remote=self.remote))
372 else:
373 command = 'git push'
374 message = (N_('"%(command)s" returned exit status %(status)d') %
375 dict(command=command, status=status))
377 Interaction.critical(N_('Error Deleting Remote Branch'),
378 message, out + err)
382 class Diff(Command):
383 """Perform a diff and set the model's current text."""
385 def __init__(self, filenames, cached=False):
386 Command.__init__(self)
387 # Guard against the list of files being empty
388 if not filenames:
389 return
390 opts = {}
391 if cached:
392 opts['ref'] = self.model.head
393 self.new_filename = filenames[0]
394 self.old_filename = self.model.filename
395 self.new_mode = self.model.mode_worktree
396 self.new_diff_text = gitcmds.diff_helper(filename=self.new_filename,
397 cached=cached, **opts)
400 class Diffstat(Command):
401 """Perform a diffstat and set the model's diff text."""
403 def __init__(self):
404 Command.__init__(self)
405 diff = self.model.git.diff(self.model.head,
406 unified=_config.get('diff.context', 3),
407 no_ext_diff=True,
408 no_color=True,
409 M=True,
410 stat=True)[STDOUT]
411 self.new_diff_text = diff
412 self.new_mode = self.model.mode_worktree
415 class DiffStaged(Diff):
416 """Perform a staged diff on a file."""
418 def __init__(self, filenames):
419 Diff.__init__(self, filenames, cached=True)
420 self.new_mode = self.model.mode_index
423 class DiffStagedSummary(Command):
425 def __init__(self):
426 Command.__init__(self)
427 diff = self.model.git.diff(self.model.head,
428 cached=True,
429 no_color=True,
430 no_ext_diff=True,
431 patch_with_stat=True,
432 M=True)[STDOUT]
433 self.new_diff_text = diff
434 self.new_mode = self.model.mode_index
437 class Difftool(Command):
438 """Run git-difftool limited by path."""
440 def __init__(self, staged, filenames):
441 Command.__init__(self)
442 self.staged = staged
443 self.filenames = filenames
445 def do(self):
446 difftool.launch_with_head(self.filenames,
447 self.staged, self.model.head)
450 class Edit(Command):
451 """Edit a file using the configured gui.editor."""
452 SHORTCUT = 'Ctrl+E'
454 @staticmethod
455 def name():
456 return N_('Edit')
458 def __init__(self, filenames, line_number=None):
459 Command.__init__(self)
460 self.filenames = filenames
461 self.line_number = line_number
463 def do(self):
464 if not self.filenames:
465 return
466 filename = self.filenames[0]
467 if not core.exists(filename):
468 return
469 editor = prefs.editor()
470 opts = []
472 if self.line_number is None:
473 opts = self.filenames
474 else:
475 # Single-file w/ line-numbers (likely from grep)
476 editor_opts = {
477 '*vim*': ['+'+self.line_number, filename],
478 '*emacs*': ['+'+self.line_number, filename],
479 '*textpad*': ['%s(%s,0)' % (filename, self.line_number)],
480 '*notepad++*': ['-n'+self.line_number, filename],
483 opts = self.filenames
484 for pattern, opt in editor_opts.items():
485 if fnmatch(editor, pattern):
486 opts = opt
487 break
489 try:
490 utils.fork(utils.shell_split(editor) + opts)
491 except Exception as e:
492 message = (N_('Cannot exec "%s": please configure your editor') %
493 editor)
494 Interaction.critical(N_('Error Editing File'),
495 message, str(e))
498 class FormatPatch(Command):
499 """Output a patch series given all revisions and a selected subset."""
501 def __init__(self, to_export, revs):
502 Command.__init__(self)
503 self.to_export = to_export
504 self.revs = revs
506 def do(self):
507 status, out, err = gitcmds.format_patchsets(self.to_export, self.revs)
508 Interaction.log_status(status, out, err)
511 class LaunchDifftool(BaseCommand):
513 SHORTCUT = 'Ctrl+D'
515 @staticmethod
516 def name():
517 return N_('Launch Diff Tool')
519 def __init__(self):
520 BaseCommand.__init__(self)
522 def do(self):
523 s = cola.selection()
524 if s.unmerged:
525 paths = s.unmerged
526 if utils.is_win32():
527 utils.fork(['git', 'mergetool', '--no-prompt', '--'] + paths)
528 else:
529 utils.fork(['xterm', '-e',
530 'git', 'mergetool', '--no-prompt', '--'] + paths)
531 else:
532 difftool.run()
535 class LaunchEditor(Edit):
536 SHORTCUT = 'Ctrl+E'
538 @staticmethod
539 def name():
540 return N_('Launch Editor')
542 def __init__(self):
543 s = cola.selection()
544 allfiles = s.staged + s.unmerged + s.modified + s.untracked
545 Edit.__init__(self, allfiles)
548 class LoadCommitMessageFromFile(Command):
549 """Loads a commit message from a path."""
551 def __init__(self, path):
552 Command.__init__(self)
553 self.undoable = True
554 self.path = path
555 self.old_commitmsg = self.model.commitmsg
556 self.old_directory = self.model.directory
558 def do(self):
559 path = self.path
560 if not path or not core.isfile(path):
561 raise errors.UsageError(N_('Error: Cannot find commit template'),
562 N_('%s: No such file or directory.') % path)
563 self.model.set_directory(os.path.dirname(path))
564 self.model.set_commitmsg(core.read(path))
566 def undo(self):
567 self.model.set_commitmsg(self.old_commitmsg)
568 self.model.set_directory(self.old_directory)
571 class LoadCommitMessageFromTemplate(LoadCommitMessageFromFile):
572 """Loads the commit message template specified by commit.template."""
574 def __init__(self):
575 template = _config.get('commit.template')
576 LoadCommitMessageFromFile.__init__(self, template)
578 def do(self):
579 if self.path is None:
580 raise errors.UsageError(
581 N_('Error: Unconfigured commit template'),
582 N_('A commit template has not been configured.\n'
583 'Use "git config" to define "commit.template"\n'
584 'so that it points to a commit template.'))
585 return LoadCommitMessageFromFile.do(self)
589 class LoadCommitMessageFromSHA1(Command):
590 """Load a previous commit message"""
592 def __init__(self, sha1, prefix=''):
593 Command.__init__(self)
594 self.sha1 = sha1
595 self.old_commitmsg = self.model.commitmsg
596 self.new_commitmsg = prefix + self.model.prev_commitmsg(sha1)
597 self.undoable = True
599 def do(self):
600 self.model.set_commitmsg(self.new_commitmsg)
602 def undo(self):
603 self.model.set_commitmsg(self.old_commitmsg)
606 class LoadFixupMessage(LoadCommitMessageFromSHA1):
607 """Load a fixup message"""
609 def __init__(self, sha1):
610 LoadCommitMessageFromSHA1.__init__(self, sha1, prefix='fixup! ')
613 class Merge(Command):
614 def __init__(self, revision, no_commit, squash):
615 Command.__init__(self)
616 self.revision = revision
617 self.no_commit = no_commit
618 self.squash = squash
620 def do(self):
621 squash = self.squash
622 revision = self.revision
623 no_commit = self.no_commit
624 msg = gitcmds.merge_message(revision)
626 status, out, err = self.model.git.merge('-m', msg,
627 revision,
628 no_commit=no_commit,
629 squash=squash)
631 Interaction.log_status(status, out, err)
632 self.model.update_status()
635 class OpenDefaultApp(BaseCommand):
636 """Open a file using the OS default."""
637 SHORTCUT = 'Space'
639 @staticmethod
640 def name():
641 return N_('Open Using Default Application')
643 def __init__(self, filenames):
644 BaseCommand.__init__(self)
645 if utils.is_darwin():
646 launcher = 'open'
647 else:
648 launcher = 'xdg-open'
649 self.launcher = launcher
650 self.filenames = filenames
652 def do(self):
653 if not self.filenames:
654 return
655 utils.fork([self.launcher] + self.filenames)
658 class OpenParentDir(OpenDefaultApp):
659 """Open parent directories using the OS default."""
660 SHORTCUT = 'Shift+Space'
662 @staticmethod
663 def name():
664 return N_('Open Parent Directory')
666 def __init__(self, filenames):
667 OpenDefaultApp.__init__(self, filenames)
669 def do(self):
670 if not self.filenames:
671 return
672 dirs = set(map(os.path.dirname, self.filenames))
673 utils.fork([self.launcher] + dirs)
676 class OpenRepo(Command):
677 """Launches git-cola on a repo."""
679 def __init__(self, repo_path):
680 Command.__init__(self)
681 self.repo_path = repo_path
683 def do(self):
684 self.model.set_directory(self.repo_path)
685 utils.fork([sys.executable, sys.argv[0], '--repo', self.repo_path])
688 class Clone(Command):
689 """Clones a repository and optionally spawns a new cola session."""
691 def __init__(self, url, new_directory, spawn=True):
692 Command.__init__(self)
693 self.url = url
694 self.new_directory = new_directory
695 self.spawn = spawn
697 def do(self):
698 status, out, err = self.model.git.clone(self.url, self.new_directory)
699 if status != 0:
700 Interaction.information(
701 N_('Error: could not clone "%s"') % self.url,
702 (N_('git clone returned exit code %s') % status) +
703 ((out+err) and ('\n\n' + out + err) or ''))
704 return False
705 if self.spawn:
706 utils.fork([sys.executable, sys.argv[0],
707 '--repo', self.new_directory])
708 return True
711 class GitXBaseContext(object):
713 def __init__(self, **kwargs):
714 self.extras = kwargs
716 def __enter__(self):
717 compat.setenv('GIT_SEQUENCE_EDITOR',
718 resources.share('bin', 'git-xbase'))
719 for var, value in self.extras.items():
720 compat.setenv(var, value)
721 return self
723 def __exit__(self, exc_type, exc_val, exc_tb):
724 compat.unsetenv('GIT_SEQUENCE_EDITOR')
725 for var in self.extras:
726 compat.unsetenv(var)
729 class Rebase(Command):
731 def __init__(self, branch):
732 Command.__init__(self)
733 self.branch = branch
735 def do(self):
736 branch = self.branch
737 if not branch:
738 return
739 status = 1
740 out = ''
741 err = ''
742 with GitXBaseContext(
743 GIT_EDITOR=prefs.editor(),
744 GIT_XBASE_TITLE=N_('Rebase onto %s') % branch,
745 GIT_XBASE_ACTION=N_('Rebase')):
746 status, out, err = self.model.git.rebase(branch,
747 interactive=True,
748 autosquash=True)
749 Interaction.log_status(status, out, err)
750 self.model.update_status()
751 return status, out, err
754 class RebaseEditTodo(Command):
756 def do(self):
757 with GitXBaseContext(
758 GIT_XBASE_TITLE=N_('Edit Rebase'),
759 GIT_XBASE_ACTION=N_('Save')):
760 status, out, err = self.model.git.rebase(edit_todo=True)
761 Interaction.log_status(status, out, err)
762 self.model.update_status()
765 class RebaseContinue(Command):
767 def do(self):
768 status, out, err = self.model.git.rebase('--continue')
769 Interaction.log_status(status, out, err)
770 self.model.update_status()
773 class RebaseSkip(Command):
775 def do(self):
776 status, out, err = self.model.git.rebase(skip=True)
777 Interaction.log_status(status, out, err)
778 self.model.update_status()
781 class RebaseAbort(Command):
783 def do(self):
784 status, out, err = self.model.git.rebase(abort=True)
785 Interaction.log_status(status, out, err)
786 self.model.update_status()
789 class Rescan(Command):
790 """Rescan for changes"""
792 def do(self):
793 self.model.update_status()
796 class Refresh(Command):
797 """Update refs and refresh the index"""
799 SHORTCUT = 'Ctrl+R'
801 @staticmethod
802 def name():
803 return N_('Refresh')
805 def do(self):
806 self.model.update_status(update_index=True)
809 class RunConfigAction(Command):
810 """Run a user-configured action, typically from the "Tools" menu"""
812 def __init__(self, action_name):
813 Command.__init__(self)
814 self.action_name = action_name
815 self.model = cola.model()
817 def do(self):
818 for env in ('FILENAME', 'REVISION', 'ARGS'):
819 try:
820 compat.unsetenv(env)
821 except KeyError:
822 pass
823 rev = None
824 args = None
825 opts = _config.get_guitool_opts(self.action_name)
826 cmd = opts.get('cmd')
827 if 'title' not in opts:
828 opts['title'] = cmd
830 if 'prompt' not in opts or opts.get('prompt') is True:
831 prompt = N_('Run "%s"?') % cmd
832 opts['prompt'] = prompt
834 if opts.get('needsfile'):
835 filename = selection.filename()
836 if not filename:
837 Interaction.information(
838 N_('Please select a file'),
839 N_('"%s" requires a selected file.') % cmd)
840 return False
841 compat.setenv('FILENAME', filename)
843 if opts.get('revprompt') or opts.get('argprompt'):
844 while True:
845 ok = Interaction.confirm_config_action(cmd, opts)
846 if not ok:
847 return False
848 rev = opts.get('revision')
849 args = opts.get('args')
850 if opts.get('revprompt') and not rev:
851 title = N_('Invalid Revision')
852 msg = N_('The revision expression cannot be empty.')
853 Interaction.critical(title, msg)
854 continue
855 break
857 elif opts.get('confirm'):
858 title = os.path.expandvars(opts.get('title'))
859 prompt = os.path.expandvars(opts.get('prompt'))
860 if Interaction.question(title, prompt):
861 return
862 if rev:
863 compat.setenv('REVISION', rev)
864 if args:
865 compat.setenv('ARGS', args)
866 title = os.path.expandvars(cmd)
867 Interaction.log(N_('Running command: %s') % title)
868 cmd = ['sh', '-c', cmd]
870 if opts.get('noconsole'):
871 status, out, err = utils.run_command(cmd)
872 else:
873 status, out, err = Interaction.run_command(title, cmd)
875 Interaction.log_status(status,
876 out and (N_('Output: %s') % out) or '',
877 err and (N_('Errors: %s') % err) or '')
879 if not opts.get('norescan'):
880 self.model.update_status()
881 return status
884 class SetDiffText(Command):
886 def __init__(self, text):
887 Command.__init__(self)
888 self.undoable = True
889 self.new_diff_text = text
892 class ShowUntracked(Command):
893 """Show an untracked file."""
895 def __init__(self, filenames):
896 Command.__init__(self)
897 self.filenames = filenames
898 self.new_mode = self.model.mode_untracked
899 self.new_diff_text = ''
900 if filenames:
901 self.new_diff_text = self.diff_text_for(filenames[0])
903 def diff_text_for(self, filename):
904 size = _config.get('cola.readsize', 1024 * 2)
905 try:
906 result = core.read(filename, size=size)
907 except:
908 result = ''
910 if len(result) == size:
911 result += '...'
912 return result
915 class SignOff(Command):
916 SHORTCUT = 'Ctrl+I'
918 @staticmethod
919 def name():
920 return N_('Sign Off')
922 def __init__(self):
923 Command.__init__(self)
924 self.undoable = True
925 self.old_commitmsg = self.model.commitmsg
927 def do(self):
928 signoff = self.signoff()
929 if signoff in self.model.commitmsg:
930 return
931 self.model.set_commitmsg(self.model.commitmsg + '\n' + signoff)
933 def undo(self):
934 self.model.set_commitmsg(self.old_commitmsg)
936 def signoff(self):
937 try:
938 import pwd
939 user = pwd.getpwuid(os.getuid()).pw_name
940 except ImportError:
941 user = os.getenv('USER', N_('unknown'))
943 name = _config.get('user.name', user)
944 email = _config.get('user.email', '%s@%s' % (user, platform.node()))
945 return '\nSigned-off-by: %s <%s>' % (name, email)
948 class Stage(Command):
949 """Stage a set of paths."""
950 SHORTCUT = 'Ctrl+S'
952 @staticmethod
953 def name():
954 return N_('Stage')
956 def __init__(self, paths):
957 Command.__init__(self)
958 self.paths = paths
960 def do(self):
961 msg = N_('Staging: %s') % (', '.join(self.paths))
962 Interaction.log(msg)
963 # Prevent external updates while we are staging files.
964 # We update file stats at the end of this operation
965 # so there's no harm in ignoring updates from other threads
966 # (e.g. inotify).
967 with CommandDisabled(UpdateFileStatus):
968 self.model.stage_paths(self.paths)
971 class StageModified(Stage):
972 """Stage all modified files."""
974 SHORTCUT = 'Ctrl+S'
976 @staticmethod
977 def name():
978 return N_('Stage Modified')
980 def __init__(self):
981 Stage.__init__(self, None)
982 self.paths = self.model.modified
985 class StageUnmerged(Stage):
986 """Stage all modified files."""
988 SHORTCUT = 'Ctrl+S'
990 @staticmethod
991 def name():
992 return N_('Stage Unmerged')
994 def __init__(self):
995 Stage.__init__(self, None)
996 self.paths = self.model.unmerged
999 class StageUntracked(Stage):
1000 """Stage all untracked files."""
1002 SHORTCUT = 'Ctrl+S'
1004 @staticmethod
1005 def name():
1006 return N_('Stage Untracked')
1008 def __init__(self):
1009 Stage.__init__(self, None)
1010 self.paths = self.model.untracked
1013 class Tag(Command):
1014 """Create a tag object."""
1016 def __init__(self, name, revision, sign=False, message=''):
1017 Command.__init__(self)
1018 self._name = name
1019 self._message = message
1020 self._revision = revision
1021 self._sign = sign
1023 def do(self):
1024 log_msg = (N_('Tagging "%(revision)s" as "%(name)s"') %
1025 dict(revision=self._revision, name=self._name))
1026 opts = {}
1027 if self._message:
1028 opts['F'] = utils.tmp_filename('tag-message')
1029 core.write(opts['F'], self._message)
1031 if self._sign:
1032 log_msg += ' (%s)' % N_('GPG-signed')
1033 opts['s'] = True
1034 status, output, err = self.model.git.tag(self._name,
1035 self._revision, **opts)
1036 else:
1037 opts['a'] = bool(self._message)
1038 status, output, err = self.model.git.tag(self._name,
1039 self._revision, **opts)
1040 if 'F' in opts:
1041 os.unlink(opts['F'])
1043 if output:
1044 log_msg += '\n' + (N_('Output: %s') % output)
1046 Interaction.log_status(status, log_msg, err)
1047 if status == 0:
1048 self.model.update_status()
1051 class Unstage(Command):
1052 """Unstage a set of paths."""
1054 SHORTCUT = 'Ctrl+S'
1056 @staticmethod
1057 def name():
1058 return N_('Unstage')
1060 def __init__(self, paths):
1061 Command.__init__(self)
1062 self.paths = paths
1064 def do(self):
1065 msg = N_('Unstaging: %s') % (', '.join(self.paths))
1066 Interaction.log(msg)
1067 with CommandDisabled(UpdateFileStatus):
1068 self.model.unstage_paths(self.paths)
1071 class UnstageAll(Command):
1072 """Unstage all files; resets the index."""
1074 def do(self):
1075 self.model.unstage_all()
1078 class UnstageSelected(Unstage):
1079 """Unstage selected files."""
1081 def __init__(self):
1082 Unstage.__init__(self, cola.selection_model().staged)
1085 class Untrack(Command):
1086 """Unstage a set of paths."""
1088 def __init__(self, paths):
1089 Command.__init__(self)
1090 self.paths = paths
1092 def do(self):
1093 msg = N_('Untracking: %s') % (', '.join(self.paths))
1094 Interaction.log(msg)
1095 with CommandDisabled(UpdateFileStatus):
1096 status, out, err = self.model.untrack_paths(self.paths)
1097 Interaction.log_status(status, out, err)
1100 class UntrackedSummary(Command):
1101 """List possible .gitignore rules as the diff text."""
1103 def __init__(self):
1104 Command.__init__(self)
1105 untracked = self.model.untracked
1106 suffix = len(untracked) > 1 and 's' or ''
1107 io = StringIO()
1108 io.write('# %s untracked file%s\n' % (len(untracked), suffix))
1109 if untracked:
1110 io.write('# possible .gitignore rule%s:\n' % suffix)
1111 for u in untracked:
1112 io.write('/'+core.encode(u)+'\n')
1113 self.new_diff_text = core.decode(io.getvalue())
1114 self.new_mode = self.model.mode_untracked
1117 class UpdateFileStatus(Command):
1118 """Rescans for changes."""
1120 def do(self):
1121 self.model.update_file_status()
1124 class VisualizeAll(Command):
1125 """Visualize all branches."""
1127 def do(self):
1128 browser = utils.shell_split(prefs.history_browser())
1129 utils.fork(browser + ['--all'])
1132 class VisualizeCurrent(Command):
1133 """Visualize all branches."""
1135 def do(self):
1136 browser = utils.shell_split(prefs.history_browser())
1137 utils.fork(browser + [self.model.currentbranch])
1140 class VisualizePaths(Command):
1141 """Path-limited visualization."""
1143 def __init__(self, paths):
1144 Command.__init__(self)
1145 browser = utils.shell_split(prefs.history_browser())
1146 if paths:
1147 self.argv = browser + paths
1148 else:
1149 self.argv = browser
1151 def do(self):
1152 utils.fork(self.argv)
1155 class VisualizeRevision(Command):
1156 """Visualize a specific revision."""
1158 def __init__(self, revision, paths=None):
1159 Command.__init__(self)
1160 self.revision = revision
1161 self.paths = paths
1163 def do(self):
1164 argv = utils.shell_split(prefs.history_browser())
1165 if self.revision:
1166 argv.append(self.revision)
1167 if self.paths:
1168 argv.append('--')
1169 argv.extend(self.paths)
1171 try:
1172 utils.fork(argv)
1173 except Exception as e:
1174 _, details = utils.format_exception(e)
1175 title = N_('Error Launching History Browser')
1176 msg = (N_('Cannot exec "%s": please configure a history browser') %
1177 ' '.join(argv))
1178 Interaction.critical(title, message=msg, details=details)
1181 def run(cls, *args, **opts):
1183 Returns a callback that runs a command
1185 If the caller of run() provides args or opts then those are
1186 used instead of the ones provided by the invoker of the callback.
1189 def runner(*local_args, **local_opts):
1190 if args or opts:
1191 do(cls, *args, **opts)
1192 else:
1193 do(cls, *local_args, **local_opts)
1195 return runner
1198 class CommandDisabled(object):
1200 """Context manager to temporarily disable a command from running"""
1201 def __init__(self, cmdclass):
1202 self.cmdclass = cmdclass
1204 def __enter__(self):
1205 self.cmdclass.DISABLED = True
1206 return self
1208 def __exit__(self, exc_type, exc_val, exc_tb):
1209 self.cmdclass.DISABLED = False
1212 def do(cls, *args, **opts):
1213 """Run a command in-place"""
1214 return do_cmd(cls(*args, **opts))
1217 def do_cmd(cmd):
1218 if hasattr(cmd, 'DISABLED') and cmd.DISABLED:
1219 return None
1220 try:
1221 return cmd.do()
1222 except StandardError, e:
1223 msg, details = utils.format_exception(e)
1224 Interaction.critical(N_('Error'), message=msg, details=details)
1225 return None