cmds: use "git revert --no-edit"
[git-cola.git] / cola / cmds.py
blob392ddec5e1c05d19355781ca4416147b69463bc7
1 """Editor commands"""
2 from __future__ import division, absolute_import, unicode_literals
3 import os
4 import re
5 import sys
6 from fnmatch import fnmatch
7 from io import StringIO
9 try:
10 from send2trash import send2trash
11 except ImportError:
12 send2trash = None
14 from . import compat
15 from . import core
16 from . import gitcmds
17 from . import icons
18 from . import resources
19 from . import textwrap
20 from . import utils
21 from . import version
22 from .cmd import ContextCommand
23 from .diffparse import DiffParser
24 from .git import STDOUT
25 from .git import EMPTY_TREE_OID
26 from .git import MISSING_BLOB_OID
27 from .i18n import N_
28 from .interaction import Interaction
29 from .models import prefs
30 from .settings import Settings
33 class UsageError(Exception):
34 """Exception class for usage errors."""
36 def __init__(self, title, message):
37 Exception.__init__(self, message)
38 self.title = title
39 self.msg = message
42 class EditModel(ContextCommand):
43 """Commands that mutate the main model diff data"""
44 UNDOABLE = True
46 def __init__(self, context):
47 """Common edit operations on the main model"""
48 super(EditModel, self).__init__(context)
50 self.old_diff_text = self.model.diff_text
51 self.old_filename = self.model.filename
52 self.old_mode = self.model.mode
53 self.old_diff_type = self.model.diff_type
55 self.new_diff_text = self.old_diff_text
56 self.new_filename = self.old_filename
57 self.new_mode = self.old_mode
58 self.new_diff_type = self.old_diff_type
60 def do(self):
61 """Perform the operation."""
62 self.model.set_filename(self.new_filename)
63 self.model.set_mode(self.new_mode)
64 self.model.set_diff_text(self.new_diff_text)
65 self.model.set_diff_type(self.new_diff_type)
67 def undo(self):
68 """Undo the operation."""
69 self.model.set_filename(self.old_filename)
70 self.model.set_mode(self.old_mode)
71 self.model.set_diff_text(self.old_diff_text)
72 self.model.set_diff_type(self.old_diff_type)
75 class ConfirmAction(ContextCommand):
76 """Confirm an action before running it"""
78 # pylint: disable=no-self-use
79 def ok_to_run(self):
80 """Return True when the command is ok to run"""
81 return True
83 # pylint: disable=no-self-use
84 def confirm(self):
85 """Prompt for confirmation"""
86 return True
88 # pylint: disable=no-self-use
89 def action(self):
90 """Run the command and return (status, out, err)"""
91 return (-1, '', '')
93 # pylint: disable=no-self-use
94 def success(self):
95 """Callback run on success"""
96 return
98 # pylint: disable=no-self-use
99 def command(self):
100 """Command name, for error messages"""
101 return 'git'
103 # pylint: disable=no-self-use
104 def error_message(self):
105 """Command error message"""
106 return ''
108 def do(self):
109 """Prompt for confirmation before running a command"""
110 status = -1
111 out = err = ''
112 ok = self.ok_to_run() and self.confirm()
113 if ok:
114 status, out, err = self.action()
115 if status == 0:
116 self.success()
117 title = self.error_message()
118 cmd = self.command()
119 Interaction.command(title, cmd, status, out, err)
121 return ok, status, out, err
124 class AbortMerge(ConfirmAction):
125 """Reset an in-progress merge back to HEAD"""
127 def confirm(self):
128 title = N_('Abort Merge...')
129 question = N_('Aborting the current merge?')
130 info = N_('Aborting the current merge will cause '
131 '*ALL* uncommitted changes to be lost.\n'
132 'Recovering uncommitted changes is not possible.')
133 ok_txt = N_('Abort Merge')
134 return Interaction.confirm(title, question, info, ok_txt,
135 default=False, icon=icons.undo())
137 def action(self):
138 status, out, err = gitcmds.abort_merge(self.context)
139 self.model.update_file_status()
140 return status, out, err
142 def success(self):
143 self.model.set_commitmsg('')
145 def error_message(self):
146 return N_('Error')
148 def command(self):
149 return 'git merge'
152 class AmendMode(EditModel):
153 """Try to amend a commit."""
154 UNDOABLE = True
155 LAST_MESSAGE = None
157 @staticmethod
158 def name():
159 return N_('Amend')
161 def __init__(self, context, amend=True):
162 super(AmendMode, self).__init__(context)
163 self.skip = False
164 self.amending = amend
165 self.old_commitmsg = self.model.commitmsg
166 self.old_mode = self.model.mode
168 if self.amending:
169 self.new_mode = self.model.mode_amend
170 self.new_commitmsg = gitcmds.prev_commitmsg(context)
171 AmendMode.LAST_MESSAGE = self.model.commitmsg
172 return
173 # else, amend unchecked, regular commit
174 self.new_mode = self.model.mode_none
175 self.new_diff_text = ''
176 self.new_commitmsg = self.model.commitmsg
177 # If we're going back into new-commit-mode then search the
178 # undo stack for a previous amend-commit-mode and grab the
179 # commit message at that point in time.
180 if AmendMode.LAST_MESSAGE is not None:
181 self.new_commitmsg = AmendMode.LAST_MESSAGE
182 AmendMode.LAST_MESSAGE = None
184 def do(self):
185 """Leave/enter amend mode."""
186 # Attempt to enter amend mode. Do not allow this when merging.
187 if self.amending:
188 if self.model.is_merging:
189 self.skip = True
190 self.model.set_mode(self.old_mode)
191 Interaction.information(
192 N_('Cannot Amend'),
193 N_('You are in the middle of a merge.\n'
194 'Cannot amend while merging.'))
195 return
196 self.skip = False
197 super(AmendMode, self).do()
198 self.model.set_commitmsg(self.new_commitmsg)
199 self.model.update_file_status()
201 def undo(self):
202 if self.skip:
203 return
204 self.model.set_commitmsg(self.old_commitmsg)
205 super(AmendMode, self).undo()
206 self.model.update_file_status()
209 class AnnexAdd(ContextCommand):
210 """Add to Git Annex"""
212 def __init__(self, context):
213 super(AnnexAdd, self).__init__(context)
214 self.filename = self.selection.filename()
216 def do(self):
217 status, out, err = self.git.annex('add', self.filename)
218 Interaction.command(N_('Error'), 'git annex add', status, out, err)
219 self.model.update_status()
222 class AnnexInit(ContextCommand):
223 """Initialize Git Annex"""
225 def do(self):
226 status, out, err = self.git.annex('init')
227 Interaction.command(N_('Error'), 'git annex init', status, out, err)
228 self.model.cfg.reset()
229 self.model.emit_updated()
232 class LFSTrack(ContextCommand):
233 """Add a file to git lfs"""
235 def __init__(self, context):
236 super(LFSTrack, self).__init__(context)
237 self.filename = self.selection.filename()
238 self.stage_cmd = Stage(context, [self.filename])
240 def do(self):
241 status, out, err = self.git.lfs('track', self.filename)
242 Interaction.command(
243 N_('Error'), 'git lfs track', status, out, err)
244 if status == 0:
245 self.stage_cmd.do()
248 class LFSInstall(ContextCommand):
249 """Initialize git lfs"""
251 def do(self):
252 status, out, err = self.git.lfs('install')
253 Interaction.command(
254 N_('Error'), 'git lfs install', status, out, err)
255 self.model.update_config(reset=True, emit=True)
258 class ApplyDiffSelection(ContextCommand):
259 """Apply the selected diff to the worktree or index"""
261 def __init__(self, context, first_line_idx, last_line_idx, has_selection,
262 reverse, apply_to_worktree):
263 super(ApplyDiffSelection, self).__init__(context)
264 self.first_line_idx = first_line_idx
265 self.last_line_idx = last_line_idx
266 self.has_selection = has_selection
267 self.reverse = reverse
268 self.apply_to_worktree = apply_to_worktree
270 def do(self):
271 context = self.context
272 cfg = self.context.cfg
273 diff_text = self.model.diff_text
275 parser = DiffParser(self.model.filename, diff_text)
276 if self.has_selection:
277 patch = parser.generate_patch(
278 self.first_line_idx, self.last_line_idx, reverse=self.reverse)
279 else:
280 patch = parser.generate_hunk_patch(
281 self.first_line_idx, reverse=self.reverse)
282 if patch is None:
283 return
285 if isinstance(diff_text, core.UStr):
286 # original encoding must prevail
287 encoding = diff_text.encoding
288 else:
289 encoding = cfg.file_encoding(self.model.filename)
291 tmp_file = utils.tmp_filename('patch')
292 try:
293 core.write(tmp_file, patch, encoding=encoding)
294 if self.apply_to_worktree:
295 status, out, err = gitcmds.apply_diff_to_worktree(
296 context, tmp_file)
297 else:
298 status, out, err = gitcmds.apply_diff(context, tmp_file)
299 finally:
300 core.unlink(tmp_file)
302 Interaction.log_status(status, out, err)
303 self.model.update_file_status(update_index=True)
306 class ApplyPatches(ContextCommand):
307 """Apply patches using the "git am" command"""
309 def __init__(self, context, patches):
310 super(ApplyPatches, self).__init__(context)
311 self.patches = patches
313 def do(self):
314 status, out, err = self.git.am('-3', *self.patches)
315 Interaction.log_status(status, out, err)
317 # Display a diffstat
318 self.model.update_file_status()
320 patch_basenames = [os.path.basename(p) for p in self.patches]
321 if len(patch_basenames) > 25:
322 patch_basenames = patch_basenames[:25]
323 patch_basenames.append('...')
325 basenames = '\n'.join(patch_basenames)
326 Interaction.information(
327 N_('Patch(es) Applied'),
328 (N_('%d patch(es) applied.') + '\n\n%s')
329 % (len(self.patches), basenames))
332 class Archive(ContextCommand):
333 """"Export archives using the "git archive" command"""
335 def __init__(self, context, ref, fmt, prefix, filename):
336 super(Archive, self).__init__(context)
337 self.ref = ref
338 self.fmt = fmt
339 self.prefix = prefix
340 self.filename = filename
342 def do(self):
343 fp = core.xopen(self.filename, 'wb')
344 cmd = ['git', 'archive', '--format='+self.fmt]
345 if self.fmt in ('tgz', 'tar.gz'):
346 cmd.append('-9')
347 if self.prefix:
348 cmd.append('--prefix=' + self.prefix)
349 cmd.append(self.ref)
350 proc = core.start_command(cmd, stdout=fp)
351 out, err = proc.communicate()
352 fp.close()
353 status = proc.returncode
354 Interaction.log_status(status, out or '', err or '')
357 class Checkout(EditModel):
358 """A command object for git-checkout.
360 'argv' is handed off directly to git.
363 def __init__(self, context, argv, checkout_branch=False):
364 super(Checkout, self).__init__(context)
365 self.argv = argv
366 self.checkout_branch = checkout_branch
367 self.new_diff_text = ''
368 self.new_diff_type = 'text'
370 def do(self):
371 super(Checkout, self).do()
372 status, out, err = self.git.checkout(*self.argv)
373 if self.checkout_branch:
374 self.model.update_status()
375 else:
376 self.model.update_file_status()
377 Interaction.command(N_('Error'), 'git checkout', status, out, err)
380 class BlamePaths(ContextCommand):
381 """Blame view for paths."""
383 @staticmethod
384 def name():
385 return N_('Blame...')
387 def __init__(self, context, paths=None):
388 super(BlamePaths, self).__init__(context)
389 if not paths:
390 paths = context.selection.union()
391 viewer = utils.shell_split(prefs.blame_viewer(context))
392 self.argv = viewer + list(paths)
394 def do(self):
395 try:
396 core.fork(self.argv)
397 except OSError as e:
398 _, details = utils.format_exception(e)
399 title = N_('Error Launching Blame Viewer')
400 msg = (N_('Cannot exec "%s": please configure a blame viewer')
401 % ' '.join(self.argv))
402 Interaction.critical(title, message=msg, details=details)
405 class CheckoutBranch(Checkout):
406 """Checkout a branch."""
408 def __init__(self, context, branch):
409 args = [branch]
410 super(CheckoutBranch, self).__init__(
411 context, args, checkout_branch=True)
414 class CherryPick(ContextCommand):
415 """Cherry pick commits into the current branch."""
417 def __init__(self, context, commits):
418 super(CherryPick, self).__init__(context)
419 self.commits = commits
421 def do(self):
422 self.model.cherry_pick_list(self.commits)
423 self.model.update_file_status()
426 class Revert(ContextCommand):
427 """Cherry pick commits into the current branch."""
429 def __init__(self, context, oid):
430 super(Revert, self).__init__(context)
431 self.oid = oid
433 def do(self):
434 self.git.revert(self.oid, no_edit=True)
435 self.model.update_file_status()
438 class ResetMode(EditModel):
439 """Reset the mode and clear the model's diff text."""
441 def __init__(self, context):
442 super(ResetMode, self).__init__(context)
443 self.new_mode = self.model.mode_none
444 self.new_diff_text = ''
445 self.new_diff_type = 'text'
446 self.new_filename = ''
448 def do(self):
449 super(ResetMode, self).do()
450 self.model.update_file_status()
453 class ResetCommand(ConfirmAction):
454 """Reset state using the "git reset" command"""
456 def __init__(self, context, ref):
457 super(ResetCommand, self).__init__(context)
458 self.ref = ref
460 def action(self):
461 return self.reset()
463 def command(self):
464 return 'git reset'
466 def error_message(self):
467 return N_('Error')
469 def success(self):
470 self.model.update_file_status()
472 def confirm(self):
473 raise NotImplementedError('confirm() must be overridden')
475 def reset(self):
476 raise NotImplementedError('reset() must be overridden')
479 class ResetBranchHead(ResetCommand):
481 def confirm(self):
482 title = N_('Reset Branch')
483 question = N_('Point the current branch head to a new commit?')
484 info = N_('The branch will be reset using "git reset --mixed %s"')
485 ok_text = N_('Reset Branch')
486 info = info % self.ref
487 return Interaction.confirm(title, question, info, ok_text)
489 def reset(self):
490 return self.git.reset(self.ref, '--', mixed=True)
493 class ResetWorktree(ResetCommand):
495 def confirm(self):
496 title = N_('Reset Worktree')
497 question = N_('Reset worktree?')
498 info = N_('The worktree will be reset using "git reset --keep %s"')
499 ok_text = N_('Reset Worktree')
500 info = info % self.ref
501 return Interaction.confirm(title, question, info, ok_text)
503 def reset(self):
504 return self.git.reset(self.ref, '--', keep=True)
507 class ResetMerge(ResetCommand):
509 def confirm(self):
510 title = N_('Reset Merge')
511 question = N_('Reset merge?')
512 info = N_('The branch will be reset using "git reset --merge %s"')
513 ok_text = N_('Reset Merge')
514 info = info % self.ref
515 return Interaction.confirm(title, question, info, ok_text)
517 def reset(self):
518 return self.git.reset(self.ref, '--', merge=True)
521 class ResetSoft(ResetCommand):
523 def confirm(self):
524 title = N_('Reset Soft')
525 question = N_('Reset soft?')
526 info = N_('The branch will be reset using "git reset --soft %s"')
527 ok_text = N_('Reset Soft')
528 info = info % self.ref
529 return Interaction.confirm(title, question, info, ok_text)
531 def reset(self):
532 return self.git.reset(self.ref, '--', soft=True)
535 class ResetHard(ResetCommand):
537 def confirm(self):
538 title = N_('Reset Hard')
539 question = N_('Reset hard?')
540 info = N_('The branch will be reset using "git reset --hard %s"')
541 ok_text = N_('Reset Hard')
542 info = info % self.ref
543 return Interaction.confirm(title, question, info, ok_text)
545 def reset(self):
546 return self.git.reset(self.ref, '--', hard=True)
549 class Commit(ResetMode):
550 """Attempt to create a new commit."""
552 def __init__(self, context, amend, msg, sign, no_verify=False):
553 super(Commit, self).__init__(context)
554 self.amend = amend
555 self.msg = msg
556 self.sign = sign
557 self.no_verify = no_verify
558 self.old_commitmsg = self.model.commitmsg
559 self.new_commitmsg = ''
561 def do(self):
562 # Create the commit message file
563 context = self.context
564 comment_char = prefs.comment_char(context)
565 msg = self.strip_comments(self.msg, comment_char=comment_char)
566 tmp_file = utils.tmp_filename('commit-message')
567 try:
568 core.write(tmp_file, msg)
569 # Run 'git commit'
570 status, out, err = self.git.commit(
571 F=tmp_file, v=True, gpg_sign=self.sign,
572 amend=self.amend, no_verify=self.no_verify)
573 finally:
574 core.unlink(tmp_file)
575 if status == 0:
576 super(Commit, self).do()
577 if context.cfg.get(prefs.AUTOTEMPLATE):
578 template_loader = LoadCommitMessageFromTemplate(context)
579 template_loader.do()
580 else:
581 self.model.set_commitmsg(self.new_commitmsg)
583 title = N_('Commit failed')
584 Interaction.command(title, 'git commit', status, out, err)
586 return status, out, err
588 @staticmethod
589 def strip_comments(msg, comment_char='#'):
590 # Strip off comments
591 message_lines = [line for line in msg.split('\n')
592 if not line.startswith(comment_char)]
593 msg = '\n'.join(message_lines)
594 if not msg.endswith('\n'):
595 msg += '\n'
597 return msg
600 class CycleReferenceSort(ContextCommand):
601 """Choose the next reference sort type"""
602 def do(self):
603 self.model.cycle_ref_sort()
606 class Ignore(ContextCommand):
607 """Add files to an exclusion file"""
609 def __init__(self, context, filenames, local=False):
610 super(Ignore, self).__init__(context)
611 self.filenames = list(filenames)
612 self.local = local
614 def do(self):
615 if not self.filenames:
616 return
617 new_additions = '\n'.join(self.filenames) + '\n'
618 for_status = new_additions
619 if self.local:
620 filename = os.path.join('.git', 'info', 'exclude')
621 else:
622 filename = '.gitignore'
623 if core.exists(filename):
624 current_list = core.read(filename)
625 new_additions = current_list.rstrip() + '\n' + new_additions
626 core.write(filename, new_additions)
627 Interaction.log_status(0, 'Added to %s:\n%s' % (filename, for_status),
629 self.model.update_file_status()
632 def file_summary(files):
633 txt = core.list2cmdline(files)
634 if len(txt) > 768:
635 txt = txt[:768].rstrip() + '...'
636 wrap = textwrap.TextWrapper()
637 return '\n'.join(wrap.wrap(txt))
640 class RemoteCommand(ConfirmAction):
642 def __init__(self, context, remote):
643 super(RemoteCommand, self).__init__(context)
644 self.remote = remote
646 def success(self):
647 self.cfg.reset()
648 self.model.update_remotes()
651 class RemoteAdd(RemoteCommand):
653 def __init__(self, context, remote, url):
654 super(RemoteAdd, self).__init__(context, remote)
655 self.url = url
657 def action(self):
658 return self.git.remote('add', self.remote, self.url)
660 def error_message(self):
661 return N_('Error creating remote "%s"') % self.remote
663 def command(self):
664 return 'git remote add "%s" "%s"' % (self.remote, self.url)
667 class RemoteRemove(RemoteCommand):
669 def confirm(self):
670 title = N_('Delete Remote')
671 question = N_('Delete remote?')
672 info = N_('Delete remote "%s"') % self.remote
673 ok_text = N_('Delete')
674 return Interaction.confirm(title, question, info, ok_text)
676 def action(self):
677 return self.git.remote('rm', self.remote)
679 def error_message(self):
680 return N_('Error deleting remote "%s"') % self.remote
682 def command(self):
683 return 'git remote rm "%s"' % self.remote
686 class RemoteRename(RemoteCommand):
688 def __init__(self, context, remote, new_name):
689 super(RemoteRename, self).__init__(context, remote)
690 self.new_name = new_name
692 def confirm(self):
693 title = N_('Rename Remote')
694 text = (N_('Rename remote "%(current)s" to "%(new)s"?') %
695 dict(current=self.remote, new=self.new_name))
696 info_text = ''
697 ok_text = title
698 return Interaction.confirm(title, text, info_text, ok_text)
700 def action(self):
701 return self.git.remote('rename', self.remote, self.new_name)
703 def error_message(self):
704 return (N_('Error renaming "%(name)s" to "%(new_name)s"')
705 % dict(name=self.remote, new_name=self.new_name))
707 def command(self):
708 return 'git remote rename "%s" "%s"' % (self.remote, self.new_name)
711 class RemoteSetURL(RemoteCommand):
713 def __init__(self, context, remote, url):
714 super(RemoteSetURL, self).__init__(context, remote)
715 self.url = url
717 def action(self):
718 return self.git.remote('set-url', self.remote, self.url)
720 def error_message(self):
721 return (N_('Unable to set URL for "%(name)s" to "%(url)s"')
722 % dict(name=self.remote, url=self.url))
724 def command(self):
725 return 'git remote set-url "%s" "%s"' % (self.remote, self.url)
728 class RemoteEdit(ContextCommand):
729 """Combine RemoteRename and RemoteSetURL"""
731 def __init__(self, context, old_name, remote, url):
732 super(RemoteEdit, self).__init__(context)
733 self.rename = RemoteRename(context, old_name, remote)
734 self.set_url = RemoteSetURL(context, remote, url)
736 def do(self):
737 result = self.rename.do()
738 name_ok = result[0]
739 url_ok = False
740 if name_ok:
741 result = self.set_url.do()
742 url_ok = result[0]
743 return name_ok, url_ok
746 class RemoveFromSettings(ConfirmAction):
748 def __init__(self, context, settings, repo, entry, icon=None):
749 super(RemoveFromSettings, self).__init__(context)
750 self.settings = settings
751 self.repo = repo
752 self.entry = entry
753 self.icon = icon
755 def success(self):
756 self.settings.save()
759 class RemoveBookmark(RemoveFromSettings):
761 def confirm(self):
762 entry = self.entry
763 title = msg = N_('Delete Bookmark?')
764 info = N_('%s will be removed from your bookmarks.') % entry
765 ok_text = N_('Delete Bookmark')
766 return Interaction.confirm(title, msg, info, ok_text, icon=self.icon)
768 def action(self):
769 self.settings.remove_bookmark(self.repo, self.entry)
770 return (0, '', '')
773 class RemoveRecent(RemoveFromSettings):
775 def confirm(self):
776 repo = self.repo
777 title = msg = N_('Remove %s from the recent list?') % repo
778 info = N_('%s will be removed from your recent repositories.') % repo
779 ok_text = N_('Remove')
780 return Interaction.confirm(title, msg, info, ok_text, icon=self.icon)
782 def action(self):
783 self.settings.remove_recent(self.repo)
784 return (0, '', '')
787 class RemoveFiles(ContextCommand):
788 """Removes files"""
790 def __init__(self, context, remover, filenames):
791 super(RemoveFiles, self).__init__(context)
792 if remover is None:
793 remover = os.remove
794 self.remover = remover
795 self.filenames = filenames
796 # We could git-hash-object stuff and provide undo-ability
797 # as an option. Heh.
799 def do(self):
800 files = self.filenames
801 if not files:
802 return
804 rescan = False
805 bad_filenames = []
806 remove = self.remover
807 for filename in files:
808 if filename:
809 try:
810 remove(filename)
811 rescan = True
812 except OSError:
813 bad_filenames.append(filename)
815 if bad_filenames:
816 Interaction.information(
817 N_('Error'),
818 N_('Deleting "%s" failed') % file_summary(bad_filenames))
820 if rescan:
821 self.model.update_file_status()
824 class Delete(RemoveFiles):
825 """Delete files."""
827 def __init__(self, context, filenames):
828 super(Delete, self).__init__(context, os.remove, filenames)
830 def do(self):
831 files = self.filenames
832 if not files:
833 return
835 title = N_('Delete Files?')
836 msg = N_('The following files will be deleted:') + '\n\n'
837 msg += file_summary(files)
838 info_txt = N_('Delete %d file(s)?') % len(files)
839 ok_txt = N_('Delete Files')
841 if Interaction.confirm(title, msg, info_txt, ok_txt,
842 default=True, icon=icons.remove()):
843 super(Delete, self).do()
846 class MoveToTrash(RemoveFiles):
847 """Move files to the trash using send2trash"""
849 AVAILABLE = send2trash is not None
851 def __init__(self, context, filenames):
852 super(MoveToTrash, self).__init__(context, send2trash, filenames)
855 class DeleteBranch(ConfirmAction):
856 """Delete a git branch."""
858 def __init__(self, context, branch):
859 super(DeleteBranch, self).__init__(context)
860 self.branch = branch
862 def confirm(self):
863 title = N_('Delete Branch')
864 question = N_('Delete branch "%s"?') % self.branch
865 info = N_('The branch will be no longer available.')
866 ok_txt = N_('Delete Branch')
867 return Interaction.confirm(title, question, info, ok_txt,
868 default=True, icon=icons.discard())
870 def action(self):
871 return self.model.delete_branch(self.branch)
873 def error_message(self):
874 return N_('Error deleting branch "%s"' % self.branch)
876 def command(self):
877 command = 'git branch -D %s'
878 return command % self.branch
881 class Rename(ContextCommand):
882 """Rename a set of paths."""
884 def __init__(self, context, paths):
885 super(Rename, self).__init__(context)
886 self.paths = paths
888 def do(self):
889 msg = N_('Untracking: %s') % (', '.join(self.paths))
890 Interaction.log(msg)
892 for path in self.paths:
893 ok = self.rename(path)
894 if not ok:
895 return
897 self.model.update_status()
899 def rename(self, path):
900 git = self.git
901 title = N_('Rename "%s"') % path
903 if os.path.isdir(path):
904 base_path = os.path.dirname(path)
905 else:
906 base_path = path
907 new_path = Interaction.save_as(base_path, title)
908 if not new_path:
909 return False
911 status, out, err = git.mv(path, new_path, force=True, verbose=True)
912 Interaction.command(N_('Error'), 'git mv', status, out, err)
913 return status == 0
916 class RenameBranch(ContextCommand):
917 """Rename a git branch."""
919 def __init__(self, context, branch, new_branch):
920 super(RenameBranch, self).__init__(context)
921 self.branch = branch
922 self.new_branch = new_branch
924 def do(self):
925 branch = self.branch
926 new_branch = self.new_branch
927 status, out, err = self.model.rename_branch(branch, new_branch)
928 Interaction.log_status(status, out, err)
931 class DeleteRemoteBranch(DeleteBranch):
932 """Delete a remote git branch."""
934 def __init__(self, context, remote, branch):
935 super(DeleteRemoteBranch, self).__init__(context, branch)
936 self.remote = remote
938 def action(self):
939 return self.git.push(self.remote, self.branch, delete=True)
941 def success(self):
942 self.model.update_status()
943 Interaction.information(
944 N_('Remote Branch Deleted'),
945 N_('"%(branch)s" has been deleted from "%(remote)s".')
946 % dict(branch=self.branch, remote=self.remote))
948 def error_message(self):
949 return N_('Error Deleting Remote Branch')
951 def command(self):
952 command = 'git push --delete %s %s'
953 return command % (self.remote, self.branch)
956 def get_mode(model, staged, modified, unmerged, untracked):
957 if staged:
958 mode = model.mode_index
959 elif modified or unmerged:
960 mode = model.mode_worktree
961 elif untracked:
962 mode = model.mode_untracked
963 else:
964 mode = model.mode
965 return mode
968 class DiffImage(EditModel):
970 def __init__(self, context, filename,
971 deleted, staged, modified, unmerged, untracked):
972 super(DiffImage, self).__init__(context)
974 self.new_filename = filename
975 self.new_diff_text = ''
976 self.new_diff_type = 'image'
977 self.new_mode = get_mode(
978 self.model, staged, modified, unmerged, untracked)
979 self.staged = staged
980 self.modified = modified
981 self.unmerged = unmerged
982 self.untracked = untracked
983 self.deleted = deleted
984 self.annex = self.cfg.is_annex()
986 def do(self):
987 filename = self.new_filename
989 if self.staged:
990 images = self.staged_images()
991 elif self.modified:
992 images = self.modified_images()
993 elif self.unmerged:
994 images = self.unmerged_images()
995 elif self.untracked:
996 images = [(filename, False)]
997 else:
998 images = []
1000 self.model.set_images(images)
1001 super(DiffImage, self).do()
1003 def staged_images(self):
1004 context = self.context
1005 git = self.git
1006 head = self.model.head
1007 filename = self.new_filename
1008 annex = self.annex
1010 images = []
1011 index = git.diff_index(head, '--', filename, cached=True)[STDOUT]
1012 if index:
1013 # Example:
1014 # :100644 100644 fabadb8... 4866510... M describe.c
1015 parts = index.split(' ')
1016 if len(parts) > 3:
1017 old_oid = parts[2]
1018 new_oid = parts[3]
1020 if old_oid != MISSING_BLOB_OID:
1021 # First, check if we can get a pre-image from git-annex
1022 annex_image = None
1023 if annex:
1024 annex_image = gitcmds.annex_path(context, head, filename)
1025 if annex_image:
1026 images.append((annex_image, False)) # git annex HEAD
1027 else:
1028 image = gitcmds.write_blob_path(
1029 context, head, old_oid, filename)
1030 if image:
1031 images.append((image, True))
1033 if new_oid != MISSING_BLOB_OID:
1034 found_in_annex = False
1035 if annex and core.islink(filename):
1036 status, out, _ = git.annex('status', '--', filename)
1037 if status == 0:
1038 details = out.split(' ')
1039 if details and details[0] == 'A': # newly added file
1040 images.append((filename, False))
1041 found_in_annex = True
1043 if not found_in_annex:
1044 image = gitcmds.write_blob(context, new_oid, filename)
1045 if image:
1046 images.append((image, True))
1048 return images
1050 def unmerged_images(self):
1051 context = self.context
1052 git = self.git
1053 head = self.model.head
1054 filename = self.new_filename
1055 annex = self.annex
1057 candidate_merge_heads = ('HEAD', 'CHERRY_HEAD', 'MERGE_HEAD')
1058 merge_heads = [
1059 merge_head for merge_head in candidate_merge_heads
1060 if core.exists(git.git_path(merge_head))]
1062 if annex: # Attempt to find files in git-annex
1063 annex_images = []
1064 for merge_head in merge_heads:
1065 image = gitcmds.annex_path(context, merge_head, filename)
1066 if image:
1067 annex_images.append((image, False))
1068 if annex_images:
1069 annex_images.append((filename, False))
1070 return annex_images
1072 # DIFF FORMAT FOR MERGES
1073 # "git-diff-tree", "git-diff-files" and "git-diff --raw"
1074 # can take -c or --cc option to generate diff output also
1075 # for merge commits. The output differs from the format
1076 # described above in the following way:
1078 # 1. there is a colon for each parent
1079 # 2. there are more "src" modes and "src" sha1
1080 # 3. status is concatenated status characters for each parent
1081 # 4. no optional "score" number
1082 # 5. single path, only for "dst"
1083 # Example:
1084 # ::100644 100644 100644 fabadb8... cc95eb0... 4866510... \
1085 # MM describe.c
1086 images = []
1087 index = git.diff_index(head, '--', filename,
1088 cached=True, cc=True)[STDOUT]
1089 if index:
1090 parts = index.split(' ')
1091 if len(parts) > 3:
1092 first_mode = parts[0]
1093 num_parents = first_mode.count(':')
1094 # colon for each parent, but for the index, the "parents"
1095 # are really entries in stages 1,2,3 (head, base, remote)
1096 # remote, base, head
1097 for i in range(num_parents):
1098 offset = num_parents + i + 1
1099 oid = parts[offset]
1100 try:
1101 merge_head = merge_heads[i]
1102 except IndexError:
1103 merge_head = 'HEAD'
1104 if oid != MISSING_BLOB_OID:
1105 image = gitcmds.write_blob_path(
1106 context, merge_head, oid, filename)
1107 if image:
1108 images.append((image, True))
1110 images.append((filename, False))
1111 return images
1113 def modified_images(self):
1114 context = self.context
1115 git = self.git
1116 head = self.model.head
1117 filename = self.new_filename
1118 annex = self.annex
1120 images = []
1121 annex_image = None
1122 if annex: # Check for a pre-image from git-annex
1123 annex_image = gitcmds.annex_path(context, head, filename)
1124 if annex_image:
1125 images.append((annex_image, False)) # git annex HEAD
1126 else:
1127 worktree = git.diff_files('--', filename)[STDOUT]
1128 parts = worktree.split(' ')
1129 if len(parts) > 3:
1130 oid = parts[2]
1131 if oid != MISSING_BLOB_OID:
1132 image = gitcmds.write_blob_path(
1133 context, head, oid, filename)
1134 if image:
1135 images.append((image, True)) # HEAD
1137 images.append((filename, False)) # worktree
1138 return images
1141 class Diff(EditModel):
1142 """Perform a diff and set the model's current text."""
1144 def __init__(self, context, filename, cached=False, deleted=False):
1145 super(Diff, self).__init__(context)
1146 opts = {}
1147 if cached:
1148 opts['ref'] = self.model.head
1149 self.new_filename = filename
1150 self.new_mode = self.model.mode_worktree
1151 self.new_diff_text = gitcmds.diff_helper(
1152 self.context, filename=filename, cached=cached,
1153 deleted=deleted, **opts)
1154 self.new_diff_type = 'text'
1157 class Diffstat(EditModel):
1158 """Perform a diffstat and set the model's diff text."""
1160 def __init__(self, context):
1161 super(Diffstat, self).__init__(context)
1162 cfg = self.cfg
1163 diff_context = cfg.get('diff.context', 3)
1164 diff = self.git.diff(
1165 self.model.head, unified=diff_context, no_ext_diff=True,
1166 no_color=True, M=True, stat=True)[STDOUT]
1167 self.new_diff_text = diff
1168 self.new_diff_type = 'text'
1169 self.new_mode = self.model.mode_diffstat
1172 class DiffStaged(Diff):
1173 """Perform a staged diff on a file."""
1175 def __init__(self, context, filename, deleted=None):
1176 super(DiffStaged, self).__init__(
1177 context, filename, cached=True, deleted=deleted)
1178 self.new_mode = self.model.mode_index
1181 class DiffStagedSummary(EditModel):
1183 def __init__(self, context):
1184 super(DiffStagedSummary, self).__init__(context)
1185 diff = self.git.diff(
1186 self.model.head, cached=True, no_color=True,
1187 no_ext_diff=True, patch_with_stat=True, M=True)[STDOUT]
1188 self.new_diff_text = diff
1189 self.new_diff_type = 'text'
1190 self.new_mode = self.model.mode_index
1193 class Difftool(ContextCommand):
1194 """Run git-difftool limited by path."""
1196 def __init__(self, context, staged, filenames):
1197 super(Difftool, self).__init__(context)
1198 self.staged = staged
1199 self.filenames = filenames
1201 def do(self):
1202 difftool_launch_with_head(
1203 self.context, self.filenames, self.staged, self.model.head)
1206 class Edit(ContextCommand):
1207 """Edit a file using the configured gui.editor."""
1209 @staticmethod
1210 def name():
1211 return N_('Launch Editor')
1213 def __init__(self, context, filenames,
1214 line_number=None, background_editor=False):
1215 super(Edit, self).__init__(context)
1216 self.filenames = filenames
1217 self.line_number = line_number
1218 self.background_editor = background_editor
1220 def do(self):
1221 context = self.context
1222 if not self.filenames:
1223 return
1224 filename = self.filenames[0]
1225 if not core.exists(filename):
1226 return
1227 if self.background_editor:
1228 editor = prefs.background_editor(context)
1229 else:
1230 editor = prefs.editor(context)
1231 opts = []
1233 if self.line_number is None:
1234 opts = self.filenames
1235 else:
1236 # Single-file w/ line-numbers (likely from grep)
1237 editor_opts = {
1238 '*vim*': [filename, '+%s' % self.line_number],
1239 '*emacs*': ['+%s' % self.line_number, filename],
1240 '*textpad*': ['%s(%s,0)' % (filename, self.line_number)],
1241 '*notepad++*': ['-n%s' % self.line_number, filename],
1242 '*subl*': ['%s:%s' % (filename, self.line_number)],
1245 opts = self.filenames
1246 for pattern, opt in editor_opts.items():
1247 if fnmatch(editor, pattern):
1248 opts = opt
1249 break
1251 try:
1252 core.fork(utils.shell_split(editor) + opts)
1253 except (OSError, ValueError) as e:
1254 message = (N_('Cannot exec "%s": please configure your editor')
1255 % editor)
1256 _, details = utils.format_exception(e)
1257 Interaction.critical(N_('Error Editing File'), message, details)
1260 class FormatPatch(ContextCommand):
1261 """Output a patch series given all revisions and a selected subset."""
1263 def __init__(self, context, to_export, revs, output='patches'):
1264 super(FormatPatch, self).__init__(context)
1265 self.to_export = list(to_export)
1266 self.revs = list(revs)
1267 self.output = output
1269 def do(self):
1270 context = self.context
1271 status, out, err = gitcmds.format_patchsets(
1272 context, self.to_export, self.revs, self.output)
1273 Interaction.log_status(status, out, err)
1276 class LaunchDifftool(ContextCommand):
1278 @staticmethod
1279 def name():
1280 return N_('Launch Diff Tool')
1282 def do(self):
1283 s = self.selection.selection()
1284 if s.unmerged:
1285 paths = s.unmerged
1286 if utils.is_win32():
1287 core.fork(['git', 'mergetool', '--no-prompt', '--'] + paths)
1288 else:
1289 cfg = self.cfg
1290 cmd = cfg.terminal()
1291 argv = utils.shell_split(cmd)
1293 terminal = os.path.basename(argv[0])
1294 shellquote_terms = set(['xfce4-terminal'])
1295 shellquote_default = terminal in shellquote_terms
1297 mergetool = ['git', 'mergetool', '--no-prompt', '--']
1298 mergetool.extend(paths)
1299 needs_shellquote = cfg.get(
1300 'cola.terminalshellquote', shellquote_default)
1302 if needs_shellquote:
1303 argv.append(core.list2cmdline(mergetool))
1304 else:
1305 argv.extend(mergetool)
1307 core.fork(argv)
1308 else:
1309 difftool_run(self.context)
1312 class LaunchTerminal(ContextCommand):
1314 @staticmethod
1315 def name():
1316 return N_('Launch Terminal')
1318 @staticmethod
1319 def is_available(context):
1320 return context.cfg.terminal() is not None
1322 def __init__(self, context, path):
1323 super(LaunchTerminal, self).__init__(context)
1324 self.path = path
1326 def do(self):
1327 cmd = self.context.cfg.terminal()
1328 if cmd is None:
1329 return
1330 if utils.is_win32():
1331 argv = ['start', '', cmd, '--login']
1332 shell = True
1333 else:
1334 argv = utils.shell_split(cmd)
1335 argv.append(os.getenv('SHELL', '/bin/sh'))
1336 shell = False
1337 core.fork(argv, cwd=self.path, shell=shell)
1340 class LaunchEditor(Edit):
1342 @staticmethod
1343 def name():
1344 return N_('Launch Editor')
1346 def __init__(self, context):
1347 s = context.selection.selection()
1348 filenames = s.staged + s.unmerged + s.modified + s.untracked
1349 super(LaunchEditor, self).__init__(
1350 context, filenames, background_editor=True)
1353 class LaunchEditorAtLine(LaunchEditor):
1354 """Launch an editor at the specified line"""
1356 def __init__(self, context):
1357 super(LaunchEditorAtLine, self).__init__(context)
1358 self.line_number = context.selection.line_number
1361 class LoadCommitMessageFromFile(ContextCommand):
1362 """Loads a commit message from a path."""
1363 UNDOABLE = True
1365 def __init__(self, context, path):
1366 super(LoadCommitMessageFromFile, self).__init__(context)
1367 self.path = path
1368 self.old_commitmsg = self.model.commitmsg
1369 self.old_directory = self.model.directory
1371 def do(self):
1372 path = os.path.expanduser(self.path)
1373 if not path or not core.isfile(path):
1374 raise UsageError(N_('Error: Cannot find commit template'),
1375 N_('%s: No such file or directory.') % path)
1376 self.model.set_directory(os.path.dirname(path))
1377 self.model.set_commitmsg(core.read(path))
1379 def undo(self):
1380 self.model.set_commitmsg(self.old_commitmsg)
1381 self.model.set_directory(self.old_directory)
1384 class LoadCommitMessageFromTemplate(LoadCommitMessageFromFile):
1385 """Loads the commit message template specified by commit.template."""
1387 def __init__(self, context):
1388 cfg = context.cfg
1389 template = cfg.get('commit.template')
1390 super(LoadCommitMessageFromTemplate, self).__init__(context, template)
1392 def do(self):
1393 if self.path is None:
1394 raise UsageError(
1395 N_('Error: Unconfigured commit template'),
1396 N_('A commit template has not been configured.\n'
1397 'Use "git config" to define "commit.template"\n'
1398 'so that it points to a commit template.'))
1399 return LoadCommitMessageFromFile.do(self)
1402 class LoadCommitMessageFromOID(ContextCommand):
1403 """Load a previous commit message"""
1404 UNDOABLE = True
1406 def __init__(self, context, oid, prefix=''):
1407 super(LoadCommitMessageFromOID, self).__init__(context)
1408 self.oid = oid
1409 self.old_commitmsg = self.model.commitmsg
1410 self.new_commitmsg = prefix + gitcmds.prev_commitmsg(context, oid)
1412 def do(self):
1413 self.model.set_commitmsg(self.new_commitmsg)
1415 def undo(self):
1416 self.model.set_commitmsg(self.old_commitmsg)
1419 class PrepareCommitMessageHook(ContextCommand):
1420 """Use the cola-prepare-commit-msg hook to prepare the commit message
1422 UNDOABLE = True
1424 def __init__(self, context):
1425 super(PrepareCommitMessageHook, self).__init__(context)
1426 self.old_commitmsg = self.model.commitmsg
1428 def get_message(self):
1430 title = N_('Error running prepare-commitmsg hook')
1431 hook = gitcmds.prepare_commit_message_hook(self.context)
1433 if os.path.exists(hook):
1434 filename = self.model.save_commitmsg()
1435 status, out, err = core.run_command([hook, filename])
1437 if status == 0:
1438 result = core.read(filename)
1439 else:
1440 result = self.old_commitmsg
1441 Interaction.command_error(title, hook, status, out, err)
1442 else:
1443 message = N_('A hook must be provided at "%s"') % hook
1444 Interaction.critical(title, message=message)
1445 result = self.old_commitmsg
1447 return result
1449 def do(self):
1450 msg = self.get_message()
1451 self.model.set_commitmsg(msg)
1453 def undo(self):
1454 self.model.set_commitmsg(self.old_commitmsg)
1457 class LoadFixupMessage(LoadCommitMessageFromOID):
1458 """Load a fixup message"""
1460 def __init__(self, context, oid):
1461 super(LoadFixupMessage, self).__init__(context, oid, prefix='fixup! ')
1462 if self.new_commitmsg:
1463 self.new_commitmsg = self.new_commitmsg.splitlines()[0]
1466 class Merge(ContextCommand):
1467 """Merge commits"""
1469 def __init__(self, context, revision, no_commit, squash, no_ff, sign):
1470 super(Merge, self).__init__(context)
1471 self.revision = revision
1472 self.no_ff = no_ff
1473 self.no_commit = no_commit
1474 self.squash = squash
1475 self.sign = sign
1477 def do(self):
1478 squash = self.squash
1479 revision = self.revision
1480 no_ff = self.no_ff
1481 no_commit = self.no_commit
1482 sign = self.sign
1484 status, out, err = self.git.merge(
1485 revision, gpg_sign=sign, no_ff=no_ff,
1486 no_commit=no_commit, squash=squash)
1487 self.model.update_status()
1488 title = N_('Merge failed. Conflict resolution is required.')
1489 Interaction.command(title, 'git merge', status, out, err)
1491 return status, out, err
1494 class OpenDefaultApp(ContextCommand):
1495 """Open a file using the OS default."""
1497 @staticmethod
1498 def name():
1499 return N_('Open Using Default Application')
1501 def __init__(self, context, filenames):
1502 super(OpenDefaultApp, self).__init__(context)
1503 if utils.is_darwin():
1504 launcher = 'open'
1505 else:
1506 launcher = 'xdg-open'
1507 self.launcher = launcher
1508 self.filenames = filenames
1510 def do(self):
1511 if not self.filenames:
1512 return
1513 core.fork([self.launcher] + self.filenames)
1516 class OpenParentDir(OpenDefaultApp):
1517 """Open parent directories using the OS default."""
1519 @staticmethod
1520 def name():
1521 return N_('Open Parent Directory')
1523 def __init__(self, context, filenames):
1524 OpenDefaultApp.__init__(self, context, filenames)
1526 def do(self):
1527 if not self.filenames:
1528 return
1529 dirnames = list(set([os.path.dirname(x) for x in self.filenames]))
1530 # os.path.dirname() can return an empty string so we fallback to
1531 # the current directory
1532 dirs = [(dirname or core.getcwd()) for dirname in dirnames]
1533 core.fork([self.launcher] + dirs)
1536 class OpenNewRepo(ContextCommand):
1537 """Launches git-cola on a repo."""
1539 def __init__(self, context, repo_path):
1540 super(OpenNewRepo, self).__init__(context)
1541 self.repo_path = repo_path
1543 def do(self):
1544 self.model.set_directory(self.repo_path)
1545 core.fork([sys.executable, sys.argv[0], '--repo', self.repo_path])
1548 class OpenRepo(EditModel):
1550 def __init__(self, context, repo_path):
1551 super(OpenRepo, self).__init__(context)
1552 self.repo_path = repo_path
1553 self.new_mode = self.model.mode_none
1554 self.new_diff_text = ''
1555 self.new_diff_type = 'text'
1556 self.new_commitmsg = ''
1557 self.new_filename = ''
1559 def do(self):
1560 old_repo = self.git.getcwd()
1561 if self.model.set_worktree(self.repo_path):
1562 self.fsmonitor.stop()
1563 self.fsmonitor.start()
1564 self.model.update_status()
1565 # Check if template should be loaded
1566 if self.context.cfg.get(prefs.AUTOTEMPLATE):
1567 template_loader = LoadCommitMessageFromTemplate(self.context)
1568 template_loader.do()
1569 else:
1570 self.model.set_commitmsg(self.new_commitmsg)
1571 settings = Settings()
1572 settings.load()
1573 settings.add_recent(self.repo_path, prefs.maxrecent(self.context))
1574 settings.save()
1575 super(OpenRepo, self).do()
1576 else:
1577 self.model.set_worktree(old_repo)
1580 class OpenParentRepo(OpenRepo):
1582 def __init__(self, context):
1583 path = ''
1584 if version.check_git(context, 'show-superproject-working-tree'):
1585 status, out, _ = context.git.rev_parse(
1586 show_superproject_working_tree=True)
1587 if status == 0:
1588 path = out
1589 if not path:
1590 path = os.path.dirname(core.getcwd())
1591 super(OpenParentRepo, self).__init__(context, path)
1594 class Clone(ContextCommand):
1595 """Clones a repository and optionally spawns a new cola session."""
1597 def __init__(self, context, url, new_directory,
1598 submodules=False, shallow=False, spawn=True):
1599 super(Clone, self).__init__(context)
1600 self.url = url
1601 self.new_directory = new_directory
1602 self.submodules = submodules
1603 self.shallow = shallow
1604 self.spawn = spawn
1605 self.status = -1
1606 self.out = ''
1607 self.err = ''
1609 def do(self):
1610 kwargs = {}
1611 if self.shallow:
1612 kwargs['depth'] = 1
1613 recurse_submodules = self.submodules
1614 shallow_submodules = self.submodules and self.shallow
1616 status, out, err = self.git.clone(
1617 self.url, self.new_directory,
1618 recurse_submodules=recurse_submodules,
1619 shallow_submodules=shallow_submodules,
1620 **kwargs)
1622 self.status = status
1623 self.out = out
1624 self.err = err
1625 if status == 0 and self.spawn:
1626 executable = sys.executable
1627 core.fork([executable, sys.argv[0], '--repo', self.new_directory])
1628 return self
1631 class NewBareRepo(ContextCommand):
1632 """Create a new shared bare repository"""
1634 def __init__(self, context, path):
1635 super(NewBareRepo, self).__init__(context)
1636 self.path = path
1638 def do(self):
1639 path = self.path
1640 status, out, err = self.git.init(path, bare=True, shared=True)
1641 Interaction.command(
1642 N_('Error'), 'git init --bare --shared "%s"' % path,
1643 status, out, err)
1644 return status == 0
1647 def unix_path(path, is_win32=utils.is_win32):
1648 """Git for Windows requires unix paths, so force them here
1650 if is_win32():
1651 path = path.replace('\\', '/')
1652 first = path[0]
1653 second = path[1]
1654 if second == ':': # sanity check, this better be a Windows-style path
1655 path = '/' + first + path[2:]
1657 return path
1660 def sequence_editor():
1661 """Return a GIT_SEQUENCE_EDITOR environment value that enables git-xbase"""
1662 xbase = unix_path(resources.share('bin', 'git-xbase'))
1663 editor = core.list2cmdline([unix_path(sys.executable), xbase])
1664 return editor
1667 class GitXBaseContext(object):
1669 def __init__(self, context, **kwargs):
1670 self.env = {
1671 'GIT_EDITOR': prefs.editor(context),
1672 'GIT_SEQUENCE_EDITOR': sequence_editor(),
1673 'GIT_XBASE_CANCEL_ACTION': 'save',
1675 self.env.update(kwargs)
1677 def __enter__(self):
1678 for var, value in self.env.items():
1679 compat.setenv(var, value)
1680 return self
1682 def __exit__(self, exc_type, exc_val, exc_tb):
1683 for var in self.env:
1684 compat.unsetenv(var)
1687 class Rebase(ContextCommand):
1689 def __init__(self, context, upstream=None, branch=None, **kwargs):
1690 """Start an interactive rebase session
1692 :param upstream: upstream branch
1693 :param branch: optional branch to checkout
1694 :param kwargs: forwarded directly to `git.rebase()`
1697 super(Rebase, self).__init__(context)
1699 self.upstream = upstream
1700 self.branch = branch
1701 self.kwargs = kwargs
1703 def prepare_arguments(self, upstream):
1704 args = []
1705 kwargs = {}
1707 # Rebase actions must be the only option specified
1708 for action in ('continue', 'abort', 'skip', 'edit_todo'):
1709 if self.kwargs.get(action, False):
1710 kwargs[action] = self.kwargs[action]
1711 return args, kwargs
1713 kwargs['interactive'] = True
1714 kwargs['autosquash'] = self.kwargs.get('autosquash', True)
1715 kwargs.update(self.kwargs)
1717 if upstream:
1718 args.append(upstream)
1719 if self.branch:
1720 args.append(self.branch)
1722 return args, kwargs
1724 def do(self):
1725 (status, out, err) = (1, '', '')
1726 context = self.context
1727 cfg = self.cfg
1728 model = self.model
1730 if not cfg.get('rebase.autostash', False):
1731 if model.staged or model.unmerged or model.modified:
1732 Interaction.information(
1733 N_('Unable to rebase'),
1734 N_('You cannot rebase with uncommitted changes.'))
1735 return status, out, err
1737 upstream = self.upstream or Interaction.choose_ref(
1738 context, N_('Select New Upstream'), N_('Interactive Rebase'),
1739 default='@{upstream}')
1740 if not upstream:
1741 return status, out, err
1743 self.model.is_rebasing = True
1744 self.model.emit_updated()
1746 args, kwargs = self.prepare_arguments(upstream)
1747 upstream_title = upstream or '@{upstream}'
1748 with GitXBaseContext(
1749 self.context,
1750 GIT_XBASE_TITLE=N_('Rebase onto %s') % upstream_title,
1751 GIT_XBASE_ACTION=N_('Rebase')
1753 # TODO this blocks the user interface window for the duration
1754 # of git-xbase's invocation. We would need to implement
1755 # signals for QProcess and continue running the main thread.
1756 # alternatively we could hide the main window while rebasing.
1757 # that doesn't require as much effort.
1758 status, out, err = self.git.rebase(
1759 *args, _no_win32_startupinfo=True, **kwargs)
1760 self.model.update_status()
1761 if err.strip() != 'Nothing to do':
1762 title = N_('Rebase stopped')
1763 Interaction.command(title, 'git rebase', status, out, err)
1764 return status, out, err
1767 class RebaseEditTodo(ContextCommand):
1769 def do(self):
1770 (status, out, err) = (1, '', '')
1771 with GitXBaseContext(
1772 self.context,
1773 GIT_XBASE_TITLE=N_('Edit Rebase'),
1774 GIT_XBASE_ACTION=N_('Save')
1776 status, out, err = self.git.rebase(edit_todo=True)
1777 Interaction.log_status(status, out, err)
1778 self.model.update_status()
1779 return status, out, err
1782 class RebaseContinue(ContextCommand):
1784 def do(self):
1785 (status, out, err) = (1, '', '')
1786 with GitXBaseContext(
1787 self.context,
1788 GIT_XBASE_TITLE=N_('Rebase'),
1789 GIT_XBASE_ACTION=N_('Rebase')
1791 status, out, err = self.git.rebase('--continue')
1792 Interaction.log_status(status, out, err)
1793 self.model.update_status()
1794 return status, out, err
1797 class RebaseSkip(ContextCommand):
1799 def do(self):
1800 (status, out, err) = (1, '', '')
1801 with GitXBaseContext(
1802 self.context,
1803 GIT_XBASE_TITLE=N_('Rebase'),
1804 GIT_XBASE_ACTION=N_('Rebase')
1806 status, out, err = self.git.rebase(skip=True)
1807 Interaction.log_status(status, out, err)
1808 self.model.update_status()
1809 return status, out, err
1812 class RebaseAbort(ContextCommand):
1814 def do(self):
1815 status, out, err = self.git.rebase(abort=True)
1816 Interaction.log_status(status, out, err)
1817 self.model.update_status()
1820 class Rescan(ContextCommand):
1821 """Rescan for changes"""
1823 def do(self):
1824 self.model.update_status()
1827 class Refresh(ContextCommand):
1828 """Update refs, refresh the index, and update config"""
1830 @staticmethod
1831 def name():
1832 return N_('Refresh')
1834 def do(self):
1835 self.model.update_status(update_index=True)
1836 self.cfg.update()
1837 self.fsmonitor.refresh()
1840 class RefreshConfig(ContextCommand):
1841 """Refresh the git config cache"""
1843 def do(self):
1844 self.cfg.update()
1847 class RevertEditsCommand(ConfirmAction):
1849 def __init__(self, context):
1850 super(RevertEditsCommand, self).__init__(context)
1851 self.icon = icons.undo()
1853 def ok_to_run(self):
1854 return self.model.undoable()
1856 # pylint: disable=no-self-use
1857 def checkout_from_head(self):
1858 return False
1860 def checkout_args(self):
1861 args = []
1862 s = self.selection.selection()
1863 if self.checkout_from_head():
1864 args.append(self.model.head)
1865 args.append('--')
1867 if s.staged:
1868 items = s.staged
1869 else:
1870 items = s.modified
1871 args.extend(items)
1873 return args
1875 def action(self):
1876 checkout_args = self.checkout_args()
1877 return self.git.checkout(*checkout_args)
1879 def success(self):
1880 self.model.update_file_status()
1883 class RevertUnstagedEdits(RevertEditsCommand):
1885 @staticmethod
1886 def name():
1887 return N_('Revert Unstaged Edits...')
1889 def checkout_from_head(self):
1890 # Being in amend mode should not affect the behavior of this command.
1891 # The only sensible thing to do is to checkout from the index.
1892 return False
1894 def confirm(self):
1895 title = N_('Revert Unstaged Changes?')
1896 text = N_(
1897 'This operation removes unstaged edits from selected files.\n'
1898 'These changes cannot be recovered.')
1899 info = N_('Revert the unstaged changes?')
1900 ok_text = N_('Revert Unstaged Changes')
1901 return Interaction.confirm(title, text, info, ok_text,
1902 default=True, icon=self.icon)
1905 class RevertUncommittedEdits(RevertEditsCommand):
1907 @staticmethod
1908 def name():
1909 return N_('Revert Uncommitted Edits...')
1911 def checkout_from_head(self):
1912 return True
1914 def confirm(self):
1915 """Prompt for reverting changes"""
1916 title = N_('Revert Uncommitted Changes?')
1917 text = N_(
1918 'This operation removes uncommitted edits from selected files.\n'
1919 'These changes cannot be recovered.')
1920 info = N_('Revert the uncommitted changes?')
1921 ok_text = N_('Revert Uncommitted Changes')
1922 return Interaction.confirm(title, text, info, ok_text,
1923 default=True, icon=self.icon)
1926 class RunConfigAction(ContextCommand):
1927 """Run a user-configured action, typically from the "Tools" menu"""
1929 def __init__(self, context, action_name):
1930 super(RunConfigAction, self).__init__(context)
1931 self.action_name = action_name
1933 def do(self):
1934 """Run the user-configured action"""
1935 for env in ('ARGS', 'DIRNAME', 'FILENAME', 'REVISION'):
1936 try:
1937 compat.unsetenv(env)
1938 except KeyError:
1939 pass
1940 rev = None
1941 args = None
1942 context = self.context
1943 cfg = self.cfg
1944 opts = cfg.get_guitool_opts(self.action_name)
1945 cmd = opts.get('cmd')
1946 if 'title' not in opts:
1947 opts['title'] = cmd
1949 if 'prompt' not in opts or opts.get('prompt') is True:
1950 prompt = N_('Run "%s"?') % cmd
1951 opts['prompt'] = prompt
1953 if opts.get('needsfile'):
1954 filename = self.selection.filename()
1955 if not filename:
1956 Interaction.information(
1957 N_('Please select a file'),
1958 N_('"%s" requires a selected file.') % cmd)
1959 return False
1960 dirname = utils.dirname(filename, current_dir='.')
1961 compat.setenv('FILENAME', filename)
1962 compat.setenv('DIRNAME', dirname)
1964 if opts.get('revprompt') or opts.get('argprompt'):
1965 while True:
1966 ok = Interaction.confirm_config_action(context, cmd, opts)
1967 if not ok:
1968 return False
1969 rev = opts.get('revision')
1970 args = opts.get('args')
1971 if opts.get('revprompt') and not rev:
1972 title = N_('Invalid Revision')
1973 msg = N_('The revision expression cannot be empty.')
1974 Interaction.critical(title, msg)
1975 continue
1976 break
1978 elif opts.get('confirm'):
1979 title = os.path.expandvars(opts.get('title'))
1980 prompt = os.path.expandvars(opts.get('prompt'))
1981 if not Interaction.question(title, prompt):
1982 return False
1983 if rev:
1984 compat.setenv('REVISION', rev)
1985 if args:
1986 compat.setenv('ARGS', args)
1987 title = os.path.expandvars(cmd)
1988 Interaction.log(N_('Running command: %s') % title)
1989 cmd = ['sh', '-c', cmd]
1991 if opts.get('background'):
1992 core.fork(cmd)
1993 status, out, err = (0, '', '')
1994 elif opts.get('noconsole'):
1995 status, out, err = core.run_command(cmd)
1996 else:
1997 status, out, err = Interaction.run_command(title, cmd)
1999 if not opts.get('background') and not opts.get('norescan'):
2000 self.model.update_status()
2002 title = N_('Error')
2003 Interaction.command(title, cmd, status, out, err)
2005 return status == 0
2008 class SetDefaultRepo(ContextCommand):
2009 """Set the default repository"""
2011 def __init__(self, context, repo):
2012 super(SetDefaultRepo, self).__init__(context)
2013 self.repo = repo
2015 def do(self):
2016 self.cfg.set_user('cola.defaultrepo', self.repo)
2019 class SetDiffText(EditModel):
2020 """Set the diff text"""
2021 UNDOABLE = True
2023 def __init__(self, context, text):
2024 super(SetDiffText, self).__init__(context)
2025 self.new_diff_text = text
2026 self.new_diff_type = 'text'
2029 class SetUpstreamBranch(ContextCommand):
2030 """Set the upstream branch"""
2032 def __init__(self, context, branch, remote, remote_branch):
2033 super(SetUpstreamBranch, self).__init__(context)
2034 self.branch = branch
2035 self.remote = remote
2036 self.remote_branch = remote_branch
2038 def do(self):
2039 cfg = self.cfg
2040 remote = self.remote
2041 branch = self.branch
2042 remote_branch = self.remote_branch
2043 cfg.set_repo('branch.%s.remote' % branch, remote)
2044 cfg.set_repo('branch.%s.merge' % branch, 'refs/heads/' + remote_branch)
2047 class ShowUntracked(EditModel):
2048 """Show an untracked file."""
2050 def __init__(self, context, filename):
2051 super(ShowUntracked, self).__init__(context)
2052 self.new_filename = filename
2053 self.new_mode = self.model.mode_untracked
2054 self.new_diff_text = self.read(filename)
2055 self.new_diff_type = 'text'
2057 def read(self, filename):
2058 """Read file contents"""
2059 cfg = self.cfg
2060 size = cfg.get('cola.readsize', 2048)
2061 try:
2062 result = core.read(filename, size=size,
2063 encoding=core.ENCODING, errors='ignore')
2064 except (IOError, OSError):
2065 result = ''
2067 if len(result) == size:
2068 result += '...'
2069 return result
2072 class SignOff(ContextCommand):
2073 """Append a signoff to the commit message"""
2074 UNDOABLE = True
2076 @staticmethod
2077 def name():
2078 return N_('Sign Off')
2080 def __init__(self, context):
2081 super(SignOff, self).__init__(context)
2082 self.old_commitmsg = self.model.commitmsg
2084 def do(self):
2085 """Add a signoff to the commit message"""
2086 signoff = self.signoff()
2087 if signoff in self.model.commitmsg:
2088 return
2089 msg = self.model.commitmsg.rstrip()
2090 self.model.set_commitmsg(msg + '\n' + signoff)
2092 def undo(self):
2093 """Restore the commit message"""
2094 self.model.set_commitmsg(self.old_commitmsg)
2096 def signoff(self):
2097 """Generate the signoff string"""
2098 try:
2099 import pwd
2100 user = pwd.getpwuid(os.getuid()).pw_name
2101 except ImportError:
2102 user = os.getenv('USER', N_('unknown'))
2104 cfg = self.cfg
2105 name = cfg.get('user.name', user)
2106 email = cfg.get('user.email', '%s@%s' % (user, core.node()))
2107 return '\nSigned-off-by: %s <%s>' % (name, email)
2110 def check_conflicts(context, unmerged):
2111 """Check paths for conflicts
2113 Conflicting files can be filtered out one-by-one.
2116 if prefs.check_conflicts(context):
2117 unmerged = [path for path in unmerged if is_conflict_free(path)]
2118 return unmerged
2121 def is_conflict_free(path):
2122 """Return True if `path` contains no conflict markers
2124 rgx = re.compile(r'^(<<<<<<<|\|\|\|\|\|\|\||>>>>>>>) ')
2125 try:
2126 with core.xopen(path, 'r') as f:
2127 for line in f:
2128 line = core.decode(line, errors='ignore')
2129 if rgx.match(line):
2130 return should_stage_conflicts(path)
2131 except IOError:
2132 # We can't read this file ~ we may be staging a removal
2133 pass
2134 return True
2137 def should_stage_conflicts(path):
2138 """Inform the user that a file contains merge conflicts
2140 Return `True` if we should stage the path nonetheless.
2143 title = msg = N_('Stage conflicts?')
2144 info = N_('%s appears to contain merge conflicts.\n\n'
2145 'You should probably skip this file.\n'
2146 'Stage it anyways?') % path
2147 ok_text = N_('Stage conflicts')
2148 cancel_text = N_('Skip')
2149 return Interaction.confirm(title, msg, info, ok_text,
2150 default=False, cancel_text=cancel_text)
2153 class Stage(ContextCommand):
2154 """Stage a set of paths."""
2156 @staticmethod
2157 def name():
2158 return N_('Stage')
2160 def __init__(self, context, paths):
2161 super(Stage, self).__init__(context)
2162 self.paths = paths
2164 def do(self):
2165 msg = N_('Staging: %s') % (', '.join(self.paths))
2166 Interaction.log(msg)
2167 return self.stage_paths()
2169 def stage_paths(self):
2170 """Stages add/removals to git."""
2171 context = self.context
2172 paths = self.paths
2173 if not paths:
2174 if self.model.cfg.get('cola.safemode', False):
2175 return (0, '', '')
2176 return self.stage_all()
2178 add = []
2179 remove = []
2181 for path in set(paths):
2182 if core.exists(path) or core.islink(path):
2183 if path.endswith('/'):
2184 path = path.rstrip('/')
2185 add.append(path)
2186 else:
2187 remove.append(path)
2189 self.model.emit_about_to_update()
2191 # `git add -u` doesn't work on untracked files
2192 if add:
2193 status, out, err = gitcmds.add(context, add)
2194 Interaction.command(N_('Error'), 'git add', status, out, err)
2196 # If a path doesn't exist then that means it should be removed
2197 # from the index. We use `git add -u` for that.
2198 if remove:
2199 status, out, err = gitcmds.add(context, remove, u=True)
2200 Interaction.command(N_('Error'), 'git add -u', status, out, err)
2202 self.model.update_files(emit=True)
2203 return status, out, err
2205 def stage_all(self):
2206 """Stage all files"""
2207 status, out, err = self.git.add(v=True, u=True)
2208 Interaction.command(N_('Error'), 'git add -u', status, out, err)
2209 self.model.update_file_status()
2210 return (status, out, err)
2213 class StageCarefully(Stage):
2214 """Only stage when the path list is non-empty
2216 We use "git add -u -- <pathspec>" to stage, and it stages everything by
2217 default when no pathspec is specified, so this class ensures that paths
2218 are specified before calling git.
2220 When no paths are specified, the command does nothing.
2223 def __init__(self, context):
2224 super(StageCarefully, self).__init__(context, None)
2225 self.init_paths()
2227 # pylint: disable=no-self-use
2228 def init_paths(self):
2229 """Initialize path data"""
2230 return
2232 def ok_to_run(self):
2233 """Prevent catch-all "git add -u" from adding unmerged files"""
2234 return self.paths or not self.model.unmerged
2236 def do(self):
2237 """Stage files when ok_to_run() return True"""
2238 if self.ok_to_run():
2239 return super(StageCarefully, self).do()
2240 return (0, '', '')
2243 class StageModified(StageCarefully):
2244 """Stage all modified files."""
2246 @staticmethod
2247 def name():
2248 return N_('Stage Modified')
2250 def init_paths(self):
2251 self.paths = self.model.modified
2254 class StageUnmerged(StageCarefully):
2255 """Stage unmerged files."""
2257 @staticmethod
2258 def name():
2259 return N_('Stage Unmerged')
2261 def init_paths(self):
2262 self.paths = check_conflicts(self.context, self.model.unmerged)
2265 class StageUntracked(StageCarefully):
2266 """Stage all untracked files."""
2268 @staticmethod
2269 def name():
2270 return N_('Stage Untracked')
2272 def init_paths(self):
2273 self.paths = self.model.untracked
2276 class StageOrUnstage(ContextCommand):
2277 """If the selection is staged, unstage it, otherwise stage"""
2279 @staticmethod
2280 def name():
2281 return N_('Stage / Unstage')
2283 def do(self):
2284 s = self.selection.selection()
2285 if s.staged:
2286 do(Unstage, self.context, s.staged)
2288 unstaged = []
2289 unmerged = check_conflicts(self.context, s.unmerged)
2290 if unmerged:
2291 unstaged.extend(unmerged)
2292 if s.modified:
2293 unstaged.extend(s.modified)
2294 if s.untracked:
2295 unstaged.extend(s.untracked)
2296 if unstaged:
2297 do(Stage, self.context, unstaged)
2300 class Tag(ContextCommand):
2301 """Create a tag object."""
2303 def __init__(self, context, name, revision, sign=False, message=''):
2304 super(Tag, self).__init__(context)
2305 self._name = name
2306 self._message = message
2307 self._revision = revision
2308 self._sign = sign
2310 def do(self):
2311 result = False
2312 git = self.git
2313 revision = self._revision
2314 tag_name = self._name
2315 tag_message = self._message
2317 if not revision:
2318 Interaction.critical(
2319 N_('Missing Revision'),
2320 N_('Please specify a revision to tag.'))
2321 return result
2323 if not tag_name:
2324 Interaction.critical(
2325 N_('Missing Name'),
2326 N_('Please specify a name for the new tag.'))
2327 return result
2329 title = N_('Missing Tag Message')
2330 message = N_('Tag-signing was requested but the tag message is empty.')
2331 info = N_('An unsigned, lightweight tag will be created instead.\n'
2332 'Create an unsigned tag?')
2333 ok_text = N_('Create Unsigned Tag')
2334 sign = self._sign
2335 if sign and not tag_message:
2336 # We require a message in order to sign the tag, so if they
2337 # choose to create an unsigned tag we have to clear the sign flag.
2338 if not Interaction.confirm(title, message, info, ok_text,
2339 default=False, icon=icons.save()):
2340 return result
2341 sign = False
2343 opts = {}
2344 tmp_file = None
2345 try:
2346 if tag_message:
2347 tmp_file = utils.tmp_filename('tag-message')
2348 opts['file'] = tmp_file
2349 core.write(tmp_file, tag_message)
2351 if sign:
2352 opts['sign'] = True
2353 if tag_message:
2354 opts['annotate'] = True
2355 status, out, err = git.tag(tag_name, revision, **opts)
2356 finally:
2357 if tmp_file:
2358 core.unlink(tmp_file)
2360 title = N_('Error: could not create tag "%s"') % tag_name
2361 Interaction.command(title, 'git tag', status, out, err)
2363 if status == 0:
2364 result = True
2365 self.model.update_status()
2366 Interaction.information(
2367 N_('Tag Created'),
2368 N_('Created a new tag named "%s"') % tag_name,
2369 details=tag_message or None)
2371 return result
2374 class Unstage(ContextCommand):
2375 """Unstage a set of paths."""
2377 @staticmethod
2378 def name():
2379 return N_('Unstage')
2381 def __init__(self, context, paths):
2382 super(Unstage, self).__init__(context)
2383 self.paths = paths
2385 def do(self):
2386 """Unstage paths"""
2387 context = self.context
2388 head = self.model.head
2389 paths = self.paths
2391 msg = N_('Unstaging: %s') % (', '.join(paths))
2392 Interaction.log(msg)
2393 if not paths:
2394 return unstage_all(context)
2395 status, out, err = gitcmds.unstage_paths(context, paths, head=head)
2396 Interaction.command(N_('Error'), 'git reset', status, out, err)
2397 self.model.update_file_status()
2398 return (status, out, err)
2401 class UnstageAll(ContextCommand):
2402 """Unstage all files; resets the index."""
2404 def do(self):
2405 return unstage_all(self.context)
2408 def unstage_all(context):
2409 """Unstage all files, even while amending"""
2410 model = context.model
2411 git = context.git
2412 head = model.head
2413 status, out, err = git.reset(head, '--', '.')
2414 Interaction.command(N_('Error'), 'git reset', status, out, err)
2415 model.update_file_status()
2416 return (status, out, err)
2419 class StageSelected(ContextCommand):
2420 """Stage selected files, or all files if no selection exists."""
2422 def do(self):
2423 context = self.context
2424 paths = self.selection.unstaged
2425 if paths:
2426 do(Stage, context, paths)
2427 elif self.cfg.get('cola.safemode', False):
2428 do(StageModified, context)
2431 class UnstageSelected(Unstage):
2432 """Unstage selected files."""
2434 def __init__(self, context):
2435 staged = self.selection.staged
2436 super(UnstageSelected, self).__init__(context, staged)
2439 class Untrack(ContextCommand):
2440 """Unstage a set of paths."""
2442 def __init__(self, context, paths):
2443 super(Untrack, self).__init__(context)
2444 self.paths = paths
2446 def do(self):
2447 msg = N_('Untracking: %s') % (', '.join(self.paths))
2448 Interaction.log(msg)
2449 status, out, err = self.model.untrack_paths(self.paths)
2450 Interaction.log_status(status, out, err)
2453 class UntrackedSummary(EditModel):
2454 """List possible .gitignore rules as the diff text."""
2456 def __init__(self, context):
2457 super(UntrackedSummary, self).__init__(context)
2458 untracked = self.model.untracked
2459 suffix = 's' if untracked else ''
2460 io = StringIO()
2461 io.write('# %s untracked file%s\n' % (len(untracked), suffix))
2462 if untracked:
2463 io.write('# possible .gitignore rule%s:\n' % suffix)
2464 for u in untracked:
2465 io.write('/'+u+'\n')
2466 self.new_diff_text = io.getvalue()
2467 self.new_diff_type = 'text'
2468 self.new_mode = self.model.mode_untracked
2471 class VisualizeAll(ContextCommand):
2472 """Visualize all branches."""
2474 def do(self):
2475 context = self.context
2476 browser = utils.shell_split(prefs.history_browser(context))
2477 launch_history_browser(browser + ['--all'])
2480 class VisualizeCurrent(ContextCommand):
2481 """Visualize all branches."""
2483 def do(self):
2484 context = self.context
2485 browser = utils.shell_split(prefs.history_browser(context))
2486 launch_history_browser(browser + [self.model.currentbranch] + ['--'])
2489 class VisualizePaths(ContextCommand):
2490 """Path-limited visualization."""
2492 def __init__(self, context, paths):
2493 super(VisualizePaths, self).__init__(context)
2494 context = self.context
2495 browser = utils.shell_split(prefs.history_browser(context))
2496 if paths:
2497 self.argv = browser + ['--'] + list(paths)
2498 else:
2499 self.argv = browser
2501 def do(self):
2502 launch_history_browser(self.argv)
2505 class VisualizeRevision(ContextCommand):
2506 """Visualize a specific revision."""
2508 def __init__(self, context, revision, paths=None):
2509 super(VisualizeRevision, self).__init__(context)
2510 self.revision = revision
2511 self.paths = paths
2513 def do(self):
2514 context = self.context
2515 argv = utils.shell_split(prefs.history_browser(context))
2516 if self.revision:
2517 argv.append(self.revision)
2518 if self.paths:
2519 argv.append('--')
2520 argv.extend(self.paths)
2521 launch_history_browser(argv)
2524 class SubmoduleUpdate(ConfirmAction):
2525 """Update specified submodule"""
2527 def __init__(self, context, path):
2528 super(SubmoduleUpdate, self).__init__(context)
2529 self.path = path
2531 def confirm(self):
2532 title = N_('Update Submodule...')
2533 question = N_('Update this submodule?')
2534 info = N_('The submodule will be updated using\n'
2535 '"%s"' % self.command())
2536 ok_txt = N_('Update Submodule')
2537 return Interaction.confirm(title, question, info, ok_txt,
2538 default=False, icon=icons.pull())
2540 def action(self):
2541 context = self.context
2542 return context.git.submodule('update', '--', self.path)
2544 def success(self):
2545 self.model.update_file_status()
2547 def error_message(self):
2548 return N_('Error updating submodule %s' % self.path)
2550 def command(self):
2551 command = 'git submodule update -- %s'
2552 return command % self.path
2555 class SubmodulesUpdate(ConfirmAction):
2556 """Update all submodules"""
2558 def confirm(self):
2559 title = N_('Update submodules...')
2560 question = N_('Update all submodules?')
2561 info = N_('All submodules will be updated using\n'
2562 '"%s"' % self.command())
2563 ok_txt = N_('Update Submodules')
2564 return Interaction.confirm(title, question, info, ok_txt,
2565 default=False, icon=icons.pull())
2567 def action(self):
2568 context = self.context
2569 return context.git.submodule('update')
2571 def success(self):
2572 self.model.update_file_status()
2574 def error_message(self):
2575 return N_('Error updating submodules')
2577 def command(self):
2578 return 'git submodule update'
2581 def launch_history_browser(argv):
2582 """Launch the configured history browser"""
2583 try:
2584 core.fork(argv)
2585 except OSError as e:
2586 _, details = utils.format_exception(e)
2587 title = N_('Error Launching History Browser')
2588 msg = (N_('Cannot exec "%s": please configure a history browser') %
2589 ' '.join(argv))
2590 Interaction.critical(title, message=msg, details=details)
2593 def run(cls, *args, **opts):
2595 Returns a callback that runs a command
2597 If the caller of run() provides args or opts then those are
2598 used instead of the ones provided by the invoker of the callback.
2601 def runner(*local_args, **local_opts):
2602 """Closure return by run() which runs the command"""
2603 if args or opts:
2604 do(cls, *args, **opts)
2605 else:
2606 do(cls, *local_args, **local_opts)
2608 return runner
2611 def do(cls, *args, **opts):
2612 """Run a command in-place"""
2613 try:
2614 cmd = cls(*args, **opts)
2615 return cmd.do()
2616 except Exception as e: # pylint: disable=broad-except
2617 msg, details = utils.format_exception(e)
2618 if hasattr(cls, '__name__'):
2619 msg = ('%s exception:\n%s' % (cls.__name__, msg))
2620 Interaction.critical(N_('Error'), message=msg, details=details)
2621 return None
2624 def difftool_run(context):
2625 """Start a default difftool session"""
2626 selection = context.selection
2627 files = selection.group()
2628 if not files:
2629 return
2630 s = selection.selection()
2631 head = context.model.head
2632 difftool_launch_with_head(context, files, bool(s.staged), head)
2635 def difftool_launch_with_head(context, filenames, staged, head):
2636 """Launch difftool against the provided head"""
2637 if head == 'HEAD':
2638 left = None
2639 else:
2640 left = head
2641 difftool_launch(context, left=left, staged=staged, paths=filenames)
2644 def difftool_launch(context, left=None, right=None, paths=None,
2645 staged=False, dir_diff=False,
2646 left_take_magic=False, left_take_parent=False):
2647 """Launches 'git difftool' with given parameters
2649 :param left: first argument to difftool
2650 :param right: second argument to difftool_args
2651 :param paths: paths to diff
2652 :param staged: activate `git difftool --staged`
2653 :param dir_diff: activate `git difftool --dir-diff`
2654 :param left_take_magic: whether to append the magic ^! diff expression
2655 :param left_take_parent: whether to append the first-parent ~ for diffing
2659 difftool_args = ['git', 'difftool', '--no-prompt']
2660 if staged:
2661 difftool_args.append('--cached')
2662 if dir_diff:
2663 difftool_args.append('--dir-diff')
2665 if left:
2666 if left_take_parent or left_take_magic:
2667 suffix = '^!' if left_take_magic else '~'
2668 # Check root commit (no parents and thus cannot execute '~')
2669 git = context.git
2670 status, out, err = git.rev_list(left, parents=True, n=1)
2671 Interaction.log_status(status, out, err)
2672 if status:
2673 raise OSError('git rev-list command failed')
2675 if len(out.split()) >= 2:
2676 # Commit has a parent, so we can take its child as requested
2677 left += suffix
2678 else:
2679 # No parent, assume it's the root commit, so we have to diff
2680 # against the empty tree.
2681 left = EMPTY_TREE_OID
2682 if not right and left_take_magic:
2683 right = left
2684 difftool_args.append(left)
2686 if right:
2687 difftool_args.append(right)
2689 if paths:
2690 difftool_args.append('--')
2691 difftool_args.extend(paths)
2693 runtask = context.runtask
2694 if runtask:
2695 Interaction.async_command(N_('Difftool'), difftool_args, runtask)
2696 else:
2697 core.fork(difftool_args)