core: make getcwd() fail-safe
[git-cola.git] / cola / cmds.py
blobc910f686e627ce2d0fc1e30eb6062067c327843c
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 @staticmethod
383 def name():
384 return N_('Blame...')
386 def __init__(self, context, paths=None):
387 super(BlamePaths, self).__init__(context)
388 if not paths:
389 paths = context.selection.union()
390 viewer = utils.shell_split(prefs.blame_viewer(context))
391 self.argv = viewer + list(paths)
393 def do(self):
394 try:
395 core.fork(self.argv)
396 except OSError as e:
397 _, details = utils.format_exception(e)
398 title = N_('Error Launching Blame Viewer')
399 msg = (N_('Cannot exec "%s": please configure a blame viewer')
400 % ' '.join(self.argv))
401 Interaction.critical(title, message=msg, details=details)
404 class CheckoutBranch(Checkout):
405 """Checkout a branch."""
407 def __init__(self, context, branch):
408 args = [branch]
409 super(CheckoutBranch, self).__init__(
410 context, args, checkout_branch=True)
413 class CherryPick(ContextCommand):
414 """Cherry pick commits into the current branch."""
416 def __init__(self, context, commits):
417 super(CherryPick, self).__init__(context)
418 self.commits = commits
420 def do(self):
421 self.model.cherry_pick_list(self.commits)
422 self.model.update_file_status()
425 class Revert(ContextCommand):
426 """Cherry pick commits into the current branch."""
428 def __init__(self, context, oid):
429 super(Revert, self).__init__(context)
430 self.oid = oid
432 def do(self):
433 self.git.revert(self.oid)
434 self.model.update_file_status()
437 class ResetMode(EditModel):
438 """Reset the mode and clear the model's diff text."""
440 def __init__(self, context):
441 super(ResetMode, self).__init__(context)
442 self.new_mode = self.model.mode_none
443 self.new_diff_text = ''
444 self.new_diff_type = 'text'
445 self.new_filename = ''
447 def do(self):
448 super(ResetMode, self).do()
449 self.model.update_file_status()
452 class ResetCommand(ConfirmAction):
453 """Reset state using the "git reset" command"""
455 def __init__(self, context, ref):
456 super(ResetCommand, self).__init__(context)
457 self.ref = ref
459 def action(self):
460 return self.reset()
462 def command(self):
463 return 'git reset'
465 def error_message(self):
466 return N_('Error')
468 def success(self):
469 self.model.update_file_status()
471 def confirm(self):
472 raise NotImplementedError('confirm() must be overridden')
474 def reset(self):
475 raise NotImplementedError('reset() must be overridden')
478 class ResetBranchHead(ResetCommand):
480 def confirm(self):
481 title = N_('Reset Branch')
482 question = N_('Point the current branch head to a new commit?')
483 info = N_('The branch will be reset using "git reset --mixed %s"')
484 ok_text = N_('Reset Branch')
485 info = info % self.ref
486 return Interaction.confirm(title, question, info, ok_text)
488 def reset(self):
489 return self.git.reset(self.ref, '--', mixed=True)
492 class ResetWorktree(ResetCommand):
494 def confirm(self):
495 title = N_('Reset Worktree')
496 question = N_('Reset worktree?')
497 info = N_('The worktree will be reset using "git reset --keep %s"')
498 ok_text = N_('Reset Worktree')
499 info = info % self.ref
500 return Interaction.confirm(title, question, info, ok_text)
502 def reset(self):
503 return self.git.reset(self.ref, '--', keep=True)
506 class ResetMerge(ResetCommand):
508 def confirm(self):
509 title = N_('Reset Merge')
510 question = N_('Reset merge?')
511 info = N_('The branch will be reset using "git reset --merge %s"')
512 ok_text = N_('Reset Merge')
513 info = info % self.ref
514 return Interaction.confirm(title, question, info, ok_text)
516 def reset(self):
517 return self.git.reset(self.ref, '--', merge=True)
520 class ResetSoft(ResetCommand):
522 def confirm(self):
523 title = N_('Reset Soft')
524 question = N_('Reset soft?')
525 info = N_('The branch will be reset using "git reset --soft %s"')
526 ok_text = N_('Reset Soft')
527 info = info % self.ref
528 return Interaction.confirm(title, question, info, ok_text)
530 def reset(self):
531 return self.git.reset(self.ref, '--', soft=True)
534 class ResetHard(ResetCommand):
536 def confirm(self):
537 title = N_('Reset Hard')
538 question = N_('Reset hard?')
539 info = N_('The branch will be reset using "git reset --hard %s"')
540 ok_text = N_('Reset Hard')
541 info = info % self.ref
542 return Interaction.confirm(title, question, info, ok_text)
544 def reset(self):
545 return self.git.reset(self.ref, '--', hard=True)
548 class Commit(ResetMode):
549 """Attempt to create a new commit."""
551 def __init__(self, context, amend, msg, sign, no_verify=False):
552 super(Commit, self).__init__(context)
553 self.amend = amend
554 self.msg = msg
555 self.sign = sign
556 self.no_verify = no_verify
557 self.old_commitmsg = self.model.commitmsg
558 self.new_commitmsg = ''
560 def do(self):
561 # Create the commit message file
562 context = self.context
563 comment_char = prefs.comment_char(context)
564 msg = self.strip_comments(self.msg, comment_char=comment_char)
565 tmp_file = utils.tmp_filename('commit-message')
566 try:
567 core.write(tmp_file, msg)
568 # Run 'git commit'
569 status, out, err = self.git.commit(
570 F=tmp_file, v=True, gpg_sign=self.sign,
571 amend=self.amend, no_verify=self.no_verify)
572 finally:
573 core.unlink(tmp_file)
574 if status == 0:
575 super(Commit, self).do()
576 self.model.set_commitmsg(self.new_commitmsg)
578 title = N_('Commit failed')
579 Interaction.command(title, 'git commit', status, out, err)
581 return status, out, err
583 @staticmethod
584 def strip_comments(msg, comment_char='#'):
585 # Strip off comments
586 message_lines = [line for line in msg.split('\n')
587 if not line.startswith(comment_char)]
588 msg = '\n'.join(message_lines)
589 if not msg.endswith('\n'):
590 msg += '\n'
592 return msg
595 class CycleReferenceSort(ContextCommand):
596 """Choose the next reference sort type"""
597 def do(self):
598 self.model.cycle_ref_sort()
601 class Ignore(ContextCommand):
602 """Add files to .gitignore"""
604 def __init__(self, context, filenames):
605 super(Ignore, self).__init__(context)
606 self.filenames = list(filenames)
608 def do(self):
609 if not self.filenames:
610 return
611 new_additions = '\n'.join(self.filenames) + '\n'
612 for_status = new_additions
613 if core.exists('.gitignore'):
614 current_list = core.read('.gitignore')
615 new_additions = current_list.rstrip() + '\n' + new_additions
616 core.write('.gitignore', new_additions)
617 Interaction.log_status(0, 'Added to .gitignore:\n%s' % for_status, '')
618 self.model.update_file_status()
621 def file_summary(files):
622 txt = core.list2cmdline(files)
623 if len(txt) > 768:
624 txt = txt[:768].rstrip() + '...'
625 wrap = textwrap.TextWrapper()
626 return '\n'.join(wrap.wrap(txt))
629 class RemoteCommand(ConfirmAction):
631 def __init__(self, context, remote):
632 super(RemoteCommand, self).__init__(context)
633 self.remote = remote
635 def success(self):
636 self.cfg.reset()
637 self.model.update_remotes()
640 class RemoteAdd(RemoteCommand):
642 def __init__(self, context, remote, url):
643 super(RemoteAdd, self).__init__(context, remote)
644 self.url = url
646 def action(self):
647 return self.git.remote('add', self.remote, self.url)
649 def error_message(self):
650 return N_('Error creating remote "%s"') % self.remote
652 def command(self):
653 return 'git remote add "%s" "%s"' % (self.remote, self.url)
656 class RemoteRemove(RemoteCommand):
658 def confirm(self):
659 title = N_('Delete Remote')
660 question = N_('Delete remote?')
661 info = N_('Delete remote "%s"') % self.remote
662 ok_text = N_('Delete')
663 return Interaction.confirm(title, question, info, ok_text)
665 def action(self):
666 return self.git.remote('rm', self.remote)
668 def error_message(self):
669 return N_('Error deleting remote "%s"') % self.remote
671 def command(self):
672 return 'git remote rm "%s"' % self.remote
675 class RemoteRename(RemoteCommand):
677 def __init__(self, context, remote, new_name):
678 super(RemoteRename, self).__init__(context, remote)
679 self.new_name = new_name
681 def confirm(self):
682 title = N_('Rename Remote')
683 text = (N_('Rename remote "%(current)s" to "%(new)s"?') %
684 dict(current=self.remote, new=self.new_name))
685 info_text = ''
686 ok_text = title
687 return Interaction.confirm(title, text, info_text, ok_text)
689 def action(self):
690 return self.git.remote('rename', self.remote, self.new_name)
692 def error_message(self):
693 return (N_('Error renaming "%(name)s" to "%(new_name)s"')
694 % dict(name=self.remote, new_name=self.new_name))
696 def command(self):
697 return 'git remote rename "%s" "%s"' % (self.remote, self.new_name)
700 class RemoteSetURL(RemoteCommand):
702 def __init__(self, context, remote, url):
703 super(RemoteSetURL, self).__init__(context, remote)
704 self.url = url
706 def action(self):
707 return self.git.remote('set-url', self.remote, self.url)
709 def error_message(self):
710 return (N_('Unable to set URL for "%(name)s" to "%(url)s"')
711 % dict(name=self.remote, url=self.url))
713 def command(self):
714 return 'git remote set-url "%s" "%s"' % (self.remote, self.url)
717 class RemoteEdit(ContextCommand):
718 """Combine RemoteRename and RemoteSetURL"""
720 def __init__(self, context, old_name, remote, url):
721 super(RemoteEdit, self).__init__(context)
722 self.rename = RemoteRename(context, old_name, remote)
723 self.set_url = RemoteSetURL(context, remote, url)
725 def do(self):
726 result = self.rename.do()
727 name_ok = result[0]
728 url_ok = False
729 if name_ok:
730 result = self.set_url.do()
731 url_ok = result[0]
732 return name_ok, url_ok
735 class RemoveFromSettings(ConfirmAction):
737 def __init__(self, context, settings, repo, entry, icon=None):
738 super(RemoveFromSettings, self).__init__(context)
739 self.settings = settings
740 self.repo = repo
741 self.entry = entry
742 self.icon = icon
744 def success(self):
745 self.settings.save()
748 class RemoveBookmark(RemoveFromSettings):
750 def confirm(self):
751 entry = self.entry
752 title = msg = N_('Delete Bookmark?')
753 info = N_('%s will be removed from your bookmarks.') % entry
754 ok_text = N_('Delete Bookmark')
755 return Interaction.confirm(title, msg, info, ok_text, icon=self.icon)
757 def action(self):
758 self.settings.remove_bookmark(self.repo, self.entry)
759 return (0, '', '')
762 class RemoveRecent(RemoveFromSettings):
764 def confirm(self):
765 repo = self.repo
766 title = msg = N_('Remove %s from the recent list?') % repo
767 info = N_('%s will be removed from your recent repositories.') % repo
768 ok_text = N_('Remove')
769 return Interaction.confirm(title, msg, info, ok_text, icon=self.icon)
771 def action(self):
772 self.settings.remove_recent(self.repo)
773 return (0, '', '')
776 class RemoveFiles(ContextCommand):
777 """Removes files"""
779 def __init__(self, context, remover, filenames):
780 super(RemoveFiles, self).__init__(context)
781 if remover is None:
782 remover = os.remove
783 self.remover = remover
784 self.filenames = filenames
785 # We could git-hash-object stuff and provide undo-ability
786 # as an option. Heh.
788 def do(self):
789 files = self.filenames
790 if not files:
791 return
793 rescan = False
794 bad_filenames = []
795 remove = self.remover
796 for filename in files:
797 if filename:
798 try:
799 remove(filename)
800 rescan = True
801 except OSError:
802 bad_filenames.append(filename)
804 if bad_filenames:
805 Interaction.information(
806 N_('Error'),
807 N_('Deleting "%s" failed') % file_summary(bad_filenames))
809 if rescan:
810 self.model.update_file_status()
813 class Delete(RemoveFiles):
814 """Delete files."""
816 def __init__(self, context, filenames):
817 super(Delete, self).__init__(context, os.remove, filenames)
819 def do(self):
820 files = self.filenames
821 if not files:
822 return
824 title = N_('Delete Files?')
825 msg = N_('The following files will be deleted:') + '\n\n'
826 msg += file_summary(files)
827 info_txt = N_('Delete %d file(s)?') % len(files)
828 ok_txt = N_('Delete Files')
830 if Interaction.confirm(title, msg, info_txt, ok_txt,
831 default=True, icon=icons.remove()):
832 super(Delete, self).do()
835 class MoveToTrash(RemoveFiles):
836 """Move files to the trash using send2trash"""
838 AVAILABLE = send2trash is not None
840 def __init__(self, context, filenames):
841 super(MoveToTrash, self).__init__(context, send2trash, filenames)
844 class DeleteBranch(ContextCommand):
845 """Delete a git branch."""
847 def __init__(self, context, branch):
848 super(DeleteBranch, self).__init__(context)
849 self.branch = branch
851 def do(self):
852 status, out, err = self.model.delete_branch(self.branch)
853 Interaction.log_status(status, out, err)
856 class Rename(ContextCommand):
857 """Rename a set of paths."""
859 def __init__(self, context, paths):
860 super(Rename, self).__init__(context)
861 self.paths = paths
863 def do(self):
864 msg = N_('Untracking: %s') % (', '.join(self.paths))
865 Interaction.log(msg)
867 for path in self.paths:
868 ok = self.rename(path)
869 if not ok:
870 return
872 self.model.update_status()
874 def rename(self, path):
875 git = self.git
876 title = N_('Rename "%s"') % path
878 if os.path.isdir(path):
879 base_path = os.path.dirname(path)
880 else:
881 base_path = path
882 new_path = Interaction.save_as(base_path, title)
883 if not new_path:
884 return False
886 status, out, err = git.mv(path, new_path, force=True, verbose=True)
887 Interaction.command(N_('Error'), 'git mv', status, out, err)
888 return status == 0
891 class RenameBranch(ContextCommand):
892 """Rename a git branch."""
894 def __init__(self, context, branch, new_branch):
895 super(RenameBranch, self).__init__(context)
896 self.branch = branch
897 self.new_branch = new_branch
899 def do(self):
900 branch = self.branch
901 new_branch = self.new_branch
902 status, out, err = self.model.rename_branch(branch, new_branch)
903 Interaction.log_status(status, out, err)
906 class DeleteRemoteBranch(ContextCommand):
907 """Delete a remote git branch."""
909 def __init__(self, context, remote, branch):
910 super(DeleteRemoteBranch, self).__init__(context)
911 self.remote = remote
912 self.branch = branch
914 def do(self):
915 status, out, err = self.git.push(self.remote, self.branch, delete=True)
916 self.model.update_status()
918 title = N_('Error Deleting Remote Branch')
919 Interaction.command(title, 'git push', status, out, err)
920 if status == 0:
921 Interaction.information(
922 N_('Remote Branch Deleted'),
923 N_('"%(branch)s" has been deleted from "%(remote)s".')
924 % dict(branch=self.branch, remote=self.remote))
927 def get_mode(model, staged, modified, unmerged, untracked):
928 if staged:
929 mode = model.mode_index
930 elif modified or unmerged:
931 mode = model.mode_worktree
932 elif untracked:
933 mode = model.mode_untracked
934 else:
935 mode = model.mode
936 return mode
939 class DiffImage(EditModel):
941 def __init__(self, context, filename,
942 deleted, staged, modified, unmerged, untracked):
943 super(DiffImage, self).__init__(context)
945 self.new_filename = filename
946 self.new_diff_text = ''
947 self.new_diff_type = 'image'
948 self.new_mode = get_mode(
949 self.model, staged, modified, unmerged, untracked)
950 self.staged = staged
951 self.modified = modified
952 self.unmerged = unmerged
953 self.untracked = untracked
954 self.deleted = deleted
955 self.annex = self.cfg.is_annex()
957 def do(self):
958 filename = self.new_filename
960 if self.staged:
961 images = self.staged_images()
962 elif self.modified:
963 images = self.modified_images()
964 elif self.unmerged:
965 images = self.unmerged_images()
966 elif self.untracked:
967 images = [(filename, False)]
968 else:
969 images = []
971 self.model.set_images(images)
972 super(DiffImage, self).do()
974 def staged_images(self):
975 context = self.context
976 git = self.git
977 head = self.model.head
978 filename = self.new_filename
979 annex = self.annex
981 images = []
982 index = git.diff_index(head, '--', filename, cached=True)[STDOUT]
983 if index:
984 # Example:
985 # :100644 100644 fabadb8... 4866510... M describe.c
986 parts = index.split(' ')
987 if len(parts) > 3:
988 old_oid = parts[2]
989 new_oid = parts[3]
991 if old_oid != MISSING_BLOB_OID:
992 # First, check if we can get a pre-image from git-annex
993 annex_image = None
994 if annex:
995 annex_image = gitcmds.annex_path(context, head, filename)
996 if annex_image:
997 images.append((annex_image, False)) # git annex HEAD
998 else:
999 image = gitcmds.write_blob_path(
1000 context, head, old_oid, filename)
1001 if image:
1002 images.append((image, True))
1004 if new_oid != MISSING_BLOB_OID:
1005 found_in_annex = False
1006 if annex and core.islink(filename):
1007 status, out, _ = git.annex('status', '--', filename)
1008 if status == 0:
1009 details = out.split(' ')
1010 if details and details[0] == 'A': # newly added file
1011 images.append((filename, False))
1012 found_in_annex = True
1014 if not found_in_annex:
1015 image = gitcmds.write_blob(context, new_oid, filename)
1016 if image:
1017 images.append((image, True))
1019 return images
1021 def unmerged_images(self):
1022 context = self.context
1023 git = self.git
1024 head = self.model.head
1025 filename = self.new_filename
1026 annex = self.annex
1028 candidate_merge_heads = ('HEAD', 'CHERRY_HEAD', 'MERGE_HEAD')
1029 merge_heads = [
1030 merge_head for merge_head in candidate_merge_heads
1031 if core.exists(git.git_path(merge_head))]
1033 if annex: # Attempt to find files in git-annex
1034 annex_images = []
1035 for merge_head in merge_heads:
1036 image = gitcmds.annex_path(context, merge_head, filename)
1037 if image:
1038 annex_images.append((image, False))
1039 if annex_images:
1040 annex_images.append((filename, False))
1041 return annex_images
1043 # DIFF FORMAT FOR MERGES
1044 # "git-diff-tree", "git-diff-files" and "git-diff --raw"
1045 # can take -c or --cc option to generate diff output also
1046 # for merge commits. The output differs from the format
1047 # described above in the following way:
1049 # 1. there is a colon for each parent
1050 # 2. there are more "src" modes and "src" sha1
1051 # 3. status is concatenated status characters for each parent
1052 # 4. no optional "score" number
1053 # 5. single path, only for "dst"
1054 # Example:
1055 # ::100644 100644 100644 fabadb8... cc95eb0... 4866510... \
1056 # MM describe.c
1057 images = []
1058 index = git.diff_index(head, '--', filename,
1059 cached=True, cc=True)[STDOUT]
1060 if index:
1061 parts = index.split(' ')
1062 if len(parts) > 3:
1063 first_mode = parts[0]
1064 num_parents = first_mode.count(':')
1065 # colon for each parent, but for the index, the "parents"
1066 # are really entries in stages 1,2,3 (head, base, remote)
1067 # remote, base, head
1068 for i in range(num_parents):
1069 offset = num_parents + i + 1
1070 oid = parts[offset]
1071 try:
1072 merge_head = merge_heads[i]
1073 except IndexError:
1074 merge_head = 'HEAD'
1075 if oid != MISSING_BLOB_OID:
1076 image = gitcmds.write_blob_path(
1077 context, merge_head, oid, filename)
1078 if image:
1079 images.append((image, True))
1081 images.append((filename, False))
1082 return images
1084 def modified_images(self):
1085 context = self.context
1086 git = self.git
1087 head = self.model.head
1088 filename = self.new_filename
1089 annex = self.annex
1091 images = []
1092 annex_image = None
1093 if annex: # Check for a pre-image from git-annex
1094 annex_image = gitcmds.annex_path(context, head, filename)
1095 if annex_image:
1096 images.append((annex_image, False)) # git annex HEAD
1097 else:
1098 worktree = git.diff_files('--', filename)[STDOUT]
1099 parts = worktree.split(' ')
1100 if len(parts) > 3:
1101 oid = parts[2]
1102 if oid != MISSING_BLOB_OID:
1103 image = gitcmds.write_blob_path(
1104 context, head, oid, filename)
1105 if image:
1106 images.append((image, True)) # HEAD
1108 images.append((filename, False)) # worktree
1109 return images
1112 class Diff(EditModel):
1113 """Perform a diff and set the model's current text."""
1115 def __init__(self, context, filename, cached=False, deleted=False):
1116 super(Diff, self).__init__(context)
1117 opts = {}
1118 if cached:
1119 opts['ref'] = self.model.head
1120 self.new_filename = filename
1121 self.new_mode = self.model.mode_worktree
1122 self.new_diff_text = gitcmds.diff_helper(
1123 self.context, filename=filename, cached=cached,
1124 deleted=deleted, **opts)
1125 self.new_diff_type = 'text'
1128 class Diffstat(EditModel):
1129 """Perform a diffstat and set the model's diff text."""
1131 def __init__(self, context):
1132 super(Diffstat, self).__init__(context)
1133 cfg = self.cfg
1134 diff_context = cfg.get('diff.context', 3)
1135 diff = self.git.diff(
1136 self.model.head, unified=diff_context, no_ext_diff=True,
1137 no_color=True, M=True, stat=True)[STDOUT]
1138 self.new_diff_text = diff
1139 self.new_diff_type = 'text'
1140 self.new_mode = self.model.mode_diffstat
1143 class DiffStaged(Diff):
1144 """Perform a staged diff on a file."""
1146 def __init__(self, context, filename, deleted=None):
1147 super(DiffStaged, self).__init__(
1148 context, filename, cached=True, deleted=deleted)
1149 self.new_mode = self.model.mode_index
1152 class DiffStagedSummary(EditModel):
1154 def __init__(self, context):
1155 super(DiffStagedSummary, self).__init__(context)
1156 diff = self.git.diff(
1157 self.model.head, cached=True, no_color=True,
1158 no_ext_diff=True, patch_with_stat=True, M=True)[STDOUT]
1159 self.new_diff_text = diff
1160 self.new_diff_type = 'text'
1161 self.new_mode = self.model.mode_index
1164 class Difftool(ContextCommand):
1165 """Run git-difftool limited by path."""
1167 def __init__(self, context, staged, filenames):
1168 super(Difftool, self).__init__(context)
1169 self.staged = staged
1170 self.filenames = filenames
1172 def do(self):
1173 difftool_launch_with_head(
1174 self.context, self.filenames, self.staged, self.model.head)
1177 class Edit(ContextCommand):
1178 """Edit a file using the configured gui.editor."""
1180 @staticmethod
1181 def name():
1182 return N_('Launch Editor')
1184 def __init__(self, context, filenames,
1185 line_number=None, background_editor=False):
1186 super(Edit, self).__init__(context)
1187 self.filenames = filenames
1188 self.line_number = line_number
1189 self.background_editor = background_editor
1191 def do(self):
1192 context = self.context
1193 if not self.filenames:
1194 return
1195 filename = self.filenames[0]
1196 if not core.exists(filename):
1197 return
1198 if self.background_editor:
1199 editor = prefs.background_editor(context)
1200 else:
1201 editor = prefs.editor(context)
1202 opts = []
1204 if self.line_number is None:
1205 opts = self.filenames
1206 else:
1207 # Single-file w/ line-numbers (likely from grep)
1208 editor_opts = {
1209 '*vim*': [filename, '+%s' % self.line_number],
1210 '*emacs*': ['+%s' % self.line_number, filename],
1211 '*textpad*': ['%s(%s,0)' % (filename, self.line_number)],
1212 '*notepad++*': ['-n%s' % self.line_number, filename],
1213 '*subl*': ['%s:%s' % (filename, self.line_number)],
1216 opts = self.filenames
1217 for pattern, opt in editor_opts.items():
1218 if fnmatch(editor, pattern):
1219 opts = opt
1220 break
1222 try:
1223 core.fork(utils.shell_split(editor) + opts)
1224 except (OSError, ValueError) as e:
1225 message = (N_('Cannot exec "%s": please configure your editor')
1226 % editor)
1227 _, details = utils.format_exception(e)
1228 Interaction.critical(N_('Error Editing File'), message, details)
1231 class FormatPatch(ContextCommand):
1232 """Output a patch series given all revisions and a selected subset."""
1234 def __init__(self, context, to_export, revs, output='patches'):
1235 super(FormatPatch, self).__init__(context)
1236 self.to_export = list(to_export)
1237 self.revs = list(revs)
1238 self.output = output
1240 def do(self):
1241 context = self.context
1242 status, out, err = gitcmds.format_patchsets(
1243 context, self.to_export, self.revs, self.output)
1244 Interaction.log_status(status, out, err)
1247 class LaunchDifftool(ContextCommand):
1249 @staticmethod
1250 def name():
1251 return N_('Launch Diff Tool')
1253 def do(self):
1254 s = self.selection.selection()
1255 if s.unmerged:
1256 paths = s.unmerged
1257 if utils.is_win32():
1258 core.fork(['git', 'mergetool', '--no-prompt', '--'] + paths)
1259 else:
1260 cfg = self.cfg
1261 cmd = cfg.terminal()
1262 argv = utils.shell_split(cmd)
1264 terminal = os.path.basename(argv[0])
1265 shellquote_terms = set(['xfce4-terminal'])
1266 shellquote_default = terminal in shellquote_terms
1268 mergetool = ['git', 'mergetool', '--no-prompt', '--']
1269 mergetool.extend(paths)
1270 needs_shellquote = cfg.get(
1271 'cola.terminalshellquote', shellquote_default)
1273 if needs_shellquote:
1274 argv.append(core.list2cmdline(mergetool))
1275 else:
1276 argv.extend(mergetool)
1278 core.fork(argv)
1279 else:
1280 difftool_run(self.context)
1283 class LaunchTerminal(ContextCommand):
1285 @staticmethod
1286 def name():
1287 return N_('Launch Terminal')
1289 @staticmethod
1290 def is_available(context):
1291 return context.cfg.terminal() is not None
1293 def __init__(self, context, path):
1294 super(LaunchTerminal, self).__init__(context)
1295 self.path = path
1297 def do(self):
1298 cmd = self.context.cfg.terminal()
1299 if cmd is None:
1300 return
1301 if utils.is_win32():
1302 argv = ['start', '', cmd, '--login']
1303 shell = True
1304 else:
1305 argv = utils.shell_split(cmd)
1306 argv.append(os.getenv('SHELL', '/bin/sh'))
1307 shell = False
1308 core.fork(argv, cwd=self.path, shell=shell)
1311 class LaunchEditor(Edit):
1313 @staticmethod
1314 def name():
1315 return N_('Launch Editor')
1317 def __init__(self, context):
1318 s = context.selection.selection()
1319 filenames = s.staged + s.unmerged + s.modified + s.untracked
1320 super(LaunchEditor, self).__init__(
1321 context, filenames, background_editor=True)
1324 class LaunchEditorAtLine(LaunchEditor):
1325 """Launch an editor at the specified line"""
1327 def __init__(self, context):
1328 super(LaunchEditorAtLine, self).__init__(context)
1329 self.line_number = context.selection.line_number
1332 class LoadCommitMessageFromFile(ContextCommand):
1333 """Loads a commit message from a path."""
1334 UNDOABLE = True
1336 def __init__(self, context, path):
1337 super(LoadCommitMessageFromFile, self).__init__(context)
1338 self.path = path
1339 self.old_commitmsg = self.model.commitmsg
1340 self.old_directory = self.model.directory
1342 def do(self):
1343 path = os.path.expanduser(self.path)
1344 if not path or not core.isfile(path):
1345 raise UsageError(N_('Error: Cannot find commit template'),
1346 N_('%s: No such file or directory.') % path)
1347 self.model.set_directory(os.path.dirname(path))
1348 self.model.set_commitmsg(core.read(path))
1350 def undo(self):
1351 self.model.set_commitmsg(self.old_commitmsg)
1352 self.model.set_directory(self.old_directory)
1355 class LoadCommitMessageFromTemplate(LoadCommitMessageFromFile):
1356 """Loads the commit message template specified by commit.template."""
1358 def __init__(self, context):
1359 cfg = context.cfg
1360 template = cfg.get('commit.template')
1361 super(LoadCommitMessageFromTemplate, self).__init__(context, template)
1363 def do(self):
1364 if self.path is None:
1365 raise UsageError(
1366 N_('Error: Unconfigured commit template'),
1367 N_('A commit template has not been configured.\n'
1368 'Use "git config" to define "commit.template"\n'
1369 'so that it points to a commit template.'))
1370 return LoadCommitMessageFromFile.do(self)
1373 class LoadCommitMessageFromOID(ContextCommand):
1374 """Load a previous commit message"""
1375 UNDOABLE = True
1377 def __init__(self, context, oid, prefix=''):
1378 super(LoadCommitMessageFromOID, self).__init__(context)
1379 self.oid = oid
1380 self.old_commitmsg = self.model.commitmsg
1381 self.new_commitmsg = prefix + gitcmds.prev_commitmsg(context, oid)
1383 def do(self):
1384 self.model.set_commitmsg(self.new_commitmsg)
1386 def undo(self):
1387 self.model.set_commitmsg(self.old_commitmsg)
1390 class PrepareCommitMessageHook(ContextCommand):
1391 """Use the cola-prepare-commit-msg hook to prepare the commit message
1393 UNDOABLE = True
1395 def __init__(self, context):
1396 super(PrepareCommitMessageHook, self).__init__(context)
1397 self.old_commitmsg = self.model.commitmsg
1399 def get_message(self):
1401 title = N_('Error running prepare-commitmsg hook')
1402 hook = gitcmds.prepare_commit_message_hook(self.context)
1404 if os.path.exists(hook):
1405 filename = self.model.save_commitmsg()
1406 status, out, err = core.run_command([hook, filename])
1408 if status == 0:
1409 result = core.read(filename)
1410 else:
1411 result = self.old_commitmsg
1412 Interaction.command_error(title, hook, status, out, err)
1413 else:
1414 message = N_('A hook must be provided at "%s"') % hook
1415 Interaction.critical(title, message=message)
1416 result = self.old_commitmsg
1418 return result
1420 def do(self):
1421 msg = self.get_message()
1422 self.model.set_commitmsg(msg)
1424 def undo(self):
1425 self.model.set_commitmsg(self.old_commitmsg)
1428 class LoadFixupMessage(LoadCommitMessageFromOID):
1429 """Load a fixup message"""
1431 def __init__(self, context, oid):
1432 super(LoadFixupMessage, self).__init__(context, oid, prefix='fixup! ')
1433 if self.new_commitmsg:
1434 self.new_commitmsg = self.new_commitmsg.splitlines()[0]
1437 class Merge(ContextCommand):
1438 """Merge commits"""
1440 def __init__(self, context, revision, no_commit, squash, no_ff, sign):
1441 super(Merge, self).__init__(context)
1442 self.revision = revision
1443 self.no_ff = no_ff
1444 self.no_commit = no_commit
1445 self.squash = squash
1446 self.sign = sign
1448 def do(self):
1449 squash = self.squash
1450 revision = self.revision
1451 no_ff = self.no_ff
1452 no_commit = self.no_commit
1453 sign = self.sign
1455 status, out, err = self.git.merge(
1456 revision, gpg_sign=sign, no_ff=no_ff,
1457 no_commit=no_commit, squash=squash)
1458 self.model.update_status()
1459 title = N_('Merge failed. Conflict resolution is required.')
1460 Interaction.command(title, 'git merge', status, out, err)
1462 return status, out, err
1465 class OpenDefaultApp(ContextCommand):
1466 """Open a file using the OS default."""
1468 @staticmethod
1469 def name():
1470 return N_('Open Using Default Application')
1472 def __init__(self, context, filenames):
1473 super(OpenDefaultApp, self).__init__(context)
1474 if utils.is_darwin():
1475 launcher = 'open'
1476 else:
1477 launcher = 'xdg-open'
1478 self.launcher = launcher
1479 self.filenames = filenames
1481 def do(self):
1482 if not self.filenames:
1483 return
1484 core.fork([self.launcher] + self.filenames)
1487 class OpenParentDir(OpenDefaultApp):
1488 """Open parent directories using the OS default."""
1490 @staticmethod
1491 def name():
1492 return N_('Open Parent Directory')
1494 def __init__(self, context, filenames):
1495 OpenDefaultApp.__init__(self, context, filenames)
1497 def do(self):
1498 if not self.filenames:
1499 return
1500 dirnames = list(set([os.path.dirname(x) for x in self.filenames]))
1501 # os.path.dirname() can return an empty string so we fallback to
1502 # the current directory
1503 dirs = [(dirname or core.getcwd()) for dirname in dirnames]
1504 core.fork([self.launcher] + dirs)
1507 class OpenNewRepo(ContextCommand):
1508 """Launches git-cola on a repo."""
1510 def __init__(self, context, repo_path):
1511 super(OpenNewRepo, self).__init__(context)
1512 self.repo_path = repo_path
1514 def do(self):
1515 self.model.set_directory(self.repo_path)
1516 core.fork([sys.executable, sys.argv[0], '--repo', self.repo_path])
1519 class OpenRepo(EditModel):
1521 def __init__(self, context, repo_path):
1522 super(OpenRepo, self).__init__(context)
1523 self.repo_path = repo_path
1524 self.new_mode = self.model.mode_none
1525 self.new_diff_text = ''
1526 self.new_diff_type = 'text'
1527 self.new_commitmsg = ''
1528 self.new_filename = ''
1530 def do(self):
1531 old_repo = self.git.getcwd()
1532 if self.model.set_worktree(self.repo_path):
1533 self.fsmonitor.stop()
1534 self.fsmonitor.start()
1535 self.model.update_status()
1536 self.model.set_commitmsg(self.new_commitmsg)
1537 super(OpenRepo, self).do()
1538 else:
1539 self.model.set_worktree(old_repo)
1542 class OpenParentRepo(OpenRepo):
1544 def __init__(self, context):
1545 path = ''
1546 if version.check_git(context, 'show-superproject-working-tree'):
1547 status, out, _ = context.git.rev_parse(
1548 show_superproject_working_tree=True)
1549 if status == 0:
1550 path = out
1551 if not path:
1552 path = os.path.dirname(core.getcwd())
1553 super(OpenParentRepo, self).__init__(context, path)
1556 class Clone(ContextCommand):
1557 """Clones a repository and optionally spawns a new cola session."""
1559 def __init__(self, context, url, new_directory,
1560 submodules=False, shallow=False, spawn=True):
1561 super(Clone, self).__init__(context)
1562 self.url = url
1563 self.new_directory = new_directory
1564 self.submodules = submodules
1565 self.shallow = shallow
1566 self.spawn = spawn
1567 self.status = -1
1568 self.out = ''
1569 self.err = ''
1571 def do(self):
1572 kwargs = {}
1573 if self.shallow:
1574 kwargs['depth'] = 1
1575 recurse_submodules = self.submodules
1576 shallow_submodules = self.submodules and self.shallow
1578 status, out, err = self.git.clone(
1579 self.url, self.new_directory,
1580 recurse_submodules=recurse_submodules,
1581 shallow_submodules=shallow_submodules,
1582 **kwargs)
1584 self.status = status
1585 self.out = out
1586 self.err = err
1587 if status == 0 and self.spawn:
1588 executable = sys.executable
1589 core.fork([executable, sys.argv[0], '--repo', self.new_directory])
1590 return self
1593 class NewBareRepo(ContextCommand):
1594 """Create a new shared bare repository"""
1596 def __init__(self, context, path):
1597 super(NewBareRepo, self).__init__(context)
1598 self.path = path
1600 def do(self):
1601 path = self.path
1602 status, out, err = self.git.init(path, bare=True, shared=True)
1603 Interaction.command(
1604 N_('Error'), 'git init --bare --shared "%s"' % path,
1605 status, out, err)
1606 return status == 0
1609 def unix_path(path, is_win32=utils.is_win32):
1610 """Git for Windows requires unix paths, so force them here
1612 if is_win32():
1613 path = path.replace('\\', '/')
1614 first = path[0]
1615 second = path[1]
1616 if second == ':': # sanity check, this better be a Windows-style path
1617 path = '/' + first + path[2:]
1619 return path
1622 def sequence_editor():
1623 """Return a GIT_SEQUENCE_EDITOR environment value that enables git-xbase"""
1624 xbase = unix_path(resources.share('bin', 'git-xbase'))
1625 editor = core.list2cmdline([unix_path(sys.executable), xbase])
1626 return editor
1629 class GitXBaseContext(object):
1631 def __init__(self, context, **kwargs):
1632 self.env = {
1633 'GIT_EDITOR': prefs.editor(context),
1634 'GIT_SEQUENCE_EDITOR': sequence_editor(),
1635 'GIT_XBASE_CANCEL_ACTION': 'save',
1637 self.env.update(kwargs)
1639 def __enter__(self):
1640 for var, value in self.env.items():
1641 compat.setenv(var, value)
1642 return self
1644 def __exit__(self, exc_type, exc_val, exc_tb):
1645 for var in self.env:
1646 compat.unsetenv(var)
1649 class Rebase(ContextCommand):
1651 def __init__(self, context, upstream=None, branch=None, **kwargs):
1652 """Start an interactive rebase session
1654 :param upstream: upstream branch
1655 :param branch: optional branch to checkout
1656 :param kwargs: forwarded directly to `git.rebase()`
1659 super(Rebase, self).__init__(context)
1661 self.upstream = upstream
1662 self.branch = branch
1663 self.kwargs = kwargs
1665 def prepare_arguments(self, upstream):
1666 args = []
1667 kwargs = {}
1669 # Rebase actions must be the only option specified
1670 for action in ('continue', 'abort', 'skip', 'edit_todo'):
1671 if self.kwargs.get(action, False):
1672 kwargs[action] = self.kwargs[action]
1673 return args, kwargs
1675 kwargs['interactive'] = True
1676 kwargs['autosquash'] = self.kwargs.get('autosquash', True)
1677 kwargs.update(self.kwargs)
1679 if upstream:
1680 args.append(upstream)
1681 if self.branch:
1682 args.append(self.branch)
1684 return args, kwargs
1686 def do(self):
1687 (status, out, err) = (1, '', '')
1688 context = self.context
1689 cfg = self.cfg
1690 model = self.model
1692 if not cfg.get('rebase.autostash', False):
1693 if model.staged or model.unmerged or model.modified:
1694 Interaction.information(
1695 N_('Unable to rebase'),
1696 N_('You cannot rebase with uncommitted changes.'))
1697 return status, out, err
1699 upstream = self.upstream or Interaction.choose_ref(
1700 context, N_('Select New Upstream'), N_('Interactive Rebase'),
1701 default='@{upstream}')
1702 if not upstream:
1703 return status, out, err
1705 self.model.is_rebasing = True
1706 self.model.emit_updated()
1708 args, kwargs = self.prepare_arguments(upstream)
1709 upstream_title = upstream or '@{upstream}'
1710 with GitXBaseContext(
1711 self.context,
1712 GIT_XBASE_TITLE=N_('Rebase onto %s') % upstream_title,
1713 GIT_XBASE_ACTION=N_('Rebase')
1715 # TODO this blocks the user interface window for the duration
1716 # of git-xbase's invocation. We would need to implement
1717 # signals for QProcess and continue running the main thread.
1718 # alternatively we could hide the main window while rebasing.
1719 # that doesn't require as much effort.
1720 status, out, err = self.git.rebase(
1721 *args, _no_win32_startupinfo=True, **kwargs)
1722 self.model.update_status()
1723 if err.strip() != 'Nothing to do':
1724 title = N_('Rebase stopped')
1725 Interaction.command(title, 'git rebase', status, out, err)
1726 return status, out, err
1729 class RebaseEditTodo(ContextCommand):
1731 def do(self):
1732 (status, out, err) = (1, '', '')
1733 with GitXBaseContext(
1734 self.context,
1735 GIT_XBASE_TITLE=N_('Edit Rebase'),
1736 GIT_XBASE_ACTION=N_('Save')
1738 status, out, err = self.git.rebase(edit_todo=True)
1739 Interaction.log_status(status, out, err)
1740 self.model.update_status()
1741 return status, out, err
1744 class RebaseContinue(ContextCommand):
1746 def do(self):
1747 (status, out, err) = (1, '', '')
1748 with GitXBaseContext(
1749 self.context,
1750 GIT_XBASE_TITLE=N_('Rebase'),
1751 GIT_XBASE_ACTION=N_('Rebase')
1753 status, out, err = self.git.rebase('--continue')
1754 Interaction.log_status(status, out, err)
1755 self.model.update_status()
1756 return status, out, err
1759 class RebaseSkip(ContextCommand):
1761 def do(self):
1762 (status, out, err) = (1, '', '')
1763 with GitXBaseContext(
1764 self.context,
1765 GIT_XBASE_TITLE=N_('Rebase'),
1766 GIT_XBASE_ACTION=N_('Rebase')
1768 status, out, err = self.git.rebase(skip=True)
1769 Interaction.log_status(status, out, err)
1770 self.model.update_status()
1771 return status, out, err
1774 class RebaseAbort(ContextCommand):
1776 def do(self):
1777 status, out, err = self.git.rebase(abort=True)
1778 Interaction.log_status(status, out, err)
1779 self.model.update_status()
1782 class Rescan(ContextCommand):
1783 """Rescan for changes"""
1785 def do(self):
1786 self.model.update_status()
1789 class Refresh(ContextCommand):
1790 """Update refs, refresh the index, and update config"""
1792 @staticmethod
1793 def name():
1794 return N_('Refresh')
1796 def do(self):
1797 self.model.update_status(update_index=True)
1798 self.cfg.update()
1799 self.fsmonitor.refresh()
1802 class RefreshConfig(ContextCommand):
1803 """Refresh the git config cache"""
1805 def do(self):
1806 self.cfg.update()
1809 class RevertEditsCommand(ConfirmAction):
1811 def __init__(self, context):
1812 super(RevertEditsCommand, self).__init__(context)
1813 self.icon = icons.undo()
1815 def ok_to_run(self):
1816 return self.model.undoable()
1818 # pylint: disable=no-self-use
1819 def checkout_from_head(self):
1820 return False
1822 def checkout_args(self):
1823 args = []
1824 s = self.selection.selection()
1825 if self.checkout_from_head():
1826 args.append(self.model.head)
1827 args.append('--')
1829 if s.staged:
1830 items = s.staged
1831 else:
1832 items = s.modified
1833 args.extend(items)
1835 return args
1837 def action(self):
1838 checkout_args = self.checkout_args()
1839 return self.git.checkout(*checkout_args)
1841 def success(self):
1842 self.model.update_file_status()
1845 class RevertUnstagedEdits(RevertEditsCommand):
1847 @staticmethod
1848 def name():
1849 return N_('Revert Unstaged Edits...')
1851 def checkout_from_head(self):
1852 # Being in amend mode should not affect the behavior of this command.
1853 # The only sensible thing to do is to checkout from the index.
1854 return False
1856 def confirm(self):
1857 title = N_('Revert Unstaged Changes?')
1858 text = N_(
1859 'This operation removes unstaged edits from selected files.\n'
1860 'These changes cannot be recovered.')
1861 info = N_('Revert the unstaged changes?')
1862 ok_text = N_('Revert Unstaged Changes')
1863 return Interaction.confirm(title, text, info, ok_text,
1864 default=True, icon=self.icon)
1867 class RevertUncommittedEdits(RevertEditsCommand):
1869 @staticmethod
1870 def name():
1871 return N_('Revert Uncommitted Edits...')
1873 def checkout_from_head(self):
1874 return True
1876 def confirm(self):
1877 """Prompt for reverting changes"""
1878 title = N_('Revert Uncommitted Changes?')
1879 text = N_(
1880 'This operation removes uncommitted edits from selected files.\n'
1881 'These changes cannot be recovered.')
1882 info = N_('Revert the uncommitted changes?')
1883 ok_text = N_('Revert Uncommitted Changes')
1884 return Interaction.confirm(title, text, info, ok_text,
1885 default=True, icon=self.icon)
1888 class RunConfigAction(ContextCommand):
1889 """Run a user-configured action, typically from the "Tools" menu"""
1891 def __init__(self, context, action_name):
1892 super(RunConfigAction, self).__init__(context)
1893 self.action_name = action_name
1895 def do(self):
1896 """Run the user-configured action"""
1897 for env in ('ARGS', 'DIRNAME', 'FILENAME', 'REVISION'):
1898 try:
1899 compat.unsetenv(env)
1900 except KeyError:
1901 pass
1902 rev = None
1903 args = None
1904 context = self.context
1905 cfg = self.cfg
1906 opts = cfg.get_guitool_opts(self.action_name)
1907 cmd = opts.get('cmd')
1908 if 'title' not in opts:
1909 opts['title'] = cmd
1911 if 'prompt' not in opts or opts.get('prompt') is True:
1912 prompt = N_('Run "%s"?') % cmd
1913 opts['prompt'] = prompt
1915 if opts.get('needsfile'):
1916 filename = self.selection.filename()
1917 if not filename:
1918 Interaction.information(
1919 N_('Please select a file'),
1920 N_('"%s" requires a selected file.') % cmd)
1921 return False
1922 dirname = utils.dirname(filename, current_dir='.')
1923 compat.setenv('FILENAME', filename)
1924 compat.setenv('DIRNAME', dirname)
1926 if opts.get('revprompt') or opts.get('argprompt'):
1927 while True:
1928 ok = Interaction.confirm_config_action(context, cmd, opts)
1929 if not ok:
1930 return False
1931 rev = opts.get('revision')
1932 args = opts.get('args')
1933 if opts.get('revprompt') and not rev:
1934 title = N_('Invalid Revision')
1935 msg = N_('The revision expression cannot be empty.')
1936 Interaction.critical(title, msg)
1937 continue
1938 break
1940 elif opts.get('confirm'):
1941 title = os.path.expandvars(opts.get('title'))
1942 prompt = os.path.expandvars(opts.get('prompt'))
1943 if not Interaction.question(title, prompt):
1944 return False
1945 if rev:
1946 compat.setenv('REVISION', rev)
1947 if args:
1948 compat.setenv('ARGS', args)
1949 title = os.path.expandvars(cmd)
1950 Interaction.log(N_('Running command: %s') % title)
1951 cmd = ['sh', '-c', cmd]
1953 if opts.get('background'):
1954 core.fork(cmd)
1955 status, out, err = (0, '', '')
1956 elif opts.get('noconsole'):
1957 status, out, err = core.run_command(cmd)
1958 else:
1959 status, out, err = Interaction.run_command(title, cmd)
1961 if not opts.get('background') and not opts.get('norescan'):
1962 self.model.update_status()
1964 title = N_('Error')
1965 Interaction.command(title, cmd, status, out, err)
1967 return status == 0
1970 class SetDefaultRepo(ContextCommand):
1971 """Set the default repository"""
1973 def __init__(self, context, repo):
1974 super(SetDefaultRepo, self).__init__(context)
1975 self.repo = repo
1977 def do(self):
1978 self.cfg.set_user('cola.defaultrepo', self.repo)
1981 class SetDiffText(EditModel):
1982 """Set the diff text"""
1983 UNDOABLE = True
1985 def __init__(self, context, text):
1986 super(SetDiffText, self).__init__(context)
1987 self.new_diff_text = text
1988 self.new_diff_type = 'text'
1991 class SetUpstreamBranch(ContextCommand):
1992 """Set the upstream branch"""
1994 def __init__(self, context, branch, remote, remote_branch):
1995 super(SetUpstreamBranch, self).__init__(context)
1996 self.branch = branch
1997 self.remote = remote
1998 self.remote_branch = remote_branch
2000 def do(self):
2001 cfg = self.cfg
2002 remote = self.remote
2003 branch = self.branch
2004 remote_branch = self.remote_branch
2005 cfg.set_repo('branch.%s.remote' % branch, remote)
2006 cfg.set_repo('branch.%s.merge' % branch, 'refs/heads/' + remote_branch)
2009 class ShowUntracked(EditModel):
2010 """Show an untracked file."""
2012 def __init__(self, context, filename):
2013 super(ShowUntracked, self).__init__(context)
2014 self.new_filename = filename
2015 self.new_mode = self.model.mode_untracked
2016 self.new_diff_text = self.read(filename)
2017 self.new_diff_type = 'text'
2019 def read(self, filename):
2020 """Read file contents"""
2021 cfg = self.cfg
2022 size = cfg.get('cola.readsize', 2048)
2023 try:
2024 result = core.read(filename, size=size,
2025 encoding=core.ENCODING, errors='ignore')
2026 except (IOError, OSError):
2027 result = ''
2029 if len(result) == size:
2030 result += '...'
2031 return result
2034 class SignOff(ContextCommand):
2035 """Append a signoff to the commit message"""
2036 UNDOABLE = True
2038 @staticmethod
2039 def name():
2040 return N_('Sign Off')
2042 def __init__(self, context):
2043 super(SignOff, self).__init__(context)
2044 self.old_commitmsg = self.model.commitmsg
2046 def do(self):
2047 """Add a signoff to the commit message"""
2048 signoff = self.signoff()
2049 if signoff in self.model.commitmsg:
2050 return
2051 msg = self.model.commitmsg.rstrip()
2052 self.model.set_commitmsg(msg + '\n' + signoff)
2054 def undo(self):
2055 """Restore the commit message"""
2056 self.model.set_commitmsg(self.old_commitmsg)
2058 def signoff(self):
2059 """Generate the signoff string"""
2060 try:
2061 import pwd
2062 user = pwd.getpwuid(os.getuid()).pw_name
2063 except ImportError:
2064 user = os.getenv('USER', N_('unknown'))
2066 cfg = self.cfg
2067 name = cfg.get('user.name', user)
2068 email = cfg.get('user.email', '%s@%s' % (user, core.node()))
2069 return '\nSigned-off-by: %s <%s>' % (name, email)
2072 def check_conflicts(context, unmerged):
2073 """Check paths for conflicts
2075 Conflicting files can be filtered out one-by-one.
2078 if prefs.check_conflicts(context):
2079 unmerged = [path for path in unmerged if is_conflict_free(path)]
2080 return unmerged
2083 def is_conflict_free(path):
2084 """Return True if `path` contains no conflict markers
2086 rgx = re.compile(r'^(<<<<<<<|\|\|\|\|\|\|\||>>>>>>>) ')
2087 try:
2088 with core.xopen(path, 'r') as f:
2089 for line in f:
2090 line = core.decode(line, errors='ignore')
2091 if rgx.match(line):
2092 return should_stage_conflicts(path)
2093 except IOError:
2094 # We can't read this file ~ we may be staging a removal
2095 pass
2096 return True
2099 def should_stage_conflicts(path):
2100 """Inform the user that a file contains merge conflicts
2102 Return `True` if we should stage the path nonetheless.
2105 title = msg = N_('Stage conflicts?')
2106 info = N_('%s appears to contain merge conflicts.\n\n'
2107 'You should probably skip this file.\n'
2108 'Stage it anyways?') % path
2109 ok_text = N_('Stage conflicts')
2110 cancel_text = N_('Skip')
2111 return Interaction.confirm(title, msg, info, ok_text,
2112 default=False, cancel_text=cancel_text)
2115 class Stage(ContextCommand):
2116 """Stage a set of paths."""
2118 @staticmethod
2119 def name():
2120 return N_('Stage')
2122 def __init__(self, context, paths):
2123 super(Stage, self).__init__(context)
2124 self.paths = paths
2126 def do(self):
2127 msg = N_('Staging: %s') % (', '.join(self.paths))
2128 Interaction.log(msg)
2129 return self.stage_paths()
2131 def stage_paths(self):
2132 """Stages add/removals to git."""
2133 context = self.context
2134 paths = self.paths
2135 if not paths:
2136 if self.model.cfg.get('cola.safemode', False):
2137 return (0, '', '')
2138 return self.stage_all()
2140 add = []
2141 remove = []
2143 for path in set(paths):
2144 if core.exists(path) or core.islink(path):
2145 if path.endswith('/'):
2146 path = path.rstrip('/')
2147 add.append(path)
2148 else:
2149 remove.append(path)
2151 self.model.emit_about_to_update()
2153 # `git add -u` doesn't work on untracked files
2154 if add:
2155 status, out, err = gitcmds.add(context, add)
2156 Interaction.command(N_('Error'), 'git add', status, out, err)
2158 # If a path doesn't exist then that means it should be removed
2159 # from the index. We use `git add -u` for that.
2160 if remove:
2161 status, out, err = gitcmds.add(context, remove, u=True)
2162 Interaction.command(N_('Error'), 'git add -u', status, out, err)
2164 self.model.update_files(emit=True)
2165 return status, out, err
2167 def stage_all(self):
2168 """Stage all files"""
2169 status, out, err = self.git.add(v=True, u=True)
2170 Interaction.command(N_('Error'), 'git add -u', status, out, err)
2171 self.model.update_file_status()
2172 return (status, out, err)
2175 class StageCarefully(Stage):
2176 """Only stage when the path list is non-empty
2178 We use "git add -u -- <pathspec>" to stage, and it stages everything by
2179 default when no pathspec is specified, so this class ensures that paths
2180 are specified before calling git.
2182 When no paths are specified, the command does nothing.
2185 def __init__(self, context):
2186 super(StageCarefully, self).__init__(context, None)
2187 self.init_paths()
2189 # pylint: disable=no-self-use
2190 def init_paths(self):
2191 """Initialize path data"""
2192 return
2194 def ok_to_run(self):
2195 """Prevent catch-all "git add -u" from adding unmerged files"""
2196 return self.paths or not self.model.unmerged
2198 def do(self):
2199 """Stage files when ok_to_run() return True"""
2200 if self.ok_to_run():
2201 return super(StageCarefully, self).do()
2202 return (0, '', '')
2205 class StageModified(StageCarefully):
2206 """Stage all modified files."""
2208 @staticmethod
2209 def name():
2210 return N_('Stage Modified')
2212 def init_paths(self):
2213 self.paths = self.model.modified
2216 class StageUnmerged(StageCarefully):
2217 """Stage unmerged files."""
2219 @staticmethod
2220 def name():
2221 return N_('Stage Unmerged')
2223 def init_paths(self):
2224 self.paths = check_conflicts(self.context, self.model.unmerged)
2227 class StageUntracked(StageCarefully):
2228 """Stage all untracked files."""
2230 @staticmethod
2231 def name():
2232 return N_('Stage Untracked')
2234 def init_paths(self):
2235 self.paths = self.model.untracked
2238 class StageOrUnstage(ContextCommand):
2239 """If the selection is staged, unstage it, otherwise stage"""
2241 @staticmethod
2242 def name():
2243 return N_('Stage / Unstage')
2245 def do(self):
2246 s = self.selection.selection()
2247 if s.staged:
2248 do(Unstage, self.context, s.staged)
2250 unstaged = []
2251 unmerged = check_conflicts(self.context, s.unmerged)
2252 if unmerged:
2253 unstaged.extend(unmerged)
2254 if s.modified:
2255 unstaged.extend(s.modified)
2256 if s.untracked:
2257 unstaged.extend(s.untracked)
2258 if unstaged:
2259 do(Stage, self.context, unstaged)
2262 class Tag(ContextCommand):
2263 """Create a tag object."""
2265 def __init__(self, context, name, revision, sign=False, message=''):
2266 super(Tag, self).__init__(context)
2267 self._name = name
2268 self._message = message
2269 self._revision = revision
2270 self._sign = sign
2272 def do(self):
2273 result = False
2274 git = self.git
2275 revision = self._revision
2276 tag_name = self._name
2277 tag_message = self._message
2279 if not revision:
2280 Interaction.critical(
2281 N_('Missing Revision'),
2282 N_('Please specify a revision to tag.'))
2283 return result
2285 if not tag_name:
2286 Interaction.critical(
2287 N_('Missing Name'),
2288 N_('Please specify a name for the new tag.'))
2289 return result
2291 title = N_('Missing Tag Message')
2292 message = N_('Tag-signing was requested but the tag message is empty.')
2293 info = N_('An unsigned, lightweight tag will be created instead.\n'
2294 'Create an unsigned tag?')
2295 ok_text = N_('Create Unsigned Tag')
2296 sign = self._sign
2297 if sign and not tag_message:
2298 # We require a message in order to sign the tag, so if they
2299 # choose to create an unsigned tag we have to clear the sign flag.
2300 if not Interaction.confirm(title, message, info, ok_text,
2301 default=False, icon=icons.save()):
2302 return result
2303 sign = False
2305 opts = {}
2306 tmp_file = None
2307 try:
2308 if tag_message:
2309 tmp_file = utils.tmp_filename('tag-message')
2310 opts['file'] = tmp_file
2311 core.write(tmp_file, tag_message)
2313 if sign:
2314 opts['sign'] = True
2315 if tag_message:
2316 opts['annotate'] = True
2317 status, out, err = git.tag(tag_name, revision, **opts)
2318 finally:
2319 if tmp_file:
2320 core.unlink(tmp_file)
2322 title = N_('Error: could not create tag "%s"') % tag_name
2323 Interaction.command(title, 'git tag', status, out, err)
2325 if status == 0:
2326 result = True
2327 self.model.update_status()
2328 Interaction.information(
2329 N_('Tag Created'),
2330 N_('Created a new tag named "%s"') % tag_name,
2331 details=tag_message or None)
2333 return result
2336 class Unstage(ContextCommand):
2337 """Unstage a set of paths."""
2339 @staticmethod
2340 def name():
2341 return N_('Unstage')
2343 def __init__(self, context, paths):
2344 super(Unstage, self).__init__(context)
2345 self.paths = paths
2347 def do(self):
2348 """Unstage paths"""
2349 context = self.context
2350 head = self.model.head
2351 paths = self.paths
2353 msg = N_('Unstaging: %s') % (', '.join(paths))
2354 Interaction.log(msg)
2355 if not paths:
2356 return unstage_all(context)
2357 status, out, err = gitcmds.unstage_paths(context, paths, head=head)
2358 Interaction.command(N_('Error'), 'git reset', status, out, err)
2359 self.model.update_file_status()
2360 return (status, out, err)
2363 class UnstageAll(ContextCommand):
2364 """Unstage all files; resets the index."""
2366 def do(self):
2367 return unstage_all(self.context)
2370 def unstage_all(context):
2371 """Unstage all files, even while amending"""
2372 model = context.model
2373 git = context.git
2374 head = model.head
2375 status, out, err = git.reset(head, '--', '.')
2376 Interaction.command(N_('Error'), 'git reset', status, out, err)
2377 model.update_file_status()
2378 return (status, out, err)
2381 class StageSelected(ContextCommand):
2382 """Stage selected files, or all files if no selection exists."""
2384 def do(self):
2385 context = self.context
2386 paths = self.selection.unstaged
2387 if paths:
2388 do(Stage, context, paths)
2389 elif self.cfg.get('cola.safemode', False):
2390 do(StageModified, context)
2393 class UnstageSelected(Unstage):
2394 """Unstage selected files."""
2396 def __init__(self, context):
2397 staged = self.selection.staged
2398 super(UnstageSelected, self).__init__(context, staged)
2401 class Untrack(ContextCommand):
2402 """Unstage a set of paths."""
2404 def __init__(self, context, paths):
2405 super(Untrack, self).__init__(context)
2406 self.paths = paths
2408 def do(self):
2409 msg = N_('Untracking: %s') % (', '.join(self.paths))
2410 Interaction.log(msg)
2411 status, out, err = self.model.untrack_paths(self.paths)
2412 Interaction.log_status(status, out, err)
2415 class UntrackedSummary(EditModel):
2416 """List possible .gitignore rules as the diff text."""
2418 def __init__(self, context):
2419 super(UntrackedSummary, self).__init__(context)
2420 untracked = self.model.untracked
2421 suffix = 's' if untracked else ''
2422 io = StringIO()
2423 io.write('# %s untracked file%s\n' % (len(untracked), suffix))
2424 if untracked:
2425 io.write('# possible .gitignore rule%s:\n' % suffix)
2426 for u in untracked:
2427 io.write('/'+u+'\n')
2428 self.new_diff_text = io.getvalue()
2429 self.new_diff_type = 'text'
2430 self.new_mode = self.model.mode_untracked
2433 class VisualizeAll(ContextCommand):
2434 """Visualize all branches."""
2436 def do(self):
2437 context = self.context
2438 browser = utils.shell_split(prefs.history_browser(context))
2439 launch_history_browser(browser + ['--all'])
2442 class VisualizeCurrent(ContextCommand):
2443 """Visualize all branches."""
2445 def do(self):
2446 context = self.context
2447 browser = utils.shell_split(prefs.history_browser(context))
2448 launch_history_browser(browser + [self.model.currentbranch] + ['--'])
2451 class VisualizePaths(ContextCommand):
2452 """Path-limited visualization."""
2454 def __init__(self, context, paths):
2455 super(VisualizePaths, self).__init__(context)
2456 context = self.context
2457 browser = utils.shell_split(prefs.history_browser(context))
2458 if paths:
2459 self.argv = browser + ['--'] + list(paths)
2460 else:
2461 self.argv = browser
2463 def do(self):
2464 launch_history_browser(self.argv)
2467 class VisualizeRevision(ContextCommand):
2468 """Visualize a specific revision."""
2470 def __init__(self, context, revision, paths=None):
2471 super(VisualizeRevision, self).__init__(context)
2472 self.revision = revision
2473 self.paths = paths
2475 def do(self):
2476 context = self.context
2477 argv = utils.shell_split(prefs.history_browser(context))
2478 if self.revision:
2479 argv.append(self.revision)
2480 if self.paths:
2481 argv.append('--')
2482 argv.extend(self.paths)
2483 launch_history_browser(argv)
2486 class SubmoduleUpdate(ConfirmAction):
2487 """Update specified submodule"""
2489 def __init__(self, context, path):
2490 super(SubmoduleUpdate, self).__init__(context)
2491 self.path = path
2493 def confirm(self):
2494 title = N_('Update Submodule...')
2495 question = N_('Update this submodule?')
2496 info = N_('The submodule will be updated using\n'
2497 '"%s"' % self.command())
2498 ok_txt = N_('Update Submodule')
2499 return Interaction.confirm(title, question, info, ok_txt,
2500 default=False, icon=icons.pull())
2502 def action(self):
2503 context = self.context
2504 return context.git.submodule('update', '--', self.path)
2506 def success(self):
2507 self.model.update_file_status()
2509 def error_message(self):
2510 return N_('Error updating submodule %s' % self.path)
2512 def command(self):
2513 command = 'git submodule update -- %s'
2514 return command % self.path
2517 class SubmodulesUpdate(ConfirmAction):
2518 """Update all submodules"""
2520 def confirm(self):
2521 title = N_('Update submodules...')
2522 question = N_('Update all submodules?')
2523 info = N_('All submodules will be updated using\n'
2524 '"%s"' % self.command())
2525 ok_txt = N_('Update Submodules')
2526 return Interaction.confirm(title, question, info, ok_txt,
2527 default=False, icon=icons.pull())
2529 def action(self):
2530 context = self.context
2531 return context.git.submodule('update')
2533 def success(self):
2534 self.model.update_file_status()
2536 def error_message(self):
2537 return N_('Error updating submodules')
2539 def command(self):
2540 return 'git submodule update'
2543 def launch_history_browser(argv):
2544 """Launch the configured history browser"""
2545 try:
2546 core.fork(argv)
2547 except OSError as e:
2548 _, details = utils.format_exception(e)
2549 title = N_('Error Launching History Browser')
2550 msg = (N_('Cannot exec "%s": please configure a history browser') %
2551 ' '.join(argv))
2552 Interaction.critical(title, message=msg, details=details)
2555 def run(cls, *args, **opts):
2557 Returns a callback that runs a command
2559 If the caller of run() provides args or opts then those are
2560 used instead of the ones provided by the invoker of the callback.
2563 def runner(*local_args, **local_opts):
2564 """Closure return by run() which runs the command"""
2565 if args or opts:
2566 do(cls, *args, **opts)
2567 else:
2568 do(cls, *local_args, **local_opts)
2570 return runner
2573 def do(cls, *args, **opts):
2574 """Run a command in-place"""
2575 try:
2576 cmd = cls(*args, **opts)
2577 return cmd.do()
2578 except Exception as e: # pylint: disable=broad-except
2579 msg, details = utils.format_exception(e)
2580 if hasattr(cls, '__name__'):
2581 msg = ('%s exception:\n%s' % (cls.__name__, msg))
2582 Interaction.critical(N_('Error'), message=msg, details=details)
2583 return None
2586 def difftool_run(context):
2587 """Start a default difftool session"""
2588 selection = context.selection
2589 files = selection.group()
2590 if not files:
2591 return
2592 s = selection.selection()
2593 head = context.model.head
2594 difftool_launch_with_head(context, files, bool(s.staged), head)
2597 def difftool_launch_with_head(context, filenames, staged, head):
2598 """Launch difftool against the provided head"""
2599 if head == 'HEAD':
2600 left = None
2601 else:
2602 left = head
2603 difftool_launch(context, left=left, staged=staged, paths=filenames)
2606 def difftool_launch(context, left=None, right=None, paths=None,
2607 staged=False, dir_diff=False,
2608 left_take_magic=False, left_take_parent=False):
2609 """Launches 'git difftool' with given parameters
2611 :param left: first argument to difftool
2612 :param right: second argument to difftool_args
2613 :param paths: paths to diff
2614 :param staged: activate `git difftool --staged`
2615 :param dir_diff: activate `git difftool --dir-diff`
2616 :param left_take_magic: whether to append the magic ^! diff expression
2617 :param left_take_parent: whether to append the first-parent ~ for diffing
2621 difftool_args = ['git', 'difftool', '--no-prompt']
2622 if staged:
2623 difftool_args.append('--cached')
2624 if dir_diff:
2625 difftool_args.append('--dir-diff')
2627 if left:
2628 if left_take_parent or left_take_magic:
2629 suffix = '^!' if left_take_magic else '~'
2630 # Check root commit (no parents and thus cannot execute '~')
2631 git = context.git
2632 status, out, err = git.rev_list(left, parents=True, n=1)
2633 Interaction.log_status(status, out, err)
2634 if status:
2635 raise OSError('git rev-list command failed')
2637 if len(out.split()) >= 2:
2638 # Commit has a parent, so we can take its child as requested
2639 left += suffix
2640 else:
2641 # No parent, assume it's the root commit, so we have to diff
2642 # against the empty tree.
2643 left = EMPTY_TREE_OID
2644 if not right and left_take_magic:
2645 right = left
2646 difftool_args.append(left)
2648 if right:
2649 difftool_args.append(right)
2651 if paths:
2652 difftool_args.append('--')
2653 difftool_args.extend(paths)
2655 runtask = context.runtask
2656 if runtask:
2657 Interaction.async_command(N_('Difftool'), difftool_args, runtask)
2658 else:
2659 core.fork(difftool_args)