cmds: trivial cleanup
[git-cola.git] / cola / cmds.py
blobb5818b34203efb56c6c6355569b3b4118d910afe
1 from __future__ import division, absolute_import, unicode_literals
3 import os
4 import re
5 import subprocess
6 import sys
7 from fnmatch import fnmatch
8 from io import StringIO
10 try:
11 from send2trash import send2trash
12 except ImportError:
13 send2trash = None
15 from cola import compat
16 from cola import core
17 from cola import fsmonitor
18 from cola import gitcfg
19 from cola import gitcmds
20 from cola import icons
21 from cola import utils
22 from cola import difftool
23 from cola import resources
24 from cola.diffparse import DiffParser
25 from cola.git import STDOUT
26 from cola.i18n import N_
27 from cola.interaction import Interaction
28 from cola.models import main
29 from cola.models import prefs
30 from cola.models import selection
33 class UsageError(Exception):
34 """Exception class for usage errors."""
35 def __init__(self, title, message):
36 Exception.__init__(self, message)
37 self.title = title
38 self.msg = message
41 class BaseCommand(object):
42 """Base class for all commands; provides the command pattern"""
44 DISABLED = False
46 def __init__(self):
47 self.undoable = False
49 def is_undoable(self):
50 """Can this be undone?"""
51 return self.undoable
53 @staticmethod
54 def name():
55 return 'Unknown'
57 def do(self):
58 pass
60 def undo(self):
61 pass
64 class ConfirmAction(BaseCommand):
66 def __init__(self):
67 BaseCommand.__init__(self)
69 def ok_to_run(self):
70 return True
72 def confirm(self):
73 return True
75 def action(self):
76 return (-1, '', '')
78 def ok(self, status):
79 return status == 0
81 def success(self):
82 pass
84 def fail(self, status, out, err):
85 title = msg = self.error_message()
86 details = self.error_details() or out + err
87 Interaction.critical(title, message=msg, details=details)
89 def error_message(self):
90 return ''
92 def error_details(self):
93 return ''
95 def do(self):
96 status = -1
97 out = err = ''
98 ok = self.ok_to_run() and self.confirm()
99 if ok:
100 status, out, err = self.action()
101 if self.ok(status):
102 self.success()
103 else:
104 self.fail(status, out, err)
106 return ok, status, out, err
109 class ModelCommand(BaseCommand):
110 """Commands that manipulate the main models"""
112 def __init__(self):
113 BaseCommand.__init__(self)
114 self.model = main.model()
117 class Command(ModelCommand):
118 """Base class for commands that modify the main model"""
120 def __init__(self):
121 """Initialize the command and stash away values for use in do()"""
122 # These are commonly used so let's make it easier to write new commands.
123 ModelCommand.__init__(self)
125 self.old_diff_text = self.model.diff_text
126 self.old_filename = self.model.filename
127 self.old_mode = self.model.mode
129 self.new_diff_text = self.old_diff_text
130 self.new_filename = self.old_filename
131 self.new_mode = self.old_mode
133 def do(self):
134 """Perform the operation."""
135 self.model.set_filename(self.new_filename)
136 self.model.set_mode(self.new_mode)
137 self.model.set_diff_text(self.new_diff_text)
139 def undo(self):
140 """Undo the operation."""
141 self.model.set_diff_text(self.old_diff_text)
142 self.model.set_filename(self.old_filename)
143 self.model.set_mode(self.old_mode)
146 class AmendMode(Command):
147 """Try to amend a commit."""
149 LAST_MESSAGE = None
151 @staticmethod
152 def name():
153 return N_('Amend')
155 def __init__(self, amend):
156 Command.__init__(self)
157 self.undoable = True
158 self.skip = False
159 self.amending = amend
160 self.old_commitmsg = self.model.commitmsg
161 self.old_mode = self.model.mode
163 if self.amending:
164 self.new_mode = self.model.mode_amend
165 self.new_commitmsg = self.model.prev_commitmsg()
166 AmendMode.LAST_MESSAGE = self.model.commitmsg
167 return
168 # else, amend unchecked, regular commit
169 self.new_mode = self.model.mode_none
170 self.new_diff_text = ''
171 self.new_commitmsg = self.model.commitmsg
172 # If we're going back into new-commit-mode then search the
173 # undo stack for a previous amend-commit-mode and grab the
174 # commit message at that point in time.
175 if AmendMode.LAST_MESSAGE is not None:
176 self.new_commitmsg = AmendMode.LAST_MESSAGE
177 AmendMode.LAST_MESSAGE = None
179 def do(self):
180 """Leave/enter amend mode."""
181 """Attempt to enter amend mode. Do not allow this when merging."""
182 if self.amending:
183 if self.model.is_merging:
184 self.skip = True
185 self.model.set_mode(self.old_mode)
186 Interaction.information(
187 N_('Cannot Amend'),
188 N_('You are in the middle of a merge.\n'
189 'Cannot amend while merging.'))
190 return
191 self.skip = False
192 Command.do(self)
193 self.model.set_commitmsg(self.new_commitmsg)
194 self.model.update_file_status()
196 def undo(self):
197 if self.skip:
198 return
199 self.model.set_commitmsg(self.old_commitmsg)
200 Command.undo(self)
201 self.model.update_file_status()
204 class ApplyDiffSelection(Command):
206 def __init__(self, first_line_idx, last_line_idx, has_selection,
207 reverse, apply_to_worktree):
208 Command.__init__(self)
209 self.first_line_idx = first_line_idx
210 self.last_line_idx = last_line_idx
211 self.has_selection = has_selection
212 self.reverse = reverse
213 self.apply_to_worktree = apply_to_worktree
215 def do(self):
216 parser = DiffParser(self.model.filename, self.model.diff_text)
217 if self.has_selection:
218 patch = parser.generate_patch(self.first_line_idx,
219 self.last_line_idx,
220 reverse=self.reverse)
221 else:
222 patch = parser.generate_hunk_patch(self.first_line_idx,
223 reverse=self.reverse)
224 if patch is None:
225 return
227 cfg = gitcfg.current()
228 tmp_path = utils.tmp_filename('patch')
229 try:
230 core.write(tmp_path, patch,
231 encoding=cfg.file_encoding(self.model.filename))
232 if self.apply_to_worktree:
233 status, out, err = self.model.apply_diff_to_worktree(tmp_path)
234 else:
235 status, out, err = self.model.apply_diff(tmp_path)
236 finally:
237 os.unlink(tmp_path)
238 Interaction.log_status(status, out, err)
239 self.model.update_file_status(update_index=True)
242 class ApplyPatches(Command):
244 def __init__(self, patches):
245 Command.__init__(self)
246 self.patches = patches
248 def do(self):
249 diff_text = ''
250 num_patches = len(self.patches)
251 orig_head = self.model.git.rev_parse('HEAD')[STDOUT]
253 for idx, patch in enumerate(self.patches):
254 status, out, err = self.model.git.am(patch)
255 # Log the git-am command
256 Interaction.log_status(status, out, err)
258 if num_patches > 1:
259 diff = self.model.git.diff('HEAD^!', stat=True)[STDOUT]
260 diff_text += (N_('PATCH %(current)d/%(count)d') %
261 dict(current=idx+1, count=num_patches))
262 diff_text += ' - %s:\n%s\n\n' % (os.path.basename(patch), diff)
264 diff_text += N_('Summary:') + '\n'
265 diff_text += self.model.git.diff(orig_head, stat=True)[STDOUT]
267 # Display a diffstat
268 self.model.set_diff_text(diff_text)
269 self.model.update_file_status()
271 basenames = '\n'.join([os.path.basename(p) for p in self.patches])
272 Interaction.information(
273 N_('Patch(es) Applied'),
274 (N_('%d patch(es) applied.') + '\n\n%s') %
275 (len(self.patches), basenames))
278 class Archive(BaseCommand):
280 def __init__(self, ref, fmt, prefix, filename):
281 BaseCommand.__init__(self)
282 self.ref = ref
283 self.fmt = fmt
284 self.prefix = prefix
285 self.filename = filename
287 def do(self):
288 fp = core.xopen(self.filename, 'wb')
289 cmd = ['git', 'archive', '--format='+self.fmt]
290 if self.fmt in ('tgz', 'tar.gz'):
291 cmd.append('-9')
292 if self.prefix:
293 cmd.append('--prefix=' + self.prefix)
294 cmd.append(self.ref)
295 proc = core.start_command(cmd, stdout=fp)
296 out, err = proc.communicate()
297 fp.close()
298 status = proc.returncode
299 Interaction.log_status(status, out or '', err or '')
302 class Checkout(Command):
304 A command object for git-checkout.
306 'argv' is handed off directly to git.
310 def __init__(self, argv, checkout_branch=False):
311 Command.__init__(self)
312 self.argv = argv
313 self.checkout_branch = checkout_branch
314 self.new_diff_text = ''
316 def do(self):
317 status, out, err = self.model.git.checkout(*self.argv)
318 Interaction.log_status(status, out, err)
319 if self.checkout_branch:
320 self.model.update_status()
321 else:
322 self.model.update_file_status()
325 class CheckoutBranch(Checkout):
326 """Checkout a branch."""
328 def __init__(self, branch):
329 args = [branch]
330 Checkout.__init__(self, args, checkout_branch=True)
333 class CherryPick(Command):
334 """Cherry pick commits into the current branch."""
336 def __init__(self, commits):
337 Command.__init__(self)
338 self.commits = commits
340 def do(self):
341 self.model.cherry_pick_list(self.commits)
342 self.model.update_file_status()
345 class ResetMode(Command):
346 """Reset the mode and clear the model's diff text."""
348 def __init__(self):
349 Command.__init__(self)
350 self.new_mode = self.model.mode_none
351 self.new_diff_text = ''
353 def do(self):
354 Command.do(self)
355 self.model.update_file_status()
358 class Commit(ResetMode):
359 """Attempt to create a new commit."""
361 def __init__(self, amend, msg, sign, no_verify=False):
362 ResetMode.__init__(self)
363 self.amend = amend
364 self.msg = msg
365 self.sign = sign
366 self.no_verify = no_verify
367 self.old_commitmsg = self.model.commitmsg
368 self.new_commitmsg = ''
370 def do(self):
371 # Create the commit message file
372 comment_char = prefs.comment_char()
373 msg = self.strip_comments(self.msg, comment_char=comment_char)
374 tmpfile = utils.tmp_filename('commit-message')
375 try:
376 core.write(tmpfile, msg)
378 # Run 'git commit'
379 status, out, err = self.model.git.commit(F=tmpfile,
380 v=True,
381 gpg_sign=self.sign,
382 amend=self.amend,
383 no_verify=self.no_verify)
384 finally:
385 core.unlink(tmpfile)
387 if status == 0:
388 ResetMode.do(self)
389 self.model.set_commitmsg(self.new_commitmsg)
390 msg = N_('Created commit: %s') % out
391 else:
392 msg = N_('Commit failed: %s') % out
393 Interaction.log_status(status, msg, err)
395 return status, out, err
397 @staticmethod
398 def strip_comments(msg, comment_char='#'):
399 # Strip off comments
400 message_lines = [line for line in msg.split('\n')
401 if not line.startswith(comment_char)]
402 msg = '\n'.join(message_lines)
403 if not msg.endswith('\n'):
404 msg += '\n'
406 return msg
409 class Ignore(Command):
410 """Add files to .gitignore"""
412 def __init__(self, filenames):
413 Command.__init__(self)
414 self.filenames = list(filenames)
416 def do(self):
417 if not self.filenames:
418 return
419 new_additions = '\n'.join(self.filenames) + '\n'
420 for_status = new_additions
421 if core.exists('.gitignore'):
422 current_list = core.read('.gitignore')
423 new_additions = current_list.rstrip() + '\n' + new_additions
424 core.write('.gitignore', new_additions)
425 Interaction.log_status(0, 'Added to .gitignore:\n%s' % for_status, '')
426 self.model.update_file_status()
429 def file_summary(files):
430 txt = subprocess.list2cmdline(files)
431 if len(txt) > 768:
432 txt = txt[:768].rstrip() + '...'
433 return txt
436 class RemoteCommand(ConfirmAction):
438 def __init__(self, remote):
439 ConfirmAction.__init__(self)
440 self.model = main.model()
441 self.remote = remote
443 def success(self):
444 self.model.update_remotes()
447 class RemoteAdd(RemoteCommand):
449 def __init__(self, remote, url):
450 RemoteCommand.__init__(self, remote)
451 self.url = url
453 def action(self):
454 git = self.model.git
455 return git.remote('add', self.remote, self.url)
457 def error_message(self):
458 return N_('Error creating remote "%s"') % self.remote
461 class RemoteRemove(RemoteCommand):
463 def confirm(self):
464 title = N_('Delete Remote')
465 question = N_('Delete remote?')
466 info = N_('Delete remote "%s"') % self.remote
467 ok_btn = N_('Delete')
468 return Interaction.confirm(title, question, info, ok_btn)
470 def action(self):
471 git = self.model.git
472 return git.remote('rm', self.remote)
474 def error_message(self):
475 return N_('Error deleting remote "%s"') % self.remote
478 class RemoteRename(RemoteCommand):
480 def __init__(self, remote, new_remote):
481 RemoteCommand.__init__(self, remote)
482 self.new_remote = new_remote
484 def confirm(self):
485 title = N_('Rename Remote')
486 question = N_('Rename remote?')
487 info = (N_('Rename remote "%(current)s" to "%(new)s"?') %
488 dict(current=self.remote, new=self.new_remote))
489 ok_btn = N_('Rename')
490 return Interaction.confirm(title, question, info, ok_btn)
492 def action(self):
493 git = self.model.git
494 return git.remote('rename', self.remote, self.new_remote)
497 class RemoveFromSettings(ConfirmAction):
499 def __init__(self, settings, repo, icon=None):
500 ConfirmAction.__init__(self)
501 self.settings = settings
502 self.repo = repo
503 self.icon = icon
505 def success(self):
506 self.settings.save()
509 class RemoveBookmark(RemoveFromSettings):
511 def confirm(self):
512 repo = self.repo
513 title = msg = N_('Delete Bookmark?')
514 info = N_('%s will be removed from your bookmarks.') % repo
515 ok_text = N_('Delete Bookmark')
516 return Interaction.confirm(title, msg, info, ok_text, icon=self.icon)
518 def action(self):
519 self.settings.remove_bookmark(self.repo)
520 return (0, '', '')
523 class RemoveRecent(RemoveFromSettings):
525 def confirm(self):
526 repo = self.repo
527 title = msg = N_('Remove %s from the recent list?') % repo
528 info = N_('%s will be removed from your recent repositories.') % repo
529 ok_text = N_('Remove')
530 return Interaction.confirm(title, msg, info, ok_text, icon=self.icon)
532 def action(self):
533 self.settings.remove_recent(self.repo)
534 return (0, '', '')
537 class RemoveFiles(Command):
538 """Removes files."""
540 def __init__(self, remover, filenames):
541 Command.__init__(self)
542 if remover is None:
543 remover = os.remove
544 self.remover = remover
545 self.filenames = filenames
546 # We could git-hash-object stuff and provide undo-ability
547 # as an option. Heh.
549 def do(self):
550 files = self.filenames
551 if not files:
552 return
554 rescan = False
555 bad_filenames = []
556 remove = self.remover
557 for filename in files:
558 if filename:
559 try:
560 remove(filename)
561 rescan=True
562 except:
563 bad_filenames.append(filename)
565 if bad_filenames:
566 Interaction.information(
567 N_('Error'),
568 N_('Deleting "%s" failed') % file_summary(files))
570 if rescan:
571 self.model.update_file_status()
574 class Delete(RemoveFiles):
575 """Delete files."""
577 def __init__(self, filenames):
578 RemoveFiles.__init__(self, os.remove, filenames)
580 def do(self):
581 files = self.filenames
582 if not files:
583 return
585 title = N_('Delete Files?')
586 msg = N_('The following files will be deleted:') + '\n\n'
587 msg += file_summary(files)
588 info_txt = N_('Delete %d file(s)?') % len(files)
589 ok_txt = N_('Delete Files')
591 if not Interaction.confirm(title, msg, info_txt, ok_txt,
592 default=True, icon=icons.remove()):
593 return
595 return RemoveFiles.do(self)
598 class MoveToTrash(RemoveFiles):
599 """Move files to the trash using send2trash"""
601 AVAILABLE = send2trash is not None
603 def __init__(self, filenames):
604 RemoveFiles.__init__(self, send2trash, filenames)
607 class DeleteBranch(Command):
608 """Delete a git branch."""
610 def __init__(self, branch):
611 Command.__init__(self)
612 self.branch = branch
614 def do(self):
615 status, out, err = self.model.delete_branch(self.branch)
616 Interaction.log_status(status, out, err)
619 class RenameBranch(Command):
620 """Rename a git branch."""
622 def __init__(self, branch, new_branch):
623 Command.__init__(self)
624 self.branch = branch
625 self.new_branch = new_branch
627 def do(self):
628 status, out, err = self.model.rename_branch(self.branch, self.new_branch)
629 Interaction.log_status(status, out, err)
631 class DeleteRemoteBranch(Command):
632 """Delete a remote git branch."""
634 def __init__(self, remote, branch):
635 Command.__init__(self)
636 self.remote = remote
637 self.branch = branch
639 def do(self):
640 status, out, err = self.model.git.push(self.remote, self.branch,
641 delete=True)
642 Interaction.log_status(status, out, err)
643 self.model.update_status()
645 if status == 0:
646 Interaction.information(
647 N_('Remote Branch Deleted'),
648 N_('"%(branch)s" has been deleted from "%(remote)s".')
649 % dict(branch=self.branch, remote=self.remote))
650 else:
651 command = 'git push'
652 message = (N_('"%(command)s" returned exit status %(status)d') %
653 dict(command=command, status=status))
655 Interaction.critical(N_('Error Deleting Remote Branch'),
656 message, out + err)
660 class Diff(Command):
661 """Perform a diff and set the model's current text."""
663 def __init__(self, filename, cached=False, deleted=False):
664 Command.__init__(self)
665 opts = {}
666 if cached:
667 opts['ref'] = self.model.head
668 self.new_filename = filename
669 self.new_mode = self.model.mode_worktree
670 self.new_diff_text = gitcmds.diff_helper(filename=filename,
671 cached=cached,
672 deleted=deleted,
673 **opts)
676 class Diffstat(Command):
677 """Perform a diffstat and set the model's diff text."""
679 def __init__(self):
680 Command.__init__(self)
681 cfg = gitcfg.current()
682 diff_context = cfg.get('diff.context', 3)
683 diff = self.model.git.diff(self.model.head,
684 unified=diff_context,
685 no_ext_diff=True,
686 no_color=True,
687 M=True,
688 stat=True)[STDOUT]
689 self.new_diff_text = diff
690 self.new_mode = self.model.mode_worktree
693 class DiffStaged(Diff):
694 """Perform a staged diff on a file."""
696 def __init__(self, filename, deleted=None):
697 Diff.__init__(self, filename, cached=True, deleted=deleted)
698 self.new_mode = self.model.mode_index
701 class DiffStagedSummary(Command):
703 def __init__(self):
704 Command.__init__(self)
705 diff = self.model.git.diff(self.model.head,
706 cached=True,
707 no_color=True,
708 no_ext_diff=True,
709 patch_with_stat=True,
710 M=True)[STDOUT]
711 self.new_diff_text = diff
712 self.new_mode = self.model.mode_index
715 class Difftool(Command):
716 """Run git-difftool limited by path."""
718 def __init__(self, staged, filenames):
719 Command.__init__(self)
720 self.staged = staged
721 self.filenames = filenames
723 def do(self):
724 difftool.launch_with_head(self.filenames,
725 self.staged, self.model.head)
728 class Edit(Command):
729 """Edit a file using the configured gui.editor."""
731 @staticmethod
732 def name():
733 return N_('Launch Editor')
735 def __init__(self, filenames, line_number=None):
736 Command.__init__(self)
737 self.filenames = filenames
738 self.line_number = line_number
740 def do(self):
741 if not self.filenames:
742 return
743 filename = self.filenames[0]
744 if not core.exists(filename):
745 return
746 editor = prefs.editor()
747 opts = []
749 if self.line_number is None:
750 opts = self.filenames
751 else:
752 # Single-file w/ line-numbers (likely from grep)
753 editor_opts = {
754 '*vim*': ['+'+self.line_number, filename],
755 '*emacs*': ['+'+self.line_number, filename],
756 '*textpad*': ['%s(%s,0)' % (filename, self.line_number)],
757 '*notepad++*': ['-n'+self.line_number, filename],
760 opts = self.filenames
761 for pattern, opt in editor_opts.items():
762 if fnmatch(editor, pattern):
763 opts = opt
764 break
766 try:
767 core.fork(utils.shell_split(editor) + opts)
768 except Exception as e:
769 message = (N_('Cannot exec "%s": please configure your editor')
770 % editor)
771 details = core.decode(e.strerror)
772 Interaction.critical(N_('Error Editing File'), message, details)
775 class FormatPatch(Command):
776 """Output a patch series given all revisions and a selected subset."""
778 def __init__(self, to_export, revs):
779 Command.__init__(self)
780 self.to_export = list(to_export)
781 self.revs = list(revs)
783 def do(self):
784 status, out, err = gitcmds.format_patchsets(self.to_export, self.revs)
785 Interaction.log_status(status, out, err)
788 class LaunchDifftool(BaseCommand):
790 @staticmethod
791 def name():
792 return N_('Launch Diff Tool')
794 def __init__(self):
795 BaseCommand.__init__(self)
797 def do(self):
798 s = selection.selection()
799 if s.unmerged:
800 paths = s.unmerged
801 if utils.is_win32():
802 core.fork(['git', 'mergetool', '--no-prompt', '--'] + paths)
803 else:
804 cfg = gitcfg.current()
805 cmd = cfg.terminal()
806 argv = utils.shell_split(cmd)
807 argv.extend(['git', 'mergetool', '--no-prompt', '--'])
808 argv.extend(paths)
809 core.fork(argv)
810 else:
811 difftool.run()
814 class LaunchTerminal(BaseCommand):
816 @staticmethod
817 def name():
818 return N_('Launch Terminal')
820 def __init__(self, path):
821 BaseCommand.__init__(self)
822 self.path = path
824 def do(self):
825 cfg = gitcfg.current()
826 cmd = cfg.terminal()
827 argv = utils.shell_split(cmd)
828 argv.append(os.getenv('SHELL', '/bin/sh'))
829 core.fork(argv, cwd=self.path)
832 class LaunchEditor(Edit):
834 @staticmethod
835 def name():
836 return N_('Launch Editor')
838 def __init__(self):
839 s = selection.selection()
840 allfiles = s.staged + s.unmerged + s.modified + s.untracked
841 Edit.__init__(self, allfiles)
844 class LoadCommitMessageFromFile(Command):
845 """Loads a commit message from a path."""
847 def __init__(self, path):
848 Command.__init__(self)
849 self.undoable = True
850 self.path = path
851 self.old_commitmsg = self.model.commitmsg
852 self.old_directory = self.model.directory
854 def do(self):
855 path = self.path
856 if not path or not core.isfile(path):
857 raise UsageError(N_('Error: Cannot find commit template'),
858 N_('%s: No such file or directory.') % path)
859 self.model.set_directory(os.path.dirname(path))
860 self.model.set_commitmsg(core.read(path))
862 def undo(self):
863 self.model.set_commitmsg(self.old_commitmsg)
864 self.model.set_directory(self.old_directory)
867 class LoadCommitMessageFromTemplate(LoadCommitMessageFromFile):
868 """Loads the commit message template specified by commit.template."""
870 def __init__(self):
871 cfg = gitcfg.current()
872 template = cfg.get('commit.template')
873 LoadCommitMessageFromFile.__init__(self, template)
875 def do(self):
876 if self.path is None:
877 raise UsageError(
878 N_('Error: Unconfigured commit template'),
879 N_('A commit template has not been configured.\n'
880 'Use "git config" to define "commit.template"\n'
881 'so that it points to a commit template.'))
882 return LoadCommitMessageFromFile.do(self)
886 class LoadCommitMessageFromSHA1(Command):
887 """Load a previous commit message"""
889 def __init__(self, sha1, prefix=''):
890 Command.__init__(self)
891 self.sha1 = sha1
892 self.old_commitmsg = self.model.commitmsg
893 self.new_commitmsg = prefix + self.model.prev_commitmsg(sha1)
894 self.undoable = True
896 def do(self):
897 self.model.set_commitmsg(self.new_commitmsg)
899 def undo(self):
900 self.model.set_commitmsg(self.old_commitmsg)
903 class LoadFixupMessage(LoadCommitMessageFromSHA1):
904 """Load a fixup message"""
906 def __init__(self, sha1):
907 LoadCommitMessageFromSHA1.__init__(self, sha1, prefix='fixup! ')
910 class Merge(Command):
911 """Merge commits"""
913 def __init__(self, revision, no_commit, squash, no_ff, sign):
914 Command.__init__(self)
915 self.revision = revision
916 self.no_ff = no_ff
917 self.no_commit = no_commit
918 self.squash = squash
919 self.sign = sign
921 def do(self):
922 squash = self.squash
923 revision = self.revision
924 no_ff = self.no_ff
925 no_commit = self.no_commit
926 sign = self.sign
928 status, out, err = self.model.git.merge(revision,
929 gpg_sign=sign,
930 no_ff=no_ff,
931 no_commit=no_commit,
932 squash=squash)
933 Interaction.log_status(status, out, err)
934 self.model.update_status()
937 class OpenDefaultApp(BaseCommand):
938 """Open a file using the OS default."""
940 @staticmethod
941 def name():
942 return N_('Open Using Default Application')
944 def __init__(self, filenames):
945 BaseCommand.__init__(self)
946 if utils.is_darwin():
947 launcher = 'open'
948 else:
949 launcher = 'xdg-open'
950 self.launcher = launcher
951 self.filenames = filenames
953 def do(self):
954 if not self.filenames:
955 return
956 core.fork([self.launcher] + self.filenames)
959 class OpenParentDir(OpenDefaultApp):
960 """Open parent directories using the OS default."""
962 @staticmethod
963 def name():
964 return N_('Open Parent Directory')
966 def __init__(self, filenames):
967 OpenDefaultApp.__init__(self, filenames)
969 def do(self):
970 if not self.filenames:
971 return
972 dirs = list(set(map(os.path.dirname, self.filenames)))
973 core.fork([self.launcher] + dirs)
976 class OpenNewRepo(Command):
977 """Launches git-cola on a repo."""
979 def __init__(self, repo_path):
980 Command.__init__(self)
981 self.repo_path = repo_path
983 def do(self):
984 self.model.set_directory(self.repo_path)
985 core.fork([sys.executable, sys.argv[0], '--repo', self.repo_path])
988 class OpenRepo(Command):
989 def __init__(self, repo_path):
990 Command.__init__(self)
991 self.repo_path = repo_path
993 def do(self):
994 git = self.model.git
995 old_worktree = git.worktree()
996 if not self.model.set_worktree(self.repo_path):
997 self.model.set_worktree(old_worktree)
998 return
999 new_worktree = git.worktree()
1000 core.chdir(new_worktree)
1001 self.model.set_directory(self.repo_path)
1002 cfg = gitcfg.current()
1003 cfg.reset()
1004 fsmonitor.instance().stop()
1005 fsmonitor.instance().start()
1006 self.model.update_status()
1009 class Clone(Command):
1010 """Clones a repository and optionally spawns a new cola session."""
1012 def __init__(self, url, new_directory, spawn=True):
1013 Command.__init__(self)
1014 self.url = url
1015 self.new_directory = new_directory
1016 self.spawn = spawn
1018 self.ok = False
1019 self.error_message = ''
1020 self.error_details = ''
1022 def do(self):
1023 status, out, err = self.model.git.clone(self.url, self.new_directory)
1024 self.ok = status == 0
1026 if self.ok:
1027 if self.spawn:
1028 core.fork([sys.executable, sys.argv[0],
1029 '--repo', self.new_directory])
1030 else:
1031 self.error_message = N_('Error: could not clone "%s"') % self.url
1032 self.error_details = (
1033 (N_('git clone returned exit code %s') % status) +
1034 ((out+err) and ('\n\n' + out + err) or ''))
1036 return self
1039 def unix_path(path, is_win32=utils.is_win32):
1040 """Git for Windows requires unix paths, so force them here
1042 unix_path = path
1043 if is_win32():
1044 first = path[0]
1045 second = path[1]
1046 if second == ':': # sanity check, this better be a Windows-style path
1047 unix_path = '/' + first + path[2:].replace('\\', '/')
1049 return unix_path
1052 class GitXBaseContext(object):
1054 def __init__(self, **kwargs):
1055 self.env = {'GIT_EDITOR': prefs.editor()}
1056 self.env.update(kwargs)
1058 def __enter__(self):
1059 compat.setenv('GIT_SEQUENCE_EDITOR',
1060 unix_path(resources.share('bin', 'git-xbase')))
1061 for var, value in self.env.items():
1062 compat.setenv(var, value)
1063 return self
1065 def __exit__(self, exc_type, exc_val, exc_tb):
1066 compat.unsetenv('GIT_SEQUENCE_EDITOR')
1067 for var in self.env:
1068 compat.unsetenv(var)
1071 class Rebase(Command):
1073 def __init__(self,
1074 upstream=None, branch=None, capture_output=True, **kwargs):
1075 """Start an interactive rebase session
1077 :param upstream: upstream branch
1078 :param branch: optional branch to checkout
1079 :param capture_output: whether to capture stdout and stderr
1080 :param kwargs: forwarded directly to `git.rebase()`
1083 Command.__init__(self)
1085 self.upstream = upstream
1086 self.branch = branch
1087 self.capture_output = capture_output
1088 self.kwargs = kwargs
1090 def prepare_arguments(self):
1091 args = []
1092 kwargs = {}
1094 if self.capture_output:
1095 kwargs['_stderr'] = None
1096 kwargs['_stdout'] = None
1098 # Rebase actions must be the only option specified
1099 for action in ('continue', 'abort', 'skip', 'edit_todo'):
1100 if self.kwargs.get(action, False):
1101 kwargs[action] = self.kwargs[action]
1102 return args, kwargs
1104 kwargs['interactive'] = True
1105 kwargs['autosquash'] = self.kwargs.get('autosquash', True)
1106 kwargs.update(self.kwargs)
1108 if self.upstream:
1109 args.append(self.upstream)
1110 if self.branch:
1111 args.append(self.branch)
1113 return args, kwargs
1115 def do(self):
1116 (status, out, err) = (1, '', '')
1117 args, kwargs = self.prepare_arguments()
1118 upstream_title = self.upstream or '@{upstream}'
1119 with GitXBaseContext(
1120 GIT_XBASE_TITLE=N_('Rebase onto %s') % upstream_title,
1121 GIT_XBASE_ACTION=N_('Rebase')):
1122 status, out, err = self.model.git.rebase(*args, **kwargs)
1123 Interaction.log_status(status, out, err)
1124 self.model.update_status()
1125 return status, out, err
1128 class RebaseEditTodo(Command):
1130 def do(self):
1131 (status, out, err) = (1, '', '')
1132 with GitXBaseContext(
1133 GIT_XBASE_TITLE=N_('Edit Rebase'),
1134 GIT_XBASE_ACTION=N_('Save')):
1135 status, out, err = self.model.git.rebase(edit_todo=True)
1136 Interaction.log_status(status, out, err)
1137 self.model.update_status()
1138 return status, out, err
1141 class RebaseContinue(Command):
1143 def do(self):
1144 (status, out, err) = (1, '', '')
1145 with GitXBaseContext(
1146 GIT_XBASE_TITLE=N_('Rebase'),
1147 GIT_XBASE_ACTION=N_('Rebase')):
1148 status, out, err = self.model.git.rebase('--continue')
1149 Interaction.log_status(status, out, err)
1150 self.model.update_status()
1151 return status, out, err
1154 class RebaseSkip(Command):
1156 def do(self):
1157 (status, out, err) = (1, '', '')
1158 with GitXBaseContext(
1159 GIT_XBASE_TITLE=N_('Rebase'),
1160 GIT_XBASE_ACTION=N_('Rebase')):
1161 status, out, err = self.model.git.rebase(skip=True)
1162 Interaction.log_status(status, out, err)
1163 self.model.update_status()
1164 return status, out, err
1167 class RebaseAbort(Command):
1169 def do(self):
1170 status, out, err = self.model.git.rebase(abort=True)
1171 Interaction.log_status(status, out, err)
1172 self.model.update_status()
1175 class Rescan(Command):
1176 """Rescan for changes"""
1178 def do(self):
1179 self.model.update_status()
1182 class Refresh(Command):
1183 """Update refs and refresh the index"""
1185 @staticmethod
1186 def name():
1187 return N_('Refresh')
1189 def do(self):
1190 self.model.update_status(update_index=True)
1191 fsmonitor.instance().refresh()
1194 class RevertEditsCommand(ConfirmAction):
1196 def __init__(self):
1197 ConfirmAction.__init__(self)
1198 self.model = main.model()
1199 self.icon = icons.undo()
1201 def ok_to_run(self):
1202 return self.model.undoable()
1204 def checkout_from_head(self):
1205 return False
1207 def checkout_args(self):
1208 args = []
1209 s = selection.selection()
1210 if self.checkout_from_head():
1211 args.append(self.model.head)
1212 args.append('--')
1214 if s.staged:
1215 items = s.staged
1216 else:
1217 items = s.modified
1218 args.extend(items)
1220 return args
1222 def action(self):
1223 git = self.model.git
1224 checkout_args = self.checkout_args()
1225 return git.checkout(*checkout_args)
1227 def success(self):
1228 self.model.update_file_status()
1231 class RevertUnstagedEdits(RevertEditsCommand):
1233 @staticmethod
1234 def name():
1235 return N_('Revert Unstaged Edits...')
1237 def checkout_from_head(self):
1238 # If we are amending and a modified file is selected
1239 # then we should include "HEAD^" on the command-line.
1240 selected = selection.selection()
1241 return not selected.staged and self.model.amending()
1243 def confirm(self):
1244 title = N_('Revert Unstaged Changes?')
1245 text = N_('This operation drops unstaged changes.\n'
1246 'These changes cannot be recovered.')
1247 info = N_('Revert the unstaged changes?')
1248 ok_text = N_('Revert Unstaged Changes')
1249 return Interaction.confirm(title, text, info, ok_text,
1250 default=True, icon=self.icon)
1253 class RevertUncommittedEdits(RevertEditsCommand):
1255 @staticmethod
1256 def name():
1257 return N_('Revert Uncommitted Edits...')
1259 def checkout_from_head(self):
1260 return True
1262 def confirm(self):
1263 title = N_('Revert Uncommitted Changes?')
1264 text = N_('This operation drops uncommitted changes.\n'
1265 'These changes cannot be recovered.')
1266 info = N_('Revert the uncommitted changes?')
1267 ok_text = N_('Revert Uncommitted Changes')
1268 return Interaction.confirm(title, text, info, ok_text,
1269 default=True, icon=self.icon)
1272 class RunConfigAction(Command):
1273 """Run a user-configured action, typically from the "Tools" menu"""
1275 def __init__(self, action_name):
1276 Command.__init__(self)
1277 self.action_name = action_name
1278 self.model = main.model()
1280 def do(self):
1281 for env in ('FILENAME', 'REVISION', 'ARGS'):
1282 try:
1283 compat.unsetenv(env)
1284 except KeyError:
1285 pass
1286 rev = None
1287 args = None
1288 cfg = gitcfg.current()
1289 opts = cfg.get_guitool_opts(self.action_name)
1290 cmd = opts.get('cmd')
1291 if 'title' not in opts:
1292 opts['title'] = cmd
1294 if 'prompt' not in opts or opts.get('prompt') is True:
1295 prompt = N_('Run "%s"?') % cmd
1296 opts['prompt'] = prompt
1298 if opts.get('needsfile'):
1299 filename = selection.filename()
1300 if not filename:
1301 Interaction.information(
1302 N_('Please select a file'),
1303 N_('"%s" requires a selected file.') % cmd)
1304 return False
1305 compat.setenv('FILENAME', filename)
1307 if opts.get('revprompt') or opts.get('argprompt'):
1308 while True:
1309 ok = Interaction.confirm_config_action(cmd, opts)
1310 if not ok:
1311 return False
1312 rev = opts.get('revision')
1313 args = opts.get('args')
1314 if opts.get('revprompt') and not rev:
1315 title = N_('Invalid Revision')
1316 msg = N_('The revision expression cannot be empty.')
1317 Interaction.critical(title, msg)
1318 continue
1319 break
1321 elif opts.get('confirm'):
1322 title = os.path.expandvars(opts.get('title'))
1323 prompt = os.path.expandvars(opts.get('prompt'))
1324 if Interaction.question(title, prompt):
1325 return
1326 if rev:
1327 compat.setenv('REVISION', rev)
1328 if args:
1329 compat.setenv('ARGS', args)
1330 title = os.path.expandvars(cmd)
1331 Interaction.log(N_('Running command: %s') % title)
1332 cmd = ['sh', '-c', cmd]
1334 if opts.get('background'):
1335 core.fork(cmd)
1336 status, out, err = (0, '', '')
1337 elif opts.get('noconsole'):
1338 status, out, err = core.run_command(cmd)
1339 else:
1340 status, out, err = Interaction.run_command(title, cmd)
1342 Interaction.log_status(status,
1343 out and (N_('Output: %s') % out) or '',
1344 err and (N_('Errors: %s') % err) or '')
1346 if not opts.get('background') and not opts.get('norescan'):
1347 self.model.update_status()
1348 return status
1351 class SetDefaultRepo(Command):
1353 def __init__(self, repo):
1354 Command.__init__(self)
1355 self.repo = repo
1357 def do(self):
1358 gitcfg.current().set_user('cola.defaultrepo', self.repo)
1361 class SetDiffText(Command):
1363 def __init__(self, text):
1364 Command.__init__(self)
1365 self.undoable = True
1366 self.new_diff_text = text
1369 class ShowUntracked(Command):
1370 """Show an untracked file."""
1372 def __init__(self, filename):
1373 Command.__init__(self)
1374 self.new_filename = filename
1375 self.new_mode = self.model.mode_untracked
1376 self.new_diff_text = self.diff_text_for(filename)
1378 def diff_text_for(self, filename):
1379 cfg = gitcfg.current()
1380 size = cfg.get('cola.readsize', 1024 * 2)
1381 try:
1382 result = core.read(filename, size=size,
1383 encoding='utf-8', errors='ignore')
1384 except:
1385 result = ''
1387 if len(result) == size:
1388 result += '...'
1389 return result
1392 class SignOff(Command):
1394 @staticmethod
1395 def name():
1396 return N_('Sign Off')
1398 def __init__(self):
1399 Command.__init__(self)
1400 self.undoable = True
1401 self.old_commitmsg = self.model.commitmsg
1403 def do(self):
1404 signoff = self.signoff()
1405 if signoff in self.model.commitmsg:
1406 return
1407 self.model.set_commitmsg(self.model.commitmsg + '\n' + signoff)
1409 def undo(self):
1410 self.model.set_commitmsg(self.old_commitmsg)
1412 def signoff(self):
1413 try:
1414 import pwd
1415 user = pwd.getpwuid(os.getuid()).pw_name
1416 except ImportError:
1417 user = os.getenv('USER', N_('unknown'))
1419 cfg = gitcfg.current()
1420 name = cfg.get('user.name', user)
1421 email = cfg.get('user.email', '%s@%s' % (user, core.node()))
1422 return '\nSigned-off-by: %s <%s>' % (name, email)
1425 def check_conflicts(unmerged):
1426 """Check paths for conflicts
1428 Conflicting files can be filtered out one-by-one.
1431 if prefs.check_conflicts():
1432 unmerged = [path for path in unmerged if is_conflict_free(path)]
1433 return unmerged
1436 def is_conflict_free(path):
1437 """Return True if `path` contains no conflict markers
1439 rgx = re.compile(r'^(<<<<<<<|\|\|\|\|\|\|\||>>>>>>>) ')
1440 try:
1441 with core.xopen(path, 'r') as f:
1442 for line in f:
1443 line = core.decode(line, errors='ignore')
1444 if rgx.match(line):
1445 if should_stage_conflicts(path):
1446 return True
1447 else:
1448 return False
1449 except IOError:
1450 # We can't read this file ~ we may be staging a removal
1451 pass
1452 return True
1455 def should_stage_conflicts(path):
1456 """Inform the user that a file contains merge conflicts
1458 Return `True` if we should stage the path nonetheless.
1461 title = msg = N_('Stage conflicts?')
1462 info = N_('%s appears to contain merge conflicts.\n\n'
1463 'You should probably skip this file.\n'
1464 'Stage it anyways?') % path
1465 ok_text = N_('Stage conflicts')
1466 cancel_text = N_('Skip')
1467 return Interaction.confirm(title, msg, info, ok_text,
1468 default=False, cancel_text=cancel_text)
1471 class Stage(Command):
1472 """Stage a set of paths."""
1474 @staticmethod
1475 def name():
1476 return N_('Stage')
1478 def __init__(self, paths):
1479 Command.__init__(self)
1480 self.paths = paths
1482 def do(self):
1483 msg = N_('Staging: %s') % (', '.join(self.paths))
1484 Interaction.log(msg)
1485 # Prevent external updates while we are staging files.
1486 # We update file stats at the end of this operation
1487 # so there's no harm in ignoring updates from other threads
1488 # (e.g. the file system change monitor).
1489 with CommandDisabled(UpdateFileStatus):
1490 self.model.stage_paths(self.paths)
1493 class StageCarefully(Stage):
1494 """Only stage when the path list is non-empty
1496 We use "git add -u -- <pathspec>" to stage, and it stages everything by
1497 default when no pathspec is specified, so this class ensures that paths
1498 are specified before calling git.
1500 When no paths are specified, the command does nothing.
1503 def __init__(self):
1504 Stage.__init__(self, None)
1505 self.init_paths()
1507 def init_paths(self):
1508 pass
1510 def ok_to_run(self):
1511 """Prevent catch-all "git add -u" from adding unmerged files"""
1512 return self.paths or not self.model.unmerged
1514 def do(self):
1515 if self.ok_to_run():
1516 Stage.do(self)
1519 class StageModified(StageCarefully):
1520 """Stage all modified files."""
1522 @staticmethod
1523 def name():
1524 return N_('Stage Modified')
1526 def init_paths(self):
1527 self.paths = self.model.modified
1530 class StageUnmerged(StageCarefully):
1531 """Stage unmerged files."""
1533 @staticmethod
1534 def name():
1535 return N_('Stage Unmerged')
1537 def init_paths(self):
1538 self.paths = check_conflicts(self.model.unmerged)
1541 class StageUntracked(StageCarefully):
1542 """Stage all untracked files."""
1544 @staticmethod
1545 def name():
1546 return N_('Stage Untracked')
1548 def init_paths(self):
1549 self.paths = self.model.untracked
1552 class StageOrUnstage(Command):
1553 """If the selection is staged, unstage it, otherwise stage"""
1555 @staticmethod
1556 def name():
1557 return N_('Stage / Unstage')
1559 def do(self):
1560 s = selection.selection()
1561 if s.staged:
1562 do(Unstage, s.staged)
1564 unstaged = []
1565 unmerged = check_conflicts(s.unmerged)
1566 if unmerged:
1567 unstaged.extend(unmerged)
1568 if s.modified:
1569 unstaged.extend(s.modified)
1570 if s.untracked:
1571 unstaged.extend(s.untracked)
1572 if unstaged:
1573 do(Stage, unstaged)
1576 class Tag(Command):
1577 """Create a tag object."""
1579 def __init__(self, name, revision, sign=False, message=''):
1580 Command.__init__(self)
1581 self._name = name
1582 self._message = message
1583 self._revision = revision
1584 self._sign = sign
1586 def do(self):
1587 log_msg = (N_('Tagging "%(revision)s" as "%(name)s"') %
1588 dict(revision=self._revision, name=self._name))
1589 opts = {}
1590 try:
1591 if self._message:
1592 opts['F'] = utils.tmp_filename('tag-message')
1593 core.write(opts['F'], self._message)
1595 if self._sign:
1596 log_msg += ' (%s)' % N_('GPG-signed')
1597 opts['s'] = True
1598 else:
1599 opts['a'] = bool(self._message)
1600 status, output, err = self.model.git.tag(self._name,
1601 self._revision, **opts)
1602 finally:
1603 if 'F' in opts:
1604 os.unlink(opts['F'])
1606 if output:
1607 log_msg += '\n' + (N_('Output: %s') % output)
1609 Interaction.log_status(status, log_msg, err)
1610 if status == 0:
1611 self.model.update_status()
1612 return (status, output, err)
1615 class Unstage(Command):
1616 """Unstage a set of paths."""
1618 @staticmethod
1619 def name():
1620 return N_('Unstage')
1622 def __init__(self, paths):
1623 Command.__init__(self)
1624 self.paths = paths
1626 def do(self):
1627 msg = N_('Unstaging: %s') % (', '.join(self.paths))
1628 Interaction.log(msg)
1629 with CommandDisabled(UpdateFileStatus):
1630 self.model.unstage_paths(self.paths)
1633 class UnstageAll(Command):
1634 """Unstage all files; resets the index."""
1636 def do(self):
1637 self.model.unstage_all()
1640 class UnstageSelected(Unstage):
1641 """Unstage selected files."""
1643 def __init__(self):
1644 Unstage.__init__(self, selection.selection_model().staged)
1647 class Untrack(Command):
1648 """Unstage a set of paths."""
1650 def __init__(self, paths):
1651 Command.__init__(self)
1652 self.paths = paths
1654 def do(self):
1655 msg = N_('Untracking: %s') % (', '.join(self.paths))
1656 Interaction.log(msg)
1657 with CommandDisabled(UpdateFileStatus):
1658 status, out, err = self.model.untrack_paths(self.paths)
1659 Interaction.log_status(status, out, err)
1662 class UntrackedSummary(Command):
1663 """List possible .gitignore rules as the diff text."""
1665 def __init__(self):
1666 Command.__init__(self)
1667 untracked = self.model.untracked
1668 suffix = len(untracked) > 1 and 's' or ''
1669 io = StringIO()
1670 io.write('# %s untracked file%s\n' % (len(untracked), suffix))
1671 if untracked:
1672 io.write('# possible .gitignore rule%s:\n' % suffix)
1673 for u in untracked:
1674 io.write('/'+u+'\n')
1675 self.new_diff_text = io.getvalue()
1676 self.new_mode = self.model.mode_untracked
1679 class UpdateFileStatus(Command):
1680 """Rescans for changes."""
1682 def do(self):
1683 self.model.update_file_status()
1686 class VisualizeAll(Command):
1687 """Visualize all branches."""
1689 def do(self):
1690 browser = utils.shell_split(prefs.history_browser())
1691 launch_history_browser(browser + ['--all'])
1694 class VisualizeCurrent(Command):
1695 """Visualize all branches."""
1697 def do(self):
1698 browser = utils.shell_split(prefs.history_browser())
1699 launch_history_browser(browser + [self.model.currentbranch])
1702 class VisualizePaths(Command):
1703 """Path-limited visualization."""
1705 def __init__(self, paths):
1706 Command.__init__(self)
1707 browser = utils.shell_split(prefs.history_browser())
1708 if paths:
1709 self.argv = browser + list(paths)
1710 else:
1711 self.argv = browser
1713 def do(self):
1714 launch_history_browser(self.argv)
1717 class VisualizeRevision(Command):
1718 """Visualize a specific revision."""
1720 def __init__(self, revision, paths=None):
1721 Command.__init__(self)
1722 self.revision = revision
1723 self.paths = paths
1725 def do(self):
1726 argv = utils.shell_split(prefs.history_browser())
1727 if self.revision:
1728 argv.append(self.revision)
1729 if self.paths:
1730 argv.append('--')
1731 argv.extend(self.paths)
1732 launch_history_browser(argv)
1735 def launch_history_browser(argv):
1736 try:
1737 core.fork(argv)
1738 except Exception as e:
1739 _, details = utils.format_exception(e)
1740 title = N_('Error Launching History Browser')
1741 msg = (N_('Cannot exec "%s": please configure a history browser') %
1742 ' '.join(argv))
1743 Interaction.critical(title, message=msg, details=details)
1746 def run(cls, *args, **opts):
1748 Returns a callback that runs a command
1750 If the caller of run() provides args or opts then those are
1751 used instead of the ones provided by the invoker of the callback.
1754 def runner(*local_args, **local_opts):
1755 if args or opts:
1756 do(cls, *args, **opts)
1757 else:
1758 do(cls, *local_args, **local_opts)
1760 return runner
1763 class CommandDisabled(object):
1765 """Context manager to temporarily disable a command from running"""
1766 def __init__(self, cmdclass):
1767 self.cmdclass = cmdclass
1769 def __enter__(self):
1770 self.cmdclass.DISABLED = True
1771 return self
1773 def __exit__(self, exc_type, exc_val, exc_tb):
1774 self.cmdclass.DISABLED = False
1777 def do(cls, *args, **opts):
1778 """Run a command in-place"""
1779 return do_cmd(cls(*args, **opts))
1782 def do_cmd(cmd):
1783 if hasattr(cmd, 'DISABLED') and cmd.DISABLED:
1784 return None
1785 try:
1786 return cmd.do()
1787 except Exception as e:
1788 msg, details = utils.format_exception(e)
1789 Interaction.critical(N_('Error'), message=msg, details=details)
1790 return None