doc: document QT_AUTO_SCREEN_SCALE_FACTORS for High-DPI displays
[git-cola.git] / cola / cmds.py
bloba85d86d827ba486303ba43d9ea6e08281137811c
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 .cmd import ContextCommand
22 from .diffparse import DiffParser
23 from .git import STDOUT
24 from .git import EMPTY_TREE_OID
25 from .git import MISSING_BLOB_OID
26 from .i18n import N_
27 from .interaction import Interaction
28 from .models import prefs
31 class UsageError(Exception):
32 """Exception class for usage errors."""
34 def __init__(self, title, message):
35 Exception.__init__(self, message)
36 self.title = title
37 self.msg = message
40 class EditModel(ContextCommand):
41 """Commands that mutate the main model diff data"""
42 UNDOABLE = True
44 def __init__(self, context):
45 """Common edit operations on the main model"""
46 super(EditModel, self).__init__(context)
48 self.old_diff_text = self.model.diff_text
49 self.old_filename = self.model.filename
50 self.old_mode = self.model.mode
51 self.old_diff_type = self.model.diff_type
53 self.new_diff_text = self.old_diff_text
54 self.new_filename = self.old_filename
55 self.new_mode = self.old_mode
56 self.new_diff_type = self.old_diff_type
58 def do(self):
59 """Perform the operation."""
60 self.model.set_filename(self.new_filename)
61 self.model.set_mode(self.new_mode)
62 self.model.set_diff_text(self.new_diff_text)
63 self.model.set_diff_type(self.new_diff_type)
65 def undo(self):
66 """Undo the operation."""
67 self.model.set_filename(self.old_filename)
68 self.model.set_mode(self.old_mode)
69 self.model.set_diff_text(self.old_diff_text)
70 self.model.set_diff_type(self.old_diff_type)
73 class ConfirmAction(ContextCommand):
74 """Confirm an action before running it"""
76 # pylint: disable=no-self-use
77 def ok_to_run(self):
78 """Return True when the command is ok to run"""
79 return True
81 # pylint: disable=no-self-use
82 def confirm(self):
83 """Prompt for confirmation"""
84 return True
86 # pylint: disable=no-self-use
87 def action(self):
88 """Run the command and return (status, out, err)"""
89 return (-1, '', '')
91 # pylint: disable=no-self-use
92 def success(self):
93 """Callback run on success"""
94 return
96 # pylint: disable=no-self-use
97 def command(self):
98 """Command name, for error messages"""
99 return 'git'
101 # pylint: disable=no-self-use
102 def error_message(self):
103 """Command error message"""
104 return ''
106 def do(self):
107 """Prompt for confirmation before running a command"""
108 status = -1
109 out = err = ''
110 ok = self.ok_to_run() and self.confirm()
111 if ok:
112 status, out, err = self.action()
113 if status == 0:
114 self.success()
115 title = self.error_message()
116 cmd = self.command()
117 Interaction.command(title, cmd, status, out, err)
119 return ok, status, out, err
122 class AbortMerge(ConfirmAction):
123 """Reset an in-progress merge back to HEAD"""
125 def confirm(self):
126 title = N_('Abort Merge...')
127 question = N_('Aborting the current merge?')
128 info = N_('Aborting the current merge will cause '
129 '*ALL* uncommitted changes to be lost.\n'
130 'Recovering uncommitted changes is not possible.')
131 ok_txt = N_('Abort Merge')
132 return Interaction.confirm(title, question, info, ok_txt,
133 default=False, icon=icons.undo())
135 def action(self):
136 status, out, err = gitcmds.abort_merge(self.context)
137 self.model.update_file_status()
138 return status, out, err
140 def success(self):
141 self.model.set_commitmsg('')
143 def error_message(self):
144 return N_('Error')
146 def command(self):
147 return 'git merge'
150 class AmendMode(EditModel):
151 """Try to amend a commit."""
152 UNDOABLE = True
153 LAST_MESSAGE = None
155 @staticmethod
156 def name():
157 return N_('Amend')
159 def __init__(self, context, amend=True):
160 super(AmendMode, self).__init__(context)
161 self.skip = False
162 self.amending = amend
163 self.old_commitmsg = self.model.commitmsg
164 self.old_mode = self.model.mode
166 if self.amending:
167 self.new_mode = self.model.mode_amend
168 self.new_commitmsg = gitcmds.prev_commitmsg(context)
169 AmendMode.LAST_MESSAGE = self.model.commitmsg
170 return
171 # else, amend unchecked, regular commit
172 self.new_mode = self.model.mode_none
173 self.new_diff_text = ''
174 self.new_commitmsg = self.model.commitmsg
175 # If we're going back into new-commit-mode then search the
176 # undo stack for a previous amend-commit-mode and grab the
177 # commit message at that point in time.
178 if AmendMode.LAST_MESSAGE is not None:
179 self.new_commitmsg = AmendMode.LAST_MESSAGE
180 AmendMode.LAST_MESSAGE = None
182 def do(self):
183 """Leave/enter amend mode."""
184 # Attempt to enter amend mode. Do not allow this when merging.
185 if self.amending:
186 if self.model.is_merging:
187 self.skip = True
188 self.model.set_mode(self.old_mode)
189 Interaction.information(
190 N_('Cannot Amend'),
191 N_('You are in the middle of a merge.\n'
192 'Cannot amend while merging.'))
193 return
194 self.skip = False
195 super(AmendMode, self).do()
196 self.model.set_commitmsg(self.new_commitmsg)
197 self.model.update_file_status()
199 def undo(self):
200 if self.skip:
201 return
202 self.model.set_commitmsg(self.old_commitmsg)
203 super(AmendMode, self).undo()
204 self.model.update_file_status()
207 class AnnexAdd(ContextCommand):
208 """Add to Git Annex"""
210 def __init__(self, context):
211 super(AnnexAdd, self).__init__(context)
212 self.filename = self.selection.filename()
214 def do(self):
215 status, out, err = self.git.annex('add', self.filename)
216 Interaction.command(N_('Error'), 'git annex add', status, out, err)
217 self.model.update_status()
220 class AnnexInit(ContextCommand):
221 """Initialize Git Annex"""
223 def do(self):
224 status, out, err = self.git.annex('init')
225 Interaction.command(N_('Error'), 'git annex init', status, out, err)
226 self.model.cfg.reset()
227 self.model.emit_updated()
230 class LFSTrack(ContextCommand):
231 """Add a file to git lfs"""
233 def __init__(self, context):
234 super(LFSTrack, self).__init__(context)
235 self.filename = self.selection.filename()
236 self.stage_cmd = Stage(context, [self.filename])
238 def do(self):
239 status, out, err = self.git.lfs('track', self.filename)
240 Interaction.command(
241 N_('Error'), 'git lfs track', status, out, err)
242 if status == 0:
243 self.stage_cmd.do()
246 class LFSInstall(ContextCommand):
247 """Initialize git lfs"""
249 def do(self):
250 status, out, err = self.git.lfs('install')
251 Interaction.command(
252 N_('Error'), 'git lfs install', status, out, err)
253 self.model.update_config(reset=True, emit=True)
256 class ApplyDiffSelection(ContextCommand):
257 """Apply the selected diff to the worktree or index"""
259 def __init__(self, context, first_line_idx, last_line_idx, has_selection,
260 reverse, apply_to_worktree):
261 super(ApplyDiffSelection, self).__init__(context)
262 self.first_line_idx = first_line_idx
263 self.last_line_idx = last_line_idx
264 self.has_selection = has_selection
265 self.reverse = reverse
266 self.apply_to_worktree = apply_to_worktree
268 def do(self):
269 context = self.context
270 cfg = self.context.cfg
271 diff_text = self.model.diff_text
273 parser = DiffParser(self.model.filename, diff_text)
274 if self.has_selection:
275 patch = parser.generate_patch(
276 self.first_line_idx, self.last_line_idx, reverse=self.reverse)
277 else:
278 patch = parser.generate_hunk_patch(
279 self.first_line_idx, reverse=self.reverse)
280 if patch is None:
281 return
283 if isinstance(diff_text, core.UStr):
284 # original encoding must prevail
285 encoding = diff_text.encoding
286 else:
287 encoding = cfg.file_encoding(self.model.filename)
289 tmp_file = utils.tmp_filename('patch')
290 try:
291 core.write(tmp_file, patch, encoding=encoding)
292 if self.apply_to_worktree:
293 status, out, err = gitcmds.apply_diff_to_worktree(
294 context, tmp_file)
295 else:
296 status, out, err = gitcmds.apply_diff(context, tmp_file)
297 finally:
298 core.unlink(tmp_file)
300 Interaction.log_status(status, out, err)
301 self.model.update_file_status(update_index=True)
304 class ApplyPatches(ContextCommand):
305 """Apply patches using the "git am" command"""
307 def __init__(self, context, patches):
308 super(ApplyPatches, self).__init__(context)
309 self.patches = patches
311 def do(self):
312 status, out, err = self.git.am('-3', *self.patches)
313 Interaction.log_status(status, out, err)
315 # Display a diffstat
316 self.model.update_file_status()
318 patch_basenames = [os.path.basename(p) for p in self.patches]
319 if len(patch_basenames) > 25:
320 patch_basenames = patch_basenames[:25]
321 patch_basenames.append('...')
323 basenames = '\n'.join(patch_basenames)
324 Interaction.information(
325 N_('Patch(es) Applied'),
326 (N_('%d patch(es) applied.') + '\n\n%s')
327 % (len(self.patches), basenames))
330 class Archive(ContextCommand):
331 """"Export archives using the "git archive" command"""
333 def __init__(self, context, ref, fmt, prefix, filename):
334 super(Archive, self).__init__(context)
335 self.ref = ref
336 self.fmt = fmt
337 self.prefix = prefix
338 self.filename = filename
340 def do(self):
341 fp = core.xopen(self.filename, 'wb')
342 cmd = ['git', 'archive', '--format='+self.fmt]
343 if self.fmt in ('tgz', 'tar.gz'):
344 cmd.append('-9')
345 if self.prefix:
346 cmd.append('--prefix=' + self.prefix)
347 cmd.append(self.ref)
348 proc = core.start_command(cmd, stdout=fp)
349 out, err = proc.communicate()
350 fp.close()
351 status = proc.returncode
352 Interaction.log_status(status, out or '', err or '')
355 class Checkout(EditModel):
356 """A command object for git-checkout.
358 'argv' is handed off directly to git.
361 def __init__(self, context, argv, checkout_branch=False):
362 super(Checkout, self).__init__(context)
363 self.argv = argv
364 self.checkout_branch = checkout_branch
365 self.new_diff_text = ''
366 self.new_diff_type = 'text'
368 def do(self):
369 super(Checkout, self).do()
370 status, out, err = self.git.checkout(*self.argv)
371 if self.checkout_branch:
372 self.model.update_status()
373 else:
374 self.model.update_file_status()
375 Interaction.command(N_('Error'), 'git checkout', status, out, err)
378 class BlamePaths(ContextCommand):
379 """Blame view for paths."""
381 def __init__(self, context, paths):
382 super(BlamePaths, self).__init__(context)
383 viewer = utils.shell_split(prefs.blame_viewer(context))
384 self.argv = viewer + list(paths)
386 def do(self):
387 try:
388 core.fork(self.argv)
389 except OSError as e:
390 _, details = utils.format_exception(e)
391 title = N_('Error Launching Blame Viewer')
392 msg = (N_('Cannot exec "%s": please configure a blame viewer')
393 % ' '.join(self.argv))
394 Interaction.critical(title, message=msg, details=details)
397 class CheckoutBranch(Checkout):
398 """Checkout a branch."""
400 def __init__(self, context, branch):
401 args = [branch]
402 super(CheckoutBranch, self).__init__(
403 context, args, checkout_branch=True)
406 class CherryPick(ContextCommand):
407 """Cherry pick commits into the current branch."""
409 def __init__(self, context, commits):
410 super(CherryPick, self).__init__(context)
411 self.commits = commits
413 def do(self):
414 self.model.cherry_pick_list(self.commits)
415 self.model.update_file_status()
418 class Revert(ContextCommand):
419 """Cherry pick commits into the current branch."""
421 def __init__(self, context, oid):
422 super(Revert, self).__init__(context)
423 self.oid = oid
425 def do(self):
426 self.git.revert(self.oid)
427 self.model.update_file_status()
430 class ResetMode(EditModel):
431 """Reset the mode and clear the model's diff text."""
433 def __init__(self, context):
434 super(ResetMode, self).__init__(context)
435 self.new_mode = self.model.mode_none
436 self.new_diff_text = ''
437 self.new_diff_type = 'text'
438 self.new_filename = ''
440 def do(self):
441 super(ResetMode, self).do()
442 self.model.update_file_status()
445 class ResetCommand(ConfirmAction):
446 """Reset state using the "git reset" command"""
448 def __init__(self, context, ref):
449 super(ResetCommand, self).__init__(context)
450 self.ref = ref
452 def action(self):
453 return self.reset()
455 def command(self):
456 return 'git reset'
458 def error_message(self):
459 return N_('Error')
461 def success(self):
462 self.model.update_file_status()
464 def confirm(self):
465 raise NotImplementedError('confirm() must be overridden')
467 def reset(self):
468 raise NotImplementedError('reset() must be overridden')
471 class ResetBranchHead(ResetCommand):
473 def confirm(self):
474 title = N_('Reset Branch')
475 question = N_('Point the current branch head to a new commit?')
476 info = N_('The branch will be reset using "git reset --mixed %s"')
477 ok_text = N_('Reset Branch')
478 info = info % self.ref
479 return Interaction.confirm(title, question, info, ok_text)
481 def reset(self):
482 return self.git.reset(self.ref, '--', mixed=True)
485 class ResetWorktree(ResetCommand):
487 def confirm(self):
488 title = N_('Reset Worktree')
489 question = N_('Reset worktree?')
490 info = N_('The worktree will be reset using "git reset --keep %s"')
491 ok_text = N_('Reset Worktree')
492 info = info % self.ref
493 return Interaction.confirm(title, question, info, ok_text)
495 def reset(self):
496 return self.git.reset(self.ref, '--', keep=True)
499 class ResetMerge(ResetCommand):
501 def confirm(self):
502 title = N_('Reset Merge')
503 question = N_('Reset merge?')
504 info = N_('The branch will be reset using "git reset --merge %s"')
505 ok_text = N_('Reset Merge')
506 info = info % self.ref
507 return Interaction.confirm(title, question, info, ok_text)
509 def reset(self):
510 return self.git.reset(self.ref, '--', merge=True)
513 class ResetSoft(ResetCommand):
515 def confirm(self):
516 title = N_('Reset Soft')
517 question = N_('Reset soft?')
518 info = N_('The branch will be reset using "git reset --soft %s"')
519 ok_text = N_('Reset Soft')
520 info = info % self.ref
521 return Interaction.confirm(title, question, info, ok_text)
523 def reset(self):
524 return self.git.reset(self.ref, '--', soft=True)
527 class ResetHard(ResetCommand):
529 def confirm(self):
530 title = N_('Reset Hard')
531 question = N_('Reset hard?')
532 info = N_('The branch will be reset using "git reset --hard %s"')
533 ok_text = N_('Reset Hard')
534 info = info % self.ref
535 return Interaction.confirm(title, question, info, ok_text)
537 def reset(self):
538 return self.git.reset(self.ref, '--', hard=True)
541 class Commit(ResetMode):
542 """Attempt to create a new commit."""
544 def __init__(self, context, amend, msg, sign, no_verify=False):
545 super(Commit, self).__init__(context)
546 self.amend = amend
547 self.msg = msg
548 self.sign = sign
549 self.no_verify = no_verify
550 self.old_commitmsg = self.model.commitmsg
551 self.new_commitmsg = ''
553 def do(self):
554 # Create the commit message file
555 context = self.context
556 comment_char = prefs.comment_char(context)
557 msg = self.strip_comments(self.msg, comment_char=comment_char)
558 tmp_file = utils.tmp_filename('commit-message')
559 try:
560 core.write(tmp_file, msg)
561 # Run 'git commit'
562 status, out, err = self.git.commit(
563 F=tmp_file, v=True, gpg_sign=self.sign,
564 amend=self.amend, no_verify=self.no_verify)
565 finally:
566 core.unlink(tmp_file)
567 if status == 0:
568 super(Commit, self).do()
569 self.model.set_commitmsg(self.new_commitmsg)
571 title = N_('Commit failed')
572 Interaction.command(title, 'git commit', status, out, err)
574 return status, out, err
576 @staticmethod
577 def strip_comments(msg, comment_char='#'):
578 # Strip off comments
579 message_lines = [line for line in msg.split('\n')
580 if not line.startswith(comment_char)]
581 msg = '\n'.join(message_lines)
582 if not msg.endswith('\n'):
583 msg += '\n'
585 return msg
588 class Ignore(ContextCommand):
589 """Add files to .gitignore"""
591 def __init__(self, context, filenames):
592 super(Ignore, self).__init__(context)
593 self.filenames = list(filenames)
595 def do(self):
596 if not self.filenames:
597 return
598 new_additions = '\n'.join(self.filenames) + '\n'
599 for_status = new_additions
600 if core.exists('.gitignore'):
601 current_list = core.read('.gitignore')
602 new_additions = current_list.rstrip() + '\n' + new_additions
603 core.write('.gitignore', new_additions)
604 Interaction.log_status(0, 'Added to .gitignore:\n%s' % for_status, '')
605 self.model.update_file_status()
608 def file_summary(files):
609 txt = core.list2cmdline(files)
610 if len(txt) > 768:
611 txt = txt[:768].rstrip() + '...'
612 wrap = textwrap.TextWrapper()
613 return '\n'.join(wrap.wrap(txt))
616 class RemoteCommand(ConfirmAction):
618 def __init__(self, context, remote):
619 super(RemoteCommand, self).__init__(context)
620 self.remote = remote
622 def success(self):
623 self.cfg.reset()
624 self.model.update_remotes()
627 class RemoteAdd(RemoteCommand):
629 def __init__(self, context, remote, url):
630 super(RemoteAdd, self).__init__(context, remote)
631 self.url = url
633 def action(self):
634 return self.git.remote('add', self.remote, self.url)
636 def error_message(self):
637 return N_('Error creating remote "%s"') % self.remote
639 def command(self):
640 return 'git remote add "%s" "%s"' % (self.remote, self.url)
643 class RemoteRemove(RemoteCommand):
645 def confirm(self):
646 title = N_('Delete Remote')
647 question = N_('Delete remote?')
648 info = N_('Delete remote "%s"') % self.remote
649 ok_text = N_('Delete')
650 return Interaction.confirm(title, question, info, ok_text)
652 def action(self):
653 return self.git.remote('rm', self.remote)
655 def error_message(self):
656 return N_('Error deleting remote "%s"') % self.remote
658 def command(self):
659 return 'git remote rm "%s"' % self.remote
662 class RemoteRename(RemoteCommand):
664 def __init__(self, context, remote, new_name):
665 super(RemoteRename, self).__init__(context, remote)
666 self.new_name = new_name
668 def confirm(self):
669 title = N_('Rename Remote')
670 text = (N_('Rename remote "%(current)s" to "%(new)s"?') %
671 dict(current=self.remote, new=self.new_name))
672 info_text = ''
673 ok_text = title
674 return Interaction.confirm(title, text, info_text, ok_text)
676 def action(self):
677 return self.git.remote('rename', self.remote, self.new_name)
679 def error_message(self):
680 return (N_('Error renaming "%(name)s" to "%(new_name)s"')
681 % dict(name=self.remote, new_name=self.new_name))
683 def command(self):
684 return 'git remote rename "%s" "%s"' % (self.remote, self.new_name)
687 class RemoteSetURL(RemoteCommand):
689 def __init__(self, context, remote, url):
690 super(RemoteSetURL, self).__init__(context, remote)
691 self.url = url
693 def action(self):
694 return self.git.remote('set-url', self.remote, self.url)
696 def error_message(self):
697 return (N_('Unable to set URL for "%(name)s" to "%(url)s"')
698 % dict(name=self.remote, url=self.url))
700 def command(self):
701 return 'git remote set-url "%s" "%s"' % (self.remote, self.url)
704 class RemoteEdit(ContextCommand):
705 """Combine RemoteRename and RemoteSetURL"""
707 def __init__(self, context, old_name, remote, url):
708 super(RemoteEdit, self).__init__(context)
709 self.rename = RemoteRename(context, old_name, remote)
710 self.set_url = RemoteSetURL(context, remote, url)
712 def do(self):
713 result = self.rename.do()
714 name_ok = result[0]
715 url_ok = False
716 if name_ok:
717 result = self.set_url.do()
718 url_ok = result[0]
719 return name_ok, url_ok
722 class RemoveFromSettings(ConfirmAction):
724 def __init__(self, context, settings, repo, entry, icon=None):
725 super(RemoveFromSettings, self).__init__(context)
726 self.settings = settings
727 self.repo = repo
728 self.entry = entry
729 self.icon = icon
731 def success(self):
732 self.settings.save()
735 class RemoveBookmark(RemoveFromSettings):
737 def confirm(self):
738 entry = self.entry
739 title = msg = N_('Delete Bookmark?')
740 info = N_('%s will be removed from your bookmarks.') % entry
741 ok_text = N_('Delete Bookmark')
742 return Interaction.confirm(title, msg, info, ok_text, icon=self.icon)
744 def action(self):
745 self.settings.remove_bookmark(self.repo, self.entry)
746 return (0, '', '')
749 class RemoveRecent(RemoveFromSettings):
751 def confirm(self):
752 repo = self.repo
753 title = msg = N_('Remove %s from the recent list?') % repo
754 info = N_('%s will be removed from your recent repositories.') % repo
755 ok_text = N_('Remove')
756 return Interaction.confirm(title, msg, info, ok_text, icon=self.icon)
758 def action(self):
759 self.settings.remove_recent(self.repo)
760 return (0, '', '')
763 class RemoveFiles(ContextCommand):
764 """Removes files"""
766 def __init__(self, context, remover, filenames):
767 super(RemoveFiles, self).__init__(context)
768 if remover is None:
769 remover = os.remove
770 self.remover = remover
771 self.filenames = filenames
772 # We could git-hash-object stuff and provide undo-ability
773 # as an option. Heh.
775 def do(self):
776 files = self.filenames
777 if not files:
778 return
780 rescan = False
781 bad_filenames = []
782 remove = self.remover
783 for filename in files:
784 if filename:
785 try:
786 remove(filename)
787 rescan = True
788 except OSError:
789 bad_filenames.append(filename)
791 if bad_filenames:
792 Interaction.information(
793 N_('Error'),
794 N_('Deleting "%s" failed') % file_summary(bad_filenames))
796 if rescan:
797 self.model.update_file_status()
800 class Delete(RemoveFiles):
801 """Delete files."""
803 def __init__(self, context, filenames):
804 super(Delete, self).__init__(context, os.remove, filenames)
806 def do(self):
807 files = self.filenames
808 if not files:
809 return
811 title = N_('Delete Files?')
812 msg = N_('The following files will be deleted:') + '\n\n'
813 msg += file_summary(files)
814 info_txt = N_('Delete %d file(s)?') % len(files)
815 ok_txt = N_('Delete Files')
817 if Interaction.confirm(title, msg, info_txt, ok_txt,
818 default=True, icon=icons.remove()):
819 super(Delete, self).do()
822 class MoveToTrash(RemoveFiles):
823 """Move files to the trash using send2trash"""
825 AVAILABLE = send2trash is not None
827 def __init__(self, context, filenames):
828 super(MoveToTrash, self).__init__(context, send2trash, filenames)
831 class DeleteBranch(ContextCommand):
832 """Delete a git branch."""
834 def __init__(self, context, branch):
835 super(DeleteBranch, self).__init__(context)
836 self.branch = branch
838 def do(self):
839 status, out, err = self.model.delete_branch(self.branch)
840 Interaction.log_status(status, out, err)
843 class Rename(ContextCommand):
844 """Rename a set of paths."""
846 def __init__(self, context, paths):
847 super(Rename, self).__init__(context)
848 self.paths = paths
850 def do(self):
851 msg = N_('Untracking: %s') % (', '.join(self.paths))
852 Interaction.log(msg)
854 for path in self.paths:
855 ok = self.rename(path)
856 if not ok:
857 return
859 self.model.update_status()
861 def rename(self, path):
862 git = self.git
863 title = N_('Rename "%s"') % path
865 if os.path.isdir(path):
866 base_path = os.path.dirname(path)
867 else:
868 base_path = path
869 new_path = Interaction.save_as(base_path, title)
870 if not new_path:
871 return False
873 status, out, err = git.mv(path, new_path, force=True, verbose=True)
874 Interaction.command(N_('Error'), 'git mv', status, out, err)
875 return status == 0
878 class RenameBranch(ContextCommand):
879 """Rename a git branch."""
881 def __init__(self, context, branch, new_branch):
882 super(RenameBranch, self).__init__(context)
883 self.branch = branch
884 self.new_branch = new_branch
886 def do(self):
887 branch = self.branch
888 new_branch = self.new_branch
889 status, out, err = self.model.rename_branch(branch, new_branch)
890 Interaction.log_status(status, out, err)
893 class DeleteRemoteBranch(ContextCommand):
894 """Delete a remote git branch."""
896 def __init__(self, context, remote, branch):
897 super(DeleteRemoteBranch, self).__init__(context)
898 self.remote = remote
899 self.branch = branch
901 def do(self):
902 status, out, err = self.git.push(self.remote, self.branch, delete=True)
903 self.model.update_status()
905 title = N_('Error Deleting Remote Branch')
906 Interaction.command(title, 'git push', status, out, err)
907 if status == 0:
908 Interaction.information(
909 N_('Remote Branch Deleted'),
910 N_('"%(branch)s" has been deleted from "%(remote)s".')
911 % dict(branch=self.branch, remote=self.remote))
914 def get_mode(model, staged, modified, unmerged, untracked):
915 if staged:
916 mode = model.mode_index
917 elif modified or unmerged:
918 mode = model.mode_worktree
919 elif untracked:
920 mode = model.mode_untracked
921 else:
922 mode = model.mode
923 return mode
926 class DiffImage(EditModel):
928 def __init__(self, context, filename,
929 deleted, staged, modified, unmerged, untracked):
930 super(DiffImage, self).__init__(context)
932 self.new_filename = filename
933 self.new_diff_text = ''
934 self.new_diff_type = 'image'
935 self.new_mode = get_mode(
936 self.model, staged, modified, unmerged, untracked)
937 self.staged = staged
938 self.modified = modified
939 self.unmerged = unmerged
940 self.untracked = untracked
941 self.deleted = deleted
942 self.annex = self.cfg.is_annex()
944 def do(self):
945 filename = self.new_filename
947 if self.staged:
948 images = self.staged_images()
949 elif self.modified:
950 images = self.modified_images()
951 elif self.unmerged:
952 images = self.unmerged_images()
953 elif self.untracked:
954 images = [(filename, False)]
955 else:
956 images = []
958 self.model.set_images(images)
959 super(DiffImage, self).do()
961 def staged_images(self):
962 context = self.context
963 git = self.git
964 head = self.model.head
965 filename = self.new_filename
966 annex = self.annex
968 images = []
969 index = git.diff_index(head, '--', filename, cached=True)[STDOUT]
970 if index:
971 # Example:
972 # :100644 100644 fabadb8... 4866510... M describe.c
973 parts = index.split(' ')
974 if len(parts) > 3:
975 old_oid = parts[2]
976 new_oid = parts[3]
978 if old_oid != MISSING_BLOB_OID:
979 # First, check if we can get a pre-image from git-annex
980 annex_image = None
981 if annex:
982 annex_image = gitcmds.annex_path(context, head, filename)
983 if annex_image:
984 images.append((annex_image, False)) # git annex HEAD
985 else:
986 image = gitcmds.write_blob_path(
987 context, head, old_oid, filename)
988 if image:
989 images.append((image, True))
991 if new_oid != MISSING_BLOB_OID:
992 found_in_annex = False
993 if annex and core.islink(filename):
994 status, out, _ = git.annex('status', '--', filename)
995 if status == 0:
996 details = out.split(' ')
997 if details and details[0] == 'A': # newly added file
998 images.append((filename, False))
999 found_in_annex = True
1001 if not found_in_annex:
1002 image = gitcmds.write_blob(context, new_oid, filename)
1003 if image:
1004 images.append((image, True))
1006 return images
1008 def unmerged_images(self):
1009 context = self.context
1010 git = self.git
1011 head = self.model.head
1012 filename = self.new_filename
1013 annex = self.annex
1015 candidate_merge_heads = ('HEAD', 'CHERRY_HEAD', 'MERGE_HEAD')
1016 merge_heads = [
1017 merge_head for merge_head in candidate_merge_heads
1018 if core.exists(git.git_path(merge_head))]
1020 if annex: # Attempt to find files in git-annex
1021 annex_images = []
1022 for merge_head in merge_heads:
1023 image = gitcmds.annex_path(context, merge_head, filename)
1024 if image:
1025 annex_images.append((image, False))
1026 if annex_images:
1027 annex_images.append((filename, False))
1028 return annex_images
1030 # DIFF FORMAT FOR MERGES
1031 # "git-diff-tree", "git-diff-files" and "git-diff --raw"
1032 # can take -c or --cc option to generate diff output also
1033 # for merge commits. The output differs from the format
1034 # described above in the following way:
1036 # 1. there is a colon for each parent
1037 # 2. there are more "src" modes and "src" sha1
1038 # 3. status is concatenated status characters for each parent
1039 # 4. no optional "score" number
1040 # 5. single path, only for "dst"
1041 # Example:
1042 # ::100644 100644 100644 fabadb8... cc95eb0... 4866510... \
1043 # MM describe.c
1044 images = []
1045 index = git.diff_index(head, '--', filename,
1046 cached=True, cc=True)[STDOUT]
1047 if index:
1048 parts = index.split(' ')
1049 if len(parts) > 3:
1050 first_mode = parts[0]
1051 num_parents = first_mode.count(':')
1052 # colon for each parent, but for the index, the "parents"
1053 # are really entries in stages 1,2,3 (head, base, remote)
1054 # remote, base, head
1055 for i in range(num_parents):
1056 offset = num_parents + i + 1
1057 oid = parts[offset]
1058 try:
1059 merge_head = merge_heads[i]
1060 except IndexError:
1061 merge_head = 'HEAD'
1062 if oid != MISSING_BLOB_OID:
1063 image = gitcmds.write_blob_path(
1064 context, merge_head, oid, filename)
1065 if image:
1066 images.append((image, True))
1068 images.append((filename, False))
1069 return images
1071 def modified_images(self):
1072 context = self.context
1073 git = self.git
1074 head = self.model.head
1075 filename = self.new_filename
1076 annex = self.annex
1078 images = []
1079 annex_image = None
1080 if annex: # Check for a pre-image from git-annex
1081 annex_image = gitcmds.annex_path(context, head, filename)
1082 if annex_image:
1083 images.append((annex_image, False)) # git annex HEAD
1084 else:
1085 worktree = git.diff_files('--', filename)[STDOUT]
1086 parts = worktree.split(' ')
1087 if len(parts) > 3:
1088 oid = parts[2]
1089 if oid != MISSING_BLOB_OID:
1090 image = gitcmds.write_blob_path(
1091 context, head, oid, filename)
1092 if image:
1093 images.append((image, True)) # HEAD
1095 images.append((filename, False)) # worktree
1096 return images
1099 class Diff(EditModel):
1100 """Perform a diff and set the model's current text."""
1102 def __init__(self, context, filename, cached=False, deleted=False):
1103 super(Diff, self).__init__(context)
1104 opts = {}
1105 if cached:
1106 opts['ref'] = self.model.head
1107 self.new_filename = filename
1108 self.new_mode = self.model.mode_worktree
1109 self.new_diff_text = gitcmds.diff_helper(
1110 self.context, filename=filename, cached=cached,
1111 deleted=deleted, **opts)
1112 self.new_diff_type = 'text'
1115 class Diffstat(EditModel):
1116 """Perform a diffstat and set the model's diff text."""
1118 def __init__(self, context):
1119 super(Diffstat, self).__init__(context)
1120 cfg = self.cfg
1121 diff_context = cfg.get('diff.context', 3)
1122 diff = self.git.diff(
1123 self.model.head, unified=diff_context, no_ext_diff=True,
1124 no_color=True, M=True, stat=True)[STDOUT]
1125 self.new_diff_text = diff
1126 self.new_diff_type = 'text'
1127 self.new_mode = self.model.mode_diffstat
1130 class DiffStaged(Diff):
1131 """Perform a staged diff on a file."""
1133 def __init__(self, context, filename, deleted=None):
1134 super(DiffStaged, self).__init__(
1135 context, filename, cached=True, deleted=deleted)
1136 self.new_mode = self.model.mode_index
1139 class DiffStagedSummary(EditModel):
1141 def __init__(self, context):
1142 super(DiffStagedSummary, self).__init__(context)
1143 diff = self.git.diff(
1144 self.model.head, cached=True, no_color=True,
1145 no_ext_diff=True, patch_with_stat=True, M=True)[STDOUT]
1146 self.new_diff_text = diff
1147 self.new_diff_type = 'text'
1148 self.new_mode = self.model.mode_index
1151 class Difftool(ContextCommand):
1152 """Run git-difftool limited by path."""
1154 def __init__(self, context, staged, filenames):
1155 super(Difftool, self).__init__(context)
1156 self.staged = staged
1157 self.filenames = filenames
1159 def do(self):
1160 difftool_launch_with_head(
1161 self.context, self.filenames, self.staged, self.model.head)
1164 class Edit(ContextCommand):
1165 """Edit a file using the configured gui.editor."""
1167 @staticmethod
1168 def name():
1169 return N_('Launch Editor')
1171 def __init__(self, context, filenames,
1172 line_number=None, background_editor=False):
1173 super(Edit, self).__init__(context)
1174 self.filenames = filenames
1175 self.line_number = line_number
1176 self.background_editor = background_editor
1178 def do(self):
1179 context = self.context
1180 if not self.filenames:
1181 return
1182 filename = self.filenames[0]
1183 if not core.exists(filename):
1184 return
1185 if self.background_editor:
1186 editor = prefs.background_editor(context)
1187 else:
1188 editor = prefs.editor(context)
1189 opts = []
1191 if self.line_number is None:
1192 opts = self.filenames
1193 else:
1194 # Single-file w/ line-numbers (likely from grep)
1195 editor_opts = {
1196 '*vim*': [filename, '+%s' % self.line_number],
1197 '*emacs*': ['+%s' % self.line_number, filename],
1198 '*textpad*': ['%s(%s,0)' % (filename, self.line_number)],
1199 '*notepad++*': ['-n%s' % self.line_number, filename],
1200 '*subl*': ['%s:%s' % (filename, self.line_number)],
1203 opts = self.filenames
1204 for pattern, opt in editor_opts.items():
1205 if fnmatch(editor, pattern):
1206 opts = opt
1207 break
1209 try:
1210 core.fork(utils.shell_split(editor) + opts)
1211 except (OSError, ValueError) as e:
1212 message = (N_('Cannot exec "%s": please configure your editor')
1213 % editor)
1214 _, details = utils.format_exception(e)
1215 Interaction.critical(N_('Error Editing File'), message, details)
1218 class FormatPatch(ContextCommand):
1219 """Output a patch series given all revisions and a selected subset."""
1221 def __init__(self, context, to_export, revs, output='patches'):
1222 super(FormatPatch, self).__init__(context)
1223 self.to_export = list(to_export)
1224 self.revs = list(revs)
1225 self.output = output
1227 def do(self):
1228 context = self.context
1229 status, out, err = gitcmds.format_patchsets(
1230 context, self.to_export, self.revs, self.output)
1231 Interaction.log_status(status, out, err)
1234 class LaunchDifftool(ContextCommand):
1236 @staticmethod
1237 def name():
1238 return N_('Launch Diff Tool')
1240 def do(self):
1241 s = self.selection.selection()
1242 if s.unmerged:
1243 paths = s.unmerged
1244 if utils.is_win32():
1245 core.fork(['git', 'mergetool', '--no-prompt', '--'] + paths)
1246 else:
1247 cfg = self.cfg
1248 cmd = cfg.terminal()
1249 argv = utils.shell_split(cmd)
1250 mergetool = ['git', 'mergetool', '--no-prompt', '--']
1251 mergetool.extend(paths)
1252 needs_shellquote = set(['gnome-terminal', 'xfce4-terminal'])
1253 if os.path.basename(argv[0]) in needs_shellquote:
1254 argv.append(core.list2cmdline(mergetool))
1255 else:
1256 argv.extend(mergetool)
1257 core.fork(argv)
1258 else:
1259 difftool_run(self.context)
1262 class LaunchTerminal(ContextCommand):
1264 @staticmethod
1265 def name():
1266 return N_('Launch Terminal')
1268 def __init__(self, context, path):
1269 super(LaunchTerminal, self).__init__(context)
1270 self.path = path
1272 def do(self):
1273 cmd = self.cfg.terminal()
1274 argv = utils.shell_split(cmd)
1275 argv.append(os.getenv('SHELL', '/bin/sh'))
1276 core.fork(argv, cwd=self.path)
1279 class LaunchEditor(Edit):
1281 @staticmethod
1282 def name():
1283 return N_('Launch Editor')
1285 def __init__(self, context):
1286 s = context.selection.selection()
1287 filenames = s.staged + s.unmerged + s.modified + s.untracked
1288 super(LaunchEditor, self).__init__(
1289 context, filenames, background_editor=True)
1292 class LaunchEditorAtLine(LaunchEditor):
1293 """Launch an editor at the specified line"""
1295 def __init__(self, context):
1296 super(LaunchEditorAtLine, self).__init__(context)
1297 self.line_number = context.selection.line_number
1300 class LoadCommitMessageFromFile(ContextCommand):
1301 """Loads a commit message from a path."""
1302 UNDOABLE = True
1304 def __init__(self, context, path):
1305 super(LoadCommitMessageFromFile, self).__init__(context)
1306 self.path = path
1307 self.old_commitmsg = self.model.commitmsg
1308 self.old_directory = self.model.directory
1310 def do(self):
1311 path = os.path.expanduser(self.path)
1312 if not path or not core.isfile(path):
1313 raise UsageError(N_('Error: Cannot find commit template'),
1314 N_('%s: No such file or directory.') % path)
1315 self.model.set_directory(os.path.dirname(path))
1316 self.model.set_commitmsg(core.read(path))
1318 def undo(self):
1319 self.model.set_commitmsg(self.old_commitmsg)
1320 self.model.set_directory(self.old_directory)
1323 class LoadCommitMessageFromTemplate(LoadCommitMessageFromFile):
1324 """Loads the commit message template specified by commit.template."""
1326 def __init__(self, context):
1327 cfg = context.cfg
1328 template = cfg.get('commit.template')
1329 super(LoadCommitMessageFromTemplate, self).__init__(context, template)
1331 def do(self):
1332 if self.path is None:
1333 raise UsageError(
1334 N_('Error: Unconfigured commit template'),
1335 N_('A commit template has not been configured.\n'
1336 'Use "git config" to define "commit.template"\n'
1337 'so that it points to a commit template.'))
1338 return LoadCommitMessageFromFile.do(self)
1341 class LoadCommitMessageFromOID(ContextCommand):
1342 """Load a previous commit message"""
1343 UNDOABLE = True
1345 def __init__(self, context, oid, prefix=''):
1346 super(LoadCommitMessageFromOID, self).__init__(context)
1347 self.oid = oid
1348 self.old_commitmsg = self.model.commitmsg
1349 self.new_commitmsg = prefix + gitcmds.prev_commitmsg(context, oid)
1351 def do(self):
1352 self.model.set_commitmsg(self.new_commitmsg)
1354 def undo(self):
1355 self.model.set_commitmsg(self.old_commitmsg)
1358 class PrepareCommitMessageHook(ContextCommand):
1359 """Use the cola-prepare-commit-msg hook to prepare the commit message
1361 UNDOABLE = True
1363 def __init__(self, context):
1364 super(PrepareCommitMessageHook, self).__init__(context)
1365 self.old_commitmsg = self.model.commitmsg
1367 def get_message(self):
1369 title = N_('Error running prepare-commitmsg hook')
1370 hook = gitcmds.prepare_commit_message_hook(self.context)
1372 if os.path.exists(hook):
1373 filename = self.model.save_commitmsg()
1374 status, out, err = core.run_command([hook, filename])
1376 if status == 0:
1377 result = core.read(filename)
1378 else:
1379 result = self.old_commitmsg
1380 Interaction.command_error(title, hook, status, out, err)
1381 else:
1382 message = N_('A hook must be provided at "%s"') % hook
1383 Interaction.critical(title, message=message)
1384 result = self.old_commitmsg
1386 return result
1388 def do(self):
1389 msg = self.get_message()
1390 self.model.set_commitmsg(msg)
1392 def undo(self):
1393 self.model.set_commitmsg(self.old_commitmsg)
1396 class LoadFixupMessage(LoadCommitMessageFromOID):
1397 """Load a fixup message"""
1399 def __init__(self, context, oid):
1400 super(LoadFixupMessage, self).__init__(context, oid, prefix='fixup! ')
1401 if self.new_commitmsg:
1402 self.new_commitmsg = self.new_commitmsg.splitlines()[0]
1405 class Merge(ContextCommand):
1406 """Merge commits"""
1408 def __init__(self, context, revision, no_commit, squash, no_ff, sign):
1409 super(Merge, self).__init__(context)
1410 self.revision = revision
1411 self.no_ff = no_ff
1412 self.no_commit = no_commit
1413 self.squash = squash
1414 self.sign = sign
1416 def do(self):
1417 squash = self.squash
1418 revision = self.revision
1419 no_ff = self.no_ff
1420 no_commit = self.no_commit
1421 sign = self.sign
1423 status, out, err = self.git.merge(
1424 revision, gpg_sign=sign, no_ff=no_ff,
1425 no_commit=no_commit, squash=squash)
1426 self.model.update_status()
1427 title = N_('Merge failed. Conflict resolution is required.')
1428 Interaction.command(title, 'git merge', status, out, err)
1430 return status, out, err
1433 class OpenDefaultApp(ContextCommand):
1434 """Open a file using the OS default."""
1436 @staticmethod
1437 def name():
1438 return N_('Open Using Default Application')
1440 def __init__(self, context, filenames):
1441 super(OpenDefaultApp, self).__init__(context)
1442 if utils.is_darwin():
1443 launcher = 'open'
1444 else:
1445 launcher = 'xdg-open'
1446 self.launcher = launcher
1447 self.filenames = filenames
1449 def do(self):
1450 if not self.filenames:
1451 return
1452 core.fork([self.launcher] + self.filenames)
1455 class OpenParentDir(OpenDefaultApp):
1456 """Open parent directories using the OS default."""
1458 @staticmethod
1459 def name():
1460 return N_('Open Parent Directory')
1462 def __init__(self, context, filenames):
1463 OpenDefaultApp.__init__(self, context, filenames)
1465 def do(self):
1466 if not self.filenames:
1467 return
1468 dirnames = list(set([os.path.dirname(x) for x in self.filenames]))
1469 # os.path.dirname() can return an empty string so we fallback to
1470 # the current directory
1471 dirs = [(dirname or core.getcwd()) for dirname in dirnames]
1472 core.fork([self.launcher] + dirs)
1475 class OpenNewRepo(ContextCommand):
1476 """Launches git-cola on a repo."""
1478 def __init__(self, context, repo_path):
1479 super(OpenNewRepo, self).__init__(context)
1480 self.repo_path = repo_path
1482 def do(self):
1483 self.model.set_directory(self.repo_path)
1484 core.fork([sys.executable, sys.argv[0], '--repo', self.repo_path])
1487 class OpenRepo(EditModel):
1489 def __init__(self, context, repo_path):
1490 super(OpenRepo, self).__init__(context)
1491 self.repo_path = repo_path
1492 self.new_mode = self.model.mode_none
1493 self.new_diff_text = ''
1494 self.new_diff_type = 'text'
1495 self.new_commitmsg = ''
1496 self.new_filename = ''
1498 def do(self):
1499 old_repo = self.git.getcwd()
1500 if self.model.set_worktree(self.repo_path):
1501 self.fsmonitor.stop()
1502 self.fsmonitor.start()
1503 self.model.update_status()
1504 self.model.set_commitmsg(self.new_commitmsg)
1505 super(OpenRepo, self).do()
1506 else:
1507 self.model.set_worktree(old_repo)
1510 class Clone(ContextCommand):
1511 """Clones a repository and optionally spawns a new cola session."""
1513 def __init__(self, context, url, new_directory,
1514 submodules=False, shallow=False, spawn=True):
1515 super(Clone, self).__init__(context)
1516 self.url = url
1517 self.new_directory = new_directory
1518 self.submodules = submodules
1519 self.shallow = shallow
1520 self.spawn = spawn
1521 self.status = -1
1522 self.out = ''
1523 self.err = ''
1525 def do(self):
1526 kwargs = {}
1527 if self.shallow:
1528 kwargs['depth'] = 1
1529 recurse_submodules = self.submodules
1530 shallow_submodules = self.submodules and self.shallow
1532 status, out, err = self.git.clone(
1533 self.url, self.new_directory,
1534 recurse_submodules=recurse_submodules,
1535 shallow_submodules=shallow_submodules,
1536 **kwargs)
1538 self.status = status
1539 self.out = out
1540 self.err = err
1541 if status == 0 and self.spawn:
1542 executable = sys.executable
1543 core.fork([executable, sys.argv[0], '--repo', self.new_directory])
1544 return self
1547 class NewBareRepo(ContextCommand):
1548 """Create a new shared bare repository"""
1550 def __init__(self, context, path):
1551 super(NewBareRepo, self).__init__(context)
1552 self.path = path
1554 def do(self):
1555 path = self.path
1556 status, out, err = self.git.init(path, bare=True, shared=True)
1557 Interaction.command(
1558 N_('Error'), 'git init --bare --shared "%s"' % path,
1559 status, out, err)
1560 return status == 0
1563 def unix_path(path, is_win32=utils.is_win32):
1564 """Git for Windows requires unix paths, so force them here
1566 if is_win32():
1567 path = path.replace('\\', '/')
1568 first = path[0]
1569 second = path[1]
1570 if second == ':': # sanity check, this better be a Windows-style path
1571 path = '/' + first + path[2:]
1573 return path
1576 def sequence_editor():
1577 """Return a GIT_SEQUENCE_EDITOR environment value that enables git-xbase"""
1578 xbase = unix_path(resources.share('bin', 'git-xbase'))
1579 editor = core.list2cmdline([unix_path(sys.executable), xbase])
1580 return editor
1583 class GitXBaseContext(object):
1585 def __init__(self, context, **kwargs):
1586 self.env = {
1587 'GIT_EDITOR': prefs.editor(context),
1588 'GIT_SEQUENCE_EDITOR': sequence_editor(),
1589 'GIT_XBASE_CANCEL_ACTION': 'save',
1591 self.env.update(kwargs)
1593 def __enter__(self):
1594 for var, value in self.env.items():
1595 compat.setenv(var, value)
1596 return self
1598 def __exit__(self, exc_type, exc_val, exc_tb):
1599 for var in self.env:
1600 compat.unsetenv(var)
1603 class Rebase(ContextCommand):
1605 def __init__(self, context, upstream=None, branch=None, **kwargs):
1606 """Start an interactive rebase session
1608 :param upstream: upstream branch
1609 :param branch: optional branch to checkout
1610 :param kwargs: forwarded directly to `git.rebase()`
1613 super(Rebase, self).__init__(context)
1615 self.upstream = upstream
1616 self.branch = branch
1617 self.kwargs = kwargs
1619 def prepare_arguments(self, upstream):
1620 args = []
1621 kwargs = {}
1623 # Rebase actions must be the only option specified
1624 for action in ('continue', 'abort', 'skip', 'edit_todo'):
1625 if self.kwargs.get(action, False):
1626 kwargs[action] = self.kwargs[action]
1627 return args, kwargs
1629 kwargs['interactive'] = True
1630 kwargs['autosquash'] = self.kwargs.get('autosquash', True)
1631 kwargs.update(self.kwargs)
1633 if upstream:
1634 args.append(upstream)
1635 if self.branch:
1636 args.append(self.branch)
1638 return args, kwargs
1640 def do(self):
1641 (status, out, err) = (1, '', '')
1642 context = self.context
1643 cfg = self.cfg
1644 model = self.model
1646 if not cfg.get('rebase.autostash', False):
1647 if model.staged or model.unmerged or model.modified:
1648 Interaction.information(
1649 N_('Unable to rebase'),
1650 N_('You cannot rebase with uncommitted changes.'))
1651 return status, out, err
1653 upstream = self.upstream or Interaction.choose_ref(
1654 context, N_('Select New Upstream'), N_('Interactive Rebase'),
1655 default='@{upstream}')
1656 if not upstream:
1657 return status, out, err
1659 self.model.is_rebasing = True
1660 self.model.emit_updated()
1662 args, kwargs = self.prepare_arguments(upstream)
1663 upstream_title = upstream or '@{upstream}'
1664 with GitXBaseContext(
1665 self.context,
1666 GIT_XBASE_TITLE=N_('Rebase onto %s') % upstream_title,
1667 GIT_XBASE_ACTION=N_('Rebase')
1669 # TODO this blocks the user interface window for the duration
1670 # of git-xbase's invocation. We would need to implement
1671 # signals for QProcess and continue running the main thread.
1672 # alternatively we could hide the main window while rebasing.
1673 # that doesn't require as much effort.
1674 status, out, err = self.git.rebase(
1675 *args, _no_win32_startupinfo=True, **kwargs)
1676 self.model.update_status()
1677 if err.strip() != 'Nothing to do':
1678 title = N_('Rebase stopped')
1679 Interaction.command(title, 'git rebase', status, out, err)
1680 return status, out, err
1683 class RebaseEditTodo(ContextCommand):
1685 def do(self):
1686 (status, out, err) = (1, '', '')
1687 with GitXBaseContext(
1688 self.context,
1689 GIT_XBASE_TITLE=N_('Edit Rebase'),
1690 GIT_XBASE_ACTION=N_('Save')
1692 status, out, err = self.git.rebase(edit_todo=True)
1693 Interaction.log_status(status, out, err)
1694 self.model.update_status()
1695 return status, out, err
1698 class RebaseContinue(ContextCommand):
1700 def do(self):
1701 (status, out, err) = (1, '', '')
1702 with GitXBaseContext(
1703 self.context,
1704 GIT_XBASE_TITLE=N_('Rebase'),
1705 GIT_XBASE_ACTION=N_('Rebase')
1707 status, out, err = self.git.rebase('--continue')
1708 Interaction.log_status(status, out, err)
1709 self.model.update_status()
1710 return status, out, err
1713 class RebaseSkip(ContextCommand):
1715 def do(self):
1716 (status, out, err) = (1, '', '')
1717 with GitXBaseContext(
1718 self.context,
1719 GIT_XBASE_TITLE=N_('Rebase'),
1720 GIT_XBASE_ACTION=N_('Rebase')
1722 status, out, err = self.git.rebase(skip=True)
1723 Interaction.log_status(status, out, err)
1724 self.model.update_status()
1725 return status, out, err
1728 class RebaseAbort(ContextCommand):
1730 def do(self):
1731 status, out, err = self.git.rebase(abort=True)
1732 Interaction.log_status(status, out, err)
1733 self.model.update_status()
1736 class Rescan(ContextCommand):
1737 """Rescan for changes"""
1739 def do(self):
1740 self.model.update_status()
1743 class Refresh(ContextCommand):
1744 """Update refs, refresh the index, and update config"""
1746 @staticmethod
1747 def name():
1748 return N_('Refresh')
1750 def do(self):
1751 self.model.update_status(update_index=True)
1752 self.cfg.update()
1753 self.fsmonitor.refresh()
1756 class RefreshConfig(ContextCommand):
1757 """Refresh the git config cache"""
1759 def do(self):
1760 self.cfg.update()
1763 class RevertEditsCommand(ConfirmAction):
1765 def __init__(self, context):
1766 super(RevertEditsCommand, self).__init__(context)
1767 self.icon = icons.undo()
1769 def ok_to_run(self):
1770 return self.model.undoable()
1772 # pylint: disable=no-self-use
1773 def checkout_from_head(self):
1774 return False
1776 def checkout_args(self):
1777 args = []
1778 s = self.selection.selection()
1779 if self.checkout_from_head():
1780 args.append(self.model.head)
1781 args.append('--')
1783 if s.staged:
1784 items = s.staged
1785 else:
1786 items = s.modified
1787 args.extend(items)
1789 return args
1791 def action(self):
1792 checkout_args = self.checkout_args()
1793 return self.git.checkout(*checkout_args)
1795 def success(self):
1796 self.model.update_file_status()
1799 class RevertUnstagedEdits(RevertEditsCommand):
1801 @staticmethod
1802 def name():
1803 return N_('Revert Unstaged Edits...')
1805 def checkout_from_head(self):
1806 # If we are amending and a modified file is selected
1807 # then we should include "HEAD^" on the command-line.
1808 selected = self.selection.selection()
1809 return not selected.staged and self.model.amending()
1811 def confirm(self):
1812 title = N_('Revert Unstaged Changes?')
1813 text = N_(
1814 'This operation removes unstaged edits from selected files.\n'
1815 'These changes cannot be recovered.')
1816 info = N_('Revert the unstaged changes?')
1817 ok_text = N_('Revert Unstaged Changes')
1818 return Interaction.confirm(title, text, info, ok_text,
1819 default=True, icon=self.icon)
1822 class RevertUncommittedEdits(RevertEditsCommand):
1824 @staticmethod
1825 def name():
1826 return N_('Revert Uncommitted Edits...')
1828 def checkout_from_head(self):
1829 return True
1831 def confirm(self):
1832 """Prompt for reverting changes"""
1833 title = N_('Revert Uncommitted Changes?')
1834 text = N_(
1835 'This operation removes uncommitted edits from selected files.\n'
1836 'These changes cannot be recovered.')
1837 info = N_('Revert the uncommitted changes?')
1838 ok_text = N_('Revert Uncommitted Changes')
1839 return Interaction.confirm(title, text, info, ok_text,
1840 default=True, icon=self.icon)
1843 class RunConfigAction(ContextCommand):
1844 """Run a user-configured action, typically from the "Tools" menu"""
1846 def __init__(self, context, action_name):
1847 super(RunConfigAction, self).__init__(context)
1848 self.action_name = action_name
1850 def do(self):
1851 """Run the user-configured action"""
1852 for env in ('ARGS', 'DIRNAME', 'FILENAME', 'REVISION'):
1853 try:
1854 compat.unsetenv(env)
1855 except KeyError:
1856 pass
1857 rev = None
1858 args = None
1859 context = self.context
1860 cfg = self.cfg
1861 opts = cfg.get_guitool_opts(self.action_name)
1862 cmd = opts.get('cmd')
1863 if 'title' not in opts:
1864 opts['title'] = cmd
1866 if 'prompt' not in opts or opts.get('prompt') is True:
1867 prompt = N_('Run "%s"?') % cmd
1868 opts['prompt'] = prompt
1870 if opts.get('needsfile'):
1871 filename = self.selection.filename()
1872 if not filename:
1873 Interaction.information(
1874 N_('Please select a file'),
1875 N_('"%s" requires a selected file.') % cmd)
1876 return False
1877 dirname = utils.dirname(filename, current_dir='.')
1878 compat.setenv('FILENAME', filename)
1879 compat.setenv('DIRNAME', dirname)
1881 if opts.get('revprompt') or opts.get('argprompt'):
1882 while True:
1883 ok = Interaction.confirm_config_action(context, cmd, opts)
1884 if not ok:
1885 return False
1886 rev = opts.get('revision')
1887 args = opts.get('args')
1888 if opts.get('revprompt') and not rev:
1889 title = N_('Invalid Revision')
1890 msg = N_('The revision expression cannot be empty.')
1891 Interaction.critical(title, msg)
1892 continue
1893 break
1895 elif opts.get('confirm'):
1896 title = os.path.expandvars(opts.get('title'))
1897 prompt = os.path.expandvars(opts.get('prompt'))
1898 if not Interaction.question(title, prompt):
1899 return False
1900 if rev:
1901 compat.setenv('REVISION', rev)
1902 if args:
1903 compat.setenv('ARGS', args)
1904 title = os.path.expandvars(cmd)
1905 Interaction.log(N_('Running command: %s') % title)
1906 cmd = ['sh', '-c', cmd]
1908 if opts.get('background'):
1909 core.fork(cmd)
1910 status, out, err = (0, '', '')
1911 elif opts.get('noconsole'):
1912 status, out, err = core.run_command(cmd)
1913 else:
1914 status, out, err = Interaction.run_command(title, cmd)
1916 if not opts.get('background') and not opts.get('norescan'):
1917 self.model.update_status()
1919 title = N_('Error')
1920 Interaction.command(title, cmd, status, out, err)
1922 return status == 0
1925 class SetDefaultRepo(ContextCommand):
1926 """Set the default repository"""
1928 def __init__(self, context, repo):
1929 super(SetDefaultRepo, self).__init__(context)
1930 self.repo = repo
1932 def do(self):
1933 self.cfg.set_user('cola.defaultrepo', self.repo)
1936 class SetDiffText(EditModel):
1937 """Set the diff text"""
1938 UNDOABLE = True
1940 def __init__(self, context, text):
1941 super(SetDiffText, self).__init__(context)
1942 self.new_diff_text = text
1943 self.new_diff_type = 'text'
1946 class SetUpstreamBranch(ContextCommand):
1947 """Set the upstream branch"""
1949 def __init__(self, context, branch, remote, remote_branch):
1950 super(SetUpstreamBranch, self).__init__(context)
1951 self.branch = branch
1952 self.remote = remote
1953 self.remote_branch = remote_branch
1955 def do(self):
1956 cfg = self.cfg
1957 remote = self.remote
1958 branch = self.branch
1959 remote_branch = self.remote_branch
1960 cfg.set_repo('branch.%s.remote' % branch, remote)
1961 cfg.set_repo('branch.%s.merge' % branch, 'refs/heads/' + remote_branch)
1964 class ShowUntracked(EditModel):
1965 """Show an untracked file."""
1967 def __init__(self, context, filename):
1968 super(ShowUntracked, self).__init__(context)
1969 self.new_filename = filename
1970 self.new_mode = self.model.mode_untracked
1971 self.new_diff_text = self.read(filename)
1972 self.new_diff_type = 'text'
1974 def read(self, filename):
1975 """Read file contents"""
1976 cfg = self.cfg
1977 size = cfg.get('cola.readsize', 2048)
1978 try:
1979 result = core.read(filename, size=size,
1980 encoding=core.ENCODING, errors='ignore')
1981 except (IOError, OSError):
1982 result = ''
1984 if len(result) == size:
1985 result += '...'
1986 return result
1989 class SignOff(ContextCommand):
1990 """Append a signoff to the commit message"""
1991 UNDOABLE = True
1993 @staticmethod
1994 def name():
1995 return N_('Sign Off')
1997 def __init__(self, context):
1998 super(SignOff, self).__init__(context)
1999 self.old_commitmsg = self.model.commitmsg
2001 def do(self):
2002 """Add a signoff to the commit message"""
2003 signoff = self.signoff()
2004 if signoff in self.model.commitmsg:
2005 return
2006 msg = self.model.commitmsg.rstrip()
2007 self.model.set_commitmsg(msg + '\n' + signoff)
2009 def undo(self):
2010 """Restore the commit message"""
2011 self.model.set_commitmsg(self.old_commitmsg)
2013 def signoff(self):
2014 """Generate the signoff string"""
2015 try:
2016 import pwd
2017 user = pwd.getpwuid(os.getuid()).pw_name
2018 except ImportError:
2019 user = os.getenv('USER', N_('unknown'))
2021 cfg = self.cfg
2022 name = cfg.get('user.name', user)
2023 email = cfg.get('user.email', '%s@%s' % (user, core.node()))
2024 return '\nSigned-off-by: %s <%s>' % (name, email)
2027 def check_conflicts(context, unmerged):
2028 """Check paths for conflicts
2030 Conflicting files can be filtered out one-by-one.
2033 if prefs.check_conflicts(context):
2034 unmerged = [path for path in unmerged if is_conflict_free(path)]
2035 return unmerged
2038 def is_conflict_free(path):
2039 """Return True if `path` contains no conflict markers
2041 rgx = re.compile(r'^(<<<<<<<|\|\|\|\|\|\|\||>>>>>>>) ')
2042 try:
2043 with core.xopen(path, 'r') as f:
2044 for line in f:
2045 line = core.decode(line, errors='ignore')
2046 if rgx.match(line):
2047 return should_stage_conflicts(path)
2048 except IOError:
2049 # We can't read this file ~ we may be staging a removal
2050 pass
2051 return True
2054 def should_stage_conflicts(path):
2055 """Inform the user that a file contains merge conflicts
2057 Return `True` if we should stage the path nonetheless.
2060 title = msg = N_('Stage conflicts?')
2061 info = N_('%s appears to contain merge conflicts.\n\n'
2062 'You should probably skip this file.\n'
2063 'Stage it anyways?') % path
2064 ok_text = N_('Stage conflicts')
2065 cancel_text = N_('Skip')
2066 return Interaction.confirm(title, msg, info, ok_text,
2067 default=False, cancel_text=cancel_text)
2070 class Stage(ContextCommand):
2071 """Stage a set of paths."""
2073 @staticmethod
2074 def name():
2075 return N_('Stage')
2077 def __init__(self, context, paths):
2078 super(Stage, self).__init__(context)
2079 self.paths = paths
2081 def do(self):
2082 msg = N_('Staging: %s') % (', '.join(self.paths))
2083 Interaction.log(msg)
2084 return self.stage_paths()
2086 def stage_paths(self):
2087 """Stages add/removals to git."""
2088 context = self.context
2089 paths = self.paths
2090 if not paths:
2091 if self.model.cfg.get('cola.safemode', False):
2092 return (0, '', '')
2093 return self.stage_all()
2095 add = []
2096 remove = []
2098 for path in set(paths):
2099 if core.exists(path) or core.islink(path):
2100 if path.endswith('/'):
2101 path = path.rstrip('/')
2102 add.append(path)
2103 else:
2104 remove.append(path)
2106 self.model.emit_about_to_update()
2108 # `git add -u` doesn't work on untracked files
2109 if add:
2110 status, out, err = gitcmds.add(context, add)
2111 Interaction.command(N_('Error'), 'git add', status, out, err)
2113 # If a path doesn't exist then that means it should be removed
2114 # from the index. We use `git add -u` for that.
2115 if remove:
2116 status, out, err = gitcmds.add(context, remove, u=True)
2117 Interaction.command(N_('Error'), 'git add -u', status, out, err)
2119 self.model.update_files(emit=True)
2120 return status, out, err
2122 def stage_all(self):
2123 """Stage all files"""
2124 status, out, err = self.git.add(v=True, u=True)
2125 Interaction.command(N_('Error'), 'git add -u', status, out, err)
2126 self.model.update_file_status()
2127 return (status, out, err)
2130 class StageCarefully(Stage):
2131 """Only stage when the path list is non-empty
2133 We use "git add -u -- <pathspec>" to stage, and it stages everything by
2134 default when no pathspec is specified, so this class ensures that paths
2135 are specified before calling git.
2137 When no paths are specified, the command does nothing.
2140 def __init__(self, context):
2141 super(StageCarefully, self).__init__(context, None)
2142 self.init_paths()
2144 # pylint: disable=no-self-use
2145 def init_paths(self):
2146 """Initialize path data"""
2147 return
2149 def ok_to_run(self):
2150 """Prevent catch-all "git add -u" from adding unmerged files"""
2151 return self.paths or not self.model.unmerged
2153 def do(self):
2154 """Stage files when ok_to_run() return True"""
2155 if self.ok_to_run():
2156 return super(StageCarefully, self).do()
2157 return (0, '', '')
2160 class StageModified(StageCarefully):
2161 """Stage all modified files."""
2163 @staticmethod
2164 def name():
2165 return N_('Stage Modified')
2167 def init_paths(self):
2168 self.paths = self.model.modified
2171 class StageUnmerged(StageCarefully):
2172 """Stage unmerged files."""
2174 @staticmethod
2175 def name():
2176 return N_('Stage Unmerged')
2178 def init_paths(self):
2179 self.paths = check_conflicts(self.context, self.model.unmerged)
2182 class StageUntracked(StageCarefully):
2183 """Stage all untracked files."""
2185 @staticmethod
2186 def name():
2187 return N_('Stage Untracked')
2189 def init_paths(self):
2190 self.paths = self.model.untracked
2193 class StageOrUnstage(ContextCommand):
2194 """If the selection is staged, unstage it, otherwise stage"""
2196 @staticmethod
2197 def name():
2198 return N_('Stage / Unstage')
2200 def do(self):
2201 s = self.selection.selection()
2202 if s.staged:
2203 do(Unstage, self.context, s.staged)
2205 unstaged = []
2206 unmerged = check_conflicts(self.context, s.unmerged)
2207 if unmerged:
2208 unstaged.extend(unmerged)
2209 if s.modified:
2210 unstaged.extend(s.modified)
2211 if s.untracked:
2212 unstaged.extend(s.untracked)
2213 if unstaged:
2214 do(Stage, self.context, unstaged)
2217 class Tag(ContextCommand):
2218 """Create a tag object."""
2220 def __init__(self, context, name, revision, sign=False, message=''):
2221 super(Tag, self).__init__(context)
2222 self._name = name
2223 self._message = message
2224 self._revision = revision
2225 self._sign = sign
2227 def do(self):
2228 result = False
2229 git = self.git
2230 revision = self._revision
2231 tag_name = self._name
2232 tag_message = self._message
2234 if not revision:
2235 Interaction.critical(
2236 N_('Missing Revision'),
2237 N_('Please specify a revision to tag.'))
2238 return result
2240 if not tag_name:
2241 Interaction.critical(
2242 N_('Missing Name'),
2243 N_('Please specify a name for the new tag.'))
2244 return result
2246 title = N_('Missing Tag Message')
2247 message = N_('Tag-signing was requested but the tag message is empty.')
2248 info = N_('An unsigned, lightweight tag will be created instead.\n'
2249 'Create an unsigned tag?')
2250 ok_text = N_('Create Unsigned Tag')
2251 sign = self._sign
2252 if sign and not tag_message:
2253 # We require a message in order to sign the tag, so if they
2254 # choose to create an unsigned tag we have to clear the sign flag.
2255 if not Interaction.confirm(title, message, info, ok_text,
2256 default=False, icon=icons.save()):
2257 return result
2258 sign = False
2260 opts = {}
2261 tmp_file = None
2262 try:
2263 if tag_message:
2264 tmp_file = utils.tmp_filename('tag-message')
2265 opts['file'] = tmp_file
2266 core.write(tmp_file, tag_message)
2268 if sign:
2269 opts['sign'] = True
2270 if tag_message:
2271 opts['annotate'] = True
2272 status, out, err = git.tag(tag_name, revision, **opts)
2273 finally:
2274 if tmp_file:
2275 core.unlink(tmp_file)
2277 title = N_('Error: could not create tag "%s"') % tag_name
2278 Interaction.command(title, 'git tag', status, out, err)
2280 if status == 0:
2281 result = True
2282 self.model.update_status()
2283 Interaction.information(
2284 N_('Tag Created'),
2285 N_('Created a new tag named "%s"') % tag_name,
2286 details=tag_message or None)
2288 return result
2291 class Unstage(ContextCommand):
2292 """Unstage a set of paths."""
2294 @staticmethod
2295 def name():
2296 return N_('Unstage')
2298 def __init__(self, context, paths):
2299 super(Unstage, self).__init__(context)
2300 self.paths = paths
2302 def do(self):
2303 """Unstage paths"""
2304 context = self.context
2305 head = self.model.head
2306 paths = self.paths
2308 msg = N_('Unstaging: %s') % (', '.join(paths))
2309 Interaction.log(msg)
2310 if not paths:
2311 return unstage_all(context)
2312 status, out, err = gitcmds.unstage_paths(context, paths, head=head)
2313 Interaction.command(N_('Error'), 'git reset', status, out, err)
2314 self.model.update_file_status()
2315 return (status, out, err)
2318 class UnstageAll(ContextCommand):
2319 """Unstage all files; resets the index."""
2321 def do(self):
2322 return unstage_all(self.context)
2325 def unstage_all(context):
2326 """Unstage all files, even while amending"""
2327 model = context.model
2328 git = context.git
2329 head = model.head
2330 status, out, err = git.reset(head, '--', '.')
2331 Interaction.command(N_('Error'), 'git reset', status, out, err)
2332 model.update_file_status()
2333 return (status, out, err)
2336 class StageSelected(ContextCommand):
2337 """Stage selected files, or all files if no selection exists."""
2339 def do(self):
2340 context = self.context
2341 paths = self.selection.unstaged
2342 if paths:
2343 do(Stage, context, paths)
2344 elif self.cfg.get('cola.safemode', False):
2345 do(StageModified, context)
2348 class UnstageSelected(Unstage):
2349 """Unstage selected files."""
2351 def __init__(self, context):
2352 staged = self.selection.staged
2353 super(UnstageSelected, self).__init__(context, staged)
2356 class Untrack(ContextCommand):
2357 """Unstage a set of paths."""
2359 def __init__(self, context, paths):
2360 super(Untrack, self).__init__(context)
2361 self.paths = paths
2363 def do(self):
2364 msg = N_('Untracking: %s') % (', '.join(self.paths))
2365 Interaction.log(msg)
2366 status, out, err = self.model.untrack_paths(self.paths)
2367 Interaction.log_status(status, out, err)
2370 class UntrackedSummary(EditModel):
2371 """List possible .gitignore rules as the diff text."""
2373 def __init__(self, context):
2374 super(UntrackedSummary, self).__init__(context)
2375 untracked = self.model.untracked
2376 suffix = 's' if untracked else ''
2377 io = StringIO()
2378 io.write('# %s untracked file%s\n' % (len(untracked), suffix))
2379 if untracked:
2380 io.write('# possible .gitignore rule%s:\n' % suffix)
2381 for u in untracked:
2382 io.write('/'+u+'\n')
2383 self.new_diff_text = io.getvalue()
2384 self.new_diff_type = 'text'
2385 self.new_mode = self.model.mode_untracked
2388 class VisualizeAll(ContextCommand):
2389 """Visualize all branches."""
2391 def do(self):
2392 context = self.context
2393 browser = utils.shell_split(prefs.history_browser(context))
2394 launch_history_browser(browser + ['--all'])
2397 class VisualizeCurrent(ContextCommand):
2398 """Visualize all branches."""
2400 def do(self):
2401 context = self.context
2402 browser = utils.shell_split(prefs.history_browser(context))
2403 launch_history_browser(browser + [self.model.currentbranch] + ['--'])
2406 class VisualizePaths(ContextCommand):
2407 """Path-limited visualization."""
2409 def __init__(self, context, paths):
2410 super(VisualizePaths, self).__init__(context)
2411 context = self.context
2412 browser = utils.shell_split(prefs.history_browser(context))
2413 if paths:
2414 self.argv = browser + ['--'] + list(paths)
2415 else:
2416 self.argv = browser
2418 def do(self):
2419 launch_history_browser(self.argv)
2422 class VisualizeRevision(ContextCommand):
2423 """Visualize a specific revision."""
2425 def __init__(self, context, revision, paths=None):
2426 super(VisualizeRevision, self).__init__(context)
2427 self.revision = revision
2428 self.paths = paths
2430 def do(self):
2431 context = self.context
2432 argv = utils.shell_split(prefs.history_browser(context))
2433 if self.revision:
2434 argv.append(self.revision)
2435 if self.paths:
2436 argv.append('--')
2437 argv.extend(self.paths)
2438 launch_history_browser(argv)
2441 def launch_history_browser(argv):
2442 """Launch the configured history browser"""
2443 try:
2444 core.fork(argv)
2445 except OSError as e:
2446 _, details = utils.format_exception(e)
2447 title = N_('Error Launching History Browser')
2448 msg = (N_('Cannot exec "%s": please configure a history browser') %
2449 ' '.join(argv))
2450 Interaction.critical(title, message=msg, details=details)
2453 def run(cls, *args, **opts):
2455 Returns a callback that runs a command
2457 If the caller of run() provides args or opts then those are
2458 used instead of the ones provided by the invoker of the callback.
2461 def runner(*local_args, **local_opts):
2462 """Closure return by run() which runs the command"""
2463 if args or opts:
2464 do(cls, *args, **opts)
2465 else:
2466 do(cls, *local_args, **local_opts)
2468 return runner
2471 def do(cls, *args, **opts):
2472 """Run a command in-place"""
2473 try:
2474 cmd = cls(*args, **opts)
2475 return cmd.do()
2476 except Exception as e: # pylint: disable=broad-except
2477 msg, details = utils.format_exception(e)
2478 if hasattr(cls, '__name__'):
2479 msg = ('%s exception:\n%s' % (cls.__name__, msg))
2480 Interaction.critical(N_('Error'), message=msg, details=details)
2481 return None
2484 def difftool_run(context):
2485 """Start a default difftool session"""
2486 selection = context.selection
2487 files = selection.group()
2488 if not files:
2489 return
2490 s = selection.selection()
2491 head = context.model.head
2492 difftool_launch_with_head(context, files, bool(s.staged), head)
2495 def difftool_launch_with_head(context, filenames, staged, head):
2496 """Launch difftool against the provided head"""
2497 if head == 'HEAD':
2498 left = None
2499 else:
2500 left = head
2501 difftool_launch(context, left=left, staged=staged, paths=filenames)
2504 def difftool_launch(context, left=None, right=None, paths=None,
2505 staged=False, dir_diff=False,
2506 left_take_magic=False, left_take_parent=False):
2507 """Launches 'git difftool' with given parameters
2509 :param left: first argument to difftool
2510 :param right: second argument to difftool_args
2511 :param paths: paths to diff
2512 :param staged: activate `git difftool --staged`
2513 :param dir_diff: activate `git difftool --dir-diff`
2514 :param left_take_magic: whether to append the magic ^! diff expression
2515 :param left_take_parent: whether to append the first-parent ~ for diffing
2519 difftool_args = ['git', 'difftool', '--no-prompt']
2520 if staged:
2521 difftool_args.append('--cached')
2522 if dir_diff:
2523 difftool_args.append('--dir-diff')
2525 if left:
2526 if left_take_parent or left_take_magic:
2527 suffix = '^!' if left_take_magic else '~'
2528 # Check root commit (no parents and thus cannot execute '~')
2529 git = context.git
2530 status, out, err = git.rev_list(left, parents=True, n=1)
2531 Interaction.log_status(status, out, err)
2532 if status:
2533 raise OSError('git rev-list command failed')
2535 if len(out.split()) >= 2:
2536 # Commit has a parent, so we can take its child as requested
2537 left += suffix
2538 else:
2539 # No parent, assume it's the root commit, so we have to diff
2540 # against the empty tree.
2541 left = EMPTY_TREE_OID
2542 if not right and left_take_magic:
2543 right = left
2544 difftool_args.append(left)
2546 if right:
2547 difftool_args.append(right)
2549 if paths:
2550 difftool_args.append('--')
2551 difftool_args.extend(paths)
2553 runtask = context.runtask
2554 if runtask:
2555 Interaction.async_command(N_('Difftool'), difftool_args, runtask)
2556 else:
2557 core.fork(difftool_args)