qtutils: try to support older PyQt versions
[git-cola.git] / cola / cmds.py
blob8986f6ed1713c0041411af0d81b0dcff485790c6
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 gitcfg
18 from cola import gitcmds
19 from cola import inotify
20 from cola import utils
21 from cola import difftool
22 from cola import resources
23 from cola.diffparse import DiffParser
24 from cola.git import STDOUT
25 from cola.i18n import N_
26 from cola.interaction import Interaction
27 from cola.models import main
28 from cola.models import prefs
29 from cola.models import selection
32 class UsageError(Exception):
33 """Exception class for usage errors."""
34 def __init__(self, title, message):
35 Exception.__init__(self, message)
36 self.title = title
37 self.msg = message
40 class BaseCommand(object):
41 """Base class for all commands; provides the command pattern"""
43 DISABLED = False
45 def __init__(self):
46 self.undoable = False
48 def is_undoable(self):
49 """Can this be undone?"""
50 return self.undoable
52 @staticmethod
53 def name():
54 return 'Unknown'
56 def do(self):
57 pass
59 def undo(self):
60 pass
63 class ConfirmAction(BaseCommand):
65 def __init__(self):
66 BaseCommand.__init__(self)
68 def ok_to_run(self):
69 return True
71 def confirm(self):
72 return True
74 def action(self):
75 return (-1, '', '')
77 def ok(self, status):
78 return status == 0
80 def success(self):
81 pass
83 def fail(self, status, out, err):
84 title = msg = self.error_message()
85 details = self.error_details() or out + err
86 Interaction.critical(title, message=msg, details=details)
88 def error_message(self):
89 return ''
91 def error_details(self):
92 return ''
94 def do(self):
95 status = -1
96 out = err = ''
97 ok = self.ok_to_run() and self.confirm()
98 if ok:
99 status, out, err = self.action()
100 if self.ok(status):
101 self.success()
102 else:
103 self.fail(status, out, err)
105 return ok, status, out, err
108 class ModelCommand(BaseCommand):
109 """Commands that manipulate the main models"""
111 def __init__(self):
112 BaseCommand.__init__(self)
113 self.model = main.model()
116 class Command(ModelCommand):
117 """Base class for commands that modify the main model"""
119 def __init__(self):
120 """Initialize the command and stash away values for use in do()"""
121 # These are commonly used so let's make it easier to write new commands.
122 ModelCommand.__init__(self)
124 self.old_diff_text = self.model.diff_text
125 self.old_filename = self.model.filename
126 self.old_mode = self.model.mode
128 self.new_diff_text = self.old_diff_text
129 self.new_filename = self.old_filename
130 self.new_mode = self.old_mode
132 def do(self):
133 """Perform the operation."""
134 self.model.set_filename(self.new_filename)
135 self.model.set_mode(self.new_mode)
136 self.model.set_diff_text(self.new_diff_text)
138 def undo(self):
139 """Undo the operation."""
140 self.model.set_diff_text(self.old_diff_text)
141 self.model.set_filename(self.old_filename)
142 self.model.set_mode(self.old_mode)
145 class AmendMode(Command):
146 """Try to amend a commit."""
148 LAST_MESSAGE = None
150 @staticmethod
151 def name():
152 return N_('Amend')
154 def __init__(self, amend):
155 Command.__init__(self)
156 self.undoable = True
157 self.skip = False
158 self.amending = amend
159 self.old_commitmsg = self.model.commitmsg
160 self.old_mode = self.model.mode
162 if self.amending:
163 self.new_mode = self.model.mode_amend
164 self.new_commitmsg = self.model.prev_commitmsg()
165 AmendMode.LAST_MESSAGE = self.model.commitmsg
166 return
167 # else, amend unchecked, regular commit
168 self.new_mode = self.model.mode_none
169 self.new_diff_text = ''
170 self.new_commitmsg = self.model.commitmsg
171 # If we're going back into new-commit-mode then search the
172 # undo stack for a previous amend-commit-mode and grab the
173 # commit message at that point in time.
174 if AmendMode.LAST_MESSAGE is not None:
175 self.new_commitmsg = AmendMode.LAST_MESSAGE
176 AmendMode.LAST_MESSAGE = None
178 def do(self):
179 """Leave/enter amend mode."""
180 """Attempt to enter amend mode. Do not allow this when merging."""
181 if self.amending:
182 if self.model.is_merging:
183 self.skip = True
184 self.model.set_mode(self.old_mode)
185 Interaction.information(
186 N_('Cannot Amend'),
187 N_('You are in the middle of a merge.\n'
188 'Cannot amend while merging.'))
189 return
190 self.skip = False
191 Command.do(self)
192 self.model.set_commitmsg(self.new_commitmsg)
193 self.model.update_file_status()
195 def undo(self):
196 if self.skip:
197 return
198 self.model.set_commitmsg(self.old_commitmsg)
199 Command.undo(self)
200 self.model.update_file_status()
203 class ApplyDiffSelection(Command):
205 def __init__(self, first_line_idx, last_line_idx, has_selection,
206 reverse, apply_to_worktree):
207 Command.__init__(self)
208 self.first_line_idx = first_line_idx
209 self.last_line_idx = last_line_idx
210 self.has_selection = has_selection
211 self.reverse = reverse
212 self.apply_to_worktree = apply_to_worktree
214 def do(self):
215 parser = DiffParser(self.model.filename, self.model.diff_text)
216 if self.has_selection:
217 patch = parser.generate_patch(self.first_line_idx,
218 self.last_line_idx,
219 reverse=self.reverse)
220 else:
221 patch = parser.generate_hunk_patch(self.first_line_idx,
222 reverse=self.reverse)
223 if patch is None:
224 return
226 cfg = gitcfg.current()
227 tmp_path = utils.tmp_filename('patch')
228 try:
229 core.write(tmp_path, patch,
230 encoding=cfg.file_encoding(self.model.filename))
231 if self.apply_to_worktree:
232 status, out, err = self.model.apply_diff_to_worktree(tmp_path)
233 else:
234 status, out, err = self.model.apply_diff(tmp_path)
235 finally:
236 os.unlink(tmp_path)
237 Interaction.log_status(status, out, err)
238 self.model.update_file_status(update_index=True)
241 class ApplyPatches(Command):
243 def __init__(self, patches):
244 Command.__init__(self)
245 self.patches = patches
247 def do(self):
248 diff_text = ''
249 num_patches = len(self.patches)
250 orig_head = self.model.git.rev_parse('HEAD')[STDOUT]
252 for idx, patch in enumerate(self.patches):
253 status, out, err = self.model.git.am(patch)
254 # Log the git-am command
255 Interaction.log_status(status, out, err)
257 if num_patches > 1:
258 diff = self.model.git.diff('HEAD^!', stat=True)[STDOUT]
259 diff_text += (N_('PATCH %(current)d/%(count)d') %
260 dict(current=idx+1, count=num_patches))
261 diff_text += ' - %s:\n%s\n\n' % (os.path.basename(patch), diff)
263 diff_text += N_('Summary:') + '\n'
264 diff_text += self.model.git.diff(orig_head, stat=True)[STDOUT]
266 # Display a diffstat
267 self.model.set_diff_text(diff_text)
268 self.model.update_file_status()
270 basenames = '\n'.join([os.path.basename(p) for p in self.patches])
271 Interaction.information(
272 N_('Patch(es) Applied'),
273 (N_('%d patch(es) applied.') + '\n\n%s') %
274 (len(self.patches), basenames))
277 class Archive(BaseCommand):
279 def __init__(self, ref, fmt, prefix, filename):
280 BaseCommand.__init__(self)
281 self.ref = ref
282 self.fmt = fmt
283 self.prefix = prefix
284 self.filename = filename
286 def do(self):
287 fp = core.xopen(self.filename, 'wb')
288 cmd = ['git', 'archive', '--format='+self.fmt]
289 if self.fmt in ('tgz', 'tar.gz'):
290 cmd.append('-9')
291 if self.prefix:
292 cmd.append('--prefix=' + self.prefix)
293 cmd.append(self.ref)
294 proc = core.start_command(cmd, stdout=fp)
295 out, err = proc.communicate()
296 fp.close()
297 status = proc.returncode
298 Interaction.log_status(status, out or '', err or '')
301 class Checkout(Command):
303 A command object for git-checkout.
305 'argv' is handed off directly to git.
309 def __init__(self, argv, checkout_branch=False):
310 Command.__init__(self)
311 self.argv = argv
312 self.checkout_branch = checkout_branch
313 self.new_diff_text = ''
315 def do(self):
316 status, out, err = self.model.git.checkout(*self.argv)
317 Interaction.log_status(status, out, err)
318 if self.checkout_branch:
319 self.model.update_status()
320 else:
321 self.model.update_file_status()
324 class CheckoutBranch(Checkout):
325 """Checkout a branch."""
327 def __init__(self, branch):
328 args = [branch]
329 Checkout.__init__(self, args, checkout_branch=True)
332 class CherryPick(Command):
333 """Cherry pick commits into the current branch."""
335 def __init__(self, commits):
336 Command.__init__(self)
337 self.commits = commits
339 def do(self):
340 self.model.cherry_pick_list(self.commits)
341 self.model.update_file_status()
344 class ResetMode(Command):
345 """Reset the mode and clear the model's diff text."""
347 def __init__(self):
348 Command.__init__(self)
349 self.new_mode = self.model.mode_none
350 self.new_diff_text = ''
352 def do(self):
353 Command.do(self)
354 self.model.update_file_status()
357 class Commit(ResetMode):
358 """Attempt to create a new commit."""
360 def __init__(self, amend, msg, sign, no_verify=False):
361 ResetMode.__init__(self)
362 self.amend = amend
363 self.msg = msg
364 self.sign = sign
365 self.no_verify = no_verify
366 self.old_commitmsg = self.model.commitmsg
367 self.new_commitmsg = ''
369 def do(self):
370 # Create the commit message file
371 comment_char = prefs.comment_char()
372 msg = self.strip_comments(self.msg, comment_char=comment_char)
373 tmpfile = utils.tmp_filename('commit-message')
374 try:
375 core.write(tmpfile, msg)
377 # Run 'git commit'
378 status, out, err = self.model.git.commit(F=tmpfile,
379 v=True,
380 gpg_sign=self.sign,
381 amend=self.amend,
382 no_verify=self.no_verify)
383 finally:
384 core.unlink(tmpfile)
386 if status == 0:
387 ResetMode.do(self)
388 self.model.set_commitmsg(self.new_commitmsg)
389 msg = N_('Created commit: %s') % out
390 else:
391 msg = N_('Commit failed: %s') % out
392 Interaction.log_status(status, msg, err)
394 return status, out, err
396 @staticmethod
397 def strip_comments(msg, comment_char='#'):
398 # Strip off comments
399 message_lines = [line for line in msg.split('\n')
400 if not line.startswith(comment_char)]
401 msg = '\n'.join(message_lines)
402 if not msg.endswith('\n'):
403 msg += '\n'
405 return msg
408 class Ignore(Command):
409 """Add files to .gitignore"""
411 def __init__(self, filenames):
412 Command.__init__(self)
413 self.filenames = list(filenames)
415 def do(self):
416 if not self.filenames:
417 return
418 new_additions = '\n'.join(self.filenames) + '\n'
419 for_status = new_additions
420 if core.exists('.gitignore'):
421 current_list = core.read('.gitignore')
422 new_additions = current_list.rstrip() + '\n' + new_additions
423 core.write('.gitignore', new_additions)
424 Interaction.log_status(0, 'Added to .gitignore:\n%s' % for_status, '')
425 self.model.update_file_status()
428 def file_summary(files):
429 txt = subprocess.list2cmdline(files)
430 if len(txt) > 768:
431 txt = txt[:768].rstrip() + '...'
432 return txt
435 class RemoteCommand(ConfirmAction):
437 def __init__(self, remote):
438 ConfirmAction.__init__(self)
439 self.model = main.model()
440 self.remote = remote
442 def success(self):
443 self.model.update_remotes()
446 class RemoteAdd(RemoteCommand):
448 def __init__(self, remote, url):
449 RemoteCommand.__init__(self, remote)
450 self.url = url
452 def action(self):
453 git = self.model.git
454 return git.remote('add', self.remote, self.url)
456 def error_message(self):
457 return N_('Error creating remote "%s"') % self.remote
460 class RemoteRemove(RemoteCommand):
462 def confirm(self):
463 title = N_('Delete Remote')
464 question = N_('Delete remote?')
465 info = N_('Delete remote "%s"') % self.remote
466 ok_btn = N_('Delete')
467 return Interaction.confirm(title, question, info, ok_btn)
469 def action(self):
470 git = self.model.git
471 return git.remote('rm', self.remote)
473 def error_message(self):
474 return N_('Error deleting remote "%s"') % self.remote
477 class RemoteRename(RemoteCommand):
479 def __init__(self, remote, new_remote):
480 RemoteCommand.__init__(self, remote)
481 self.new_remote = new_remote
483 def confirm(self):
484 title = N_('Rename Remote')
485 question = N_('Rename remote?')
486 info = (N_('Rename remote "%(current)s" to "%(new)s"?') %
487 dict(current=self.remote, new=self.new_remote))
488 ok_btn = N_('Rename')
489 return Interaction.confirm(title, question, info, ok_btn)
491 def action(self):
492 git = self.model.git
493 return git.remote('rename', self.remote, self.new_remote)
496 class RemoveFromSettings(ConfirmAction):
498 def __init__(self, settings, repo, icon=None):
499 ConfirmAction.__init__(self)
500 self.settings = settings
501 self.repo = repo
502 self.icon = icon
504 def success(self):
505 self.settings.save()
508 class RemoveBookmark(RemoveFromSettings):
510 def confirm(self):
511 repo = self.repo
512 title = msg = N_('Delete Bookmark?')
513 info = N_('%s will be removed from your bookmarks.') % repo
514 ok_text = N_('Delete Bookmark')
515 return Interaction.confirm(title, msg, info, ok_text, icon=self.icon)
517 def action(self):
518 self.settings.remove_bookmark(self.repo)
519 return (0, '', '')
522 class RemoveRecent(RemoveFromSettings):
524 def confirm(self):
525 repo = self.repo
526 title = msg = N_('Remove %s from the recent list?') % repo
527 info = N_('%s will be removed from your recent repositories.') % repo
528 ok_text = N_('Remove')
529 return Interaction.confirm(title, msg, info, ok_text, icon=self.icon)
531 def action(self):
532 self.settings.remove_recent(self.repo)
533 return (0, '', '')
536 class RemoveFiles(Command):
537 """Removes files."""
539 def __init__(self, remover, filenames):
540 Command.__init__(self)
541 if remover is None:
542 remover = os.remove
543 self.remover = remover
544 self.filenames = filenames
545 # We could git-hash-object stuff and provide undo-ability
546 # as an option. Heh.
548 def do(self):
549 files = self.filenames
550 if not files:
551 return
553 rescan = False
554 bad_filenames = []
555 remove = self.remover
556 for filename in files:
557 if filename:
558 try:
559 remove(filename)
560 rescan=True
561 except:
562 bad_filenames.append(filename)
564 if bad_filenames:
565 Interaction.information(
566 N_('Error'),
567 N_('Deleting "%s" failed') % file_summary(files))
569 if rescan:
570 self.model.update_file_status()
573 class Delete(RemoveFiles):
574 """Delete files."""
576 def __init__(self, filenames):
577 RemoveFiles.__init__(self, os.remove, filenames)
579 def do(self):
580 files = self.filenames
581 if not files:
582 return
584 title = N_('Delete Files?')
585 msg = N_('The following files will be deleted:') + '\n\n'
586 msg += file_summary(files)
587 info_txt = N_('Delete %d file(s)?') % len(files)
588 ok_txt = N_('Delete Files')
590 if not Interaction.confirm(title, msg, info_txt, ok_txt,
591 default=True,
592 icon=resources.icon('remove.svg')):
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, noff, sign):
914 Command.__init__(self)
915 self.revision = revision
916 self.no_ff = noff
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
927 msg = gitcmds.merge_message(revision)
929 status, out, err = self.model.git.merge('-m', msg, revision,
930 gpg_sign=sign,
931 no_ff=no_ff,
932 no_commit=no_commit,
933 squash=squash)
934 Interaction.log_status(status, out, err)
935 self.model.update_status()
938 class OpenDefaultApp(BaseCommand):
939 """Open a file using the OS default."""
941 @staticmethod
942 def name():
943 return N_('Open Using Default Application')
945 def __init__(self, filenames):
946 BaseCommand.__init__(self)
947 if utils.is_darwin():
948 launcher = 'open'
949 else:
950 launcher = 'xdg-open'
951 self.launcher = launcher
952 self.filenames = filenames
954 def do(self):
955 if not self.filenames:
956 return
957 core.fork([self.launcher] + self.filenames)
960 class OpenParentDir(OpenDefaultApp):
961 """Open parent directories using the OS default."""
963 @staticmethod
964 def name():
965 return N_('Open Parent Directory')
967 def __init__(self, filenames):
968 OpenDefaultApp.__init__(self, filenames)
970 def do(self):
971 if not self.filenames:
972 return
973 dirs = list(set(map(os.path.dirname, self.filenames)))
974 core.fork([self.launcher] + dirs)
977 class OpenNewRepo(Command):
978 """Launches git-cola on a repo."""
980 def __init__(self, repo_path):
981 Command.__init__(self)
982 self.repo_path = repo_path
984 def do(self):
985 self.model.set_directory(self.repo_path)
986 core.fork([sys.executable, sys.argv[0], '--repo', self.repo_path])
989 class OpenRepo(Command):
990 def __init__(self, repo_path):
991 Command.__init__(self)
992 self.repo_path = repo_path
994 def do(self):
995 git = self.model.git
996 old_worktree = git.worktree()
997 if not self.model.set_worktree(self.repo_path):
998 self.model.set_worktree(old_worktree)
999 return
1000 new_worktree = git.worktree()
1001 core.chdir(new_worktree)
1002 self.model.set_directory(self.repo_path)
1003 cfg = gitcfg.current()
1004 cfg.reset()
1005 inotify.stop()
1006 inotify.start()
1007 self.model.update_status()
1010 class Clone(Command):
1011 """Clones a repository and optionally spawns a new cola session."""
1013 def __init__(self, url, new_directory, spawn=True):
1014 Command.__init__(self)
1015 self.url = url
1016 self.new_directory = new_directory
1017 self.spawn = spawn
1019 self.ok = False
1020 self.error_message = ''
1021 self.error_details = ''
1023 def do(self):
1024 status, out, err = self.model.git.clone(self.url, self.new_directory)
1025 self.ok = status == 0
1027 if self.ok:
1028 if self.spawn:
1029 core.fork([sys.executable, sys.argv[0],
1030 '--repo', self.new_directory])
1031 else:
1032 self.error_message = N_('Error: could not clone "%s"') % self.url
1033 self.error_details = (
1034 (N_('git clone returned exit code %s') % status) +
1035 ((out+err) and ('\n\n' + out + err) or ''))
1037 return self
1040 def unix_path(path, is_win32=utils.is_win32):
1041 """Git for Windows requires unix paths, so force them here
1043 unix_path = path
1044 if is_win32():
1045 first = path[0]
1046 second = path[1]
1047 if second == ':': # sanity check, this better be a Windows-style path
1048 unix_path = '/' + first + path[2:].replace('\\', '/')
1050 return unix_path
1053 class GitXBaseContext(object):
1055 def __init__(self, **kwargs):
1056 self.env = {'GIT_EDITOR': prefs.editor()}
1057 self.env.update(kwargs)
1059 def __enter__(self):
1060 compat.setenv('GIT_SEQUENCE_EDITOR',
1061 unix_path(resources.share('bin', 'git-xbase')))
1062 for var, value in self.env.items():
1063 compat.setenv(var, value)
1064 return self
1066 def __exit__(self, exc_type, exc_val, exc_tb):
1067 compat.unsetenv('GIT_SEQUENCE_EDITOR')
1068 for var in self.env:
1069 compat.unsetenv(var)
1072 class Rebase(Command):
1074 def __init__(self,
1075 upstream=None, branch=None, capture_output=True, **kwargs):
1076 """Start an interactive rebase session
1078 :param upstream: upstream branch
1079 :param branch: optional branch to checkout
1080 :param capture_output: whether to capture stdout and stderr
1081 :param kwargs: forwarded directly to `git.rebase()`
1084 Command.__init__(self)
1086 self.upstream = upstream
1087 self.branch = branch
1088 self.capture_output = capture_output
1089 self.kwargs = kwargs
1091 def prepare_arguments(self):
1092 args = []
1093 kwargs = {}
1095 if self.capture_output:
1096 kwargs['_stderr'] = None
1097 kwargs['_stdout'] = None
1099 # Rebase actions must be the only option specified
1100 for action in ('continue', 'abort', 'skip', 'edit_todo'):
1101 if self.kwargs.get(action, False):
1102 kwargs[action] = self.kwargs[action]
1103 return args, kwargs
1105 kwargs['interactive'] = True
1106 kwargs['autosquash'] = self.kwargs.get('autosquash', True)
1107 kwargs.update(self.kwargs)
1109 if self.upstream:
1110 args.append(self.upstream)
1111 if self.branch:
1112 args.append(self.branch)
1114 return args, kwargs
1116 def do(self):
1117 (status, out, err) = (1, '', '')
1118 args, kwargs = self.prepare_arguments()
1119 upstream_title = self.upstream or '@{upstream}'
1120 with GitXBaseContext(
1121 GIT_XBASE_TITLE=N_('Rebase onto %s') % upstream_title,
1122 GIT_XBASE_ACTION=N_('Rebase')):
1123 status, out, err = self.model.git.rebase(*args, **kwargs)
1124 Interaction.log_status(status, out, err)
1125 self.model.update_status()
1126 return status, out, err
1129 class RebaseEditTodo(Command):
1131 def do(self):
1132 (status, out, err) = (1, '', '')
1133 with GitXBaseContext(
1134 GIT_XBASE_TITLE=N_('Edit Rebase'),
1135 GIT_XBASE_ACTION=N_('Save')):
1136 status, out, err = self.model.git.rebase(edit_todo=True)
1137 Interaction.log_status(status, out, err)
1138 self.model.update_status()
1139 return status, out, err
1142 class RebaseContinue(Command):
1144 def do(self):
1145 (status, out, err) = (1, '', '')
1146 with GitXBaseContext(
1147 GIT_XBASE_TITLE=N_('Rebase'),
1148 GIT_XBASE_ACTION=N_('Rebase')):
1149 status, out, err = self.model.git.rebase('--continue')
1150 Interaction.log_status(status, out, err)
1151 self.model.update_status()
1152 return status, out, err
1155 class RebaseSkip(Command):
1157 def do(self):
1158 (status, out, err) = (1, '', '')
1159 with GitXBaseContext(
1160 GIT_XBASE_TITLE=N_('Rebase'),
1161 GIT_XBASE_ACTION=N_('Rebase')):
1162 status, out, err = self.model.git.rebase(skip=True)
1163 Interaction.log_status(status, out, err)
1164 self.model.update_status()
1165 return status, out, err
1168 class RebaseAbort(Command):
1170 def do(self):
1171 status, out, err = self.model.git.rebase(abort=True)
1172 Interaction.log_status(status, out, err)
1173 self.model.update_status()
1176 class Rescan(Command):
1177 """Rescan for changes"""
1179 def do(self):
1180 self.model.update_status()
1183 class Refresh(Command):
1184 """Update refs and refresh the index"""
1186 @staticmethod
1187 def name():
1188 return N_('Refresh')
1190 def do(self):
1191 self.model.update_status(update_index=True)
1194 class RevertEditsCommand(ConfirmAction):
1196 def __init__(self):
1197 ConfirmAction.__init__(self)
1198 self.model = main.model()
1199 self.icon = resources.icon('edit-undo.svg')
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 SetDiffText(Command):
1353 def __init__(self, text):
1354 Command.__init__(self)
1355 self.undoable = True
1356 self.new_diff_text = text
1359 class ShowUntracked(Command):
1360 """Show an untracked file."""
1362 def __init__(self, filename):
1363 Command.__init__(self)
1364 self.new_filename = filename
1365 self.new_mode = self.model.mode_untracked
1366 self.new_diff_text = self.diff_text_for(filename)
1368 def diff_text_for(self, filename):
1369 cfg = gitcfg.current()
1370 size = cfg.get('cola.readsize', 1024 * 2)
1371 try:
1372 result = core.read(filename, size=size,
1373 encoding='utf-8', errors='ignore')
1374 except:
1375 result = ''
1377 if len(result) == size:
1378 result += '...'
1379 return result
1382 class SignOff(Command):
1384 @staticmethod
1385 def name():
1386 return N_('Sign Off')
1388 def __init__(self):
1389 Command.__init__(self)
1390 self.undoable = True
1391 self.old_commitmsg = self.model.commitmsg
1393 def do(self):
1394 signoff = self.signoff()
1395 if signoff in self.model.commitmsg:
1396 return
1397 self.model.set_commitmsg(self.model.commitmsg + '\n' + signoff)
1399 def undo(self):
1400 self.model.set_commitmsg(self.old_commitmsg)
1402 def signoff(self):
1403 try:
1404 import pwd
1405 user = pwd.getpwuid(os.getuid()).pw_name
1406 except ImportError:
1407 user = os.getenv('USER', N_('unknown'))
1409 cfg = gitcfg.current()
1410 name = cfg.get('user.name', user)
1411 email = cfg.get('user.email', '%s@%s' % (user, core.node()))
1412 return '\nSigned-off-by: %s <%s>' % (name, email)
1415 def check_conflicts(unmerged):
1416 """Check paths for conflicts
1418 Conflicting files can be filtered out one-by-one.
1421 if prefs.check_conflicts():
1422 unmerged = [path for path in unmerged if is_conflict_free(path)]
1423 return unmerged
1426 def is_conflict_free(path):
1427 """Return True if `path` contains no conflict markers
1429 rgx = re.compile(r'^(<<<<<<<|\|\|\|\|\|\|\||>>>>>>>) ')
1430 try:
1431 with core.xopen(path, 'r') as f:
1432 for line in f:
1433 line = core.decode(line, errors='ignore')
1434 if rgx.match(line):
1435 if should_stage_conflicts(path):
1436 return True
1437 else:
1438 return False
1439 except IOError:
1440 # We can't read this file ~ we may be staging a removal
1441 pass
1442 return True
1445 def should_stage_conflicts(path):
1446 """Inform the user that a file contains merge conflicts
1448 Return `True` if we should stage the path nonetheless.
1451 title = msg = N_('Stage conflicts?')
1452 info = N_('%s appears to contain merge conflicts.\n\n'
1453 'You should probably skip this file.\n'
1454 'Stage it anyways?') % path
1455 ok_text = N_('Stage conflicts')
1456 cancel_text = N_('Skip')
1457 return Interaction.confirm(title, msg, info, ok_text,
1458 default=False, cancel_text=cancel_text)
1461 class Stage(Command):
1462 """Stage a set of paths."""
1464 @staticmethod
1465 def name():
1466 return N_('Stage')
1468 def __init__(self, paths):
1469 Command.__init__(self)
1470 self.paths = paths
1472 def do(self):
1473 msg = N_('Staging: %s') % (', '.join(self.paths))
1474 Interaction.log(msg)
1475 # Prevent external updates while we are staging files.
1476 # We update file stats at the end of this operation
1477 # so there's no harm in ignoring updates from other threads
1478 # (e.g. inotify).
1479 with CommandDisabled(UpdateFileStatus):
1480 self.model.stage_paths(self.paths)
1483 class StageCarefully(Stage):
1484 """Only stage when the path list is non-empty
1486 We use "git add -u -- <pathspec>" to stage, and it stages everything by
1487 default when no pathspec is specified, so this class ensures that paths
1488 are specified before calling git.
1490 When no paths are specified, the command does nothing.
1493 def __init__(self):
1494 Stage.__init__(self, None)
1495 self.init_paths()
1497 def init_paths(self):
1498 pass
1500 def ok_to_run(self):
1501 """Prevent catch-all "git add -u" from adding unmerged files"""
1502 return self.paths or not self.model.unmerged
1504 def do(self):
1505 if self.ok_to_run():
1506 Stage.do(self)
1509 class StageModified(StageCarefully):
1510 """Stage all modified files."""
1512 @staticmethod
1513 def name():
1514 return N_('Stage Modified')
1516 def init_paths(self):
1517 self.paths = self.model.modified
1520 class StageUnmerged(StageCarefully):
1521 """Stage unmerged files."""
1523 @staticmethod
1524 def name():
1525 return N_('Stage Unmerged')
1527 def init_paths(self):
1528 self.paths = check_conflicts(self.model.unmerged)
1531 class StageUntracked(StageCarefully):
1532 """Stage all untracked files."""
1534 @staticmethod
1535 def name():
1536 return N_('Stage Untracked')
1538 def init_paths(self):
1539 self.paths = self.model.untracked
1542 class StageOrUnstage(Command):
1543 """If the selection is staged, unstage it, otherwise stage"""
1545 @staticmethod
1546 def name():
1547 return N_('Stage / Unstage')
1549 def do(self):
1550 s = selection.selection()
1551 if s.staged:
1552 do(Unstage, s.staged)
1554 unstaged = []
1555 unmerged = check_conflicts(s.unmerged)
1556 if unmerged:
1557 unstaged.extend(unmerged)
1558 if s.modified:
1559 unstaged.extend(s.modified)
1560 if s.untracked:
1561 unstaged.extend(s.untracked)
1562 if unstaged:
1563 do(Stage, unstaged)
1566 class Tag(Command):
1567 """Create a tag object."""
1569 def __init__(self, name, revision, sign=False, message=''):
1570 Command.__init__(self)
1571 self._name = name
1572 self._message = message
1573 self._revision = revision
1574 self._sign = sign
1576 def do(self):
1577 log_msg = (N_('Tagging "%(revision)s" as "%(name)s"') %
1578 dict(revision=self._revision, name=self._name))
1579 opts = {}
1580 try:
1581 if self._message:
1582 opts['F'] = utils.tmp_filename('tag-message')
1583 core.write(opts['F'], self._message)
1585 if self._sign:
1586 log_msg += ' (%s)' % N_('GPG-signed')
1587 opts['s'] = True
1588 else:
1589 opts['a'] = bool(self._message)
1590 status, output, err = self.model.git.tag(self._name,
1591 self._revision, **opts)
1592 finally:
1593 if 'F' in opts:
1594 os.unlink(opts['F'])
1596 if output:
1597 log_msg += '\n' + (N_('Output: %s') % output)
1599 Interaction.log_status(status, log_msg, err)
1600 if status == 0:
1601 self.model.update_status()
1602 return (status, output, err)
1605 class Unstage(Command):
1606 """Unstage a set of paths."""
1608 @staticmethod
1609 def name():
1610 return N_('Unstage')
1612 def __init__(self, paths):
1613 Command.__init__(self)
1614 self.paths = paths
1616 def do(self):
1617 msg = N_('Unstaging: %s') % (', '.join(self.paths))
1618 Interaction.log(msg)
1619 with CommandDisabled(UpdateFileStatus):
1620 self.model.unstage_paths(self.paths)
1623 class UnstageAll(Command):
1624 """Unstage all files; resets the index."""
1626 def do(self):
1627 self.model.unstage_all()
1630 class UnstageSelected(Unstage):
1631 """Unstage selected files."""
1633 def __init__(self):
1634 Unstage.__init__(self, selection.selection_model().staged)
1637 class Untrack(Command):
1638 """Unstage a set of paths."""
1640 def __init__(self, paths):
1641 Command.__init__(self)
1642 self.paths = paths
1644 def do(self):
1645 msg = N_('Untracking: %s') % (', '.join(self.paths))
1646 Interaction.log(msg)
1647 with CommandDisabled(UpdateFileStatus):
1648 status, out, err = self.model.untrack_paths(self.paths)
1649 Interaction.log_status(status, out, err)
1652 class UntrackedSummary(Command):
1653 """List possible .gitignore rules as the diff text."""
1655 def __init__(self):
1656 Command.__init__(self)
1657 untracked = self.model.untracked
1658 suffix = len(untracked) > 1 and 's' or ''
1659 io = StringIO()
1660 io.write('# %s untracked file%s\n' % (len(untracked), suffix))
1661 if untracked:
1662 io.write('# possible .gitignore rule%s:\n' % suffix)
1663 for u in untracked:
1664 io.write('/'+u+'\n')
1665 self.new_diff_text = io.getvalue()
1666 self.new_mode = self.model.mode_untracked
1669 class UpdateFileStatus(Command):
1670 """Rescans for changes."""
1672 def do(self):
1673 self.model.update_file_status()
1676 class VisualizeAll(Command):
1677 """Visualize all branches."""
1679 def do(self):
1680 browser = utils.shell_split(prefs.history_browser())
1681 launch_history_browser(browser + ['--all'])
1684 class VisualizeCurrent(Command):
1685 """Visualize all branches."""
1687 def do(self):
1688 browser = utils.shell_split(prefs.history_browser())
1689 launch_history_browser(browser + [self.model.currentbranch])
1692 class VisualizePaths(Command):
1693 """Path-limited visualization."""
1695 def __init__(self, paths):
1696 Command.__init__(self)
1697 browser = utils.shell_split(prefs.history_browser())
1698 if paths:
1699 self.argv = browser + list(paths)
1700 else:
1701 self.argv = browser
1703 def do(self):
1704 launch_history_browser(self.argv)
1707 class VisualizeRevision(Command):
1708 """Visualize a specific revision."""
1710 def __init__(self, revision, paths=None):
1711 Command.__init__(self)
1712 self.revision = revision
1713 self.paths = paths
1715 def do(self):
1716 argv = utils.shell_split(prefs.history_browser())
1717 if self.revision:
1718 argv.append(self.revision)
1719 if self.paths:
1720 argv.append('--')
1721 argv.extend(self.paths)
1722 launch_history_browser(argv)
1725 def launch_history_browser(argv):
1726 try:
1727 core.fork(argv)
1728 except Exception as e:
1729 _, details = utils.format_exception(e)
1730 title = N_('Error Launching History Browser')
1731 msg = (N_('Cannot exec "%s": please configure a history browser') %
1732 ' '.join(argv))
1733 Interaction.critical(title, message=msg, details=details)
1736 def run(cls, *args, **opts):
1738 Returns a callback that runs a command
1740 If the caller of run() provides args or opts then those are
1741 used instead of the ones provided by the invoker of the callback.
1744 def runner(*local_args, **local_opts):
1745 if args or opts:
1746 do(cls, *args, **opts)
1747 else:
1748 do(cls, *local_args, **local_opts)
1750 return runner
1753 class CommandDisabled(object):
1755 """Context manager to temporarily disable a command from running"""
1756 def __init__(self, cmdclass):
1757 self.cmdclass = cmdclass
1759 def __enter__(self):
1760 self.cmdclass.DISABLED = True
1761 return self
1763 def __exit__(self, exc_type, exc_val, exc_tb):
1764 self.cmdclass.DISABLED = False
1767 def do(cls, *args, **opts):
1768 """Run a command in-place"""
1769 return do_cmd(cls(*args, **opts))
1772 def do_cmd(cmd):
1773 if hasattr(cmd, 'DISABLED') and cmd.DISABLED:
1774 return None
1775 try:
1776 return cmd.do()
1777 except Exception as e:
1778 msg, details = utils.format_exception(e)
1779 Interaction.critical(N_('Error'), message=msg, details=details)
1780 return None