models: add notification when submodules are updated
[git-cola.git] / cola / cmds.py
blobb17e7aa241b0c9159e56b2100405c63793ba1b7b
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
32 class UsageError(Exception):
33 """Exception class for usage errors."""
35 def __init__(self, title, message):
36 Exception.__init__(self, message)
37 self.title = title
38 self.msg = message
41 class EditModel(ContextCommand):
42 """Commands that mutate the main model diff data"""
43 UNDOABLE = True
45 def __init__(self, context):
46 """Common edit operations on the main model"""
47 super(EditModel, self).__init__(context)
49 self.old_diff_text = self.model.diff_text
50 self.old_filename = self.model.filename
51 self.old_mode = self.model.mode
52 self.old_diff_type = self.model.diff_type
54 self.new_diff_text = self.old_diff_text
55 self.new_filename = self.old_filename
56 self.new_mode = self.old_mode
57 self.new_diff_type = self.old_diff_type
59 def do(self):
60 """Perform the operation."""
61 self.model.set_filename(self.new_filename)
62 self.model.set_mode(self.new_mode)
63 self.model.set_diff_text(self.new_diff_text)
64 self.model.set_diff_type(self.new_diff_type)
66 def undo(self):
67 """Undo the operation."""
68 self.model.set_filename(self.old_filename)
69 self.model.set_mode(self.old_mode)
70 self.model.set_diff_text(self.old_diff_text)
71 self.model.set_diff_type(self.old_diff_type)
74 class ConfirmAction(ContextCommand):
75 """Confirm an action before running it"""
77 # pylint: disable=no-self-use
78 def ok_to_run(self):
79 """Return True when the command is ok to run"""
80 return True
82 # pylint: disable=no-self-use
83 def confirm(self):
84 """Prompt for confirmation"""
85 return True
87 # pylint: disable=no-self-use
88 def action(self):
89 """Run the command and return (status, out, err)"""
90 return (-1, '', '')
92 # pylint: disable=no-self-use
93 def success(self):
94 """Callback run on success"""
95 return
97 # pylint: disable=no-self-use
98 def command(self):
99 """Command name, for error messages"""
100 return 'git'
102 # pylint: disable=no-self-use
103 def error_message(self):
104 """Command error message"""
105 return ''
107 def do(self):
108 """Prompt for confirmation before running a command"""
109 status = -1
110 out = err = ''
111 ok = self.ok_to_run() and self.confirm()
112 if ok:
113 status, out, err = self.action()
114 if status == 0:
115 self.success()
116 title = self.error_message()
117 cmd = self.command()
118 Interaction.command(title, cmd, status, out, err)
120 return ok, status, out, err
123 class AbortMerge(ConfirmAction):
124 """Reset an in-progress merge back to HEAD"""
126 def confirm(self):
127 title = N_('Abort Merge...')
128 question = N_('Aborting the current merge?')
129 info = N_('Aborting the current merge will cause '
130 '*ALL* uncommitted changes to be lost.\n'
131 'Recovering uncommitted changes is not possible.')
132 ok_txt = N_('Abort Merge')
133 return Interaction.confirm(title, question, info, ok_txt,
134 default=False, icon=icons.undo())
136 def action(self):
137 status, out, err = gitcmds.abort_merge(self.context)
138 self.model.update_file_status()
139 return status, out, err
141 def success(self):
142 self.model.set_commitmsg('')
144 def error_message(self):
145 return N_('Error')
147 def command(self):
148 return 'git merge'
151 class AmendMode(EditModel):
152 """Try to amend a commit."""
153 UNDOABLE = True
154 LAST_MESSAGE = None
156 @staticmethod
157 def name():
158 return N_('Amend')
160 def __init__(self, context, amend=True):
161 super(AmendMode, self).__init__(context)
162 self.skip = False
163 self.amending = amend
164 self.old_commitmsg = self.model.commitmsg
165 self.old_mode = self.model.mode
167 if self.amending:
168 self.new_mode = self.model.mode_amend
169 self.new_commitmsg = gitcmds.prev_commitmsg(context)
170 AmendMode.LAST_MESSAGE = self.model.commitmsg
171 return
172 # else, amend unchecked, regular commit
173 self.new_mode = self.model.mode_none
174 self.new_diff_text = ''
175 self.new_commitmsg = self.model.commitmsg
176 # If we're going back into new-commit-mode then search the
177 # undo stack for a previous amend-commit-mode and grab the
178 # commit message at that point in time.
179 if AmendMode.LAST_MESSAGE is not None:
180 self.new_commitmsg = AmendMode.LAST_MESSAGE
181 AmendMode.LAST_MESSAGE = None
183 def do(self):
184 """Leave/enter amend mode."""
185 # Attempt to enter amend mode. Do not allow this when merging.
186 if self.amending:
187 if self.model.is_merging:
188 self.skip = True
189 self.model.set_mode(self.old_mode)
190 Interaction.information(
191 N_('Cannot Amend'),
192 N_('You are in the middle of a merge.\n'
193 'Cannot amend while merging.'))
194 return
195 self.skip = False
196 super(AmendMode, self).do()
197 self.model.set_commitmsg(self.new_commitmsg)
198 self.model.update_file_status()
200 def undo(self):
201 if self.skip:
202 return
203 self.model.set_commitmsg(self.old_commitmsg)
204 super(AmendMode, self).undo()
205 self.model.update_file_status()
208 class AnnexAdd(ContextCommand):
209 """Add to Git Annex"""
211 def __init__(self, context):
212 super(AnnexAdd, self).__init__(context)
213 self.filename = self.selection.filename()
215 def do(self):
216 status, out, err = self.git.annex('add', self.filename)
217 Interaction.command(N_('Error'), 'git annex add', status, out, err)
218 self.model.update_status()
221 class AnnexInit(ContextCommand):
222 """Initialize Git Annex"""
224 def do(self):
225 status, out, err = self.git.annex('init')
226 Interaction.command(N_('Error'), 'git annex init', status, out, err)
227 self.model.cfg.reset()
228 self.model.emit_updated()
231 class LFSTrack(ContextCommand):
232 """Add a file to git lfs"""
234 def __init__(self, context):
235 super(LFSTrack, self).__init__(context)
236 self.filename = self.selection.filename()
237 self.stage_cmd = Stage(context, [self.filename])
239 def do(self):
240 status, out, err = self.git.lfs('track', self.filename)
241 Interaction.command(
242 N_('Error'), 'git lfs track', status, out, err)
243 if status == 0:
244 self.stage_cmd.do()
247 class LFSInstall(ContextCommand):
248 """Initialize git lfs"""
250 def do(self):
251 status, out, err = self.git.lfs('install')
252 Interaction.command(
253 N_('Error'), 'git lfs install', status, out, err)
254 self.model.update_config(reset=True, emit=True)
257 class ApplyDiffSelection(ContextCommand):
258 """Apply the selected diff to the worktree or index"""
260 def __init__(self, context, first_line_idx, last_line_idx, has_selection,
261 reverse, apply_to_worktree):
262 super(ApplyDiffSelection, self).__init__(context)
263 self.first_line_idx = first_line_idx
264 self.last_line_idx = last_line_idx
265 self.has_selection = has_selection
266 self.reverse = reverse
267 self.apply_to_worktree = apply_to_worktree
269 def do(self):
270 context = self.context
271 cfg = self.context.cfg
272 diff_text = self.model.diff_text
274 parser = DiffParser(self.model.filename, diff_text)
275 if self.has_selection:
276 patch = parser.generate_patch(
277 self.first_line_idx, self.last_line_idx, reverse=self.reverse)
278 else:
279 patch = parser.generate_hunk_patch(
280 self.first_line_idx, reverse=self.reverse)
281 if patch is None:
282 return
284 if isinstance(diff_text, core.UStr):
285 # original encoding must prevail
286 encoding = diff_text.encoding
287 else:
288 encoding = cfg.file_encoding(self.model.filename)
290 tmp_file = utils.tmp_filename('patch')
291 try:
292 core.write(tmp_file, patch, encoding=encoding)
293 if self.apply_to_worktree:
294 status, out, err = gitcmds.apply_diff_to_worktree(
295 context, tmp_file)
296 else:
297 status, out, err = gitcmds.apply_diff(context, tmp_file)
298 finally:
299 core.unlink(tmp_file)
301 Interaction.log_status(status, out, err)
302 self.model.update_file_status(update_index=True)
305 class ApplyPatches(ContextCommand):
306 """Apply patches using the "git am" command"""
308 def __init__(self, context, patches):
309 super(ApplyPatches, self).__init__(context)
310 self.patches = patches
312 def do(self):
313 status, out, err = self.git.am('-3', *self.patches)
314 Interaction.log_status(status, out, err)
316 # Display a diffstat
317 self.model.update_file_status()
319 patch_basenames = [os.path.basename(p) for p in self.patches]
320 if len(patch_basenames) > 25:
321 patch_basenames = patch_basenames[:25]
322 patch_basenames.append('...')
324 basenames = '\n'.join(patch_basenames)
325 Interaction.information(
326 N_('Patch(es) Applied'),
327 (N_('%d patch(es) applied.') + '\n\n%s')
328 % (len(self.patches), basenames))
331 class Archive(ContextCommand):
332 """"Export archives using the "git archive" command"""
334 def __init__(self, context, ref, fmt, prefix, filename):
335 super(Archive, self).__init__(context)
336 self.ref = ref
337 self.fmt = fmt
338 self.prefix = prefix
339 self.filename = filename
341 def do(self):
342 fp = core.xopen(self.filename, 'wb')
343 cmd = ['git', 'archive', '--format='+self.fmt]
344 if self.fmt in ('tgz', 'tar.gz'):
345 cmd.append('-9')
346 if self.prefix:
347 cmd.append('--prefix=' + self.prefix)
348 cmd.append(self.ref)
349 proc = core.start_command(cmd, stdout=fp)
350 out, err = proc.communicate()
351 fp.close()
352 status = proc.returncode
353 Interaction.log_status(status, out or '', err or '')
356 class Checkout(EditModel):
357 """A command object for git-checkout.
359 'argv' is handed off directly to git.
362 def __init__(self, context, argv, checkout_branch=False):
363 super(Checkout, self).__init__(context)
364 self.argv = argv
365 self.checkout_branch = checkout_branch
366 self.new_diff_text = ''
367 self.new_diff_type = 'text'
369 def do(self):
370 super(Checkout, self).do()
371 status, out, err = self.git.checkout(*self.argv)
372 if self.checkout_branch:
373 self.model.update_status()
374 else:
375 self.model.update_file_status()
376 Interaction.command(N_('Error'), 'git checkout', status, out, err)
379 class BlamePaths(ContextCommand):
380 """Blame view for paths."""
382 def __init__(self, context, paths):
383 super(BlamePaths, self).__init__(context)
384 viewer = utils.shell_split(prefs.blame_viewer(context))
385 self.argv = viewer + list(paths)
387 def do(self):
388 try:
389 core.fork(self.argv)
390 except OSError as e:
391 _, details = utils.format_exception(e)
392 title = N_('Error Launching Blame Viewer')
393 msg = (N_('Cannot exec "%s": please configure a blame viewer')
394 % ' '.join(self.argv))
395 Interaction.critical(title, message=msg, details=details)
398 class CheckoutBranch(Checkout):
399 """Checkout a branch."""
401 def __init__(self, context, branch):
402 args = [branch]
403 super(CheckoutBranch, self).__init__(
404 context, args, checkout_branch=True)
407 class CherryPick(ContextCommand):
408 """Cherry pick commits into the current branch."""
410 def __init__(self, context, commits):
411 super(CherryPick, self).__init__(context)
412 self.commits = commits
414 def do(self):
415 self.model.cherry_pick_list(self.commits)
416 self.model.update_file_status()
419 class Revert(ContextCommand):
420 """Cherry pick commits into the current branch."""
422 def __init__(self, context, oid):
423 super(Revert, self).__init__(context)
424 self.oid = oid
426 def do(self):
427 self.git.revert(self.oid)
428 self.model.update_file_status()
431 class ResetMode(EditModel):
432 """Reset the mode and clear the model's diff text."""
434 def __init__(self, context):
435 super(ResetMode, self).__init__(context)
436 self.new_mode = self.model.mode_none
437 self.new_diff_text = ''
438 self.new_diff_type = 'text'
439 self.new_filename = ''
441 def do(self):
442 super(ResetMode, self).do()
443 self.model.update_file_status()
446 class ResetCommand(ConfirmAction):
447 """Reset state using the "git reset" command"""
449 def __init__(self, context, ref):
450 super(ResetCommand, self).__init__(context)
451 self.ref = ref
453 def action(self):
454 return self.reset()
456 def command(self):
457 return 'git reset'
459 def error_message(self):
460 return N_('Error')
462 def success(self):
463 self.model.update_file_status()
465 def confirm(self):
466 raise NotImplementedError('confirm() must be overridden')
468 def reset(self):
469 raise NotImplementedError('reset() must be overridden')
472 class ResetBranchHead(ResetCommand):
474 def confirm(self):
475 title = N_('Reset Branch')
476 question = N_('Point the current branch head to a new commit?')
477 info = N_('The branch will be reset using "git reset --mixed %s"')
478 ok_text = N_('Reset Branch')
479 info = info % self.ref
480 return Interaction.confirm(title, question, info, ok_text)
482 def reset(self):
483 return self.git.reset(self.ref, '--', mixed=True)
486 class ResetWorktree(ResetCommand):
488 def confirm(self):
489 title = N_('Reset Worktree')
490 question = N_('Reset worktree?')
491 info = N_('The worktree will be reset using "git reset --keep %s"')
492 ok_text = N_('Reset Worktree')
493 info = info % self.ref
494 return Interaction.confirm(title, question, info, ok_text)
496 def reset(self):
497 return self.git.reset(self.ref, '--', keep=True)
500 class ResetMerge(ResetCommand):
502 def confirm(self):
503 title = N_('Reset Merge')
504 question = N_('Reset merge?')
505 info = N_('The branch will be reset using "git reset --merge %s"')
506 ok_text = N_('Reset Merge')
507 info = info % self.ref
508 return Interaction.confirm(title, question, info, ok_text)
510 def reset(self):
511 return self.git.reset(self.ref, '--', merge=True)
514 class ResetSoft(ResetCommand):
516 def confirm(self):
517 title = N_('Reset Soft')
518 question = N_('Reset soft?')
519 info = N_('The branch will be reset using "git reset --soft %s"')
520 ok_text = N_('Reset Soft')
521 info = info % self.ref
522 return Interaction.confirm(title, question, info, ok_text)
524 def reset(self):
525 return self.git.reset(self.ref, '--', soft=True)
528 class ResetHard(ResetCommand):
530 def confirm(self):
531 title = N_('Reset Hard')
532 question = N_('Reset hard?')
533 info = N_('The branch will be reset using "git reset --hard %s"')
534 ok_text = N_('Reset Hard')
535 info = info % self.ref
536 return Interaction.confirm(title, question, info, ok_text)
538 def reset(self):
539 return self.git.reset(self.ref, '--', hard=True)
542 class Commit(ResetMode):
543 """Attempt to create a new commit."""
545 def __init__(self, context, amend, msg, sign, no_verify=False):
546 super(Commit, self).__init__(context)
547 self.amend = amend
548 self.msg = msg
549 self.sign = sign
550 self.no_verify = no_verify
551 self.old_commitmsg = self.model.commitmsg
552 self.new_commitmsg = ''
554 def do(self):
555 # Create the commit message file
556 context = self.context
557 comment_char = prefs.comment_char(context)
558 msg = self.strip_comments(self.msg, comment_char=comment_char)
559 tmp_file = utils.tmp_filename('commit-message')
560 try:
561 core.write(tmp_file, msg)
562 # Run 'git commit'
563 status, out, err = self.git.commit(
564 F=tmp_file, v=True, gpg_sign=self.sign,
565 amend=self.amend, no_verify=self.no_verify)
566 finally:
567 core.unlink(tmp_file)
568 if status == 0:
569 super(Commit, self).do()
570 self.model.set_commitmsg(self.new_commitmsg)
572 title = N_('Commit failed')
573 Interaction.command(title, 'git commit', status, out, err)
575 return status, out, err
577 @staticmethod
578 def strip_comments(msg, comment_char='#'):
579 # Strip off comments
580 message_lines = [line for line in msg.split('\n')
581 if not line.startswith(comment_char)]
582 msg = '\n'.join(message_lines)
583 if not msg.endswith('\n'):
584 msg += '\n'
586 return msg
589 class Ignore(ContextCommand):
590 """Add files to .gitignore"""
592 def __init__(self, context, filenames):
593 super(Ignore, self).__init__(context)
594 self.filenames = list(filenames)
596 def do(self):
597 if not self.filenames:
598 return
599 new_additions = '\n'.join(self.filenames) + '\n'
600 for_status = new_additions
601 if core.exists('.gitignore'):
602 current_list = core.read('.gitignore')
603 new_additions = current_list.rstrip() + '\n' + new_additions
604 core.write('.gitignore', new_additions)
605 Interaction.log_status(0, 'Added to .gitignore:\n%s' % for_status, '')
606 self.model.update_file_status()
609 def file_summary(files):
610 txt = core.list2cmdline(files)
611 if len(txt) > 768:
612 txt = txt[:768].rstrip() + '...'
613 wrap = textwrap.TextWrapper()
614 return '\n'.join(wrap.wrap(txt))
617 class RemoteCommand(ConfirmAction):
619 def __init__(self, context, remote):
620 super(RemoteCommand, self).__init__(context)
621 self.remote = remote
623 def success(self):
624 self.cfg.reset()
625 self.model.update_remotes()
628 class RemoteAdd(RemoteCommand):
630 def __init__(self, context, remote, url):
631 super(RemoteAdd, self).__init__(context, remote)
632 self.url = url
634 def action(self):
635 return self.git.remote('add', self.remote, self.url)
637 def error_message(self):
638 return N_('Error creating remote "%s"') % self.remote
640 def command(self):
641 return 'git remote add "%s" "%s"' % (self.remote, self.url)
644 class RemoteRemove(RemoteCommand):
646 def confirm(self):
647 title = N_('Delete Remote')
648 question = N_('Delete remote?')
649 info = N_('Delete remote "%s"') % self.remote
650 ok_text = N_('Delete')
651 return Interaction.confirm(title, question, info, ok_text)
653 def action(self):
654 return self.git.remote('rm', self.remote)
656 def error_message(self):
657 return N_('Error deleting remote "%s"') % self.remote
659 def command(self):
660 return 'git remote rm "%s"' % self.remote
663 class RemoteRename(RemoteCommand):
665 def __init__(self, context, remote, new_name):
666 super(RemoteRename, self).__init__(context, remote)
667 self.new_name = new_name
669 def confirm(self):
670 title = N_('Rename Remote')
671 text = (N_('Rename remote "%(current)s" to "%(new)s"?') %
672 dict(current=self.remote, new=self.new_name))
673 info_text = ''
674 ok_text = title
675 return Interaction.confirm(title, text, info_text, ok_text)
677 def action(self):
678 return self.git.remote('rename', self.remote, self.new_name)
680 def error_message(self):
681 return (N_('Error renaming "%(name)s" to "%(new_name)s"')
682 % dict(name=self.remote, new_name=self.new_name))
684 def command(self):
685 return 'git remote rename "%s" "%s"' % (self.remote, self.new_name)
688 class RemoteSetURL(RemoteCommand):
690 def __init__(self, context, remote, url):
691 super(RemoteSetURL, self).__init__(context, remote)
692 self.url = url
694 def action(self):
695 return self.git.remote('set-url', self.remote, self.url)
697 def error_message(self):
698 return (N_('Unable to set URL for "%(name)s" to "%(url)s"')
699 % dict(name=self.remote, url=self.url))
701 def command(self):
702 return 'git remote set-url "%s" "%s"' % (self.remote, self.url)
705 class RemoteEdit(ContextCommand):
706 """Combine RemoteRename and RemoteSetURL"""
708 def __init__(self, context, old_name, remote, url):
709 super(RemoteEdit, self).__init__(context)
710 self.rename = RemoteRename(context, old_name, remote)
711 self.set_url = RemoteSetURL(context, remote, url)
713 def do(self):
714 result = self.rename.do()
715 name_ok = result[0]
716 url_ok = False
717 if name_ok:
718 result = self.set_url.do()
719 url_ok = result[0]
720 return name_ok, url_ok
723 class RemoveFromSettings(ConfirmAction):
725 def __init__(self, context, settings, repo, entry, icon=None):
726 super(RemoveFromSettings, self).__init__(context)
727 self.settings = settings
728 self.repo = repo
729 self.entry = entry
730 self.icon = icon
732 def success(self):
733 self.settings.save()
736 class RemoveBookmark(RemoveFromSettings):
738 def confirm(self):
739 entry = self.entry
740 title = msg = N_('Delete Bookmark?')
741 info = N_('%s will be removed from your bookmarks.') % entry
742 ok_text = N_('Delete Bookmark')
743 return Interaction.confirm(title, msg, info, ok_text, icon=self.icon)
745 def action(self):
746 self.settings.remove_bookmark(self.repo, self.entry)
747 return (0, '', '')
750 class RemoveRecent(RemoveFromSettings):
752 def confirm(self):
753 repo = self.repo
754 title = msg = N_('Remove %s from the recent list?') % repo
755 info = N_('%s will be removed from your recent repositories.') % repo
756 ok_text = N_('Remove')
757 return Interaction.confirm(title, msg, info, ok_text, icon=self.icon)
759 def action(self):
760 self.settings.remove_recent(self.repo)
761 return (0, '', '')
764 class RemoveFiles(ContextCommand):
765 """Removes files"""
767 def __init__(self, context, remover, filenames):
768 super(RemoveFiles, self).__init__(context)
769 if remover is None:
770 remover = os.remove
771 self.remover = remover
772 self.filenames = filenames
773 # We could git-hash-object stuff and provide undo-ability
774 # as an option. Heh.
776 def do(self):
777 files = self.filenames
778 if not files:
779 return
781 rescan = False
782 bad_filenames = []
783 remove = self.remover
784 for filename in files:
785 if filename:
786 try:
787 remove(filename)
788 rescan = True
789 except OSError:
790 bad_filenames.append(filename)
792 if bad_filenames:
793 Interaction.information(
794 N_('Error'),
795 N_('Deleting "%s" failed') % file_summary(bad_filenames))
797 if rescan:
798 self.model.update_file_status()
801 class Delete(RemoveFiles):
802 """Delete files."""
804 def __init__(self, context, filenames):
805 super(Delete, self).__init__(context, os.remove, filenames)
807 def do(self):
808 files = self.filenames
809 if not files:
810 return
812 title = N_('Delete Files?')
813 msg = N_('The following files will be deleted:') + '\n\n'
814 msg += file_summary(files)
815 info_txt = N_('Delete %d file(s)?') % len(files)
816 ok_txt = N_('Delete Files')
818 if Interaction.confirm(title, msg, info_txt, ok_txt,
819 default=True, icon=icons.remove()):
820 super(Delete, self).do()
823 class MoveToTrash(RemoveFiles):
824 """Move files to the trash using send2trash"""
826 AVAILABLE = send2trash is not None
828 def __init__(self, context, filenames):
829 super(MoveToTrash, self).__init__(context, send2trash, filenames)
832 class DeleteBranch(ContextCommand):
833 """Delete a git branch."""
835 def __init__(self, context, branch):
836 super(DeleteBranch, self).__init__(context)
837 self.branch = branch
839 def do(self):
840 status, out, err = self.model.delete_branch(self.branch)
841 Interaction.log_status(status, out, err)
844 class Rename(ContextCommand):
845 """Rename a set of paths."""
847 def __init__(self, context, paths):
848 super(Rename, self).__init__(context)
849 self.paths = paths
851 def do(self):
852 msg = N_('Untracking: %s') % (', '.join(self.paths))
853 Interaction.log(msg)
855 for path in self.paths:
856 ok = self.rename(path)
857 if not ok:
858 return
860 self.model.update_status()
862 def rename(self, path):
863 git = self.git
864 title = N_('Rename "%s"') % path
866 if os.path.isdir(path):
867 base_path = os.path.dirname(path)
868 else:
869 base_path = path
870 new_path = Interaction.save_as(base_path, title)
871 if not new_path:
872 return False
874 status, out, err = git.mv(path, new_path, force=True, verbose=True)
875 Interaction.command(N_('Error'), 'git mv', status, out, err)
876 return status == 0
879 class RenameBranch(ContextCommand):
880 """Rename a git branch."""
882 def __init__(self, context, branch, new_branch):
883 super(RenameBranch, self).__init__(context)
884 self.branch = branch
885 self.new_branch = new_branch
887 def do(self):
888 branch = self.branch
889 new_branch = self.new_branch
890 status, out, err = self.model.rename_branch(branch, new_branch)
891 Interaction.log_status(status, out, err)
894 class DeleteRemoteBranch(ContextCommand):
895 """Delete a remote git branch."""
897 def __init__(self, context, remote, branch):
898 super(DeleteRemoteBranch, self).__init__(context)
899 self.remote = remote
900 self.branch = branch
902 def do(self):
903 status, out, err = self.git.push(self.remote, self.branch, delete=True)
904 self.model.update_status()
906 title = N_('Error Deleting Remote Branch')
907 Interaction.command(title, 'git push', status, out, err)
908 if status == 0:
909 Interaction.information(
910 N_('Remote Branch Deleted'),
911 N_('"%(branch)s" has been deleted from "%(remote)s".')
912 % dict(branch=self.branch, remote=self.remote))
915 def get_mode(model, staged, modified, unmerged, untracked):
916 if staged:
917 mode = model.mode_index
918 elif modified or unmerged:
919 mode = model.mode_worktree
920 elif untracked:
921 mode = model.mode_untracked
922 else:
923 mode = model.mode
924 return mode
927 class DiffImage(EditModel):
929 def __init__(self, context, filename,
930 deleted, staged, modified, unmerged, untracked):
931 super(DiffImage, self).__init__(context)
933 self.new_filename = filename
934 self.new_diff_text = ''
935 self.new_diff_type = 'image'
936 self.new_mode = get_mode(
937 self.model, staged, modified, unmerged, untracked)
938 self.staged = staged
939 self.modified = modified
940 self.unmerged = unmerged
941 self.untracked = untracked
942 self.deleted = deleted
943 self.annex = self.cfg.is_annex()
945 def do(self):
946 filename = self.new_filename
948 if self.staged:
949 images = self.staged_images()
950 elif self.modified:
951 images = self.modified_images()
952 elif self.unmerged:
953 images = self.unmerged_images()
954 elif self.untracked:
955 images = [(filename, False)]
956 else:
957 images = []
959 self.model.set_images(images)
960 super(DiffImage, self).do()
962 def staged_images(self):
963 context = self.context
964 git = self.git
965 head = self.model.head
966 filename = self.new_filename
967 annex = self.annex
969 images = []
970 index = git.diff_index(head, '--', filename, cached=True)[STDOUT]
971 if index:
972 # Example:
973 # :100644 100644 fabadb8... 4866510... M describe.c
974 parts = index.split(' ')
975 if len(parts) > 3:
976 old_oid = parts[2]
977 new_oid = parts[3]
979 if old_oid != MISSING_BLOB_OID:
980 # First, check if we can get a pre-image from git-annex
981 annex_image = None
982 if annex:
983 annex_image = gitcmds.annex_path(context, head, filename)
984 if annex_image:
985 images.append((annex_image, False)) # git annex HEAD
986 else:
987 image = gitcmds.write_blob_path(
988 context, head, old_oid, filename)
989 if image:
990 images.append((image, True))
992 if new_oid != MISSING_BLOB_OID:
993 found_in_annex = False
994 if annex and core.islink(filename):
995 status, out, _ = git.annex('status', '--', filename)
996 if status == 0:
997 details = out.split(' ')
998 if details and details[0] == 'A': # newly added file
999 images.append((filename, False))
1000 found_in_annex = True
1002 if not found_in_annex:
1003 image = gitcmds.write_blob(context, new_oid, filename)
1004 if image:
1005 images.append((image, True))
1007 return images
1009 def unmerged_images(self):
1010 context = self.context
1011 git = self.git
1012 head = self.model.head
1013 filename = self.new_filename
1014 annex = self.annex
1016 candidate_merge_heads = ('HEAD', 'CHERRY_HEAD', 'MERGE_HEAD')
1017 merge_heads = [
1018 merge_head for merge_head in candidate_merge_heads
1019 if core.exists(git.git_path(merge_head))]
1021 if annex: # Attempt to find files in git-annex
1022 annex_images = []
1023 for merge_head in merge_heads:
1024 image = gitcmds.annex_path(context, merge_head, filename)
1025 if image:
1026 annex_images.append((image, False))
1027 if annex_images:
1028 annex_images.append((filename, False))
1029 return annex_images
1031 # DIFF FORMAT FOR MERGES
1032 # "git-diff-tree", "git-diff-files" and "git-diff --raw"
1033 # can take -c or --cc option to generate diff output also
1034 # for merge commits. The output differs from the format
1035 # described above in the following way:
1037 # 1. there is a colon for each parent
1038 # 2. there are more "src" modes and "src" sha1
1039 # 3. status is concatenated status characters for each parent
1040 # 4. no optional "score" number
1041 # 5. single path, only for "dst"
1042 # Example:
1043 # ::100644 100644 100644 fabadb8... cc95eb0... 4866510... \
1044 # MM describe.c
1045 images = []
1046 index = git.diff_index(head, '--', filename,
1047 cached=True, cc=True)[STDOUT]
1048 if index:
1049 parts = index.split(' ')
1050 if len(parts) > 3:
1051 first_mode = parts[0]
1052 num_parents = first_mode.count(':')
1053 # colon for each parent, but for the index, the "parents"
1054 # are really entries in stages 1,2,3 (head, base, remote)
1055 # remote, base, head
1056 for i in range(num_parents):
1057 offset = num_parents + i + 1
1058 oid = parts[offset]
1059 try:
1060 merge_head = merge_heads[i]
1061 except IndexError:
1062 merge_head = 'HEAD'
1063 if oid != MISSING_BLOB_OID:
1064 image = gitcmds.write_blob_path(
1065 context, merge_head, oid, filename)
1066 if image:
1067 images.append((image, True))
1069 images.append((filename, False))
1070 return images
1072 def modified_images(self):
1073 context = self.context
1074 git = self.git
1075 head = self.model.head
1076 filename = self.new_filename
1077 annex = self.annex
1079 images = []
1080 annex_image = None
1081 if annex: # Check for a pre-image from git-annex
1082 annex_image = gitcmds.annex_path(context, head, filename)
1083 if annex_image:
1084 images.append((annex_image, False)) # git annex HEAD
1085 else:
1086 worktree = git.diff_files('--', filename)[STDOUT]
1087 parts = worktree.split(' ')
1088 if len(parts) > 3:
1089 oid = parts[2]
1090 if oid != MISSING_BLOB_OID:
1091 image = gitcmds.write_blob_path(
1092 context, head, oid, filename)
1093 if image:
1094 images.append((image, True)) # HEAD
1096 images.append((filename, False)) # worktree
1097 return images
1100 class Diff(EditModel):
1101 """Perform a diff and set the model's current text."""
1103 def __init__(self, context, filename, cached=False, deleted=False):
1104 super(Diff, self).__init__(context)
1105 opts = {}
1106 if cached:
1107 opts['ref'] = self.model.head
1108 self.new_filename = filename
1109 self.new_mode = self.model.mode_worktree
1110 self.new_diff_text = gitcmds.diff_helper(
1111 self.context, filename=filename, cached=cached,
1112 deleted=deleted, **opts)
1113 self.new_diff_type = 'text'
1116 class Diffstat(EditModel):
1117 """Perform a diffstat and set the model's diff text."""
1119 def __init__(self, context):
1120 super(Diffstat, self).__init__(context)
1121 cfg = self.cfg
1122 diff_context = cfg.get('diff.context', 3)
1123 diff = self.git.diff(
1124 self.model.head, unified=diff_context, no_ext_diff=True,
1125 no_color=True, M=True, stat=True)[STDOUT]
1126 self.new_diff_text = diff
1127 self.new_diff_type = 'text'
1128 self.new_mode = self.model.mode_diffstat
1131 class DiffStaged(Diff):
1132 """Perform a staged diff on a file."""
1134 def __init__(self, context, filename, deleted=None):
1135 super(DiffStaged, self).__init__(
1136 context, filename, cached=True, deleted=deleted)
1137 self.new_mode = self.model.mode_index
1140 class DiffStagedSummary(EditModel):
1142 def __init__(self, context):
1143 super(DiffStagedSummary, self).__init__(context)
1144 diff = self.git.diff(
1145 self.model.head, cached=True, no_color=True,
1146 no_ext_diff=True, patch_with_stat=True, M=True)[STDOUT]
1147 self.new_diff_text = diff
1148 self.new_diff_type = 'text'
1149 self.new_mode = self.model.mode_index
1152 class Difftool(ContextCommand):
1153 """Run git-difftool limited by path."""
1155 def __init__(self, context, staged, filenames):
1156 super(Difftool, self).__init__(context)
1157 self.staged = staged
1158 self.filenames = filenames
1160 def do(self):
1161 difftool_launch_with_head(
1162 self.context, self.filenames, self.staged, self.model.head)
1165 class Edit(ContextCommand):
1166 """Edit a file using the configured gui.editor."""
1168 @staticmethod
1169 def name():
1170 return N_('Launch Editor')
1172 def __init__(self, context, filenames,
1173 line_number=None, background_editor=False):
1174 super(Edit, self).__init__(context)
1175 self.filenames = filenames
1176 self.line_number = line_number
1177 self.background_editor = background_editor
1179 def do(self):
1180 context = self.context
1181 if not self.filenames:
1182 return
1183 filename = self.filenames[0]
1184 if not core.exists(filename):
1185 return
1186 if self.background_editor:
1187 editor = prefs.background_editor(context)
1188 else:
1189 editor = prefs.editor(context)
1190 opts = []
1192 if self.line_number is None:
1193 opts = self.filenames
1194 else:
1195 # Single-file w/ line-numbers (likely from grep)
1196 editor_opts = {
1197 '*vim*': [filename, '+%s' % self.line_number],
1198 '*emacs*': ['+%s' % self.line_number, filename],
1199 '*textpad*': ['%s(%s,0)' % (filename, self.line_number)],
1200 '*notepad++*': ['-n%s' % self.line_number, filename],
1201 '*subl*': ['%s:%s' % (filename, self.line_number)],
1204 opts = self.filenames
1205 for pattern, opt in editor_opts.items():
1206 if fnmatch(editor, pattern):
1207 opts = opt
1208 break
1210 try:
1211 core.fork(utils.shell_split(editor) + opts)
1212 except (OSError, ValueError) as e:
1213 message = (N_('Cannot exec "%s": please configure your editor')
1214 % editor)
1215 _, details = utils.format_exception(e)
1216 Interaction.critical(N_('Error Editing File'), message, details)
1219 class FormatPatch(ContextCommand):
1220 """Output a patch series given all revisions and a selected subset."""
1222 def __init__(self, context, to_export, revs, output='patches'):
1223 super(FormatPatch, self).__init__(context)
1224 self.to_export = list(to_export)
1225 self.revs = list(revs)
1226 self.output = output
1228 def do(self):
1229 context = self.context
1230 status, out, err = gitcmds.format_patchsets(
1231 context, self.to_export, self.revs, self.output)
1232 Interaction.log_status(status, out, err)
1235 class LaunchDifftool(ContextCommand):
1237 @staticmethod
1238 def name():
1239 return N_('Launch Diff Tool')
1241 def do(self):
1242 s = self.selection.selection()
1243 if s.unmerged:
1244 paths = s.unmerged
1245 if utils.is_win32():
1246 core.fork(['git', 'mergetool', '--no-prompt', '--'] + paths)
1247 else:
1248 cfg = self.cfg
1249 cmd = cfg.terminal()
1250 argv = utils.shell_split(cmd)
1251 mergetool = ['git', 'mergetool', '--no-prompt', '--']
1252 mergetool.extend(paths)
1253 needs_shellquote = set(['gnome-terminal', 'xfce4-terminal'])
1254 if os.path.basename(argv[0]) in needs_shellquote:
1255 argv.append(core.list2cmdline(mergetool))
1256 else:
1257 argv.extend(mergetool)
1258 core.fork(argv)
1259 else:
1260 difftool_run(self.context)
1263 class LaunchTerminal(ContextCommand):
1265 @staticmethod
1266 def name():
1267 return N_('Launch Terminal')
1269 @staticmethod
1270 def is_available(context):
1271 return context.cfg.terminal() is not None
1273 def __init__(self, context, path):
1274 super(LaunchTerminal, self).__init__(context)
1275 self.path = path
1277 def do(self):
1278 cmd = self.context.cfg.terminal()
1279 if cmd is None:
1280 return
1281 if utils.is_win32():
1282 argv = ['start', '', cmd, '--login']
1283 shell = True
1284 else:
1285 argv = utils.shell_split(cmd)
1286 argv.append(os.getenv('SHELL', '/bin/sh'))
1287 shell = False
1288 core.fork(argv, cwd=self.path, shell=shell)
1291 class LaunchEditor(Edit):
1293 @staticmethod
1294 def name():
1295 return N_('Launch Editor')
1297 def __init__(self, context):
1298 s = context.selection.selection()
1299 filenames = s.staged + s.unmerged + s.modified + s.untracked
1300 super(LaunchEditor, self).__init__(
1301 context, filenames, background_editor=True)
1304 class LaunchEditorAtLine(LaunchEditor):
1305 """Launch an editor at the specified line"""
1307 def __init__(self, context):
1308 super(LaunchEditorAtLine, self).__init__(context)
1309 self.line_number = context.selection.line_number
1312 class LoadCommitMessageFromFile(ContextCommand):
1313 """Loads a commit message from a path."""
1314 UNDOABLE = True
1316 def __init__(self, context, path):
1317 super(LoadCommitMessageFromFile, self).__init__(context)
1318 self.path = path
1319 self.old_commitmsg = self.model.commitmsg
1320 self.old_directory = self.model.directory
1322 def do(self):
1323 path = os.path.expanduser(self.path)
1324 if not path or not core.isfile(path):
1325 raise UsageError(N_('Error: Cannot find commit template'),
1326 N_('%s: No such file or directory.') % path)
1327 self.model.set_directory(os.path.dirname(path))
1328 self.model.set_commitmsg(core.read(path))
1330 def undo(self):
1331 self.model.set_commitmsg(self.old_commitmsg)
1332 self.model.set_directory(self.old_directory)
1335 class LoadCommitMessageFromTemplate(LoadCommitMessageFromFile):
1336 """Loads the commit message template specified by commit.template."""
1338 def __init__(self, context):
1339 cfg = context.cfg
1340 template = cfg.get('commit.template')
1341 super(LoadCommitMessageFromTemplate, self).__init__(context, template)
1343 def do(self):
1344 if self.path is None:
1345 raise UsageError(
1346 N_('Error: Unconfigured commit template'),
1347 N_('A commit template has not been configured.\n'
1348 'Use "git config" to define "commit.template"\n'
1349 'so that it points to a commit template.'))
1350 return LoadCommitMessageFromFile.do(self)
1353 class LoadCommitMessageFromOID(ContextCommand):
1354 """Load a previous commit message"""
1355 UNDOABLE = True
1357 def __init__(self, context, oid, prefix=''):
1358 super(LoadCommitMessageFromOID, self).__init__(context)
1359 self.oid = oid
1360 self.old_commitmsg = self.model.commitmsg
1361 self.new_commitmsg = prefix + gitcmds.prev_commitmsg(context, oid)
1363 def do(self):
1364 self.model.set_commitmsg(self.new_commitmsg)
1366 def undo(self):
1367 self.model.set_commitmsg(self.old_commitmsg)
1370 class PrepareCommitMessageHook(ContextCommand):
1371 """Use the cola-prepare-commit-msg hook to prepare the commit message
1373 UNDOABLE = True
1375 def __init__(self, context):
1376 super(PrepareCommitMessageHook, self).__init__(context)
1377 self.old_commitmsg = self.model.commitmsg
1379 def get_message(self):
1381 title = N_('Error running prepare-commitmsg hook')
1382 hook = gitcmds.prepare_commit_message_hook(self.context)
1384 if os.path.exists(hook):
1385 filename = self.model.save_commitmsg()
1386 status, out, err = core.run_command([hook, filename])
1388 if status == 0:
1389 result = core.read(filename)
1390 else:
1391 result = self.old_commitmsg
1392 Interaction.command_error(title, hook, status, out, err)
1393 else:
1394 message = N_('A hook must be provided at "%s"') % hook
1395 Interaction.critical(title, message=message)
1396 result = self.old_commitmsg
1398 return result
1400 def do(self):
1401 msg = self.get_message()
1402 self.model.set_commitmsg(msg)
1404 def undo(self):
1405 self.model.set_commitmsg(self.old_commitmsg)
1408 class LoadFixupMessage(LoadCommitMessageFromOID):
1409 """Load a fixup message"""
1411 def __init__(self, context, oid):
1412 super(LoadFixupMessage, self).__init__(context, oid, prefix='fixup! ')
1413 if self.new_commitmsg:
1414 self.new_commitmsg = self.new_commitmsg.splitlines()[0]
1417 class Merge(ContextCommand):
1418 """Merge commits"""
1420 def __init__(self, context, revision, no_commit, squash, no_ff, sign):
1421 super(Merge, self).__init__(context)
1422 self.revision = revision
1423 self.no_ff = no_ff
1424 self.no_commit = no_commit
1425 self.squash = squash
1426 self.sign = sign
1428 def do(self):
1429 squash = self.squash
1430 revision = self.revision
1431 no_ff = self.no_ff
1432 no_commit = self.no_commit
1433 sign = self.sign
1435 status, out, err = self.git.merge(
1436 revision, gpg_sign=sign, no_ff=no_ff,
1437 no_commit=no_commit, squash=squash)
1438 self.model.update_status()
1439 title = N_('Merge failed. Conflict resolution is required.')
1440 Interaction.command(title, 'git merge', status, out, err)
1442 return status, out, err
1445 class OpenDefaultApp(ContextCommand):
1446 """Open a file using the OS default."""
1448 @staticmethod
1449 def name():
1450 return N_('Open Using Default Application')
1452 def __init__(self, context, filenames):
1453 super(OpenDefaultApp, self).__init__(context)
1454 if utils.is_darwin():
1455 launcher = 'open'
1456 else:
1457 launcher = 'xdg-open'
1458 self.launcher = launcher
1459 self.filenames = filenames
1461 def do(self):
1462 if not self.filenames:
1463 return
1464 core.fork([self.launcher] + self.filenames)
1467 class OpenParentDir(OpenDefaultApp):
1468 """Open parent directories using the OS default."""
1470 @staticmethod
1471 def name():
1472 return N_('Open Parent Directory')
1474 def __init__(self, context, filenames):
1475 OpenDefaultApp.__init__(self, context, filenames)
1477 def do(self):
1478 if not self.filenames:
1479 return
1480 dirnames = list(set([os.path.dirname(x) for x in self.filenames]))
1481 # os.path.dirname() can return an empty string so we fallback to
1482 # the current directory
1483 dirs = [(dirname or core.getcwd()) for dirname in dirnames]
1484 core.fork([self.launcher] + dirs)
1487 class OpenNewRepo(ContextCommand):
1488 """Launches git-cola on a repo."""
1490 def __init__(self, context, repo_path):
1491 super(OpenNewRepo, self).__init__(context)
1492 self.repo_path = repo_path
1494 def do(self):
1495 self.model.set_directory(self.repo_path)
1496 core.fork([sys.executable, sys.argv[0], '--repo', self.repo_path])
1499 class OpenRepo(EditModel):
1501 def __init__(self, context, repo_path):
1502 super(OpenRepo, self).__init__(context)
1503 self.repo_path = repo_path
1504 self.new_mode = self.model.mode_none
1505 self.new_diff_text = ''
1506 self.new_diff_type = 'text'
1507 self.new_commitmsg = ''
1508 self.new_filename = ''
1510 def do(self):
1511 old_repo = self.git.getcwd()
1512 if self.model.set_worktree(self.repo_path):
1513 self.fsmonitor.stop()
1514 self.fsmonitor.start()
1515 self.model.update_status()
1516 self.model.set_commitmsg(self.new_commitmsg)
1517 super(OpenRepo, self).do()
1518 else:
1519 self.model.set_worktree(old_repo)
1522 class OpenParentRepo(OpenRepo):
1524 def __init__(self, context):
1525 path = ''
1526 if version.check_git(context, 'show-superproject-working-tree'):
1527 status, out, _ = context.git.rev_parse(
1528 show_superproject_working_tree=True)
1529 if status == 0:
1530 path = out
1531 if not path:
1532 path = os.path.dirname(core.getcwd())
1533 super(OpenParentRepo, self).__init__(context, path)
1536 class Clone(ContextCommand):
1537 """Clones a repository and optionally spawns a new cola session."""
1539 def __init__(self, context, url, new_directory,
1540 submodules=False, shallow=False, spawn=True):
1541 super(Clone, self).__init__(context)
1542 self.url = url
1543 self.new_directory = new_directory
1544 self.submodules = submodules
1545 self.shallow = shallow
1546 self.spawn = spawn
1547 self.status = -1
1548 self.out = ''
1549 self.err = ''
1551 def do(self):
1552 kwargs = {}
1553 if self.shallow:
1554 kwargs['depth'] = 1
1555 recurse_submodules = self.submodules
1556 shallow_submodules = self.submodules and self.shallow
1558 status, out, err = self.git.clone(
1559 self.url, self.new_directory,
1560 recurse_submodules=recurse_submodules,
1561 shallow_submodules=shallow_submodules,
1562 **kwargs)
1564 self.status = status
1565 self.out = out
1566 self.err = err
1567 if status == 0 and self.spawn:
1568 executable = sys.executable
1569 core.fork([executable, sys.argv[0], '--repo', self.new_directory])
1570 return self
1573 class NewBareRepo(ContextCommand):
1574 """Create a new shared bare repository"""
1576 def __init__(self, context, path):
1577 super(NewBareRepo, self).__init__(context)
1578 self.path = path
1580 def do(self):
1581 path = self.path
1582 status, out, err = self.git.init(path, bare=True, shared=True)
1583 Interaction.command(
1584 N_('Error'), 'git init --bare --shared "%s"' % path,
1585 status, out, err)
1586 return status == 0
1589 def unix_path(path, is_win32=utils.is_win32):
1590 """Git for Windows requires unix paths, so force them here
1592 if is_win32():
1593 path = path.replace('\\', '/')
1594 first = path[0]
1595 second = path[1]
1596 if second == ':': # sanity check, this better be a Windows-style path
1597 path = '/' + first + path[2:]
1599 return path
1602 def sequence_editor():
1603 """Return a GIT_SEQUENCE_EDITOR environment value that enables git-xbase"""
1604 xbase = unix_path(resources.share('bin', 'git-xbase'))
1605 editor = core.list2cmdline([unix_path(sys.executable), xbase])
1606 return editor
1609 class GitXBaseContext(object):
1611 def __init__(self, context, **kwargs):
1612 self.env = {
1613 'GIT_EDITOR': prefs.editor(context),
1614 'GIT_SEQUENCE_EDITOR': sequence_editor(),
1615 'GIT_XBASE_CANCEL_ACTION': 'save',
1617 self.env.update(kwargs)
1619 def __enter__(self):
1620 for var, value in self.env.items():
1621 compat.setenv(var, value)
1622 return self
1624 def __exit__(self, exc_type, exc_val, exc_tb):
1625 for var in self.env:
1626 compat.unsetenv(var)
1629 class Rebase(ContextCommand):
1631 def __init__(self, context, upstream=None, branch=None, **kwargs):
1632 """Start an interactive rebase session
1634 :param upstream: upstream branch
1635 :param branch: optional branch to checkout
1636 :param kwargs: forwarded directly to `git.rebase()`
1639 super(Rebase, self).__init__(context)
1641 self.upstream = upstream
1642 self.branch = branch
1643 self.kwargs = kwargs
1645 def prepare_arguments(self, upstream):
1646 args = []
1647 kwargs = {}
1649 # Rebase actions must be the only option specified
1650 for action in ('continue', 'abort', 'skip', 'edit_todo'):
1651 if self.kwargs.get(action, False):
1652 kwargs[action] = self.kwargs[action]
1653 return args, kwargs
1655 kwargs['interactive'] = True
1656 kwargs['autosquash'] = self.kwargs.get('autosquash', True)
1657 kwargs.update(self.kwargs)
1659 if upstream:
1660 args.append(upstream)
1661 if self.branch:
1662 args.append(self.branch)
1664 return args, kwargs
1666 def do(self):
1667 (status, out, err) = (1, '', '')
1668 context = self.context
1669 cfg = self.cfg
1670 model = self.model
1672 if not cfg.get('rebase.autostash', False):
1673 if model.staged or model.unmerged or model.modified:
1674 Interaction.information(
1675 N_('Unable to rebase'),
1676 N_('You cannot rebase with uncommitted changes.'))
1677 return status, out, err
1679 upstream = self.upstream or Interaction.choose_ref(
1680 context, N_('Select New Upstream'), N_('Interactive Rebase'),
1681 default='@{upstream}')
1682 if not upstream:
1683 return status, out, err
1685 self.model.is_rebasing = True
1686 self.model.emit_updated()
1688 args, kwargs = self.prepare_arguments(upstream)
1689 upstream_title = upstream or '@{upstream}'
1690 with GitXBaseContext(
1691 self.context,
1692 GIT_XBASE_TITLE=N_('Rebase onto %s') % upstream_title,
1693 GIT_XBASE_ACTION=N_('Rebase')
1695 # TODO this blocks the user interface window for the duration
1696 # of git-xbase's invocation. We would need to implement
1697 # signals for QProcess and continue running the main thread.
1698 # alternatively we could hide the main window while rebasing.
1699 # that doesn't require as much effort.
1700 status, out, err = self.git.rebase(
1701 *args, _no_win32_startupinfo=True, **kwargs)
1702 self.model.update_status()
1703 if err.strip() != 'Nothing to do':
1704 title = N_('Rebase stopped')
1705 Interaction.command(title, 'git rebase', status, out, err)
1706 return status, out, err
1709 class RebaseEditTodo(ContextCommand):
1711 def do(self):
1712 (status, out, err) = (1, '', '')
1713 with GitXBaseContext(
1714 self.context,
1715 GIT_XBASE_TITLE=N_('Edit Rebase'),
1716 GIT_XBASE_ACTION=N_('Save')
1718 status, out, err = self.git.rebase(edit_todo=True)
1719 Interaction.log_status(status, out, err)
1720 self.model.update_status()
1721 return status, out, err
1724 class RebaseContinue(ContextCommand):
1726 def do(self):
1727 (status, out, err) = (1, '', '')
1728 with GitXBaseContext(
1729 self.context,
1730 GIT_XBASE_TITLE=N_('Rebase'),
1731 GIT_XBASE_ACTION=N_('Rebase')
1733 status, out, err = self.git.rebase('--continue')
1734 Interaction.log_status(status, out, err)
1735 self.model.update_status()
1736 return status, out, err
1739 class RebaseSkip(ContextCommand):
1741 def do(self):
1742 (status, out, err) = (1, '', '')
1743 with GitXBaseContext(
1744 self.context,
1745 GIT_XBASE_TITLE=N_('Rebase'),
1746 GIT_XBASE_ACTION=N_('Rebase')
1748 status, out, err = self.git.rebase(skip=True)
1749 Interaction.log_status(status, out, err)
1750 self.model.update_status()
1751 return status, out, err
1754 class RebaseAbort(ContextCommand):
1756 def do(self):
1757 status, out, err = self.git.rebase(abort=True)
1758 Interaction.log_status(status, out, err)
1759 self.model.update_status()
1762 class Rescan(ContextCommand):
1763 """Rescan for changes"""
1765 def do(self):
1766 self.model.update_status()
1769 class Refresh(ContextCommand):
1770 """Update refs, refresh the index, and update config"""
1772 @staticmethod
1773 def name():
1774 return N_('Refresh')
1776 def do(self):
1777 self.model.update_status(update_index=True)
1778 self.cfg.update()
1779 self.fsmonitor.refresh()
1782 class RefreshConfig(ContextCommand):
1783 """Refresh the git config cache"""
1785 def do(self):
1786 self.cfg.update()
1789 class RevertEditsCommand(ConfirmAction):
1791 def __init__(self, context):
1792 super(RevertEditsCommand, self).__init__(context)
1793 self.icon = icons.undo()
1795 def ok_to_run(self):
1796 return self.model.undoable()
1798 # pylint: disable=no-self-use
1799 def checkout_from_head(self):
1800 return False
1802 def checkout_args(self):
1803 args = []
1804 s = self.selection.selection()
1805 if self.checkout_from_head():
1806 args.append(self.model.head)
1807 args.append('--')
1809 if s.staged:
1810 items = s.staged
1811 else:
1812 items = s.modified
1813 args.extend(items)
1815 return args
1817 def action(self):
1818 checkout_args = self.checkout_args()
1819 return self.git.checkout(*checkout_args)
1821 def success(self):
1822 self.model.update_file_status()
1825 class RevertUnstagedEdits(RevertEditsCommand):
1827 @staticmethod
1828 def name():
1829 return N_('Revert Unstaged Edits...')
1831 def checkout_from_head(self):
1832 # If we are amending and a modified file is selected
1833 # then we should include "HEAD^" on the command-line.
1834 selected = self.selection.selection()
1835 return not selected.staged and self.model.amending()
1837 def confirm(self):
1838 title = N_('Revert Unstaged Changes?')
1839 text = N_(
1840 'This operation removes unstaged edits from selected files.\n'
1841 'These changes cannot be recovered.')
1842 info = N_('Revert the unstaged changes?')
1843 ok_text = N_('Revert Unstaged Changes')
1844 return Interaction.confirm(title, text, info, ok_text,
1845 default=True, icon=self.icon)
1848 class RevertUncommittedEdits(RevertEditsCommand):
1850 @staticmethod
1851 def name():
1852 return N_('Revert Uncommitted Edits...')
1854 def checkout_from_head(self):
1855 return True
1857 def confirm(self):
1858 """Prompt for reverting changes"""
1859 title = N_('Revert Uncommitted Changes?')
1860 text = N_(
1861 'This operation removes uncommitted edits from selected files.\n'
1862 'These changes cannot be recovered.')
1863 info = N_('Revert the uncommitted changes?')
1864 ok_text = N_('Revert Uncommitted Changes')
1865 return Interaction.confirm(title, text, info, ok_text,
1866 default=True, icon=self.icon)
1869 class RunConfigAction(ContextCommand):
1870 """Run a user-configured action, typically from the "Tools" menu"""
1872 def __init__(self, context, action_name):
1873 super(RunConfigAction, self).__init__(context)
1874 self.action_name = action_name
1876 def do(self):
1877 """Run the user-configured action"""
1878 for env in ('ARGS', 'DIRNAME', 'FILENAME', 'REVISION'):
1879 try:
1880 compat.unsetenv(env)
1881 except KeyError:
1882 pass
1883 rev = None
1884 args = None
1885 context = self.context
1886 cfg = self.cfg
1887 opts = cfg.get_guitool_opts(self.action_name)
1888 cmd = opts.get('cmd')
1889 if 'title' not in opts:
1890 opts['title'] = cmd
1892 if 'prompt' not in opts or opts.get('prompt') is True:
1893 prompt = N_('Run "%s"?') % cmd
1894 opts['prompt'] = prompt
1896 if opts.get('needsfile'):
1897 filename = self.selection.filename()
1898 if not filename:
1899 Interaction.information(
1900 N_('Please select a file'),
1901 N_('"%s" requires a selected file.') % cmd)
1902 return False
1903 dirname = utils.dirname(filename, current_dir='.')
1904 compat.setenv('FILENAME', filename)
1905 compat.setenv('DIRNAME', dirname)
1907 if opts.get('revprompt') or opts.get('argprompt'):
1908 while True:
1909 ok = Interaction.confirm_config_action(context, cmd, opts)
1910 if not ok:
1911 return False
1912 rev = opts.get('revision')
1913 args = opts.get('args')
1914 if opts.get('revprompt') and not rev:
1915 title = N_('Invalid Revision')
1916 msg = N_('The revision expression cannot be empty.')
1917 Interaction.critical(title, msg)
1918 continue
1919 break
1921 elif opts.get('confirm'):
1922 title = os.path.expandvars(opts.get('title'))
1923 prompt = os.path.expandvars(opts.get('prompt'))
1924 if not Interaction.question(title, prompt):
1925 return False
1926 if rev:
1927 compat.setenv('REVISION', rev)
1928 if args:
1929 compat.setenv('ARGS', args)
1930 title = os.path.expandvars(cmd)
1931 Interaction.log(N_('Running command: %s') % title)
1932 cmd = ['sh', '-c', cmd]
1934 if opts.get('background'):
1935 core.fork(cmd)
1936 status, out, err = (0, '', '')
1937 elif opts.get('noconsole'):
1938 status, out, err = core.run_command(cmd)
1939 else:
1940 status, out, err = Interaction.run_command(title, cmd)
1942 if not opts.get('background') and not opts.get('norescan'):
1943 self.model.update_status()
1945 title = N_('Error')
1946 Interaction.command(title, cmd, status, out, err)
1948 return status == 0
1951 class SetDefaultRepo(ContextCommand):
1952 """Set the default repository"""
1954 def __init__(self, context, repo):
1955 super(SetDefaultRepo, self).__init__(context)
1956 self.repo = repo
1958 def do(self):
1959 self.cfg.set_user('cola.defaultrepo', self.repo)
1962 class SetDiffText(EditModel):
1963 """Set the diff text"""
1964 UNDOABLE = True
1966 def __init__(self, context, text):
1967 super(SetDiffText, self).__init__(context)
1968 self.new_diff_text = text
1969 self.new_diff_type = 'text'
1972 class SetUpstreamBranch(ContextCommand):
1973 """Set the upstream branch"""
1975 def __init__(self, context, branch, remote, remote_branch):
1976 super(SetUpstreamBranch, self).__init__(context)
1977 self.branch = branch
1978 self.remote = remote
1979 self.remote_branch = remote_branch
1981 def do(self):
1982 cfg = self.cfg
1983 remote = self.remote
1984 branch = self.branch
1985 remote_branch = self.remote_branch
1986 cfg.set_repo('branch.%s.remote' % branch, remote)
1987 cfg.set_repo('branch.%s.merge' % branch, 'refs/heads/' + remote_branch)
1990 class ShowUntracked(EditModel):
1991 """Show an untracked file."""
1993 def __init__(self, context, filename):
1994 super(ShowUntracked, self).__init__(context)
1995 self.new_filename = filename
1996 self.new_mode = self.model.mode_untracked
1997 self.new_diff_text = self.read(filename)
1998 self.new_diff_type = 'text'
2000 def read(self, filename):
2001 """Read file contents"""
2002 cfg = self.cfg
2003 size = cfg.get('cola.readsize', 2048)
2004 try:
2005 result = core.read(filename, size=size,
2006 encoding=core.ENCODING, errors='ignore')
2007 except (IOError, OSError):
2008 result = ''
2010 if len(result) == size:
2011 result += '...'
2012 return result
2015 class SignOff(ContextCommand):
2016 """Append a signoff to the commit message"""
2017 UNDOABLE = True
2019 @staticmethod
2020 def name():
2021 return N_('Sign Off')
2023 def __init__(self, context):
2024 super(SignOff, self).__init__(context)
2025 self.old_commitmsg = self.model.commitmsg
2027 def do(self):
2028 """Add a signoff to the commit message"""
2029 signoff = self.signoff()
2030 if signoff in self.model.commitmsg:
2031 return
2032 msg = self.model.commitmsg.rstrip()
2033 self.model.set_commitmsg(msg + '\n' + signoff)
2035 def undo(self):
2036 """Restore the commit message"""
2037 self.model.set_commitmsg(self.old_commitmsg)
2039 def signoff(self):
2040 """Generate the signoff string"""
2041 try:
2042 import pwd
2043 user = pwd.getpwuid(os.getuid()).pw_name
2044 except ImportError:
2045 user = os.getenv('USER', N_('unknown'))
2047 cfg = self.cfg
2048 name = cfg.get('user.name', user)
2049 email = cfg.get('user.email', '%s@%s' % (user, core.node()))
2050 return '\nSigned-off-by: %s <%s>' % (name, email)
2053 def check_conflicts(context, unmerged):
2054 """Check paths for conflicts
2056 Conflicting files can be filtered out one-by-one.
2059 if prefs.check_conflicts(context):
2060 unmerged = [path for path in unmerged if is_conflict_free(path)]
2061 return unmerged
2064 def is_conflict_free(path):
2065 """Return True if `path` contains no conflict markers
2067 rgx = re.compile(r'^(<<<<<<<|\|\|\|\|\|\|\||>>>>>>>) ')
2068 try:
2069 with core.xopen(path, 'r') as f:
2070 for line in f:
2071 line = core.decode(line, errors='ignore')
2072 if rgx.match(line):
2073 return should_stage_conflicts(path)
2074 except IOError:
2075 # We can't read this file ~ we may be staging a removal
2076 pass
2077 return True
2080 def should_stage_conflicts(path):
2081 """Inform the user that a file contains merge conflicts
2083 Return `True` if we should stage the path nonetheless.
2086 title = msg = N_('Stage conflicts?')
2087 info = N_('%s appears to contain merge conflicts.\n\n'
2088 'You should probably skip this file.\n'
2089 'Stage it anyways?') % path
2090 ok_text = N_('Stage conflicts')
2091 cancel_text = N_('Skip')
2092 return Interaction.confirm(title, msg, info, ok_text,
2093 default=False, cancel_text=cancel_text)
2096 class Stage(ContextCommand):
2097 """Stage a set of paths."""
2099 @staticmethod
2100 def name():
2101 return N_('Stage')
2103 def __init__(self, context, paths):
2104 super(Stage, self).__init__(context)
2105 self.paths = paths
2107 def do(self):
2108 msg = N_('Staging: %s') % (', '.join(self.paths))
2109 Interaction.log(msg)
2110 return self.stage_paths()
2112 def stage_paths(self):
2113 """Stages add/removals to git."""
2114 context = self.context
2115 paths = self.paths
2116 if not paths:
2117 if self.model.cfg.get('cola.safemode', False):
2118 return (0, '', '')
2119 return self.stage_all()
2121 add = []
2122 remove = []
2124 for path in set(paths):
2125 if core.exists(path) or core.islink(path):
2126 if path.endswith('/'):
2127 path = path.rstrip('/')
2128 add.append(path)
2129 else:
2130 remove.append(path)
2132 self.model.emit_about_to_update()
2134 # `git add -u` doesn't work on untracked files
2135 if add:
2136 status, out, err = gitcmds.add(context, add)
2137 Interaction.command(N_('Error'), 'git add', status, out, err)
2139 # If a path doesn't exist then that means it should be removed
2140 # from the index. We use `git add -u` for that.
2141 if remove:
2142 status, out, err = gitcmds.add(context, remove, u=True)
2143 Interaction.command(N_('Error'), 'git add -u', status, out, err)
2145 self.model.update_files(emit=True)
2146 return status, out, err
2148 def stage_all(self):
2149 """Stage all files"""
2150 status, out, err = self.git.add(v=True, u=True)
2151 Interaction.command(N_('Error'), 'git add -u', status, out, err)
2152 self.model.update_file_status()
2153 return (status, out, err)
2156 class StageCarefully(Stage):
2157 """Only stage when the path list is non-empty
2159 We use "git add -u -- <pathspec>" to stage, and it stages everything by
2160 default when no pathspec is specified, so this class ensures that paths
2161 are specified before calling git.
2163 When no paths are specified, the command does nothing.
2166 def __init__(self, context):
2167 super(StageCarefully, self).__init__(context, None)
2168 self.init_paths()
2170 # pylint: disable=no-self-use
2171 def init_paths(self):
2172 """Initialize path data"""
2173 return
2175 def ok_to_run(self):
2176 """Prevent catch-all "git add -u" from adding unmerged files"""
2177 return self.paths or not self.model.unmerged
2179 def do(self):
2180 """Stage files when ok_to_run() return True"""
2181 if self.ok_to_run():
2182 return super(StageCarefully, self).do()
2183 return (0, '', '')
2186 class StageModified(StageCarefully):
2187 """Stage all modified files."""
2189 @staticmethod
2190 def name():
2191 return N_('Stage Modified')
2193 def init_paths(self):
2194 self.paths = self.model.modified
2197 class StageUnmerged(StageCarefully):
2198 """Stage unmerged files."""
2200 @staticmethod
2201 def name():
2202 return N_('Stage Unmerged')
2204 def init_paths(self):
2205 self.paths = check_conflicts(self.context, self.model.unmerged)
2208 class StageUntracked(StageCarefully):
2209 """Stage all untracked files."""
2211 @staticmethod
2212 def name():
2213 return N_('Stage Untracked')
2215 def init_paths(self):
2216 self.paths = self.model.untracked
2219 class StageOrUnstage(ContextCommand):
2220 """If the selection is staged, unstage it, otherwise stage"""
2222 @staticmethod
2223 def name():
2224 return N_('Stage / Unstage')
2226 def do(self):
2227 s = self.selection.selection()
2228 if s.staged:
2229 do(Unstage, self.context, s.staged)
2231 unstaged = []
2232 unmerged = check_conflicts(self.context, s.unmerged)
2233 if unmerged:
2234 unstaged.extend(unmerged)
2235 if s.modified:
2236 unstaged.extend(s.modified)
2237 if s.untracked:
2238 unstaged.extend(s.untracked)
2239 if unstaged:
2240 do(Stage, self.context, unstaged)
2243 class Tag(ContextCommand):
2244 """Create a tag object."""
2246 def __init__(self, context, name, revision, sign=False, message=''):
2247 super(Tag, self).__init__(context)
2248 self._name = name
2249 self._message = message
2250 self._revision = revision
2251 self._sign = sign
2253 def do(self):
2254 result = False
2255 git = self.git
2256 revision = self._revision
2257 tag_name = self._name
2258 tag_message = self._message
2260 if not revision:
2261 Interaction.critical(
2262 N_('Missing Revision'),
2263 N_('Please specify a revision to tag.'))
2264 return result
2266 if not tag_name:
2267 Interaction.critical(
2268 N_('Missing Name'),
2269 N_('Please specify a name for the new tag.'))
2270 return result
2272 title = N_('Missing Tag Message')
2273 message = N_('Tag-signing was requested but the tag message is empty.')
2274 info = N_('An unsigned, lightweight tag will be created instead.\n'
2275 'Create an unsigned tag?')
2276 ok_text = N_('Create Unsigned Tag')
2277 sign = self._sign
2278 if sign and not tag_message:
2279 # We require a message in order to sign the tag, so if they
2280 # choose to create an unsigned tag we have to clear the sign flag.
2281 if not Interaction.confirm(title, message, info, ok_text,
2282 default=False, icon=icons.save()):
2283 return result
2284 sign = False
2286 opts = {}
2287 tmp_file = None
2288 try:
2289 if tag_message:
2290 tmp_file = utils.tmp_filename('tag-message')
2291 opts['file'] = tmp_file
2292 core.write(tmp_file, tag_message)
2294 if sign:
2295 opts['sign'] = True
2296 if tag_message:
2297 opts['annotate'] = True
2298 status, out, err = git.tag(tag_name, revision, **opts)
2299 finally:
2300 if tmp_file:
2301 core.unlink(tmp_file)
2303 title = N_('Error: could not create tag "%s"') % tag_name
2304 Interaction.command(title, 'git tag', status, out, err)
2306 if status == 0:
2307 result = True
2308 self.model.update_status()
2309 Interaction.information(
2310 N_('Tag Created'),
2311 N_('Created a new tag named "%s"') % tag_name,
2312 details=tag_message or None)
2314 return result
2317 class Unstage(ContextCommand):
2318 """Unstage a set of paths."""
2320 @staticmethod
2321 def name():
2322 return N_('Unstage')
2324 def __init__(self, context, paths):
2325 super(Unstage, self).__init__(context)
2326 self.paths = paths
2328 def do(self):
2329 """Unstage paths"""
2330 context = self.context
2331 head = self.model.head
2332 paths = self.paths
2334 msg = N_('Unstaging: %s') % (', '.join(paths))
2335 Interaction.log(msg)
2336 if not paths:
2337 return unstage_all(context)
2338 status, out, err = gitcmds.unstage_paths(context, paths, head=head)
2339 Interaction.command(N_('Error'), 'git reset', status, out, err)
2340 self.model.update_file_status()
2341 return (status, out, err)
2344 class UnstageAll(ContextCommand):
2345 """Unstage all files; resets the index."""
2347 def do(self):
2348 return unstage_all(self.context)
2351 def unstage_all(context):
2352 """Unstage all files, even while amending"""
2353 model = context.model
2354 git = context.git
2355 head = model.head
2356 status, out, err = git.reset(head, '--', '.')
2357 Interaction.command(N_('Error'), 'git reset', status, out, err)
2358 model.update_file_status()
2359 return (status, out, err)
2362 class StageSelected(ContextCommand):
2363 """Stage selected files, or all files if no selection exists."""
2365 def do(self):
2366 context = self.context
2367 paths = self.selection.unstaged
2368 if paths:
2369 do(Stage, context, paths)
2370 elif self.cfg.get('cola.safemode', False):
2371 do(StageModified, context)
2374 class UnstageSelected(Unstage):
2375 """Unstage selected files."""
2377 def __init__(self, context):
2378 staged = self.selection.staged
2379 super(UnstageSelected, self).__init__(context, staged)
2382 class Untrack(ContextCommand):
2383 """Unstage a set of paths."""
2385 def __init__(self, context, paths):
2386 super(Untrack, self).__init__(context)
2387 self.paths = paths
2389 def do(self):
2390 msg = N_('Untracking: %s') % (', '.join(self.paths))
2391 Interaction.log(msg)
2392 status, out, err = self.model.untrack_paths(self.paths)
2393 Interaction.log_status(status, out, err)
2396 class UntrackedSummary(EditModel):
2397 """List possible .gitignore rules as the diff text."""
2399 def __init__(self, context):
2400 super(UntrackedSummary, self).__init__(context)
2401 untracked = self.model.untracked
2402 suffix = 's' if untracked else ''
2403 io = StringIO()
2404 io.write('# %s untracked file%s\n' % (len(untracked), suffix))
2405 if untracked:
2406 io.write('# possible .gitignore rule%s:\n' % suffix)
2407 for u in untracked:
2408 io.write('/'+u+'\n')
2409 self.new_diff_text = io.getvalue()
2410 self.new_diff_type = 'text'
2411 self.new_mode = self.model.mode_untracked
2414 class VisualizeAll(ContextCommand):
2415 """Visualize all branches."""
2417 def do(self):
2418 context = self.context
2419 browser = utils.shell_split(prefs.history_browser(context))
2420 launch_history_browser(browser + ['--all'])
2423 class VisualizeCurrent(ContextCommand):
2424 """Visualize all branches."""
2426 def do(self):
2427 context = self.context
2428 browser = utils.shell_split(prefs.history_browser(context))
2429 launch_history_browser(browser + [self.model.currentbranch] + ['--'])
2432 class VisualizePaths(ContextCommand):
2433 """Path-limited visualization."""
2435 def __init__(self, context, paths):
2436 super(VisualizePaths, self).__init__(context)
2437 context = self.context
2438 browser = utils.shell_split(prefs.history_browser(context))
2439 if paths:
2440 self.argv = browser + ['--'] + list(paths)
2441 else:
2442 self.argv = browser
2444 def do(self):
2445 launch_history_browser(self.argv)
2448 class VisualizeRevision(ContextCommand):
2449 """Visualize a specific revision."""
2451 def __init__(self, context, revision, paths=None):
2452 super(VisualizeRevision, self).__init__(context)
2453 self.revision = revision
2454 self.paths = paths
2456 def do(self):
2457 context = self.context
2458 argv = utils.shell_split(prefs.history_browser(context))
2459 if self.revision:
2460 argv.append(self.revision)
2461 if self.paths:
2462 argv.append('--')
2463 argv.extend(self.paths)
2464 launch_history_browser(argv)
2467 class SubmoduleUpdate(ConfirmAction):
2468 """Update specified submodule"""
2470 def __init__(self, context, path):
2471 super(SubmoduleUpdate, self).__init__(context)
2472 self.path = path
2474 def confirm(self):
2475 title = N_('Update Submodule...')
2476 question = N_('Update this submodule?')
2477 info = N_('The submodule will be updated using\n'
2478 '"%s"' % self.command())
2479 ok_txt = N_('Update Submodule')
2480 return Interaction.confirm(title, question, info, ok_txt,
2481 default=False, icon=icons.pull())
2483 def action(self):
2484 context = self.context
2485 return context.git.submodule('update', '--', self.path)
2487 def success(self):
2488 self.model.update_file_status()
2490 def error_message(self):
2491 return N_('Error updating submodule %s' % self.path)
2493 def command(self):
2494 command = 'git submodule update -- %s'
2495 return command % self.path
2498 class SubmodulesUpdate(ConfirmAction):
2499 """Update all submodules"""
2501 def confirm(self):
2502 title = N_('Update submodules...')
2503 question = N_('Update all submodules?')
2504 info = N_('All submodules will be updated using\n'
2505 '"%s"' % self.command())
2506 ok_txt = N_('Update Submodules')
2507 return Interaction.confirm(title, question, info, ok_txt,
2508 default=False, icon=icons.pull())
2510 def action(self):
2511 context = self.context
2512 return context.git.submodule('update')
2514 def success(self):
2515 self.model.update_file_status()
2517 def error_message(self):
2518 return N_('Error updating submodules')
2520 def command(self):
2521 return 'git submodule update'
2524 def launch_history_browser(argv):
2525 """Launch the configured history browser"""
2526 try:
2527 core.fork(argv)
2528 except OSError as e:
2529 _, details = utils.format_exception(e)
2530 title = N_('Error Launching History Browser')
2531 msg = (N_('Cannot exec "%s": please configure a history browser') %
2532 ' '.join(argv))
2533 Interaction.critical(title, message=msg, details=details)
2536 def run(cls, *args, **opts):
2538 Returns a callback that runs a command
2540 If the caller of run() provides args or opts then those are
2541 used instead of the ones provided by the invoker of the callback.
2544 def runner(*local_args, **local_opts):
2545 """Closure return by run() which runs the command"""
2546 if args or opts:
2547 do(cls, *args, **opts)
2548 else:
2549 do(cls, *local_args, **local_opts)
2551 return runner
2554 def do(cls, *args, **opts):
2555 """Run a command in-place"""
2556 try:
2557 cmd = cls(*args, **opts)
2558 return cmd.do()
2559 except Exception as e: # pylint: disable=broad-except
2560 msg, details = utils.format_exception(e)
2561 if hasattr(cls, '__name__'):
2562 msg = ('%s exception:\n%s' % (cls.__name__, msg))
2563 Interaction.critical(N_('Error'), message=msg, details=details)
2564 return None
2567 def difftool_run(context):
2568 """Start a default difftool session"""
2569 selection = context.selection
2570 files = selection.group()
2571 if not files:
2572 return
2573 s = selection.selection()
2574 head = context.model.head
2575 difftool_launch_with_head(context, files, bool(s.staged), head)
2578 def difftool_launch_with_head(context, filenames, staged, head):
2579 """Launch difftool against the provided head"""
2580 if head == 'HEAD':
2581 left = None
2582 else:
2583 left = head
2584 difftool_launch(context, left=left, staged=staged, paths=filenames)
2587 def difftool_launch(context, left=None, right=None, paths=None,
2588 staged=False, dir_diff=False,
2589 left_take_magic=False, left_take_parent=False):
2590 """Launches 'git difftool' with given parameters
2592 :param left: first argument to difftool
2593 :param right: second argument to difftool_args
2594 :param paths: paths to diff
2595 :param staged: activate `git difftool --staged`
2596 :param dir_diff: activate `git difftool --dir-diff`
2597 :param left_take_magic: whether to append the magic ^! diff expression
2598 :param left_take_parent: whether to append the first-parent ~ for diffing
2602 difftool_args = ['git', 'difftool', '--no-prompt']
2603 if staged:
2604 difftool_args.append('--cached')
2605 if dir_diff:
2606 difftool_args.append('--dir-diff')
2608 if left:
2609 if left_take_parent or left_take_magic:
2610 suffix = '^!' if left_take_magic else '~'
2611 # Check root commit (no parents and thus cannot execute '~')
2612 git = context.git
2613 status, out, err = git.rev_list(left, parents=True, n=1)
2614 Interaction.log_status(status, out, err)
2615 if status:
2616 raise OSError('git rev-list command failed')
2618 if len(out.split()) >= 2:
2619 # Commit has a parent, so we can take its child as requested
2620 left += suffix
2621 else:
2622 # No parent, assume it's the root commit, so we have to diff
2623 # against the empty tree.
2624 left = EMPTY_TREE_OID
2625 if not right and left_take_magic:
2626 right = left
2627 difftool_args.append(left)
2629 if right:
2630 difftool_args.append(right)
2632 if paths:
2633 difftool_args.append('--')
2634 difftool_args.extend(paths)
2636 runtask = context.runtask
2637 if runtask:
2638 Interaction.async_command(N_('Difftool'), difftool_args, runtask)
2639 else:
2640 core.fork(difftool_args)