dag: add commits_were_invalidated method to Edge
[git-cola.git] / cola / cmds.py
blob23d501d75535762058018d263ae78dd036498108
1 from __future__ import division, absolute_import, unicode_literals
2 import os
3 import re
4 import sys
5 from fnmatch import fnmatch
6 from io import StringIO
8 try:
9 from send2trash import send2trash
10 except ImportError:
11 send2trash = None
13 from . import compat
14 from . import core
15 from . import fsmonitor
16 from . import gitcfg
17 from . import gitcmds
18 from . import icons
19 from . import utils
20 from . import resources
21 from .diffparse import DiffParser
22 from .git import STDOUT
23 from .i18n import N_
24 from .interaction import Interaction
25 from .models import main
26 from .models import prefs
27 from .models import selection
30 class UsageError(Exception):
31 """Exception class for usage errors."""
32 def __init__(self, title, message):
33 Exception.__init__(self, message)
34 self.title = title
35 self.msg = message
38 class BaseCommand(object):
39 """Base class for all commands; provides the command pattern"""
41 DISABLED = False
43 def __init__(self, **kwargs):
44 self.undoable = False
45 for k, v in kwargs.items():
46 setattr(self, k, v)
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, **kwargs):
66 BaseCommand.__init__(self, **kwargs)
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, **kwargs):
112 # Note: self.model is set before calling the base class constructor
113 # to allow being having the `model` value be overridden by passing
114 # `model=xxx` during construction.
115 self.model = main.model()
116 BaseCommand.__init__(self, **kwargs)
119 class Command(ModelCommand):
120 """Base class for commands that modify the main model"""
122 def __init__(self):
123 """Initialize the command and stash away values for use in do()"""
124 # These are commonly used so let's make it easier to write new commands.
125 ModelCommand.__init__(self)
127 self.old_diff_text = self.model.diff_text
128 self.old_filename = self.model.filename
129 self.old_mode = self.model.mode
131 self.new_diff_text = self.old_diff_text
132 self.new_filename = self.old_filename
133 self.new_mode = self.old_mode
135 def do(self):
136 """Perform the operation."""
137 self.model.set_filename(self.new_filename)
138 self.model.set_mode(self.new_mode)
139 self.model.set_diff_text(self.new_diff_text)
141 def undo(self):
142 """Undo the operation."""
143 self.model.set_diff_text(self.old_diff_text)
144 self.model.set_filename(self.old_filename)
145 self.model.set_mode(self.old_mode)
148 class AmendMode(Command):
149 """Try to amend a commit."""
151 LAST_MESSAGE = None
153 @staticmethod
154 def name():
155 return N_('Amend')
157 def __init__(self, amend):
158 Command.__init__(self)
159 self.undoable = True
160 self.skip = False
161 self.amending = amend
162 self.old_commitmsg = self.model.commitmsg
163 self.old_mode = self.model.mode
165 if self.amending:
166 self.new_mode = self.model.mode_amend
167 self.new_commitmsg = self.model.prev_commitmsg()
168 AmendMode.LAST_MESSAGE = self.model.commitmsg
169 return
170 # else, amend unchecked, regular commit
171 self.new_mode = self.model.mode_none
172 self.new_diff_text = ''
173 self.new_commitmsg = self.model.commitmsg
174 # If we're going back into new-commit-mode then search the
175 # undo stack for a previous amend-commit-mode and grab the
176 # commit message at that point in time.
177 if AmendMode.LAST_MESSAGE is not None:
178 self.new_commitmsg = AmendMode.LAST_MESSAGE
179 AmendMode.LAST_MESSAGE = None
181 def do(self):
182 """Leave/enter amend mode."""
183 """Attempt to enter amend mode. Do not allow this when merging."""
184 if self.amending:
185 if self.model.is_merging:
186 self.skip = True
187 self.model.set_mode(self.old_mode)
188 Interaction.information(
189 N_('Cannot Amend'),
190 N_('You are in the middle of a merge.\n'
191 'Cannot amend while merging.'))
192 return
193 self.skip = False
194 Command.do(self)
195 self.model.set_commitmsg(self.new_commitmsg)
196 self.model.update_file_status()
198 def undo(self):
199 if self.skip:
200 return
201 self.model.set_commitmsg(self.old_commitmsg)
202 Command.undo(self)
203 self.model.update_file_status()
206 class ApplyDiffSelection(Command):
208 def __init__(self, first_line_idx, last_line_idx, has_selection,
209 reverse, apply_to_worktree):
210 Command.__init__(self)
211 self.first_line_idx = first_line_idx
212 self.last_line_idx = last_line_idx
213 self.has_selection = has_selection
214 self.reverse = reverse
215 self.apply_to_worktree = apply_to_worktree
217 def do(self):
218 parser = DiffParser(self.model.filename, self.model.diff_text)
219 if self.has_selection:
220 patch = parser.generate_patch(self.first_line_idx,
221 self.last_line_idx,
222 reverse=self.reverse)
223 else:
224 patch = parser.generate_hunk_patch(self.first_line_idx,
225 reverse=self.reverse)
226 if patch is None:
227 return
229 cfg = gitcfg.current()
230 encoding = cfg.file_encoding(self.model.filename)
231 tmp_file = utils.tmp_filename('patch')
232 try:
233 core.write(tmp_file, patch, encoding=encoding)
234 if self.apply_to_worktree:
235 status, out, err = self.model.apply_diff_to_worktree(tmp_file)
236 else:
237 status, out, err = self.model.apply_diff(tmp_file)
238 finally:
239 core.unlink(tmp_file)
241 Interaction.log_status(status, out, err)
242 self.model.update_file_status(update_index=True)
245 class ApplyPatches(Command):
247 def __init__(self, patches):
248 Command.__init__(self)
249 self.patches = patches
251 def do(self):
252 diff_text = ''
253 num_patches = len(self.patches)
254 orig_head = self.model.git.rev_parse('HEAD')[STDOUT]
256 for idx, patch in enumerate(self.patches):
257 status, out, err = self.model.git.am(patch)
258 # Log the git-am command
259 Interaction.log_status(status, out, err)
261 if num_patches > 1:
262 diff = self.model.git.diff('HEAD^!', stat=True)[STDOUT]
263 diff_text += (N_('PATCH %(current)d/%(count)d') %
264 dict(current=idx+1, count=num_patches))
265 diff_text += ' - %s:\n%s\n\n' % (os.path.basename(patch), diff)
267 diff_text += N_('Summary:') + '\n'
268 diff_text += self.model.git.diff(orig_head, stat=True)[STDOUT]
270 # Display a diffstat
271 self.model.set_diff_text(diff_text)
272 self.model.update_file_status()
274 basenames = '\n'.join([os.path.basename(p) for p in self.patches])
275 Interaction.information(
276 N_('Patch(es) Applied'),
277 (N_('%d patch(es) applied.') +
278 '\n\n%s') % (len(self.patches), basenames))
281 class Archive(BaseCommand):
283 def __init__(self, ref, fmt, prefix, filename):
284 BaseCommand.__init__(self)
285 self.ref = ref
286 self.fmt = fmt
287 self.prefix = prefix
288 self.filename = filename
290 def do(self):
291 fp = core.xopen(self.filename, 'wb')
292 cmd = ['git', 'archive', '--format='+self.fmt]
293 if self.fmt in ('tgz', 'tar.gz'):
294 cmd.append('-9')
295 if self.prefix:
296 cmd.append('--prefix=' + self.prefix)
297 cmd.append(self.ref)
298 proc = core.start_command(cmd, stdout=fp)
299 out, err = proc.communicate()
300 fp.close()
301 status = proc.returncode
302 Interaction.log_status(status, out or '', err or '')
305 class Checkout(Command):
307 A command object for git-checkout.
309 'argv' is handed off directly to git.
313 def __init__(self, argv, checkout_branch=False):
314 Command.__init__(self)
315 self.argv = argv
316 self.checkout_branch = checkout_branch
317 self.new_diff_text = ''
319 def do(self):
320 status, out, err = self.model.git.checkout(*self.argv)
321 Interaction.log_status(status, out, err)
322 if self.checkout_branch:
323 self.model.update_status()
324 else:
325 self.model.update_file_status()
328 class BlamePaths(Command):
329 """Blame view for paths."""
331 def __init__(self, paths):
332 Command.__init__(self)
333 viewer = utils.shell_split(prefs.blame_viewer())
334 self.argv = viewer + list(paths)
336 def do(self):
337 try:
338 core.fork(self.argv)
339 except Exception as e:
340 _, details = utils.format_exception(e)
341 title = N_('Error Launching Blame Viewer')
342 msg = (N_('Cannot exec "%s": please configure a blame viewer') %
343 ' '.join(self.argv))
344 Interaction.critical(title, message=msg, details=details)
347 class CheckoutBranch(Checkout):
348 """Checkout a branch."""
350 def __init__(self, branch):
351 args = [branch]
352 Checkout.__init__(self, args, checkout_branch=True)
355 class CherryPick(Command):
356 """Cherry pick commits into the current branch."""
358 def __init__(self, commits):
359 Command.__init__(self)
360 self.commits = commits
362 def do(self):
363 self.model.cherry_pick_list(self.commits)
364 self.model.update_file_status()
367 class ResetMode(Command):
368 """Reset the mode and clear the model's diff text."""
370 def __init__(self):
371 Command.__init__(self)
372 self.new_mode = self.model.mode_none
373 self.new_diff_text = ''
375 def do(self):
376 Command.do(self)
377 self.model.update_file_status()
380 class ResetCommand(ConfirmAction):
382 def __init__(self, ref):
383 ConfirmAction.__init__(self, model=main.model(), ref=ref)
385 def action(self):
386 status, out, err = self.reset()
387 Interaction.log_status(status, out, err)
388 return status, out, err
390 def success(self):
391 self.model.update_file_status()
393 def confirm(self):
394 raise NotImplemented('confirm() must be overridden')
396 def reset(self):
397 raise NotImplemented('reset() must be overridden')
400 class ResetBranchHead(ResetCommand):
402 def confirm(self):
403 title = N_('Reset Branch')
404 question = N_('Point the current branch head to a new commit?')
405 info = N_('The branch will be reset using "git reset --mixed %s"')
406 ok_btn = N_('Reset Branch')
407 info = info % self.ref
408 return Interaction.confirm(title, question, info, ok_btn)
410 def reset(self):
411 git = self.model.git
412 return git.reset(self.ref, '--', mixed=True)
415 class ResetWorktree(ResetCommand):
417 def confirm(self):
418 title = N_('Reset Worktree')
419 question = N_('Reset worktree?')
420 info = N_('The worktree will be reset using "git reset --merge %s"')
421 ok_btn = N_('Reset Worktree')
422 info = info % self.ref
423 return Interaction.confirm(title, question, info, ok_btn)
425 def reset(self):
426 return self.model.git.reset(self.ref, '--', merge=True)
429 class Commit(ResetMode):
430 """Attempt to create a new commit."""
432 def __init__(self, amend, msg, sign, no_verify=False):
433 ResetMode.__init__(self)
434 self.amend = amend
435 self.msg = msg
436 self.sign = sign
437 self.no_verify = no_verify
438 self.old_commitmsg = self.model.commitmsg
439 self.new_commitmsg = ''
441 def do(self):
442 # Create the commit message file
443 comment_char = prefs.comment_char()
444 msg = self.strip_comments(self.msg, comment_char=comment_char)
445 tmp_file = utils.tmp_filename('commit-message')
446 try:
447 core.write(tmp_file, msg)
449 # Run 'git commit'
450 status, out, err = self.model.git.commit(F=tmp_file,
451 v=True,
452 gpg_sign=self.sign,
453 amend=self.amend,
454 no_verify=self.no_verify)
455 finally:
456 core.unlink(tmp_file)
458 if status == 0:
459 ResetMode.do(self)
460 self.model.set_commitmsg(self.new_commitmsg)
461 msg = N_('Created commit: %s') % out
462 else:
463 msg = N_('Commit failed: %s') % out
464 Interaction.log_status(status, msg, err)
466 return status, out, err
468 @staticmethod
469 def strip_comments(msg, comment_char='#'):
470 # Strip off comments
471 message_lines = [line for line in msg.split('\n')
472 if not line.startswith(comment_char)]
473 msg = '\n'.join(message_lines)
474 if not msg.endswith('\n'):
475 msg += '\n'
477 return msg
480 class Ignore(Command):
481 """Add files to .gitignore"""
483 def __init__(self, filenames):
484 Command.__init__(self)
485 self.filenames = list(filenames)
487 def do(self):
488 if not self.filenames:
489 return
490 new_additions = '\n'.join(self.filenames) + '\n'
491 for_status = new_additions
492 if core.exists('.gitignore'):
493 current_list = core.read('.gitignore')
494 new_additions = current_list.rstrip() + '\n' + new_additions
495 core.write('.gitignore', new_additions)
496 Interaction.log_status(0, 'Added to .gitignore:\n%s' % for_status, '')
497 self.model.update_file_status()
500 def file_summary(files):
501 txt = core.list2cmdline(files)
502 if len(txt) > 768:
503 txt = txt[:768].rstrip() + '...'
504 return txt
507 class RemoteCommand(ConfirmAction):
509 def __init__(self, remote):
510 ConfirmAction.__init__(self)
511 self.model = main.model()
512 self.remote = remote
514 def success(self):
515 self.model.update_remotes()
518 class RemoteAdd(RemoteCommand):
520 def __init__(self, remote, url):
521 RemoteCommand.__init__(self, remote)
522 self.url = url
524 def action(self):
525 git = self.model.git
526 return git.remote('add', self.remote, self.url)
528 def error_message(self):
529 return N_('Error creating remote "%s"') % self.remote
532 class RemoteRemove(RemoteCommand):
534 def confirm(self):
535 title = N_('Delete Remote')
536 question = N_('Delete remote?')
537 info = N_('Delete remote "%s"') % self.remote
538 ok_btn = N_('Delete')
539 return Interaction.confirm(title, question, info, ok_btn)
541 def action(self):
542 git = self.model.git
543 return git.remote('rm', self.remote)
545 def error_message(self):
546 return N_('Error deleting remote "%s"') % self.remote
549 class RemoteRename(RemoteCommand):
551 def __init__(self, remote, new_remote):
552 RemoteCommand.__init__(self, remote)
553 self.new_remote = new_remote
555 def confirm(self):
556 title = N_('Rename Remote')
557 question = N_('Rename remote?')
558 info = (N_('Rename remote "%(current)s" to "%(new)s"?') %
559 dict(current=self.remote, new=self.new_remote))
560 ok_btn = N_('Rename')
561 return Interaction.confirm(title, question, info, ok_btn)
563 def action(self):
564 git = self.model.git
565 return git.remote('rename', self.remote, self.new_remote)
568 class RemoveFromSettings(ConfirmAction):
570 def __init__(self, settings, repo, name, icon=None):
571 ConfirmAction.__init__(self)
572 self.settings = settings
573 self.repo = repo
574 self.name = name
575 self.icon = icon
577 def success(self):
578 self.settings.save()
581 class RemoveBookmark(RemoveFromSettings):
583 def confirm(self):
584 name = self.name
585 title = msg = N_('Delete Bookmark?')
586 info = N_('%s will be removed from your bookmarks.') % name
587 ok_text = N_('Delete Bookmark')
588 return Interaction.confirm(title, msg, info, ok_text, icon=self.icon)
590 def action(self):
591 self.settings.remove_bookmark(self.repo, self.name)
592 return (0, '', '')
595 class RemoveRecent(RemoveFromSettings):
597 def confirm(self):
598 repo = self.repo
599 title = msg = N_('Remove %s from the recent list?') % repo
600 info = N_('%s will be removed from your recent repositories.') % repo
601 ok_text = N_('Remove')
602 return Interaction.confirm(title, msg, info, ok_text, icon=self.icon)
604 def action(self):
605 self.settings.remove_recent(self.repo)
606 return (0, '', '')
609 class RemoveFiles(Command):
610 """Removes files."""
612 def __init__(self, remover, filenames):
613 Command.__init__(self)
614 if remover is None:
615 remover = os.remove
616 self.remover = remover
617 self.filenames = filenames
618 # We could git-hash-object stuff and provide undo-ability
619 # as an option. Heh.
621 def do(self):
622 files = self.filenames
623 if not files:
624 return
626 rescan = False
627 bad_filenames = []
628 remove = self.remover
629 for filename in files:
630 if filename:
631 try:
632 remove(filename)
633 rescan = True
634 except:
635 bad_filenames.append(filename)
637 if bad_filenames:
638 Interaction.information(
639 N_('Error'),
640 N_('Deleting "%s" failed') % file_summary(files))
642 if rescan:
643 self.model.update_file_status()
646 class Delete(RemoveFiles):
647 """Delete files."""
649 def __init__(self, filenames):
650 RemoveFiles.__init__(self, os.remove, filenames)
652 def do(self):
653 files = self.filenames
654 if not files:
655 return
657 title = N_('Delete Files?')
658 msg = N_('The following files will be deleted:') + '\n\n'
659 msg += file_summary(files)
660 info_txt = N_('Delete %d file(s)?') % len(files)
661 ok_txt = N_('Delete Files')
663 if not Interaction.confirm(title, msg, info_txt, ok_txt,
664 default=True, icon=icons.remove()):
665 return
667 return RemoveFiles.do(self)
670 class MoveToTrash(RemoveFiles):
671 """Move files to the trash using send2trash"""
673 AVAILABLE = send2trash is not None
675 def __init__(self, filenames):
676 RemoveFiles.__init__(self, send2trash, filenames)
679 class DeleteBranch(Command):
680 """Delete a git branch."""
682 def __init__(self, branch):
683 Command.__init__(self)
684 self.branch = branch
686 def do(self):
687 status, out, err = self.model.delete_branch(self.branch)
688 Interaction.log_status(status, out, err)
691 class RenameBranch(Command):
692 """Rename a git branch."""
694 def __init__(self, branch, new_branch):
695 Command.__init__(self)
696 self.branch = branch
697 self.new_branch = new_branch
699 def do(self):
700 status, out, err = self.model.rename_branch(self.branch,
701 self.new_branch)
702 Interaction.log_status(status, out, err)
705 class DeleteRemoteBranch(Command):
706 """Delete a remote git branch."""
708 def __init__(self, remote, branch):
709 Command.__init__(self)
710 self.remote = remote
711 self.branch = branch
713 def do(self):
714 status, out, err = self.model.git.push(self.remote, self.branch,
715 delete=True)
716 Interaction.log_status(status, out, err)
717 self.model.update_status()
719 if status == 0:
720 Interaction.information(
721 N_('Remote Branch Deleted'),
722 N_('"%(branch)s" has been deleted from "%(remote)s".')
723 % dict(branch=self.branch, remote=self.remote))
724 else:
725 command = 'git push'
726 message = (N_('"%(command)s" returned exit status %(status)d') %
727 dict(command=command, status=status))
729 Interaction.critical(N_('Error Deleting Remote Branch'),
730 message, out + err)
733 class Diff(Command):
734 """Perform a diff and set the model's current text."""
736 def __init__(self, filename, cached=False, deleted=False):
737 Command.__init__(self)
738 opts = {}
739 if cached:
740 opts['ref'] = self.model.head
741 self.new_filename = filename
742 self.new_mode = self.model.mode_worktree
743 self.new_diff_text = gitcmds.diff_helper(filename=filename,
744 cached=cached,
745 deleted=deleted,
746 **opts)
749 class Diffstat(Command):
750 """Perform a diffstat and set the model's diff text."""
752 def __init__(self):
753 Command.__init__(self)
754 cfg = gitcfg.current()
755 diff_context = cfg.get('diff.context', 3)
756 diff = self.model.git.diff(self.model.head,
757 unified=diff_context,
758 no_ext_diff=True,
759 no_color=True,
760 M=True,
761 stat=True)[STDOUT]
762 self.new_diff_text = diff
763 self.new_mode = self.model.mode_diffstat
766 class DiffStaged(Diff):
767 """Perform a staged diff on a file."""
769 def __init__(self, filename, deleted=None):
770 Diff.__init__(self, filename, cached=True, deleted=deleted)
771 self.new_mode = self.model.mode_index
774 class DiffStagedSummary(Command):
776 def __init__(self):
777 Command.__init__(self)
778 diff = self.model.git.diff(self.model.head,
779 cached=True,
780 no_color=True,
781 no_ext_diff=True,
782 patch_with_stat=True,
783 M=True)[STDOUT]
784 self.new_diff_text = diff
785 self.new_mode = self.model.mode_index
788 class Difftool(Command):
789 """Run git-difftool limited by path."""
791 def __init__(self, staged, filenames):
792 Command.__init__(self)
793 self.staged = staged
794 self.filenames = filenames
796 def do(self):
797 difftool_launch_with_head(self.filenames, self.staged, self.model.head)
800 class Edit(Command):
801 """Edit a file using the configured gui.editor."""
803 @staticmethod
804 def name():
805 return N_('Launch Editor')
807 def __init__(self, filenames, line_number=None):
808 Command.__init__(self)
809 self.filenames = filenames
810 self.line_number = line_number
812 def do(self):
813 if not self.filenames:
814 return
815 filename = self.filenames[0]
816 if not core.exists(filename):
817 return
818 editor = prefs.editor()
819 opts = []
821 if self.line_number is None:
822 opts = self.filenames
823 else:
824 # Single-file w/ line-numbers (likely from grep)
825 editor_opts = {
826 '*vim*': ['+'+self.line_number, filename],
827 '*emacs*': ['+'+self.line_number, filename],
828 '*textpad*': ['%s(%s,0)' % (filename, self.line_number)],
829 '*notepad++*': ['-n'+self.line_number, filename],
832 opts = self.filenames
833 for pattern, opt in editor_opts.items():
834 if fnmatch(editor, pattern):
835 opts = opt
836 break
838 try:
839 core.fork(utils.shell_split(editor) + opts)
840 except Exception as e:
841 message = (N_('Cannot exec "%s": please configure your editor')
842 % editor)
843 details = core.decode(e.strerror)
844 Interaction.critical(N_('Error Editing File'), message, details)
847 class FormatPatch(Command):
848 """Output a patch series given all revisions and a selected subset."""
850 def __init__(self, to_export, revs):
851 Command.__init__(self)
852 self.to_export = list(to_export)
853 self.revs = list(revs)
855 def do(self):
856 status, out, err = gitcmds.format_patchsets(self.to_export, self.revs)
857 Interaction.log_status(status, out, err)
860 class LaunchDifftool(BaseCommand):
862 @staticmethod
863 def name():
864 return N_('Launch Diff Tool')
866 def __init__(self):
867 BaseCommand.__init__(self)
869 def do(self):
870 s = selection.selection()
871 if s.unmerged:
872 paths = s.unmerged
873 if utils.is_win32():
874 core.fork(['git', 'mergetool', '--no-prompt', '--'] + paths)
875 else:
876 cfg = gitcfg.current()
877 cmd = cfg.terminal()
878 argv = utils.shell_split(cmd)
879 mergetool = ['git', 'mergetool', '--no-prompt', '--']
880 mergetool.extend(paths)
881 needs_shellquote = set(['gnome-terminal', 'xfce4-terminal'])
882 if os.path.basename(argv[0]) in needs_shellquote:
883 argv.append(core.list2cmdline(mergetool))
884 else:
885 argv.extend(mergetool)
886 core.fork(argv)
887 else:
888 difftool_run()
891 class LaunchTerminal(BaseCommand):
893 @staticmethod
894 def name():
895 return N_('Launch Terminal')
897 def __init__(self, path):
898 BaseCommand.__init__(self)
899 self.path = path
901 def do(self):
902 cfg = gitcfg.current()
903 cmd = cfg.terminal()
904 argv = utils.shell_split(cmd)
905 argv.append(os.getenv('SHELL', '/bin/sh'))
906 core.fork(argv, cwd=self.path)
909 class LaunchEditor(Edit):
911 @staticmethod
912 def name():
913 return N_('Launch Editor')
915 def __init__(self):
916 s = selection.selection()
917 allfiles = s.staged + s.unmerged + s.modified + s.untracked
918 Edit.__init__(self, allfiles)
921 class LoadCommitMessageFromFile(Command):
922 """Loads a commit message from a path."""
924 def __init__(self, path):
925 Command.__init__(self)
926 self.undoable = True
927 self.path = path
928 self.old_commitmsg = self.model.commitmsg
929 self.old_directory = self.model.directory
931 def do(self):
932 path = os.path.expanduser(self.path)
933 if not path or not core.isfile(path):
934 raise UsageError(N_('Error: Cannot find commit template'),
935 N_('%s: No such file or directory.') % path)
936 self.model.set_directory(os.path.dirname(path))
937 self.model.set_commitmsg(core.read(path))
939 def undo(self):
940 self.model.set_commitmsg(self.old_commitmsg)
941 self.model.set_directory(self.old_directory)
944 class LoadCommitMessageFromTemplate(LoadCommitMessageFromFile):
945 """Loads the commit message template specified by commit.template."""
947 def __init__(self):
948 cfg = gitcfg.current()
949 template = cfg.get('commit.template')
950 LoadCommitMessageFromFile.__init__(self, template)
952 def do(self):
953 if self.path is None:
954 raise UsageError(
955 N_('Error: Unconfigured commit template'),
956 N_('A commit template has not been configured.\n'
957 'Use "git config" to define "commit.template"\n'
958 'so that it points to a commit template.'))
959 return LoadCommitMessageFromFile.do(self)
962 class LoadCommitMessageFromOID(Command):
963 """Load a previous commit message"""
965 def __init__(self, oid, prefix=''):
966 Command.__init__(self)
967 self.oid = oid
968 self.old_commitmsg = self.model.commitmsg
969 self.new_commitmsg = prefix + self.model.prev_commitmsg(oid)
970 self.undoable = True
972 def do(self):
973 self.model.set_commitmsg(self.new_commitmsg)
975 def undo(self):
976 self.model.set_commitmsg(self.old_commitmsg)
979 class PrepareCommitMessageHook(Command):
980 """Use the cola-prepare-commit-msg hook to prepare the commit message
982 def __init__(self):
983 Command.__init__(self)
984 self.old_commitmsg = self.model.commitmsg
985 self.undoable = True
987 def get_message(self):
989 title = N_('Error running prepare-commitmsg hook')
990 hook = gitcmds.prepare_commit_message_hook()
992 if os.path.exists(hook):
993 filename = self.model.save_commitmsg()
994 status, out, err = core.run_command([hook, filename])
996 if status == 0:
997 result = core.read(filename)
998 else:
999 result = self.old_commitmsg
1000 details = out or ''
1001 if err:
1002 if details and not details.endswith('\n'):
1003 details += '\n'
1004 details += err
1005 message = N_('"%s" returned exit status %d') % (hook, status)
1006 Interaction.critical(title, message=message, details=details)
1007 else:
1008 message = N_('A hook must be provided at "%s"') % hook
1009 Interaction.critical(title, message=message)
1010 result = self.old_commitmsg
1012 return result
1014 def do(self):
1015 msg = self.get_message()
1016 self.model.set_commitmsg(msg)
1018 def undo(self):
1019 self.model.set_commitmsg(self.old_commitmsg)
1022 class LoadFixupMessage(LoadCommitMessageFromOID):
1023 """Load a fixup message"""
1025 def __init__(self, oid):
1026 LoadCommitMessageFromOID.__init__(self, oid, prefix='fixup! ')
1027 if self.new_commitmsg:
1028 self.new_commitmsg = self.new_commitmsg.splitlines()[0]
1031 class Merge(Command):
1032 """Merge commits"""
1034 def __init__(self, revision, no_commit, squash, no_ff, sign):
1035 Command.__init__(self)
1036 self.revision = revision
1037 self.no_ff = no_ff
1038 self.no_commit = no_commit
1039 self.squash = squash
1040 self.sign = sign
1042 def do(self):
1043 squash = self.squash
1044 revision = self.revision
1045 no_ff = self.no_ff
1046 no_commit = self.no_commit
1047 sign = self.sign
1049 status, out, err = self.model.git.merge(revision,
1050 gpg_sign=sign,
1051 no_ff=no_ff,
1052 no_commit=no_commit,
1053 squash=squash)
1054 Interaction.log_status(status, out, err)
1055 self.model.update_status()
1058 class OpenDefaultApp(BaseCommand):
1059 """Open a file using the OS default."""
1061 @staticmethod
1062 def name():
1063 return N_('Open Using Default Application')
1065 def __init__(self, filenames):
1066 BaseCommand.__init__(self)
1067 if utils.is_darwin():
1068 launcher = 'open'
1069 else:
1070 launcher = 'xdg-open'
1071 self.launcher = launcher
1072 self.filenames = filenames
1074 def do(self):
1075 if not self.filenames:
1076 return
1077 core.fork([self.launcher] + self.filenames)
1080 class OpenParentDir(OpenDefaultApp):
1081 """Open parent directories using the OS default."""
1083 @staticmethod
1084 def name():
1085 return N_('Open Parent Directory')
1087 def __init__(self, filenames):
1088 OpenDefaultApp.__init__(self, filenames)
1090 def do(self):
1091 if not self.filenames:
1092 return
1093 dirnames = list(set([os.path.dirname(x) for x in self.filenames]))
1094 # os.path.dirname() can return an empty string so we fallback to
1095 # the current directory
1096 dirs = [(dirname or core.getcwd()) for dirname in dirnames]
1097 core.fork([self.launcher] + dirs)
1100 class OpenNewRepo(Command):
1101 """Launches git-cola on a repo."""
1103 def __init__(self, repo_path):
1104 Command.__init__(self)
1105 self.repo_path = repo_path
1107 def do(self):
1108 self.model.set_directory(self.repo_path)
1109 core.fork([sys.executable, sys.argv[0], '--repo', self.repo_path])
1112 class OpenRepo(Command):
1113 def __init__(self, repo_path):
1114 Command.__init__(self)
1115 self.repo_path = repo_path
1117 def do(self):
1118 git = self.model.git
1119 old_repo = git.getcwd()
1120 if self.model.set_worktree(self.repo_path):
1121 fsmonitor.current().stop()
1122 fsmonitor.current().start()
1123 self.model.update_status()
1124 else:
1125 self.model.set_worktree(old_repo)
1128 class Clone(Command):
1129 """Clones a repository and optionally spawns a new cola session."""
1131 def __init__(self, url, new_directory, spawn=True):
1132 Command.__init__(self)
1133 self.url = url
1134 self.new_directory = new_directory
1135 self.spawn = spawn
1137 self.ok = False
1138 self.error_message = ''
1139 self.error_details = ''
1141 def do(self):
1142 status, out, err = self.model.git.clone(self.url, self.new_directory)
1143 self.ok = status == 0
1145 if self.ok:
1146 if self.spawn:
1147 core.fork([sys.executable, sys.argv[0],
1148 '--repo', self.new_directory])
1149 else:
1150 self.error_message = N_('Error: could not clone "%s"') % self.url
1151 self.error_details = (
1152 (N_('git clone returned exit code %s') % status) +
1153 ((out+err) and ('\n\n' + out + err) or ''))
1155 return self
1158 def unix_path(path, is_win32=utils.is_win32):
1159 """Git for Windows requires unix paths, so force them here
1161 unix_path = path
1162 if is_win32():
1163 first = path[0]
1164 second = path[1]
1165 if second == ':': # sanity check, this better be a Windows-style path
1166 unix_path = '/' + first + path[2:].replace('\\', '/')
1168 return unix_path
1171 class GitXBaseContext(object):
1173 def __init__(self, **kwargs):
1174 self.env = {'GIT_EDITOR': prefs.editor()}
1175 self.env.update(kwargs)
1177 def __enter__(self):
1178 compat.setenv('GIT_SEQUENCE_EDITOR',
1179 unix_path(resources.share('bin', 'git-xbase')))
1180 for var, value in self.env.items():
1181 compat.setenv(var, value)
1182 return self
1184 def __exit__(self, exc_type, exc_val, exc_tb):
1185 compat.unsetenv('GIT_SEQUENCE_EDITOR')
1186 for var in self.env:
1187 compat.unsetenv(var)
1190 class Rebase(Command):
1192 def __init__(self,
1193 upstream=None, branch=None, capture_output=True, **kwargs):
1194 """Start an interactive rebase session
1196 :param upstream: upstream branch
1197 :param branch: optional branch to checkout
1198 :param capture_output: whether to capture stdout and stderr
1199 :param kwargs: forwarded directly to `git.rebase()`
1202 Command.__init__(self)
1204 self.upstream = upstream
1205 self.branch = branch
1206 self.capture_output = capture_output
1207 self.kwargs = kwargs
1209 def prepare_arguments(self):
1210 args = []
1211 kwargs = {}
1213 if self.capture_output:
1214 kwargs['_stderr'] = None
1215 kwargs['_stdout'] = None
1217 # Rebase actions must be the only option specified
1218 for action in ('continue', 'abort', 'skip', 'edit_todo'):
1219 if self.kwargs.get(action, False):
1220 kwargs[action] = self.kwargs[action]
1221 return args, kwargs
1223 kwargs['interactive'] = True
1224 kwargs['autosquash'] = self.kwargs.get('autosquash', True)
1225 kwargs.update(self.kwargs)
1227 if self.upstream:
1228 args.append(self.upstream)
1229 if self.branch:
1230 args.append(self.branch)
1232 return args, kwargs
1234 def do(self):
1235 (status, out, err) = (1, '', '')
1236 args, kwargs = self.prepare_arguments()
1237 upstream_title = self.upstream or '@{upstream}'
1238 with GitXBaseContext(
1239 GIT_XBASE_TITLE=N_('Rebase onto %s') % upstream_title,
1240 GIT_XBASE_ACTION=N_('Rebase')):
1241 # XXX this blocks the user interface window for the duration of our
1242 # git-xbase's invocation. would need to implement signals for
1243 # QProcess and continue running the main thread. alternatively we
1244 # could hide the main window while rebasing. that doesn't require
1245 # as much effort.
1246 status, out, err = self.model.git.rebase(*args, _no_win32_startupinfo=True, **kwargs)
1247 Interaction.log_status(status, out, err)
1248 self.model.update_status()
1249 return status, out, err
1252 class RebaseEditTodo(Command):
1254 def do(self):
1255 (status, out, err) = (1, '', '')
1256 with GitXBaseContext(
1257 GIT_XBASE_TITLE=N_('Edit Rebase'),
1258 GIT_XBASE_ACTION=N_('Save')):
1259 status, out, err = self.model.git.rebase(edit_todo=True)
1260 Interaction.log_status(status, out, err)
1261 self.model.update_status()
1262 return status, out, err
1265 class RebaseContinue(Command):
1267 def do(self):
1268 (status, out, err) = (1, '', '')
1269 with GitXBaseContext(
1270 GIT_XBASE_TITLE=N_('Rebase'),
1271 GIT_XBASE_ACTION=N_('Rebase')):
1272 status, out, err = self.model.git.rebase('--continue')
1273 Interaction.log_status(status, out, err)
1274 self.model.update_status()
1275 return status, out, err
1278 class RebaseSkip(Command):
1280 def do(self):
1281 (status, out, err) = (1, '', '')
1282 with GitXBaseContext(
1283 GIT_XBASE_TITLE=N_('Rebase'),
1284 GIT_XBASE_ACTION=N_('Rebase')):
1285 status, out, err = self.model.git.rebase(skip=True)
1286 Interaction.log_status(status, out, err)
1287 self.model.update_status()
1288 return status, out, err
1291 class RebaseAbort(Command):
1293 def do(self):
1294 status, out, err = self.model.git.rebase(abort=True)
1295 Interaction.log_status(status, out, err)
1296 self.model.update_status()
1299 class Rescan(Command):
1300 """Rescan for changes"""
1302 def do(self):
1303 self.model.update_status()
1306 class Refresh(Command):
1307 """Update refs and refresh the index"""
1309 @staticmethod
1310 def name():
1311 return N_('Refresh')
1313 def do(self):
1314 self.model.update_status(update_index=True)
1315 fsmonitor.current().refresh()
1318 class RevertEditsCommand(ConfirmAction):
1320 def __init__(self):
1321 ConfirmAction.__init__(self)
1322 self.model = main.model()
1323 self.icon = icons.undo()
1325 def ok_to_run(self):
1326 return self.model.undoable()
1328 def checkout_from_head(self):
1329 return False
1331 def checkout_args(self):
1332 args = []
1333 s = selection.selection()
1334 if self.checkout_from_head():
1335 args.append(self.model.head)
1336 args.append('--')
1338 if s.staged:
1339 items = s.staged
1340 else:
1341 items = s.modified
1342 args.extend(items)
1344 return args
1346 def action(self):
1347 git = self.model.git
1348 checkout_args = self.checkout_args()
1349 return git.checkout(*checkout_args)
1351 def success(self):
1352 self.model.update_file_status()
1355 class RevertUnstagedEdits(RevertEditsCommand):
1357 @staticmethod
1358 def name():
1359 return N_('Revert Unstaged Edits...')
1361 def checkout_from_head(self):
1362 # If we are amending and a modified file is selected
1363 # then we should include "HEAD^" on the command-line.
1364 selected = selection.selection()
1365 return not selected.staged and self.model.amending()
1367 def confirm(self):
1368 title = N_('Revert Unstaged Changes?')
1369 text = N_('This operation drops unstaged changes.\n'
1370 'These changes cannot be recovered.')
1371 info = N_('Revert the unstaged changes?')
1372 ok_text = N_('Revert Unstaged Changes')
1373 return Interaction.confirm(title, text, info, ok_text,
1374 default=True, icon=self.icon)
1377 class RevertUncommittedEdits(RevertEditsCommand):
1379 @staticmethod
1380 def name():
1381 return N_('Revert Uncommitted Edits...')
1383 def checkout_from_head(self):
1384 return True
1386 def confirm(self):
1387 title = N_('Revert Uncommitted Changes?')
1388 text = N_('This operation drops uncommitted changes.\n'
1389 'These changes cannot be recovered.')
1390 info = N_('Revert the uncommitted changes?')
1391 ok_text = N_('Revert Uncommitted Changes')
1392 return Interaction.confirm(title, text, info, ok_text,
1393 default=True, icon=self.icon)
1396 class RunConfigAction(Command):
1397 """Run a user-configured action, typically from the "Tools" menu"""
1399 def __init__(self, action_name):
1400 Command.__init__(self)
1401 self.action_name = action_name
1402 self.model = main.model()
1404 def do(self):
1405 for env in ('ARGS', 'DIRNAME', 'FILENAME', 'REVISION'):
1406 try:
1407 compat.unsetenv(env)
1408 except KeyError:
1409 pass
1410 rev = None
1411 args = None
1412 cfg = gitcfg.current()
1413 opts = cfg.get_guitool_opts(self.action_name)
1414 cmd = opts.get('cmd')
1415 if 'title' not in opts:
1416 opts['title'] = cmd
1418 if 'prompt' not in opts or opts.get('prompt') is True:
1419 prompt = N_('Run "%s"?') % cmd
1420 opts['prompt'] = prompt
1422 if opts.get('needsfile'):
1423 filename = selection.filename()
1424 if not filename:
1425 Interaction.information(
1426 N_('Please select a file'),
1427 N_('"%s" requires a selected file.') % cmd)
1428 return False
1429 dirname = utils.dirname(filename, current_dir='.')
1430 compat.setenv('FILENAME', filename)
1431 compat.setenv('DIRNAME', dirname)
1433 if opts.get('revprompt') or opts.get('argprompt'):
1434 while True:
1435 ok = Interaction.confirm_config_action(cmd, opts)
1436 if not ok:
1437 return False
1438 rev = opts.get('revision')
1439 args = opts.get('args')
1440 if opts.get('revprompt') and not rev:
1441 title = N_('Invalid Revision')
1442 msg = N_('The revision expression cannot be empty.')
1443 Interaction.critical(title, msg)
1444 continue
1445 break
1447 elif opts.get('confirm'):
1448 title = os.path.expandvars(opts.get('title'))
1449 prompt = os.path.expandvars(opts.get('prompt'))
1450 if not Interaction.question(title, prompt):
1451 return
1452 if rev:
1453 compat.setenv('REVISION', rev)
1454 if args:
1455 compat.setenv('ARGS', args)
1456 title = os.path.expandvars(cmd)
1457 Interaction.log(N_('Running command: %s') % title)
1458 cmd = ['sh', '-c', cmd]
1460 if opts.get('background'):
1461 core.fork(cmd)
1462 status, out, err = (0, '', '')
1463 elif opts.get('noconsole'):
1464 status, out, err = core.run_command(cmd)
1465 else:
1466 status, out, err = Interaction.run_command(title, cmd)
1468 Interaction.log_status(status,
1469 out and (N_('Output: %s') % out) or '',
1470 err and (N_('Errors: %s') % err) or '')
1472 if not opts.get('background') and not opts.get('norescan'):
1473 self.model.update_status()
1474 return status
1477 class SetDefaultRepo(Command):
1479 def __init__(self, repo, name):
1480 Command.__init__(self)
1481 self.repo = repo
1482 self.name = name
1484 def do(self):
1485 gitcfg.current().set_user('cola.defaultrepo', self.repo)
1488 class SetDiffText(Command):
1490 def __init__(self, text):
1491 Command.__init__(self)
1492 self.undoable = True
1493 self.new_diff_text = text
1496 class ShowUntracked(Command):
1497 """Show an untracked file."""
1499 def __init__(self, filename):
1500 Command.__init__(self)
1501 self.new_filename = filename
1502 self.new_mode = self.model.mode_untracked
1503 self.new_diff_text = self.diff_text_for(filename)
1505 def diff_text_for(self, filename):
1506 cfg = gitcfg.current()
1507 size = cfg.get('cola.readsize', 1024 * 2)
1508 try:
1509 result = core.read(filename, size=size,
1510 encoding='utf-8', errors='ignore')
1511 except:
1512 result = ''
1514 if len(result) == size:
1515 result += '...'
1516 return result
1519 class SignOff(Command):
1521 @staticmethod
1522 def name():
1523 return N_('Sign Off')
1525 def __init__(self):
1526 Command.__init__(self)
1527 self.undoable = True
1528 self.old_commitmsg = self.model.commitmsg
1530 def do(self):
1531 signoff = self.signoff()
1532 if signoff in self.model.commitmsg:
1533 return
1534 self.model.set_commitmsg(self.model.commitmsg + '\n' + signoff)
1536 def undo(self):
1537 self.model.set_commitmsg(self.old_commitmsg)
1539 def signoff(self):
1540 try:
1541 import pwd
1542 user = pwd.getpwuid(os.getuid()).pw_name
1543 except ImportError:
1544 user = os.getenv('USER', N_('unknown'))
1546 cfg = gitcfg.current()
1547 name = cfg.get('user.name', user)
1548 email = cfg.get('user.email', '%s@%s' % (user, core.node()))
1549 return '\nSigned-off-by: %s <%s>' % (name, email)
1552 def check_conflicts(unmerged):
1553 """Check paths for conflicts
1555 Conflicting files can be filtered out one-by-one.
1558 if prefs.check_conflicts():
1559 unmerged = [path for path in unmerged if is_conflict_free(path)]
1560 return unmerged
1563 def is_conflict_free(path):
1564 """Return True if `path` contains no conflict markers
1566 rgx = re.compile(r'^(<<<<<<<|\|\|\|\|\|\|\||>>>>>>>) ')
1567 try:
1568 with core.xopen(path, 'r') as f:
1569 for line in f:
1570 line = core.decode(line, errors='ignore')
1571 if rgx.match(line):
1572 if should_stage_conflicts(path):
1573 return True
1574 else:
1575 return False
1576 except IOError:
1577 # We can't read this file ~ we may be staging a removal
1578 pass
1579 return True
1582 def should_stage_conflicts(path):
1583 """Inform the user that a file contains merge conflicts
1585 Return `True` if we should stage the path nonetheless.
1588 title = msg = N_('Stage conflicts?')
1589 info = N_('%s appears to contain merge conflicts.\n\n'
1590 'You should probably skip this file.\n'
1591 'Stage it anyways?') % path
1592 ok_text = N_('Stage conflicts')
1593 cancel_text = N_('Skip')
1594 return Interaction.confirm(title, msg, info, ok_text,
1595 default=False, cancel_text=cancel_text)
1598 class Stage(Command):
1599 """Stage a set of paths."""
1601 @staticmethod
1602 def name():
1603 return N_('Stage')
1605 def __init__(self, paths):
1606 Command.__init__(self)
1607 self.paths = paths
1609 def do(self):
1610 msg = N_('Staging: %s') % (', '.join(self.paths))
1611 Interaction.log(msg)
1612 # Prevent external updates while we are staging files.
1613 # We update file stats at the end of this operation
1614 # so there's no harm in ignoring updates from other threads
1615 # (e.g. the file system change monitor).
1616 with CommandDisabled(UpdateFileStatus):
1617 self.model.stage_paths(self.paths)
1620 class StageCarefully(Stage):
1621 """Only stage when the path list is non-empty
1623 We use "git add -u -- <pathspec>" to stage, and it stages everything by
1624 default when no pathspec is specified, so this class ensures that paths
1625 are specified before calling git.
1627 When no paths are specified, the command does nothing.
1630 def __init__(self):
1631 Stage.__init__(self, None)
1632 self.init_paths()
1634 def init_paths(self):
1635 pass
1637 def ok_to_run(self):
1638 """Prevent catch-all "git add -u" from adding unmerged files"""
1639 return self.paths or not self.model.unmerged
1641 def do(self):
1642 if self.ok_to_run():
1643 Stage.do(self)
1646 class StageModified(StageCarefully):
1647 """Stage all modified files."""
1649 @staticmethod
1650 def name():
1651 return N_('Stage Modified')
1653 def init_paths(self):
1654 self.paths = self.model.modified
1657 class StageUnmerged(StageCarefully):
1658 """Stage unmerged files."""
1660 @staticmethod
1661 def name():
1662 return N_('Stage Unmerged')
1664 def init_paths(self):
1665 self.paths = check_conflicts(self.model.unmerged)
1668 class StageUntracked(StageCarefully):
1669 """Stage all untracked files."""
1671 @staticmethod
1672 def name():
1673 return N_('Stage Untracked')
1675 def init_paths(self):
1676 self.paths = self.model.untracked
1679 class StageOrUnstage(Command):
1680 """If the selection is staged, unstage it, otherwise stage"""
1682 @staticmethod
1683 def name():
1684 return N_('Stage / Unstage')
1686 def do(self):
1687 s = selection.selection()
1688 if s.staged:
1689 do(Unstage, s.staged)
1691 unstaged = []
1692 unmerged = check_conflicts(s.unmerged)
1693 if unmerged:
1694 unstaged.extend(unmerged)
1695 if s.modified:
1696 unstaged.extend(s.modified)
1697 if s.untracked:
1698 unstaged.extend(s.untracked)
1699 if unstaged:
1700 do(Stage, unstaged)
1703 class Tag(Command):
1704 """Create a tag object."""
1706 def __init__(self, name, revision, sign=False, message=''):
1707 Command.__init__(self)
1708 self._name = name
1709 self._message = message
1710 self._revision = revision
1711 self._sign = sign
1713 def do(self):
1714 log_msg = (N_('Tagging "%(revision)s" as "%(name)s"') %
1715 dict(revision=self._revision, name=self._name))
1716 opts = {}
1717 tmp_file = None
1718 try:
1719 if self._message:
1720 tmp_file = utils.tmp_filename('tag-message')
1721 opts['F'] = tmp_file
1722 core.write(tmp_file, self._message)
1724 if self._sign:
1725 log_msg += ' (%s)' % N_('GPG-signed')
1726 opts['s'] = True
1727 else:
1728 opts['a'] = bool(self._message)
1729 status, output, err = self.model.git.tag(self._name,
1730 self._revision, **opts)
1731 finally:
1732 if tmp_file:
1733 core.unlink(tmp_file)
1735 if output:
1736 log_msg += '\n' + (N_('Output: %s') % output)
1738 Interaction.log_status(status, log_msg, err)
1739 if status == 0:
1740 self.model.update_status()
1741 return (status, output, err)
1744 class Unstage(Command):
1745 """Unstage a set of paths."""
1747 @staticmethod
1748 def name():
1749 return N_('Unstage')
1751 def __init__(self, paths):
1752 Command.__init__(self)
1753 self.paths = paths
1755 def do(self):
1756 msg = N_('Unstaging: %s') % (', '.join(self.paths))
1757 Interaction.log(msg)
1758 with CommandDisabled(UpdateFileStatus):
1759 self.model.unstage_paths(self.paths)
1762 class UnstageAll(Command):
1763 """Unstage all files; resets the index."""
1765 def do(self):
1766 self.model.unstage_all()
1769 class UnstageSelected(Unstage):
1770 """Unstage selected files."""
1772 def __init__(self):
1773 Unstage.__init__(self, selection.selection_model().staged)
1776 class Untrack(Command):
1777 """Unstage a set of paths."""
1779 def __init__(self, paths):
1780 Command.__init__(self)
1781 self.paths = paths
1783 def do(self):
1784 msg = N_('Untracking: %s') % (', '.join(self.paths))
1785 Interaction.log(msg)
1786 with CommandDisabled(UpdateFileStatus):
1787 status, out, err = self.model.untrack_paths(self.paths)
1788 Interaction.log_status(status, out, err)
1791 class UntrackedSummary(Command):
1792 """List possible .gitignore rules as the diff text."""
1794 def __init__(self):
1795 Command.__init__(self)
1796 untracked = self.model.untracked
1797 suffix = len(untracked) > 1 and 's' or ''
1798 io = StringIO()
1799 io.write('# %s untracked file%s\n' % (len(untracked), suffix))
1800 if untracked:
1801 io.write('# possible .gitignore rule%s:\n' % suffix)
1802 for u in untracked:
1803 io.write('/'+u+'\n')
1804 self.new_diff_text = io.getvalue()
1805 self.new_mode = self.model.mode_untracked
1808 class UpdateFileStatus(Command):
1809 """Rescans for changes."""
1811 def do(self):
1812 self.model.update_file_status()
1815 class VisualizeAll(Command):
1816 """Visualize all branches."""
1818 def do(self):
1819 browser = utils.shell_split(prefs.history_browser())
1820 launch_history_browser(browser + ['--all'])
1823 class VisualizeCurrent(Command):
1824 """Visualize all branches."""
1826 def do(self):
1827 browser = utils.shell_split(prefs.history_browser())
1828 launch_history_browser(browser + [self.model.currentbranch])
1831 class VisualizePaths(Command):
1832 """Path-limited visualization."""
1834 def __init__(self, paths):
1835 Command.__init__(self)
1836 browser = utils.shell_split(prefs.history_browser())
1837 if paths:
1838 self.argv = browser + list(paths)
1839 else:
1840 self.argv = browser
1842 def do(self):
1843 launch_history_browser(self.argv)
1846 class VisualizeRevision(Command):
1847 """Visualize a specific revision."""
1849 def __init__(self, revision, paths=None):
1850 Command.__init__(self)
1851 self.revision = revision
1852 self.paths = paths
1854 def do(self):
1855 argv = utils.shell_split(prefs.history_browser())
1856 if self.revision:
1857 argv.append(self.revision)
1858 if self.paths:
1859 argv.append('--')
1860 argv.extend(self.paths)
1861 launch_history_browser(argv)
1864 def launch_history_browser(argv):
1865 try:
1866 core.fork(argv)
1867 except Exception as e:
1868 _, details = utils.format_exception(e)
1869 title = N_('Error Launching History Browser')
1870 msg = (N_('Cannot exec "%s": please configure a history browser') %
1871 ' '.join(argv))
1872 Interaction.critical(title, message=msg, details=details)
1875 def run(cls, *args, **opts):
1877 Returns a callback that runs a command
1879 If the caller of run() provides args or opts then those are
1880 used instead of the ones provided by the invoker of the callback.
1883 def runner(*local_args, **local_opts):
1884 if args or opts:
1885 do(cls, *args, **opts)
1886 else:
1887 do(cls, *local_args, **local_opts)
1889 return runner
1892 class CommandDisabled(object):
1894 """Context manager to temporarily disable a command from running"""
1895 def __init__(self, cmdclass):
1896 self.cmdclass = cmdclass
1898 def __enter__(self):
1899 self.cmdclass.DISABLED = True
1900 return self
1902 def __exit__(self, exc_type, exc_val, exc_tb):
1903 self.cmdclass.DISABLED = False
1906 def do(cls, *args, **opts):
1907 """Run a command in-place"""
1908 return do_cmd(cls(*args, **opts))
1911 def do_cmd(cmd):
1912 if hasattr(cmd, 'DISABLED') and cmd.DISABLED:
1913 return None
1914 try:
1915 return cmd.do()
1916 except Exception as e:
1917 msg, details = utils.format_exception(e)
1918 Interaction.critical(N_('Error'), message=msg, details=details)
1919 return None
1922 def difftool_run():
1923 """Start a default difftool session"""
1924 files = selection.selected_group()
1925 if not files:
1926 return
1927 s = selection.selection()
1928 model = main.model()
1929 difftool_launch_with_head(files, bool(s.staged), model.head)
1932 def difftool_launch_with_head(filenames, staged, head):
1933 """Launch difftool against the provided head"""
1934 if head == 'HEAD':
1935 left = None
1936 else:
1937 left = head
1938 difftool_launch(left=left, staged=staged, paths=filenames)
1941 def difftool_launch(left=None, right=None, paths=None,
1942 staged=False, dir_diff=False,
1943 left_take_magic=False, left_take_parent=False):
1944 """Launches 'git difftool' with given parameters
1946 :param left: first argument to difftool
1947 :param right: second argument to difftool_args
1948 :param paths: paths to diff
1949 :param staged: activate `git difftool --staged`
1950 :param dir_diff: activate `git difftool --dir-diff`
1951 :param left_take_magic: whether to append the magic ^! diff expression
1952 :param left_take_parent: whether to append the first-parent ~ for diffing
1956 difftool_args = ['git', 'difftool', '--no-prompt']
1957 if staged:
1958 difftool_args.append('--cached')
1959 if dir_diff:
1960 difftool_args.append('--dir-diff')
1962 if left:
1963 if left_take_parent or left_take_magic:
1964 suffix = left_take_magic and '^!' or '~'
1965 # Check root commit (no parents and thus cannot execute '~')
1966 model = main.model()
1967 git = model.git
1968 status, out, err = git.rev_list(left, parents=True, n=1)
1969 Interaction.log_status(status, out, err)
1970 if status:
1971 raise OSError('git rev-list command failed')
1973 if len(out.split()) >= 2:
1974 # Commit has a parent, so we can take its child as requested
1975 left += suffix
1976 else:
1977 # No parent, assume it's the root commit, so we have to diff
1978 # against the empty tree. Git's empty tree is a built-in
1979 # constant object name.
1980 left = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'
1981 if not right and left_take_magic:
1982 right = left
1983 difftool_args.append(left)
1985 if right:
1986 difftool_args.append(right)
1988 if paths:
1989 difftool_args.append('--')
1990 difftool_args.extend(paths)
1992 core.fork(difftool_args)