tree-wide: trivial code style tweaks
[git-cola.git] / cola / cmds.py
bloba26455193db27316533c4767371e84e679bf3457
1 """Editor commands"""
2 from __future__ import absolute_import, division, print_function, 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 .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 main
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"""
44 UNDOABLE = True
46 def __init__(self, context):
47 """Common edit operations on the main model"""
48 super(EditModel, self).__init__(context)
50 self.old_diff_text = self.model.diff_text
51 self.old_filename = self.model.filename
52 self.old_mode = self.model.mode
53 self.old_diff_type = self.model.diff_type
54 self.old_file_type = self.model.file_type
56 self.new_diff_text = self.old_diff_text
57 self.new_filename = self.old_filename
58 self.new_mode = self.old_mode
59 self.new_diff_type = self.old_diff_type
60 self.new_file_type = self.old_file_type
62 def do(self):
63 """Perform the operation."""
64 self.model.filename = self.new_filename
65 self.model.set_mode(self.new_mode)
66 self.model.set_diff_text(self.new_diff_text)
67 self.model.set_diff_type(self.new_diff_type)
68 self.model.set_file_type(self.new_file_type)
70 def undo(self):
71 """Undo the operation."""
72 self.model.filename = self.old_filename
73 self.model.set_mode(self.old_mode)
74 self.model.set_diff_text(self.old_diff_text)
75 self.model.set_diff_type(self.old_diff_type)
76 self.model.set_file_type(self.old_file_type)
79 class ConfirmAction(ContextCommand):
80 """Confirm an action before running it"""
82 # pylint: disable=no-self-use
83 def ok_to_run(self):
84 """Return True when the command is ok to run"""
85 return True
87 # pylint: disable=no-self-use
88 def confirm(self):
89 """Prompt for confirmation"""
90 return True
92 # pylint: disable=no-self-use
93 def action(self):
94 """Run the command and return (status, out, err)"""
95 return (-1, '', '')
97 # pylint: disable=no-self-use
98 def success(self):
99 """Callback run on success"""
100 return
102 # pylint: disable=no-self-use
103 def command(self):
104 """Command name, for error messages"""
105 return 'git'
107 # pylint: disable=no-self-use
108 def error_message(self):
109 """Command error message"""
110 return ''
112 def do(self):
113 """Prompt for confirmation before running a command"""
114 status = -1
115 out = err = ''
116 ok = self.ok_to_run() and self.confirm()
117 if ok:
118 status, out, err = self.action()
119 if status == 0:
120 self.success()
121 title = self.error_message()
122 cmd = self.command()
123 Interaction.command(title, cmd, status, out, err)
125 return ok, status, out, err
128 class AbortApplyPatch(ConfirmAction):
129 """Reset an in-progress "git am" patch application"""
131 def confirm(self):
132 title = N_('Abort Applying Patch...')
133 question = N_('Aborting applying the current patch?')
134 info = N_(
135 'Aborting a patch can cause uncommitted changes to be lost.\n'
136 'Recovering uncommitted changes is not possible.'
138 ok_txt = N_('Abort Applying Patch')
139 return Interaction.confirm(
140 title, question, info, ok_txt, default=False, icon=icons.undo()
143 def action(self):
144 status, out, err = gitcmds.abort_apply_patch(self.context)
145 self.model.update_file_merge_status()
146 return status, out, err
148 def success(self):
149 self.model.set_commitmsg('')
151 def error_message(self):
152 return N_('Error')
154 def command(self):
155 return 'git am --abort'
158 class AbortCherryPick(ConfirmAction):
159 """Reset an in-progress cherry-pick"""
161 def confirm(self):
162 title = N_('Abort Cherry-Pick...')
163 question = N_('Aborting the current cherry-pick?')
164 info = N_(
165 'Aborting a cherry-pick can cause uncommitted changes to be lost.\n'
166 'Recovering uncommitted changes is not possible.'
168 ok_txt = N_('Abort Cherry-Pick')
169 return Interaction.confirm(
170 title, question, info, ok_txt, default=False, icon=icons.undo()
173 def action(self):
174 status, out, err = gitcmds.abort_cherry_pick(self.context)
175 self.model.update_file_merge_status()
176 return status, out, err
178 def success(self):
179 self.model.set_commitmsg('')
181 def error_message(self):
182 return N_('Error')
184 def command(self):
185 return 'git cherry-pick --abort'
188 class AbortMerge(ConfirmAction):
189 """Reset an in-progress merge back to HEAD"""
191 def confirm(self):
192 title = N_('Abort Merge...')
193 question = N_('Aborting the current merge?')
194 info = N_(
195 'Aborting the current merge will cause '
196 '*ALL* uncommitted changes to be lost.\n'
197 'Recovering uncommitted changes is not possible.'
199 ok_txt = N_('Abort Merge')
200 return Interaction.confirm(
201 title, question, info, ok_txt, default=False, icon=icons.undo()
204 def action(self):
205 status, out, err = gitcmds.abort_merge(self.context)
206 self.model.update_file_merge_status()
207 return status, out, err
209 def success(self):
210 self.model.set_commitmsg('')
212 def error_message(self):
213 return N_('Error')
215 def command(self):
216 return 'git merge'
219 class AmendMode(EditModel):
220 """Try to amend a commit."""
222 UNDOABLE = True
223 LAST_MESSAGE = None
225 @staticmethod
226 def name():
227 return N_('Amend')
229 def __init__(self, context, amend=True):
230 super(AmendMode, self).__init__(context)
231 self.skip = False
232 self.amending = amend
233 self.old_commitmsg = self.model.commitmsg
234 self.old_mode = self.model.mode
236 if self.amending:
237 self.new_mode = self.model.mode_amend
238 self.new_commitmsg = gitcmds.prev_commitmsg(context)
239 AmendMode.LAST_MESSAGE = self.model.commitmsg
240 return
241 # else, amend unchecked, regular commit
242 self.new_mode = self.model.mode_none
243 self.new_diff_text = ''
244 self.new_commitmsg = self.model.commitmsg
245 # If we're going back into new-commit-mode then search the
246 # undo stack for a previous amend-commit-mode and grab the
247 # commit message at that point in time.
248 if AmendMode.LAST_MESSAGE is not None:
249 self.new_commitmsg = AmendMode.LAST_MESSAGE
250 AmendMode.LAST_MESSAGE = None
252 def do(self):
253 """Leave/enter amend mode."""
254 # Attempt to enter amend mode. Do not allow this when merging.
255 if self.amending:
256 if self.model.is_merging:
257 self.skip = True
258 self.model.set_mode(self.old_mode)
259 Interaction.information(
260 N_('Cannot Amend'),
262 'You are in the middle of a merge.\n'
263 'Cannot amend while merging.'
266 return
267 self.skip = False
268 super(AmendMode, self).do()
269 self.model.set_commitmsg(self.new_commitmsg)
270 self.model.update_file_status()
272 def undo(self):
273 if self.skip:
274 return
275 self.model.set_commitmsg(self.old_commitmsg)
276 super(AmendMode, self).undo()
277 self.model.update_file_status()
280 class AnnexAdd(ContextCommand):
281 """Add to Git Annex"""
283 def __init__(self, context):
284 super(AnnexAdd, self).__init__(context)
285 self.filename = self.selection.filename()
287 def do(self):
288 status, out, err = self.git.annex('add', self.filename)
289 Interaction.command(N_('Error'), 'git annex add', status, out, err)
290 self.model.update_status()
293 class AnnexInit(ContextCommand):
294 """Initialize Git Annex"""
296 def do(self):
297 status, out, err = self.git.annex('init')
298 Interaction.command(N_('Error'), 'git annex init', status, out, err)
299 self.model.cfg.reset()
300 self.model.emit_updated()
303 class LFSTrack(ContextCommand):
304 """Add a file to git lfs"""
306 def __init__(self, context):
307 super(LFSTrack, self).__init__(context)
308 self.filename = self.selection.filename()
309 self.stage_cmd = Stage(context, [self.filename])
311 def do(self):
312 status, out, err = self.git.lfs('track', self.filename)
313 Interaction.command(N_('Error'), 'git lfs track', status, out, err)
314 if status == 0:
315 self.stage_cmd.do()
318 class LFSInstall(ContextCommand):
319 """Initialize git lfs"""
321 def do(self):
322 status, out, err = self.git.lfs('install')
323 Interaction.command(N_('Error'), 'git lfs install', status, out, err)
324 self.model.update_config(reset=True, emit=True)
327 class ApplyPatch(ContextCommand):
328 """Apply the specfied patch to the worktree or index"""
330 def __init__(
331 self,
332 context,
333 patch,
334 encoding,
335 apply_to_worktree,
337 super(ApplyPatch, self).__init__(context)
338 self.patch = patch
339 self.encoding = encoding
340 self.apply_to_worktree = apply_to_worktree
342 def do(self):
343 context = self.context
345 tmp_file = utils.tmp_filename('apply', suffix='.patch')
346 try:
347 core.write(tmp_file, self.patch.as_text(), encoding=self.encoding)
348 if self.apply_to_worktree:
349 status, out, err = gitcmds.apply_diff_to_worktree(context, tmp_file)
350 else:
351 status, out, err = gitcmds.apply_diff(context, tmp_file)
352 finally:
353 core.unlink(tmp_file)
355 Interaction.log_status(status, out, err)
356 self.model.update_file_status(update_index=True)
359 class ApplyPatches(ContextCommand):
360 """Apply patches using the "git am" command"""
362 def __init__(self, context, patches):
363 super(ApplyPatches, self).__init__(context)
364 self.patches = patches
366 def do(self):
367 status, output, err = self.git.am('-3', *self.patches)
368 out = '# git am -3 %s\n\n%s' % (core.list2cmdline(self.patches), output)
369 Interaction.command(N_('Patch failed to apply'), 'git am -3', status, out, err)
370 # Display a diffstat
371 self.model.update_file_status()
373 patch_basenames = [os.path.basename(p) for p in self.patches]
374 if len(patch_basenames) > 25:
375 patch_basenames = patch_basenames[:25]
376 patch_basenames.append('...')
378 basenames = '\n'.join(patch_basenames)
379 if status == 0:
380 Interaction.information(
381 N_('Patch(es) Applied'),
382 (N_('%d patch(es) applied.') + '\n\n%s')
383 % (len(self.patches), basenames),
387 class ApplyPatchesContinue(ContextCommand):
388 """Run "git am --continue" to continue on the next patch in a "git am" session"""
390 def do(self):
391 status, out, err = self.git.am('--continue')
392 Interaction.command(
393 N_('Failed to commit and continue applying patches'),
394 'git am --continue',
395 status,
396 out,
397 err,
399 self.model.update_status()
400 return status, out, err
403 class ApplyPatchesSkip(ContextCommand):
404 """Run "git am --skip" to continue on the next patch in a "git am" session"""
406 def do(self):
407 status, out, err = self.git.am(skip=True)
408 Interaction.command(
409 N_('Failed to continue applying patches after skipping the current patch'),
410 'git am --skip',
411 status,
412 out,
413 err,
415 self.model.update_status()
416 return status, out, err
419 class Archive(ContextCommand):
420 """ "Export archives using the "git archive" command"""
422 def __init__(self, context, ref, fmt, prefix, filename):
423 super(Archive, self).__init__(context)
424 self.ref = ref
425 self.fmt = fmt
426 self.prefix = prefix
427 self.filename = filename
429 def do(self):
430 fp = core.xopen(self.filename, 'wb')
431 cmd = ['git', 'archive', '--format=' + self.fmt]
432 if self.fmt in ('tgz', 'tar.gz'):
433 cmd.append('-9')
434 if self.prefix:
435 cmd.append('--prefix=' + self.prefix)
436 cmd.append(self.ref)
437 proc = core.start_command(cmd, stdout=fp)
438 out, err = proc.communicate()
439 fp.close()
440 status = proc.returncode
441 Interaction.log_status(status, out or '', err or '')
444 class Checkout(EditModel):
445 """A command object for git-checkout.
447 'argv' is handed off directly to git.
451 def __init__(self, context, argv, checkout_branch=False):
452 super(Checkout, self).__init__(context)
453 self.argv = argv
454 self.checkout_branch = checkout_branch
455 self.new_diff_text = ''
456 self.new_diff_type = main.Types.TEXT
457 self.new_file_type = main.Types.TEXT
459 def do(self):
460 super(Checkout, self).do()
461 status, out, err = self.git.checkout(*self.argv)
462 if self.checkout_branch:
463 self.model.update_status()
464 else:
465 self.model.update_file_status()
466 Interaction.command(N_('Error'), 'git checkout', status, out, err)
469 class BlamePaths(ContextCommand):
470 """Blame view for paths."""
472 @staticmethod
473 def name():
474 return N_('Blame...')
476 def __init__(self, context, paths=None):
477 super(BlamePaths, self).__init__(context)
478 if not paths:
479 paths = context.selection.union()
480 viewer = utils.shell_split(prefs.blame_viewer(context))
481 self.argv = viewer + list(paths)
483 def do(self):
484 try:
485 core.fork(self.argv)
486 except OSError as e:
487 _, details = utils.format_exception(e)
488 title = N_('Error Launching Blame Viewer')
489 msg = N_('Cannot exec "%s": please configure a blame viewer') % ' '.join(
490 self.argv
492 Interaction.critical(title, message=msg, details=details)
495 class CheckoutBranch(Checkout):
496 """Checkout a branch."""
498 def __init__(self, context, branch):
499 args = [branch]
500 super(CheckoutBranch, self).__init__(context, args, checkout_branch=True)
503 class CherryPick(ContextCommand):
504 """Cherry pick commits into the current branch."""
506 def __init__(self, context, commits):
507 super(CherryPick, self).__init__(context)
508 self.commits = commits
510 def do(self):
511 status, out, err = gitcmds.cherry_pick(self.context, self.commits)
512 self.model.update_file_merge_status()
513 title = N_('Cherry-pick failed')
514 Interaction.command(title, 'git cherry-pick', status, out, err)
517 class Revert(ContextCommand):
518 """Cherry pick commits into the current branch."""
520 def __init__(self, context, oid):
521 super(Revert, self).__init__(context)
522 self.oid = oid
524 def do(self):
525 status, output, err = self.git.revert(self.oid, no_edit=True)
526 self.model.update_file_status()
527 title = N_('Revert failed')
528 out = '# git revert %s\n\n' % self.oid
529 Interaction.command(title, 'git revert', status, out, err)
532 class ResetMode(EditModel):
533 """Reset the mode and clear the model's diff text."""
535 def __init__(self, context):
536 super(ResetMode, self).__init__(context)
537 self.new_mode = self.model.mode_none
538 self.new_diff_text = ''
539 self.new_diff_type = main.Types.TEXT
540 self.new_file_type = main.Types.TEXT
541 self.new_filename = ''
543 def do(self):
544 super(ResetMode, self).do()
545 self.model.update_file_status()
548 class ResetCommand(ConfirmAction):
549 """Reset state using the "git reset" command"""
551 def __init__(self, context, ref):
552 super(ResetCommand, self).__init__(context)
553 self.ref = ref
555 def action(self):
556 return self.reset()
558 def command(self):
559 return 'git reset'
561 def error_message(self):
562 return N_('Error')
564 def success(self):
565 self.model.update_file_status()
567 def confirm(self):
568 raise NotImplementedError('confirm() must be overridden')
570 def reset(self):
571 raise NotImplementedError('reset() must be overridden')
574 class ResetMixed(ResetCommand):
575 @staticmethod
576 def tooltip(ref):
577 tooltip = N_('The branch will be reset using "git reset --mixed %s"')
578 return tooltip % ref
580 def confirm(self):
581 title = N_('Reset Branch and Stage (Mixed)')
582 question = N_('Point the current branch head to a new commit?')
583 info = self.tooltip(self.ref)
584 ok_text = N_('Reset Branch')
585 return Interaction.confirm(title, question, info, ok_text)
587 def reset(self):
588 return self.git.reset(self.ref, '--', mixed=True)
591 class ResetKeep(ResetCommand):
592 @staticmethod
593 def tooltip(ref):
594 tooltip = N_('The repository will be reset using "git reset --keep %s"')
595 return tooltip % ref
597 def confirm(self):
598 title = N_('Restore Worktree and Reset All (Keep Unstaged Changes)')
599 question = N_('Restore worktree, reset, and preserve unstaged edits?')
600 info = self.tooltip(self.ref)
601 ok_text = N_('Reset and Restore')
602 return Interaction.confirm(title, question, info, ok_text)
604 def reset(self):
605 return self.git.reset(self.ref, '--', keep=True)
608 class ResetMerge(ResetCommand):
609 @staticmethod
610 def tooltip(ref):
611 tooltip = N_('The repository will be reset using "git reset --merge %s"')
612 return tooltip % ref
614 def confirm(self):
615 title = N_('Restore Worktree and Reset All (Merge)')
616 question = N_('Reset Worktree and Reset All?')
617 info = self.tooltip(self.ref)
618 ok_text = N_('Reset and Restore')
619 return Interaction.confirm(title, question, info, ok_text)
621 def reset(self):
622 return self.git.reset(self.ref, '--', merge=True)
625 class ResetSoft(ResetCommand):
626 @staticmethod
627 def tooltip(ref):
628 tooltip = N_('The branch will be reset using "git reset --soft %s"')
629 return tooltip % ref
631 def confirm(self):
632 title = N_('Reset Branch (Soft)')
633 question = N_('Reset branch?')
634 info = self.tooltip(self.ref)
635 ok_text = N_('Reset Branch')
636 return Interaction.confirm(title, question, info, ok_text)
638 def reset(self):
639 return self.git.reset(self.ref, '--', soft=True)
642 class ResetHard(ResetCommand):
643 @staticmethod
644 def tooltip(ref):
645 tooltip = N_('The repository will be reset using "git reset --hard %s"')
646 return tooltip % ref
648 def confirm(self):
649 title = N_('Restore Worktree and Reset All (Hard)')
650 question = N_('Restore Worktree and Reset All?')
651 info = self.tooltip(self.ref)
652 ok_text = N_('Reset and Restore')
653 return Interaction.confirm(title, question, info, ok_text)
655 def reset(self):
656 return self.git.reset(self.ref, '--', hard=True)
659 class RestoreWorktree(ConfirmAction):
660 """Reset the worktree using the "git read-tree" command"""
662 @staticmethod
663 def tooltip(ref):
664 tooltip = N_(
665 'The worktree will be restored using "git read-tree --reset -u %s"'
667 return tooltip % ref
669 def __init__(self, context, ref):
670 super(RestoreWorktree, self).__init__(context)
671 self.ref = ref
673 def action(self):
674 return self.git.read_tree(self.ref, reset=True, u=True)
676 def command(self):
677 return 'git read-tree --reset -u %s' % self.ref
679 def error_message(self):
680 return N_('Error')
682 def success(self):
683 self.model.update_file_status()
685 def confirm(self):
686 title = N_('Restore Worktree')
687 question = N_('Restore Worktree to %s?') % self.ref
688 info = self.tooltip(self.ref)
689 ok_text = N_('Restore Worktree')
690 return Interaction.confirm(title, question, info, ok_text)
693 class UndoLastCommit(ResetCommand):
694 """Undo the last commit"""
696 # NOTE: this is the similar to ResetSoft() with an additional check for
697 # published commits and different messages.
698 def __init__(self, context):
699 super(UndoLastCommit, self).__init__(context, 'HEAD^')
701 def confirm(self):
702 check_published = prefs.check_published_commits(self.context)
703 if check_published and self.model.is_commit_published():
704 return Interaction.confirm(
705 N_('Rewrite Published Commit?'),
707 'This commit has already been published.\n'
708 'This operation will rewrite published history.\n'
709 'You probably don\'t want to do this.'
711 N_('Undo the published commit?'),
712 N_('Undo Last Commit'),
713 default=False,
714 icon=icons.save(),
717 title = N_('Undo Last Commit')
718 question = N_('Undo last commit?')
719 info = N_('The branch will be reset using "git reset --soft %s"')
720 ok_text = N_('Undo Last Commit')
721 info_text = info % self.ref
722 return Interaction.confirm(title, question, info_text, ok_text)
724 def reset(self):
725 return self.git.reset('HEAD^', '--', soft=True)
728 class Commit(ResetMode):
729 """Attempt to create a new commit."""
731 def __init__(self, context, amend, msg, sign, no_verify=False):
732 super(Commit, self).__init__(context)
733 self.amend = amend
734 self.msg = msg
735 self.sign = sign
736 self.no_verify = no_verify
737 self.old_commitmsg = self.model.commitmsg
738 self.new_commitmsg = ''
740 def do(self):
741 # Create the commit message file
742 context = self.context
743 comment_char = prefs.comment_char(context)
744 msg = self.strip_comments(self.msg, comment_char=comment_char)
745 tmp_file = utils.tmp_filename('commit-message')
746 try:
747 core.write(tmp_file, msg)
748 # Run 'git commit'
749 status, out, err = self.git.commit(
750 F=tmp_file,
751 v=True,
752 gpg_sign=self.sign,
753 amend=self.amend,
754 no_verify=self.no_verify,
756 finally:
757 core.unlink(tmp_file)
758 if status == 0:
759 super(Commit, self).do()
760 if context.cfg.get(prefs.AUTOTEMPLATE):
761 template_loader = LoadCommitMessageFromTemplate(context)
762 template_loader.do()
763 else:
764 self.model.set_commitmsg(self.new_commitmsg)
766 title = N_('Commit failed')
767 Interaction.command(title, 'git commit', status, out, err)
769 return status, out, err
771 @staticmethod
772 def strip_comments(msg, comment_char='#'):
773 # Strip off comments
774 message_lines = [
775 line for line in msg.split('\n') if not line.startswith(comment_char)
777 msg = '\n'.join(message_lines)
778 if not msg.endswith('\n'):
779 msg += '\n'
781 return msg
784 class CycleReferenceSort(ContextCommand):
785 """Choose the next reference sort type"""
787 def do(self):
788 self.model.cycle_ref_sort()
791 class Ignore(ContextCommand):
792 """Add files to an exclusion file"""
794 def __init__(self, context, filenames, local=False):
795 super(Ignore, self).__init__(context)
796 self.filenames = list(filenames)
797 self.local = local
799 def do(self):
800 if not self.filenames:
801 return
802 new_additions = '\n'.join(self.filenames) + '\n'
803 for_status = new_additions
804 if self.local:
805 filename = os.path.join('.git', 'info', 'exclude')
806 else:
807 filename = '.gitignore'
808 if core.exists(filename):
809 current_list = core.read(filename)
810 new_additions = current_list.rstrip() + '\n' + new_additions
811 core.write(filename, new_additions)
812 Interaction.log_status(0, 'Added to %s:\n%s' % (filename, for_status), '')
813 self.model.update_file_status()
816 def file_summary(files):
817 txt = core.list2cmdline(files)
818 if len(txt) > 768:
819 txt = txt[:768].rstrip() + '...'
820 wrap = textwrap.TextWrapper()
821 return '\n'.join(wrap.wrap(txt))
824 class RemoteCommand(ConfirmAction):
825 def __init__(self, context, remote):
826 super(RemoteCommand, self).__init__(context)
827 self.remote = remote
829 def success(self):
830 self.cfg.reset()
831 self.model.update_remotes()
834 class RemoteAdd(RemoteCommand):
835 def __init__(self, context, remote, url):
836 super(RemoteAdd, self).__init__(context, remote)
837 self.url = url
839 def action(self):
840 return self.git.remote('add', self.remote, self.url)
842 def error_message(self):
843 return N_('Error creating remote "%s"') % self.remote
845 def command(self):
846 return 'git remote add "%s" "%s"' % (self.remote, self.url)
849 class RemoteRemove(RemoteCommand):
850 def confirm(self):
851 title = N_('Delete Remote')
852 question = N_('Delete remote?')
853 info = N_('Delete remote "%s"') % self.remote
854 ok_text = N_('Delete')
855 return Interaction.confirm(title, question, info, ok_text)
857 def action(self):
858 return self.git.remote('rm', self.remote)
860 def error_message(self):
861 return N_('Error deleting remote "%s"') % self.remote
863 def command(self):
864 return 'git remote rm "%s"' % self.remote
867 class RemoteRename(RemoteCommand):
868 def __init__(self, context, remote, new_name):
869 super(RemoteRename, self).__init__(context, remote)
870 self.new_name = new_name
872 def confirm(self):
873 title = N_('Rename Remote')
874 text = N_('Rename remote "%(current)s" to "%(new)s"?') % dict(
875 current=self.remote, new=self.new_name
877 info_text = ''
878 ok_text = title
879 return Interaction.confirm(title, text, info_text, ok_text)
881 def action(self):
882 return self.git.remote('rename', self.remote, self.new_name)
884 def error_message(self):
885 return N_('Error renaming "%(name)s" to "%(new_name)s"') % dict(
886 name=self.remote, new_name=self.new_name
889 def command(self):
890 return 'git remote rename "%s" "%s"' % (self.remote, self.new_name)
893 class RemoteSetURL(RemoteCommand):
894 def __init__(self, context, remote, url):
895 super(RemoteSetURL, self).__init__(context, remote)
896 self.url = url
898 def action(self):
899 return self.git.remote('set-url', self.remote, self.url)
901 def error_message(self):
902 return N_('Unable to set URL for "%(name)s" to "%(url)s"') % dict(
903 name=self.remote, url=self.url
906 def command(self):
907 return 'git remote set-url "%s" "%s"' % (self.remote, self.url)
910 class RemoteEdit(ContextCommand):
911 """Combine RemoteRename and RemoteSetURL"""
913 def __init__(self, context, old_name, remote, url):
914 super(RemoteEdit, self).__init__(context)
915 self.rename = RemoteRename(context, old_name, remote)
916 self.set_url = RemoteSetURL(context, remote, url)
918 def do(self):
919 result = self.rename.do()
920 name_ok = result[0]
921 url_ok = False
922 if name_ok:
923 result = self.set_url.do()
924 url_ok = result[0]
925 return name_ok, url_ok
928 class RemoveFromSettings(ConfirmAction):
929 def __init__(self, context, repo, entry, icon=None):
930 super(RemoveFromSettings, self).__init__(context)
931 self.context = context
932 self.repo = repo
933 self.entry = entry
934 self.icon = icon
936 def success(self):
937 self.context.settings.save()
940 class RemoveBookmark(RemoveFromSettings):
941 def confirm(self):
942 entry = self.entry
943 title = msg = N_('Delete Bookmark?')
944 info = N_('%s will be removed from your bookmarks.') % entry
945 ok_text = N_('Delete Bookmark')
946 return Interaction.confirm(title, msg, info, ok_text, icon=self.icon)
948 def action(self):
949 self.context.settings.remove_bookmark(self.repo, self.entry)
950 return (0, '', '')
953 class RemoveRecent(RemoveFromSettings):
954 def confirm(self):
955 repo = self.repo
956 title = msg = N_('Remove %s from the recent list?') % repo
957 info = N_('%s will be removed from your recent repositories.') % repo
958 ok_text = N_('Remove')
959 return Interaction.confirm(title, msg, info, ok_text, icon=self.icon)
961 def action(self):
962 self.context.settings.remove_recent(self.repo)
963 return (0, '', '')
966 class RemoveFiles(ContextCommand):
967 """Removes files"""
969 def __init__(self, context, remover, filenames):
970 super(RemoveFiles, self).__init__(context)
971 if remover is None:
972 remover = os.remove
973 self.remover = remover
974 self.filenames = filenames
975 # We could git-hash-object stuff and provide undo-ability
976 # as an option. Heh.
978 def do(self):
979 files = self.filenames
980 if not files:
981 return
983 rescan = False
984 bad_filenames = []
985 remove = self.remover
986 for filename in files:
987 if filename:
988 try:
989 remove(filename)
990 rescan = True
991 except OSError:
992 bad_filenames.append(filename)
994 if bad_filenames:
995 Interaction.information(
996 N_('Error'), N_('Deleting "%s" failed') % file_summary(bad_filenames)
999 if rescan:
1000 self.model.update_file_status()
1003 class Delete(RemoveFiles):
1004 """Delete files."""
1006 def __init__(self, context, filenames):
1007 super(Delete, self).__init__(context, os.remove, filenames)
1009 def do(self):
1010 files = self.filenames
1011 if not files:
1012 return
1014 title = N_('Delete Files?')
1015 msg = N_('The following files will be deleted:') + '\n\n'
1016 msg += file_summary(files)
1017 info_txt = N_('Delete %d file(s)?') % len(files)
1018 ok_txt = N_('Delete Files')
1020 if Interaction.confirm(
1021 title, msg, info_txt, ok_txt, default=True, icon=icons.remove()
1023 super(Delete, self).do()
1026 class MoveToTrash(RemoveFiles):
1027 """Move files to the trash using send2trash"""
1029 AVAILABLE = send2trash is not None
1031 def __init__(self, context, filenames):
1032 super(MoveToTrash, self).__init__(context, send2trash, filenames)
1035 class DeleteBranch(ConfirmAction):
1036 """Delete a git branch."""
1038 def __init__(self, context, branch):
1039 super(DeleteBranch, self).__init__(context)
1040 self.branch = branch
1042 def confirm(self):
1043 title = N_('Delete Branch')
1044 question = N_('Delete branch "%s"?') % self.branch
1045 info = N_('The branch will be no longer available.')
1046 ok_txt = N_('Delete Branch')
1047 return Interaction.confirm(
1048 title, question, info, ok_txt, default=True, icon=icons.discard()
1051 def action(self):
1052 return self.model.delete_branch(self.branch)
1054 def error_message(self):
1055 return N_('Error deleting branch "%s"' % self.branch)
1057 def command(self):
1058 command = 'git branch -D %s'
1059 return command % self.branch
1062 class Rename(ContextCommand):
1063 """Rename a set of paths."""
1065 def __init__(self, context, paths):
1066 super(Rename, self).__init__(context)
1067 self.paths = paths
1069 def do(self):
1070 msg = N_('Untracking: %s') % (', '.join(self.paths))
1071 Interaction.log(msg)
1073 for path in self.paths:
1074 ok = self.rename(path)
1075 if not ok:
1076 return
1078 self.model.update_status()
1080 def rename(self, path):
1081 git = self.git
1082 title = N_('Rename "%s"') % path
1084 if os.path.isdir(path):
1085 base_path = os.path.dirname(path)
1086 else:
1087 base_path = path
1088 new_path = Interaction.save_as(base_path, title)
1089 if not new_path:
1090 return False
1092 status, out, err = git.mv(path, new_path, force=True, verbose=True)
1093 Interaction.command(N_('Error'), 'git mv', status, out, err)
1094 return status == 0
1097 class RenameBranch(ContextCommand):
1098 """Rename a git branch."""
1100 def __init__(self, context, branch, new_branch):
1101 super(RenameBranch, self).__init__(context)
1102 self.branch = branch
1103 self.new_branch = new_branch
1105 def do(self):
1106 branch = self.branch
1107 new_branch = self.new_branch
1108 status, out, err = self.model.rename_branch(branch, new_branch)
1109 Interaction.log_status(status, out, err)
1112 class DeleteRemoteBranch(DeleteBranch):
1113 """Delete a remote git branch."""
1115 def __init__(self, context, remote, branch):
1116 super(DeleteRemoteBranch, self).__init__(context, branch)
1117 self.remote = remote
1119 def action(self):
1120 return self.git.push(self.remote, self.branch, delete=True)
1122 def success(self):
1123 self.model.update_status()
1124 Interaction.information(
1125 N_('Remote Branch Deleted'),
1126 N_('"%(branch)s" has been deleted from "%(remote)s".')
1127 % dict(branch=self.branch, remote=self.remote),
1130 def error_message(self):
1131 return N_('Error Deleting Remote Branch')
1133 def command(self):
1134 command = 'git push --delete %s %s'
1135 return command % (self.remote, self.branch)
1138 def get_mode(context, filename, staged, modified, unmerged, untracked):
1139 model = context.model
1140 if staged:
1141 mode = model.mode_index
1142 elif modified or unmerged:
1143 mode = model.mode_worktree
1144 elif untracked:
1145 if gitcmds.is_binary(context, filename):
1146 mode = model.mode_untracked
1147 else:
1148 mode = model.mode_untracked_diff
1149 else:
1150 mode = model.mode
1151 return mode
1154 class DiffAgainstCommitMode(ContextCommand):
1155 """Diff against arbitrary commits"""
1157 def __init__(self, context, oid):
1158 super(DiffAgainstCommitMode, self).__init__(context)
1159 self.oid = oid
1161 def do(self):
1162 self.model.set_mode(self.model.mode_diff, head=self.oid)
1163 self.model.update_file_status()
1166 class DiffText(EditModel):
1167 """Set the diff type to text"""
1169 def __init__(self, context):
1170 super(DiffText, self).__init__(context)
1171 self.new_file_type = main.Types.TEXT
1172 self.new_diff_type = main.Types.TEXT
1175 class ToggleDiffType(ContextCommand):
1176 """Toggle the diff type between image and text"""
1178 def __init__(self, context):
1179 super(ToggleDiffType, self).__init__(context)
1180 if self.model.diff_type == main.Types.IMAGE:
1181 self.new_diff_type = main.Types.TEXT
1182 self.new_value = False
1183 else:
1184 self.new_diff_type = main.Types.IMAGE
1185 self.new_value = True
1187 def do(self):
1188 diff_type = self.new_diff_type
1189 value = self.new_value
1191 self.model.set_diff_type(diff_type)
1193 filename = self.model.filename
1194 _, ext = os.path.splitext(filename)
1195 if ext.startswith('.'):
1196 cfg = 'cola.imagediff' + ext
1197 self.cfg.set_repo(cfg, value)
1200 class DiffImage(EditModel):
1201 def __init__(
1202 self, context, filename, deleted, staged, modified, unmerged, untracked
1204 super(DiffImage, self).__init__(context)
1206 self.new_filename = filename
1207 self.new_diff_type = self.get_diff_type(filename)
1208 self.new_file_type = main.Types.IMAGE
1209 self.new_mode = get_mode(
1210 context, filename, staged, modified, unmerged, untracked
1212 self.staged = staged
1213 self.modified = modified
1214 self.unmerged = unmerged
1215 self.untracked = untracked
1216 self.deleted = deleted
1217 self.annex = self.cfg.is_annex()
1219 def get_diff_type(self, filename):
1220 """Query the diff type to use based on cola.imagediff.<extension>"""
1221 _, ext = os.path.splitext(filename)
1222 if ext.startswith('.'):
1223 # Check eg. "cola.imagediff.svg" to see if we should imagediff.
1224 cfg = 'cola.imagediff' + ext
1225 if self.cfg.get(cfg, True):
1226 result = main.Types.IMAGE
1227 else:
1228 result = main.Types.TEXT
1229 else:
1230 result = main.Types.IMAGE
1231 return result
1233 def do(self):
1234 filename = self.new_filename
1236 if self.staged:
1237 images = self.staged_images()
1238 elif self.modified:
1239 images = self.modified_images()
1240 elif self.unmerged:
1241 images = self.unmerged_images()
1242 elif self.untracked:
1243 images = [(filename, False)]
1244 else:
1245 images = []
1247 self.model.set_images(images)
1248 super(DiffImage, self).do()
1250 def staged_images(self):
1251 context = self.context
1252 git = self.git
1253 head = self.model.head
1254 filename = self.new_filename
1255 annex = self.annex
1257 images = []
1258 index = git.diff_index(head, '--', filename, cached=True)[STDOUT]
1259 if index:
1260 # Example:
1261 # :100644 100644 fabadb8... 4866510... M describe.c
1262 parts = index.split(' ')
1263 if len(parts) > 3:
1264 old_oid = parts[2]
1265 new_oid = parts[3]
1267 if old_oid != MISSING_BLOB_OID:
1268 # First, check if we can get a pre-image from git-annex
1269 annex_image = None
1270 if annex:
1271 annex_image = gitcmds.annex_path(context, head, filename)
1272 if annex_image:
1273 images.append((annex_image, False)) # git annex HEAD
1274 else:
1275 image = gitcmds.write_blob_path(context, head, old_oid, filename)
1276 if image:
1277 images.append((image, True))
1279 if new_oid != MISSING_BLOB_OID:
1280 found_in_annex = False
1281 if annex and core.islink(filename):
1282 status, out, _ = git.annex('status', '--', filename)
1283 if status == 0:
1284 details = out.split(' ')
1285 if details and details[0] == 'A': # newly added file
1286 images.append((filename, False))
1287 found_in_annex = True
1289 if not found_in_annex:
1290 image = gitcmds.write_blob(context, new_oid, filename)
1291 if image:
1292 images.append((image, True))
1294 return images
1296 def unmerged_images(self):
1297 context = self.context
1298 git = self.git
1299 head = self.model.head
1300 filename = self.new_filename
1301 annex = self.annex
1303 candidate_merge_heads = ('HEAD', 'CHERRY_HEAD', 'MERGE_HEAD')
1304 merge_heads = [
1305 merge_head
1306 for merge_head in candidate_merge_heads
1307 if core.exists(git.git_path(merge_head))
1310 if annex: # Attempt to find files in git-annex
1311 annex_images = []
1312 for merge_head in merge_heads:
1313 image = gitcmds.annex_path(context, merge_head, filename)
1314 if image:
1315 annex_images.append((image, False))
1316 if annex_images:
1317 annex_images.append((filename, False))
1318 return annex_images
1320 # DIFF FORMAT FOR MERGES
1321 # "git-diff-tree", "git-diff-files" and "git-diff --raw"
1322 # can take -c or --cc option to generate diff output also
1323 # for merge commits. The output differs from the format
1324 # described above in the following way:
1326 # 1. there is a colon for each parent
1327 # 2. there are more "src" modes and "src" sha1
1328 # 3. status is concatenated status characters for each parent
1329 # 4. no optional "score" number
1330 # 5. single path, only for "dst"
1331 # Example:
1332 # ::100644 100644 100644 fabadb8... cc95eb0... 4866510... \
1333 # MM describe.c
1334 images = []
1335 index = git.diff_index(head, '--', filename, cached=True, cc=True)[STDOUT]
1336 if index:
1337 parts = index.split(' ')
1338 if len(parts) > 3:
1339 first_mode = parts[0]
1340 num_parents = first_mode.count(':')
1341 # colon for each parent, but for the index, the "parents"
1342 # are really entries in stages 1,2,3 (head, base, remote)
1343 # remote, base, head
1344 for i in range(num_parents):
1345 offset = num_parents + i + 1
1346 oid = parts[offset]
1347 try:
1348 merge_head = merge_heads[i]
1349 except IndexError:
1350 merge_head = 'HEAD'
1351 if oid != MISSING_BLOB_OID:
1352 image = gitcmds.write_blob_path(
1353 context, merge_head, oid, filename
1355 if image:
1356 images.append((image, True))
1358 images.append((filename, False))
1359 return images
1361 def modified_images(self):
1362 context = self.context
1363 git = self.git
1364 head = self.model.head
1365 filename = self.new_filename
1366 annex = self.annex
1368 images = []
1369 annex_image = None
1370 if annex: # Check for a pre-image from git-annex
1371 annex_image = gitcmds.annex_path(context, head, filename)
1372 if annex_image:
1373 images.append((annex_image, False)) # git annex HEAD
1374 else:
1375 worktree = git.diff_files('--', filename)[STDOUT]
1376 parts = worktree.split(' ')
1377 if len(parts) > 3:
1378 oid = parts[2]
1379 if oid != MISSING_BLOB_OID:
1380 image = gitcmds.write_blob_path(context, head, oid, filename)
1381 if image:
1382 images.append((image, True)) # HEAD
1384 images.append((filename, False)) # worktree
1385 return images
1388 class Diff(EditModel):
1389 """Perform a diff and set the model's current text."""
1391 def __init__(self, context, filename, cached=False, deleted=False):
1392 super(Diff, self).__init__(context)
1393 opts = {}
1394 if cached and gitcmds.is_valid_ref(context, self.model.head):
1395 opts['ref'] = self.model.head
1396 self.new_filename = filename
1397 self.new_mode = self.model.mode_worktree
1398 self.new_diff_text = gitcmds.diff_helper(
1399 self.context, filename=filename, cached=cached, deleted=deleted, **opts
1403 class Diffstat(EditModel):
1404 """Perform a diffstat and set the model's diff text."""
1406 def __init__(self, context):
1407 super(Diffstat, self).__init__(context)
1408 cfg = self.cfg
1409 diff_context = cfg.get('diff.context', 3)
1410 diff = self.git.diff(
1411 self.model.head,
1412 unified=diff_context,
1413 no_ext_diff=True,
1414 no_color=True,
1415 M=True,
1416 stat=True,
1417 )[STDOUT]
1418 self.new_diff_text = diff
1419 self.new_diff_type = main.Types.TEXT
1420 self.new_file_type = main.Types.TEXT
1421 self.new_mode = self.model.mode_diffstat
1424 class DiffStaged(Diff):
1425 """Perform a staged diff on a file."""
1427 def __init__(self, context, filename, deleted=None):
1428 super(DiffStaged, self).__init__(
1429 context, filename, cached=True, deleted=deleted
1431 self.new_mode = self.model.mode_index
1434 class DiffStagedSummary(EditModel):
1435 def __init__(self, context):
1436 super(DiffStagedSummary, self).__init__(context)
1437 diff = self.git.diff(
1438 self.model.head,
1439 cached=True,
1440 no_color=True,
1441 no_ext_diff=True,
1442 patch_with_stat=True,
1443 M=True,
1444 )[STDOUT]
1445 self.new_diff_text = diff
1446 self.new_diff_type = main.Types.TEXT
1447 self.new_file_type = main.Types.TEXT
1448 self.new_mode = self.model.mode_index
1451 class Difftool(ContextCommand):
1452 """Run git-difftool limited by path."""
1454 def __init__(self, context, staged, filenames):
1455 super(Difftool, self).__init__(context)
1456 self.staged = staged
1457 self.filenames = filenames
1459 def do(self):
1460 difftool_launch_with_head(
1461 self.context, self.filenames, self.staged, self.model.head
1465 class Edit(ContextCommand):
1466 """Edit a file using the configured gui.editor."""
1468 @staticmethod
1469 def name():
1470 return N_('Launch Editor')
1472 def __init__(self, context, filenames, line_number=None, background_editor=False):
1473 super(Edit, self).__init__(context)
1474 self.filenames = filenames
1475 self.line_number = line_number
1476 self.background_editor = background_editor
1478 def do(self):
1479 context = self.context
1480 if not self.filenames:
1481 return
1482 filename = self.filenames[0]
1483 if not core.exists(filename):
1484 return
1485 if self.background_editor:
1486 editor = prefs.background_editor(context)
1487 else:
1488 editor = prefs.editor(context)
1489 opts = []
1491 if self.line_number is None:
1492 opts = self.filenames
1493 else:
1494 # Single-file w/ line-numbers (likely from grep)
1495 editor_opts = {
1496 '*vim*': [filename, '+%s' % self.line_number],
1497 '*emacs*': ['+%s' % self.line_number, filename],
1498 '*textpad*': ['%s(%s,0)' % (filename, self.line_number)],
1499 '*notepad++*': ['-n%s' % self.line_number, filename],
1500 '*subl*': ['%s:%s' % (filename, self.line_number)],
1503 opts = self.filenames
1504 for pattern, opt in editor_opts.items():
1505 if fnmatch(editor, pattern):
1506 opts = opt
1507 break
1509 try:
1510 core.fork(utils.shell_split(editor) + opts)
1511 except (OSError, ValueError) as e:
1512 message = N_('Cannot exec "%s": please configure your editor') % editor
1513 _, details = utils.format_exception(e)
1514 Interaction.critical(N_('Error Editing File'), message, details)
1517 class FormatPatch(ContextCommand):
1518 """Output a patch series given all revisions and a selected subset."""
1520 def __init__(self, context, to_export, revs, output='patches'):
1521 super(FormatPatch, self).__init__(context)
1522 self.to_export = list(to_export)
1523 self.revs = list(revs)
1524 self.output = output
1526 def do(self):
1527 context = self.context
1528 status, out, err = gitcmds.format_patchsets(
1529 context, self.to_export, self.revs, self.output
1531 Interaction.log_status(status, out, err)
1534 class LaunchDifftool(ContextCommand):
1535 @staticmethod
1536 def name():
1537 return N_('Launch Diff Tool')
1539 def do(self):
1540 s = self.selection.selection()
1541 if s.unmerged:
1542 paths = s.unmerged
1543 if utils.is_win32():
1544 core.fork(['git', 'mergetool', '--no-prompt', '--'] + paths)
1545 else:
1546 cfg = self.cfg
1547 cmd = cfg.terminal()
1548 argv = utils.shell_split(cmd)
1550 terminal = os.path.basename(argv[0])
1551 shellquote_terms = set(['xfce4-terminal'])
1552 shellquote_default = terminal in shellquote_terms
1554 mergetool = ['git', 'mergetool', '--no-prompt', '--']
1555 mergetool.extend(paths)
1556 needs_shellquote = cfg.get(
1557 'cola.terminalshellquote', shellquote_default
1560 if needs_shellquote:
1561 argv.append(core.list2cmdline(mergetool))
1562 else:
1563 argv.extend(mergetool)
1565 core.fork(argv)
1566 else:
1567 difftool_run(self.context)
1570 class LaunchTerminal(ContextCommand):
1571 @staticmethod
1572 def name():
1573 return N_('Launch Terminal')
1575 @staticmethod
1576 def is_available(context):
1577 return context.cfg.terminal() is not None
1579 def __init__(self, context, path):
1580 super(LaunchTerminal, self).__init__(context)
1581 self.path = path
1583 def do(self):
1584 cmd = self.context.cfg.terminal()
1585 if cmd is None:
1586 return
1587 if utils.is_win32():
1588 argv = ['start', '', cmd, '--login']
1589 shell = True
1590 else:
1591 argv = utils.shell_split(cmd)
1592 command = '/bin/sh'
1593 shells = ('zsh', 'fish', 'bash', 'sh')
1594 for basename in shells:
1595 executable = core.find_executable(basename)
1596 if executable:
1597 command = executable
1598 break
1599 argv.append(os.getenv('SHELL', command))
1600 shell = False
1602 core.fork(argv, cwd=self.path, shell=shell)
1605 class LaunchEditor(Edit):
1606 @staticmethod
1607 def name():
1608 return N_('Launch Editor')
1610 def __init__(self, context):
1611 s = context.selection.selection()
1612 filenames = s.staged + s.unmerged + s.modified + s.untracked
1613 super(LaunchEditor, self).__init__(context, filenames, background_editor=True)
1616 class LaunchEditorAtLine(LaunchEditor):
1617 """Launch an editor at the specified line"""
1619 def __init__(self, context):
1620 super(LaunchEditorAtLine, self).__init__(context)
1621 self.line_number = context.selection.line_number
1624 class LoadCommitMessageFromFile(ContextCommand):
1625 """Loads a commit message from a path."""
1627 UNDOABLE = True
1629 def __init__(self, context, path):
1630 super(LoadCommitMessageFromFile, self).__init__(context)
1631 self.path = path
1632 self.old_commitmsg = self.model.commitmsg
1633 self.old_directory = self.model.directory
1635 def do(self):
1636 path = os.path.expanduser(self.path)
1637 if not path or not core.isfile(path):
1638 raise UsageError(
1639 N_('Error: Cannot find commit template'),
1640 N_('%s: No such file or directory.') % path,
1642 self.model.set_directory(os.path.dirname(path))
1643 self.model.set_commitmsg(core.read(path))
1645 def undo(self):
1646 self.model.set_commitmsg(self.old_commitmsg)
1647 self.model.set_directory(self.old_directory)
1650 class LoadCommitMessageFromTemplate(LoadCommitMessageFromFile):
1651 """Loads the commit message template specified by commit.template."""
1653 def __init__(self, context):
1654 cfg = context.cfg
1655 template = cfg.get('commit.template')
1656 super(LoadCommitMessageFromTemplate, self).__init__(context, template)
1658 def do(self):
1659 if self.path is None:
1660 raise UsageError(
1661 N_('Error: Unconfigured commit template'),
1663 'A commit template has not been configured.\n'
1664 'Use "git config" to define "commit.template"\n'
1665 'so that it points to a commit template.'
1668 return LoadCommitMessageFromFile.do(self)
1671 class LoadCommitMessageFromOID(ContextCommand):
1672 """Load a previous commit message"""
1674 UNDOABLE = True
1676 def __init__(self, context, oid, prefix=''):
1677 super(LoadCommitMessageFromOID, self).__init__(context)
1678 self.oid = oid
1679 self.old_commitmsg = self.model.commitmsg
1680 self.new_commitmsg = prefix + gitcmds.prev_commitmsg(context, oid)
1682 def do(self):
1683 self.model.set_commitmsg(self.new_commitmsg)
1685 def undo(self):
1686 self.model.set_commitmsg(self.old_commitmsg)
1689 class PrepareCommitMessageHook(ContextCommand):
1690 """Use the cola-prepare-commit-msg hook to prepare the commit message"""
1692 UNDOABLE = True
1694 def __init__(self, context):
1695 super(PrepareCommitMessageHook, self).__init__(context)
1696 self.old_commitmsg = self.model.commitmsg
1698 def get_message(self):
1700 title = N_('Error running prepare-commitmsg hook')
1701 hook = gitcmds.prepare_commit_message_hook(self.context)
1703 if os.path.exists(hook):
1704 filename = self.model.save_commitmsg()
1705 status, out, err = core.run_command([hook, filename])
1707 if status == 0:
1708 result = core.read(filename)
1709 else:
1710 result = self.old_commitmsg
1711 Interaction.command_error(title, hook, status, out, err)
1712 else:
1713 message = N_('A hook must be provided at "%s"') % hook
1714 Interaction.critical(title, message=message)
1715 result = self.old_commitmsg
1717 return result
1719 def do(self):
1720 msg = self.get_message()
1721 self.model.set_commitmsg(msg)
1723 def undo(self):
1724 self.model.set_commitmsg(self.old_commitmsg)
1727 class LoadFixupMessage(LoadCommitMessageFromOID):
1728 """Load a fixup message"""
1730 def __init__(self, context, oid):
1731 super(LoadFixupMessage, self).__init__(context, oid, prefix='fixup! ')
1732 if self.new_commitmsg:
1733 self.new_commitmsg = self.new_commitmsg.splitlines()[0]
1736 class Merge(ContextCommand):
1737 """Merge commits"""
1739 def __init__(self, context, revision, no_commit, squash, no_ff, sign):
1740 super(Merge, self).__init__(context)
1741 self.revision = revision
1742 self.no_ff = no_ff
1743 self.no_commit = no_commit
1744 self.squash = squash
1745 self.sign = sign
1747 def do(self):
1748 squash = self.squash
1749 revision = self.revision
1750 no_ff = self.no_ff
1751 no_commit = self.no_commit
1752 sign = self.sign
1754 status, out, err = self.git.merge(
1755 revision, gpg_sign=sign, no_ff=no_ff, no_commit=no_commit, squash=squash
1757 self.model.update_status()
1758 title = N_('Merge failed. Conflict resolution is required.')
1759 Interaction.command(title, 'git merge', status, out, err)
1761 return status, out, err
1764 class OpenDefaultApp(ContextCommand):
1765 """Open a file using the OS default."""
1767 @staticmethod
1768 def name():
1769 return N_('Open Using Default Application')
1771 def __init__(self, context, filenames):
1772 super(OpenDefaultApp, self).__init__(context)
1773 self.filenames = filenames
1775 def do(self):
1776 if not self.filenames:
1777 return
1778 utils.launch_default_app(self.filenames)
1781 class OpenDir(OpenDefaultApp):
1782 """Open directories using the OS default."""
1784 @staticmethod
1785 def name():
1786 return N_('Open Directory')
1788 @property
1789 def _dirnames(self):
1790 return self.filenames
1792 def do(self):
1793 dirnames = self._dirnames
1794 if not dirnames:
1795 return
1796 # An empty dirname defaults to CWD.
1797 dirs = [(dirname or core.getcwd()) for dirname in dirnames]
1798 utils.launch_default_app(dirs)
1801 class OpenParentDir(OpenDir):
1802 """Open parent directories using the OS default."""
1804 @staticmethod
1805 def name():
1806 return N_('Open Parent Directory')
1808 @property
1809 def _dirnames(self):
1810 dirnames = list(set([os.path.dirname(x) for x in self.filenames]))
1811 return dirnames
1814 class OpenWorktree(OpenDir):
1815 """Open worktree directory using the OS default."""
1817 @staticmethod
1818 def name():
1819 return N_('Open Worktree')
1821 # The _unused parameter is needed by worktree_dir_action() -> common.cmd_action().
1822 def __init__(self, context, _unused=None):
1823 dirnames = [context.git.worktree()]
1824 super(OpenWorktree, self).__init__(context, dirnames)
1827 class OpenNewRepo(ContextCommand):
1828 """Launches git-cola on a repo."""
1830 def __init__(self, context, repo_path):
1831 super(OpenNewRepo, self).__init__(context)
1832 self.repo_path = repo_path
1834 def do(self):
1835 self.model.set_directory(self.repo_path)
1836 core.fork([sys.executable, sys.argv[0], '--repo', self.repo_path])
1839 class OpenRepo(EditModel):
1840 def __init__(self, context, repo_path):
1841 super(OpenRepo, self).__init__(context)
1842 self.repo_path = repo_path
1843 self.new_mode = self.model.mode_none
1844 self.new_diff_text = ''
1845 self.new_diff_type = main.Types.TEXT
1846 self.new_file_type = main.Types.TEXT
1847 self.new_commitmsg = ''
1848 self.new_filename = ''
1850 def do(self):
1851 old_repo = self.git.getcwd()
1852 if self.model.set_worktree(self.repo_path):
1853 self.fsmonitor.stop()
1854 self.fsmonitor.start()
1855 self.model.update_status(reset=True)
1856 # Check if template should be loaded
1857 if self.context.cfg.get(prefs.AUTOTEMPLATE):
1858 template_loader = LoadCommitMessageFromTemplate(self.context)
1859 template_loader.do()
1860 else:
1861 self.model.set_commitmsg(self.new_commitmsg)
1862 settings = self.context.settings
1863 settings.load()
1864 settings.add_recent(self.repo_path, prefs.maxrecent(self.context))
1865 settings.save()
1866 super(OpenRepo, self).do()
1867 else:
1868 self.model.set_worktree(old_repo)
1871 class OpenParentRepo(OpenRepo):
1872 def __init__(self, context):
1873 path = ''
1874 if version.check_git(context, 'show-superproject-working-tree'):
1875 status, out, _ = context.git.rev_parse(show_superproject_working_tree=True)
1876 if status == 0:
1877 path = out
1878 if not path:
1879 path = os.path.dirname(core.getcwd())
1880 super(OpenParentRepo, self).__init__(context, path)
1883 class Clone(ContextCommand):
1884 """Clones a repository and optionally spawns a new cola session."""
1886 def __init__(
1887 self, context, url, new_directory, submodules=False, shallow=False, spawn=True
1889 super(Clone, self).__init__(context)
1890 self.url = url
1891 self.new_directory = new_directory
1892 self.submodules = submodules
1893 self.shallow = shallow
1894 self.spawn = spawn
1895 self.status = -1
1896 self.out = ''
1897 self.err = ''
1899 def do(self):
1900 kwargs = {}
1901 if self.shallow:
1902 kwargs['depth'] = 1
1903 recurse_submodules = self.submodules
1904 shallow_submodules = self.submodules and self.shallow
1906 status, out, err = self.git.clone(
1907 self.url,
1908 self.new_directory,
1909 recurse_submodules=recurse_submodules,
1910 shallow_submodules=shallow_submodules,
1911 **kwargs
1914 self.status = status
1915 self.out = out
1916 self.err = err
1917 if status == 0 and self.spawn:
1918 executable = sys.executable
1919 core.fork([executable, sys.argv[0], '--repo', self.new_directory])
1920 return self
1923 class NewBareRepo(ContextCommand):
1924 """Create a new shared bare repository"""
1926 def __init__(self, context, path):
1927 super(NewBareRepo, self).__init__(context)
1928 self.path = path
1930 def do(self):
1931 path = self.path
1932 status, out, err = self.git.init(path, bare=True, shared=True)
1933 Interaction.command(
1934 N_('Error'), 'git init --bare --shared "%s"' % path, status, out, err
1936 return status == 0
1939 def unix_path(path, is_win32=utils.is_win32):
1940 """Git for Windows requires unix paths, so force them here"""
1941 if is_win32():
1942 path = path.replace('\\', '/')
1943 first = path[0]
1944 second = path[1]
1945 if second == ':': # sanity check, this better be a Windows-style path
1946 path = '/' + first + path[2:]
1948 return path
1951 def sequence_editor():
1952 """Set GIT_SEQUENCE_EDITOR for running git-cola-sequence-editor"""
1953 xbase = unix_path(resources.command('git-cola-sequence-editor'))
1954 if utils.is_win32():
1955 editor = core.list2cmdline([unix_path(sys.executable), xbase])
1956 else:
1957 editor = core.list2cmdline([xbase])
1958 return editor
1961 class SequenceEditorEnvironment(object):
1962 """Set environment variables to enable git-cola-sequence-editor"""
1964 def __init__(self, context, **kwargs):
1965 self.env = {
1966 'GIT_EDITOR': prefs.editor(context),
1967 'GIT_SEQUENCE_EDITOR': sequence_editor(),
1968 'GIT_COLA_SEQ_EDITOR_CANCEL_ACTION': 'save',
1970 self.env.update(kwargs)
1972 def __enter__(self):
1973 for var, value in self.env.items():
1974 compat.setenv(var, value)
1975 return self
1977 def __exit__(self, exc_type, exc_val, exc_tb):
1978 for var in self.env:
1979 compat.unsetenv(var)
1982 class Rebase(ContextCommand):
1983 def __init__(self, context, upstream=None, branch=None, **kwargs):
1984 """Start an interactive rebase session
1986 :param upstream: upstream branch
1987 :param branch: optional branch to checkout
1988 :param kwargs: forwarded directly to `git.rebase()`
1991 super(Rebase, self).__init__(context)
1993 self.upstream = upstream
1994 self.branch = branch
1995 self.kwargs = kwargs
1997 def prepare_arguments(self, upstream):
1998 args = []
1999 kwargs = {}
2001 # Rebase actions must be the only option specified
2002 for action in ('continue', 'abort', 'skip', 'edit_todo'):
2003 if self.kwargs.get(action, False):
2004 kwargs[action] = self.kwargs[action]
2005 return args, kwargs
2007 kwargs['interactive'] = True
2008 kwargs['autosquash'] = self.kwargs.get('autosquash', True)
2009 kwargs.update(self.kwargs)
2011 # Prompt to determine whether or not to use "git rebase --update-refs".
2012 has_update_refs = version.check_git(self.context, 'rebase-update-refs')
2013 if has_update_refs and not kwargs.get('update_refs', False):
2014 title = N_('Update stacked branches when rebasing?')
2015 text = N_(
2016 '"git rebase --update-refs" automatically force-updates any\n'
2017 'branches that point to commits that are being rebased.\n\n'
2018 'Any branches that are checked out in a worktree are not updated.\n\n'
2019 'Using this feature is helpful for "stacked" branch workflows.'
2021 info = N_('Update stacked branches when rebasing?')
2022 ok_text = N_('Update stacked branches')
2023 cancel_text = N_('Do not update stacked branches')
2024 update_refs = Interaction.confirm(
2025 title,
2026 text,
2027 info,
2028 ok_text,
2029 default=True,
2030 cancel_text=cancel_text,
2032 if update_refs:
2033 kwargs['update_refs'] = True
2035 if upstream:
2036 args.append(upstream)
2037 if self.branch:
2038 args.append(self.branch)
2040 return args, kwargs
2042 def do(self):
2043 (status, out, err) = (1, '', '')
2044 context = self.context
2045 cfg = self.cfg
2046 model = self.model
2048 if not cfg.get('rebase.autostash', False):
2049 if model.staged or model.unmerged or model.modified:
2050 Interaction.information(
2051 N_('Unable to rebase'),
2052 N_('You cannot rebase with uncommitted changes.'),
2054 return status, out, err
2056 upstream = self.upstream or Interaction.choose_ref(
2057 context,
2058 N_('Select New Upstream'),
2059 N_('Interactive Rebase'),
2060 default='@{upstream}',
2062 if not upstream:
2063 return status, out, err
2065 self.model.is_rebasing = True
2066 self.model.emit_updated()
2068 args, kwargs = self.prepare_arguments(upstream)
2069 upstream_title = upstream or '@{upstream}'
2070 with SequenceEditorEnvironment(
2071 self.context,
2072 GIT_COLA_SEQ_EDITOR_TITLE=N_('Rebase onto %s') % upstream_title,
2073 GIT_COLA_SEQ_EDITOR_ACTION=N_('Rebase'),
2075 # TODO this blocks the user interface window for the duration
2076 # of git-cola-sequence-editor. We would need to implement
2077 # signals for QProcess and continue running the main thread.
2078 # Alternatively, we can hide the main window while rebasing.
2079 # That doesn't require as much effort.
2080 status, out, err = self.git.rebase(
2081 *args, _no_win32_startupinfo=True, **kwargs
2083 self.model.update_status()
2084 if err.strip() != 'Nothing to do':
2085 title = N_('Rebase stopped')
2086 Interaction.command(title, 'git rebase', status, out, err)
2087 return status, out, err
2090 class RebaseEditTodo(ContextCommand):
2091 def do(self):
2092 (status, out, err) = (1, '', '')
2093 with SequenceEditorEnvironment(
2094 self.context,
2095 GIT_COLA_SEQ_EDITOR_TITLE=N_('Edit Rebase'),
2096 GIT_COLA_SEQ_EDITOR_ACTION=N_('Save'),
2098 status, out, err = self.git.rebase(edit_todo=True)
2099 Interaction.log_status(status, out, err)
2100 self.model.update_status()
2101 return status, out, err
2104 class RebaseContinue(ContextCommand):
2105 def do(self):
2106 (status, out, err) = (1, '', '')
2107 with SequenceEditorEnvironment(
2108 self.context,
2109 GIT_COLA_SEQ_EDITOR_TITLE=N_('Rebase'),
2110 GIT_COLA_SEQ_EDITOR_ACTION=N_('Rebase'),
2112 status, out, err = self.git.rebase('--continue')
2113 Interaction.log_status(status, out, err)
2114 self.model.update_status()
2115 return status, out, err
2118 class RebaseSkip(ContextCommand):
2119 def do(self):
2120 (status, out, err) = (1, '', '')
2121 with SequenceEditorEnvironment(
2122 self.context,
2123 GIT_COLA_SEQ_EDITOR_TITLE=N_('Rebase'),
2124 GIT_COLA_SEQ_EDITOR_ACTION=N_('Rebase'),
2126 status, out, err = self.git.rebase(skip=True)
2127 Interaction.log_status(status, out, err)
2128 self.model.update_status()
2129 return status, out, err
2132 class RebaseAbort(ContextCommand):
2133 def do(self):
2134 status, out, err = self.git.rebase(abort=True)
2135 Interaction.log_status(status, out, err)
2136 self.model.update_status()
2139 class Rescan(ContextCommand):
2140 """Rescan for changes"""
2142 def do(self):
2143 self.model.update_status()
2146 class Refresh(ContextCommand):
2147 """Update refs, refresh the index, and update config"""
2149 @staticmethod
2150 def name():
2151 return N_('Refresh')
2153 def do(self):
2154 self.model.update_status(update_index=True)
2155 self.cfg.update()
2156 self.fsmonitor.refresh()
2159 class RefreshConfig(ContextCommand):
2160 """Refresh the git config cache"""
2162 def do(self):
2163 self.cfg.update()
2166 class RevertEditsCommand(ConfirmAction):
2167 def __init__(self, context):
2168 super(RevertEditsCommand, self).__init__(context)
2169 self.icon = icons.undo()
2171 def ok_to_run(self):
2172 return self.model.undoable()
2174 # pylint: disable=no-self-use
2175 def checkout_from_head(self):
2176 return False
2178 def checkout_args(self):
2179 args = []
2180 s = self.selection.selection()
2181 if self.checkout_from_head():
2182 args.append(self.model.head)
2183 args.append('--')
2185 if s.staged:
2186 items = s.staged
2187 else:
2188 items = s.modified
2189 args.extend(items)
2191 return args
2193 def action(self):
2194 checkout_args = self.checkout_args()
2195 return self.git.checkout(*checkout_args)
2197 def success(self):
2198 self.model.set_diff_type(main.Types.TEXT)
2199 self.model.update_file_status()
2202 class RevertUnstagedEdits(RevertEditsCommand):
2203 @staticmethod
2204 def name():
2205 return N_('Revert Unstaged Edits...')
2207 def checkout_from_head(self):
2208 # Being in amend mode should not affect the behavior of this command.
2209 # The only sensible thing to do is to checkout from the index.
2210 return False
2212 def confirm(self):
2213 title = N_('Revert Unstaged Changes?')
2214 text = N_(
2215 'This operation removes unstaged edits from selected files.\n'
2216 'These changes cannot be recovered.'
2218 info = N_('Revert the unstaged changes?')
2219 ok_text = N_('Revert Unstaged Changes')
2220 return Interaction.confirm(
2221 title, text, info, ok_text, default=True, icon=self.icon
2225 class RevertUncommittedEdits(RevertEditsCommand):
2226 @staticmethod
2227 def name():
2228 return N_('Revert Uncommitted Edits...')
2230 def checkout_from_head(self):
2231 return True
2233 def confirm(self):
2234 """Prompt for reverting changes"""
2235 title = N_('Revert Uncommitted Changes?')
2236 text = N_(
2237 'This operation removes uncommitted edits from selected files.\n'
2238 'These changes cannot be recovered.'
2240 info = N_('Revert the uncommitted changes?')
2241 ok_text = N_('Revert Uncommitted Changes')
2242 return Interaction.confirm(
2243 title, text, info, ok_text, default=True, icon=self.icon
2247 class RunConfigAction(ContextCommand):
2248 """Run a user-configured action, typically from the "Tools" menu"""
2250 def __init__(self, context, action_name):
2251 super(RunConfigAction, self).__init__(context)
2252 self.action_name = action_name
2254 def do(self):
2255 """Run the user-configured action"""
2256 for env in ('ARGS', 'DIRNAME', 'FILENAME', 'REVISION'):
2257 try:
2258 compat.unsetenv(env)
2259 except KeyError:
2260 pass
2261 rev = None
2262 args = None
2263 context = self.context
2264 cfg = self.cfg
2265 opts = cfg.get_guitool_opts(self.action_name)
2266 cmd = opts.get('cmd')
2267 if 'title' not in opts:
2268 opts['title'] = cmd
2270 if 'prompt' not in opts or opts.get('prompt') is True:
2271 prompt = N_('Run "%s"?') % cmd
2272 opts['prompt'] = prompt
2274 if opts.get('needsfile'):
2275 filename = self.selection.filename()
2276 if not filename:
2277 Interaction.information(
2278 N_('Please select a file'),
2279 N_('"%s" requires a selected file.') % cmd,
2281 return False
2282 dirname = utils.dirname(filename, current_dir='.')
2283 compat.setenv('FILENAME', filename)
2284 compat.setenv('DIRNAME', dirname)
2286 if opts.get('revprompt') or opts.get('argprompt'):
2287 while True:
2288 ok = Interaction.confirm_config_action(context, cmd, opts)
2289 if not ok:
2290 return False
2291 rev = opts.get('revision')
2292 args = opts.get('args')
2293 if opts.get('revprompt') and not rev:
2294 title = N_('Invalid Revision')
2295 msg = N_('The revision expression cannot be empty.')
2296 Interaction.critical(title, msg)
2297 continue
2298 break
2300 elif opts.get('confirm'):
2301 title = os.path.expandvars(opts.get('title'))
2302 prompt = os.path.expandvars(opts.get('prompt'))
2303 if not Interaction.question(title, prompt):
2304 return False
2305 if rev:
2306 compat.setenv('REVISION', rev)
2307 if args:
2308 compat.setenv('ARGS', args)
2309 title = os.path.expandvars(cmd)
2310 Interaction.log(N_('Running command: %s') % title)
2311 cmd = ['sh', '-c', cmd]
2313 if opts.get('background'):
2314 core.fork(cmd)
2315 status, out, err = (0, '', '')
2316 elif opts.get('noconsole'):
2317 status, out, err = core.run_command(cmd)
2318 else:
2319 status, out, err = Interaction.run_command(title, cmd)
2321 if not opts.get('background') and not opts.get('norescan'):
2322 self.model.update_status()
2324 title = N_('Error')
2325 Interaction.command(title, cmd, status, out, err)
2327 return status == 0
2330 class SetDefaultRepo(ContextCommand):
2331 """Set the default repository"""
2333 def __init__(self, context, repo):
2334 super(SetDefaultRepo, self).__init__(context)
2335 self.repo = repo
2337 def do(self):
2338 self.cfg.set_user('cola.defaultrepo', self.repo)
2341 class SetDiffText(EditModel):
2342 """Set the diff text"""
2344 UNDOABLE = True
2346 def __init__(self, context, text):
2347 super(SetDiffText, self).__init__(context)
2348 self.new_diff_text = text
2349 self.new_diff_type = main.Types.TEXT
2350 self.new_file_type = main.Types.TEXT
2353 class SetUpstreamBranch(ContextCommand):
2354 """Set the upstream branch"""
2356 def __init__(self, context, branch, remote, remote_branch):
2357 super(SetUpstreamBranch, self).__init__(context)
2358 self.branch = branch
2359 self.remote = remote
2360 self.remote_branch = remote_branch
2362 def do(self):
2363 cfg = self.cfg
2364 remote = self.remote
2365 branch = self.branch
2366 remote_branch = self.remote_branch
2367 cfg.set_repo('branch.%s.remote' % branch, remote)
2368 cfg.set_repo('branch.%s.merge' % branch, 'refs/heads/' + remote_branch)
2371 def format_hex(data):
2372 """Translate binary data into a hex dump"""
2373 hexdigits = '0123456789ABCDEF'
2374 result = ''
2375 offset = 0
2376 byte_offset_to_int = compat.byte_offset_to_int_converter()
2377 while offset < len(data):
2378 result += '%04u |' % offset
2379 textpart = ''
2380 for i in range(0, 16):
2381 if i > 0 and i % 4 == 0:
2382 result += ' '
2383 if offset < len(data):
2384 v = byte_offset_to_int(data[offset])
2385 result += ' ' + hexdigits[v >> 4] + hexdigits[v & 0xF]
2386 textpart += chr(v) if 32 <= v < 127 else '.'
2387 offset += 1
2388 else:
2389 result += ' '
2390 textpart += ' '
2391 result += ' | ' + textpart + ' |\n'
2393 return result
2396 class ShowUntracked(EditModel):
2397 """Show an untracked file."""
2399 def __init__(self, context, filename):
2400 super(ShowUntracked, self).__init__(context)
2401 self.new_filename = filename
2402 if gitcmds.is_binary(context, filename):
2403 self.new_mode = self.model.mode_untracked
2404 self.new_diff_text = self.read(filename)
2405 else:
2406 self.new_mode = self.model.mode_untracked_diff
2407 self.new_diff_text = gitcmds.diff_helper(
2408 self.context, filename=filename, cached=False, untracked=True
2410 self.new_diff_type = main.Types.TEXT
2411 self.new_file_type = main.Types.TEXT
2413 def read(self, filename):
2414 """Read file contents"""
2415 cfg = self.cfg
2416 size = cfg.get('cola.readsize', 2048)
2417 try:
2418 result = core.read(filename, size=size, encoding='bytes')
2419 except (IOError, OSError):
2420 result = ''
2422 truncated = len(result) == size
2424 encoding = cfg.file_encoding(filename) or core.ENCODING
2425 try:
2426 text_result = core.decode_maybe(result, encoding)
2427 except UnicodeError:
2428 text_result = format_hex(result)
2430 if truncated:
2431 text_result += '...'
2432 return text_result
2435 class SignOff(ContextCommand):
2436 """Append a signoff to the commit message"""
2438 UNDOABLE = True
2440 @staticmethod
2441 def name():
2442 return N_('Sign Off')
2444 def __init__(self, context):
2445 super(SignOff, self).__init__(context)
2446 self.old_commitmsg = self.model.commitmsg
2448 def do(self):
2449 """Add a signoff to the commit message"""
2450 signoff = self.signoff()
2451 if signoff in self.model.commitmsg:
2452 return
2453 msg = self.model.commitmsg.rstrip()
2454 self.model.set_commitmsg(msg + '\n' + signoff)
2456 def undo(self):
2457 """Restore the commit message"""
2458 self.model.set_commitmsg(self.old_commitmsg)
2460 def signoff(self):
2461 """Generate the signoff string"""
2462 try:
2463 import pwd # pylint: disable=all
2465 user = pwd.getpwuid(os.getuid()).pw_name
2466 except ImportError:
2467 user = os.getenv('USER', N_('unknown'))
2469 cfg = self.cfg
2470 name = cfg.get('user.name', user)
2471 email = cfg.get('user.email', '%s@%s' % (user, core.node()))
2472 return '\nSigned-off-by: %s <%s>' % (name, email)
2475 def check_conflicts(context, unmerged):
2476 """Check paths for conflicts
2478 Conflicting files can be filtered out one-by-one.
2481 if prefs.check_conflicts(context):
2482 unmerged = [path for path in unmerged if is_conflict_free(path)]
2483 return unmerged
2486 def is_conflict_free(path):
2487 """Return True if `path` contains no conflict markers"""
2488 rgx = re.compile(r'^(<<<<<<<|\|\|\|\|\|\|\||>>>>>>>) ')
2489 try:
2490 with core.xopen(path, 'rb') as f:
2491 for line in f:
2492 line = core.decode(line, errors='ignore')
2493 if rgx.match(line):
2494 return should_stage_conflicts(path)
2495 except IOError:
2496 # We can't read this file ~ we may be staging a removal
2497 pass
2498 return True
2501 def should_stage_conflicts(path):
2502 """Inform the user that a file contains merge conflicts
2504 Return `True` if we should stage the path nonetheless.
2507 title = msg = N_('Stage conflicts?')
2508 info = (
2510 '%s appears to contain merge conflicts.\n\n'
2511 'You should probably skip this file.\n'
2512 'Stage it anyways?'
2514 % path
2516 ok_text = N_('Stage conflicts')
2517 cancel_text = N_('Skip')
2518 return Interaction.confirm(
2519 title, msg, info, ok_text, default=False, cancel_text=cancel_text
2523 class Stage(ContextCommand):
2524 """Stage a set of paths."""
2526 @staticmethod
2527 def name():
2528 return N_('Stage')
2530 def __init__(self, context, paths):
2531 super(Stage, self).__init__(context)
2532 self.paths = paths
2534 def do(self):
2535 msg = N_('Staging: %s') % (', '.join(self.paths))
2536 Interaction.log(msg)
2537 return self.stage_paths()
2539 def stage_paths(self):
2540 """Stages add/removals to git."""
2541 context = self.context
2542 paths = self.paths
2543 if not paths:
2544 if self.model.cfg.get('cola.safemode', False):
2545 return (0, '', '')
2546 return self.stage_all()
2548 add = []
2549 remove = []
2550 status = 0
2551 out = ''
2552 err = ''
2554 for path in set(paths):
2555 if core.exists(path) or core.islink(path):
2556 if path.endswith('/'):
2557 path = path.rstrip('/')
2558 add.append(path)
2559 else:
2560 remove.append(path)
2562 self.model.emit_about_to_update()
2564 # `git add -u` doesn't work on untracked files
2565 if add:
2566 status, out, err = gitcmds.add(context, add)
2567 Interaction.command(N_('Error'), 'git add', status, out, err)
2569 # If a path doesn't exist then that means it should be removed
2570 # from the index. We use `git add -u` for that.
2571 if remove:
2572 status, out, err = gitcmds.add(context, remove, u=True)
2573 Interaction.command(N_('Error'), 'git add -u', status, out, err)
2575 self.model.update_files(emit=True)
2576 return status, out, err
2578 def stage_all(self):
2579 """Stage all files"""
2580 status, out, err = self.git.add(v=True, u=True)
2581 Interaction.command(N_('Error'), 'git add -u', status, out, err)
2582 self.model.update_file_status()
2583 return (status, out, err)
2586 class StageCarefully(Stage):
2587 """Only stage when the path list is non-empty
2589 We use "git add -u -- <pathspec>" to stage, and it stages everything by
2590 default when no pathspec is specified, so this class ensures that paths
2591 are specified before calling git.
2593 When no paths are specified, the command does nothing.
2597 def __init__(self, context):
2598 super(StageCarefully, self).__init__(context, None)
2599 self.init_paths()
2601 # pylint: disable=no-self-use
2602 def init_paths(self):
2603 """Initialize path data"""
2604 return
2606 def ok_to_run(self):
2607 """Prevent catch-all "git add -u" from adding unmerged files"""
2608 return self.paths or not self.model.unmerged
2610 def do(self):
2611 """Stage files when ok_to_run() return True"""
2612 if self.ok_to_run():
2613 return super(StageCarefully, self).do()
2614 return (0, '', '')
2617 class StageModified(StageCarefully):
2618 """Stage all modified files."""
2620 @staticmethod
2621 def name():
2622 return N_('Stage Modified')
2624 def init_paths(self):
2625 self.paths = self.model.modified
2628 class StageUnmerged(StageCarefully):
2629 """Stage unmerged files."""
2631 @staticmethod
2632 def name():
2633 return N_('Stage Unmerged')
2635 def init_paths(self):
2636 self.paths = check_conflicts(self.context, self.model.unmerged)
2639 class StageUntracked(StageCarefully):
2640 """Stage all untracked files."""
2642 @staticmethod
2643 def name():
2644 return N_('Stage Untracked')
2646 def init_paths(self):
2647 self.paths = self.model.untracked
2650 class StageModifiedAndUntracked(StageCarefully):
2651 """Stage all untracked files."""
2653 @staticmethod
2654 def name():
2655 return N_('Stage Modified and Untracked')
2657 def init_paths(self):
2658 self.paths = self.model.modified + self.model.untracked
2661 class StageOrUnstageAll(ContextCommand):
2662 """If the selection is staged, unstage it, otherwise stage"""
2664 @staticmethod
2665 def name():
2666 return N_('Stage / Unstage All')
2668 def do(self):
2669 if self.model.staged:
2670 do(Unstage, self.context, self.model.staged)
2671 else:
2672 if self.cfg.get('cola.safemode', False):
2673 unstaged = self.model.modified
2674 else:
2675 unstaged = self.model.modified + self.model.untracked
2676 do(Stage, self.context, unstaged)
2679 class StageOrUnstage(ContextCommand):
2680 """If the selection is staged, unstage it, otherwise stage"""
2682 @staticmethod
2683 def name():
2684 return N_('Stage / Unstage')
2686 def do(self):
2687 s = self.selection.selection()
2688 if s.staged:
2689 do(Unstage, self.context, s.staged)
2691 unstaged = []
2692 unmerged = check_conflicts(self.context, s.unmerged)
2693 if unmerged:
2694 unstaged.extend(unmerged)
2695 if s.modified:
2696 unstaged.extend(s.modified)
2697 if s.untracked:
2698 unstaged.extend(s.untracked)
2699 if unstaged:
2700 do(Stage, self.context, unstaged)
2703 class Tag(ContextCommand):
2704 """Create a tag object."""
2706 def __init__(self, context, name, revision, sign=False, message=''):
2707 super(Tag, self).__init__(context)
2708 self._name = name
2709 self._message = message
2710 self._revision = revision
2711 self._sign = sign
2713 def do(self):
2714 result = False
2715 git = self.git
2716 revision = self._revision
2717 tag_name = self._name
2718 tag_message = self._message
2720 if not revision:
2721 Interaction.critical(
2722 N_('Missing Revision'), N_('Please specify a revision to tag.')
2724 return result
2726 if not tag_name:
2727 Interaction.critical(
2728 N_('Missing Name'), N_('Please specify a name for the new tag.')
2730 return result
2732 title = N_('Missing Tag Message')
2733 message = N_('Tag-signing was requested but the tag message is empty.')
2734 info = N_(
2735 'An unsigned, lightweight tag will be created instead.\n'
2736 'Create an unsigned tag?'
2738 ok_text = N_('Create Unsigned Tag')
2739 sign = self._sign
2740 if sign and not tag_message:
2741 # We require a message in order to sign the tag, so if they
2742 # choose to create an unsigned tag we have to clear the sign flag.
2743 if not Interaction.confirm(
2744 title, message, info, ok_text, default=False, icon=icons.save()
2746 return result
2747 sign = False
2749 opts = {}
2750 tmp_file = None
2751 try:
2752 if tag_message:
2753 tmp_file = utils.tmp_filename('tag-message')
2754 opts['file'] = tmp_file
2755 core.write(tmp_file, tag_message)
2757 if sign:
2758 opts['sign'] = True
2759 if tag_message:
2760 opts['annotate'] = True
2761 status, out, err = git.tag(tag_name, revision, **opts)
2762 finally:
2763 if tmp_file:
2764 core.unlink(tmp_file)
2766 title = N_('Error: could not create tag "%s"') % tag_name
2767 Interaction.command(title, 'git tag', status, out, err)
2769 if status == 0:
2770 result = True
2771 self.model.update_status()
2772 Interaction.information(
2773 N_('Tag Created'),
2774 N_('Created a new tag named "%s"') % tag_name,
2775 details=tag_message or None,
2778 return result
2781 class Unstage(ContextCommand):
2782 """Unstage a set of paths."""
2784 @staticmethod
2785 def name():
2786 return N_('Unstage')
2788 def __init__(self, context, paths):
2789 super(Unstage, self).__init__(context)
2790 self.paths = paths
2792 def do(self):
2793 """Unstage paths"""
2794 context = self.context
2795 head = self.model.head
2796 paths = self.paths
2798 msg = N_('Unstaging: %s') % (', '.join(paths))
2799 Interaction.log(msg)
2800 if not paths:
2801 return unstage_all(context)
2802 status, out, err = gitcmds.unstage_paths(context, paths, head=head)
2803 Interaction.command(N_('Error'), 'git reset', status, out, err)
2804 self.model.update_file_status()
2805 return (status, out, err)
2808 class UnstageAll(ContextCommand):
2809 """Unstage all files; resets the index."""
2811 def do(self):
2812 return unstage_all(self.context)
2815 def unstage_all(context):
2816 """Unstage all files, even while amending"""
2817 model = context.model
2818 git = context.git
2819 head = model.head
2820 status, out, err = git.reset(head, '--', '.')
2821 Interaction.command(N_('Error'), 'git reset', status, out, err)
2822 model.update_file_status()
2823 return (status, out, err)
2826 class StageSelected(ContextCommand):
2827 """Stage selected files, or all files if no selection exists."""
2829 def do(self):
2830 context = self.context
2831 paths = self.selection.unstaged
2832 if paths:
2833 do(Stage, context, paths)
2834 elif self.cfg.get('cola.safemode', False):
2835 do(StageModified, context)
2838 class UnstageSelected(Unstage):
2839 """Unstage selected files."""
2841 def __init__(self, context):
2842 staged = context.selection.staged
2843 super(UnstageSelected, self).__init__(context, staged)
2846 class Untrack(ContextCommand):
2847 """Unstage a set of paths."""
2849 def __init__(self, context, paths):
2850 super(Untrack, self).__init__(context)
2851 self.paths = paths
2853 def do(self):
2854 msg = N_('Untracking: %s') % (', '.join(self.paths))
2855 Interaction.log(msg)
2856 status, out, err = self.model.untrack_paths(self.paths)
2857 Interaction.log_status(status, out, err)
2860 class UntrackedSummary(EditModel):
2861 """List possible .gitignore rules as the diff text."""
2863 def __init__(self, context):
2864 super(UntrackedSummary, self).__init__(context)
2865 untracked = self.model.untracked
2866 suffix = 's' if untracked else ''
2867 io = StringIO()
2868 io.write('# %s untracked file%s\n' % (len(untracked), suffix))
2869 if untracked:
2870 io.write('# possible .gitignore rule%s:\n' % suffix)
2871 for u in untracked:
2872 io.write('/' + u + '\n')
2873 self.new_diff_text = io.getvalue()
2874 self.new_diff_type = main.Types.TEXT
2875 self.new_file_type = main.Types.TEXT
2876 self.new_mode = self.model.mode_display
2879 class VisualizeAll(ContextCommand):
2880 """Visualize all branches."""
2882 def do(self):
2883 context = self.context
2884 browser = utils.shell_split(prefs.history_browser(context))
2885 launch_history_browser(browser + ['--all'])
2888 class VisualizeCurrent(ContextCommand):
2889 """Visualize all branches."""
2891 def do(self):
2892 context = self.context
2893 browser = utils.shell_split(prefs.history_browser(context))
2894 launch_history_browser(browser + [self.model.currentbranch] + ['--'])
2897 class VisualizePaths(ContextCommand):
2898 """Path-limited visualization."""
2900 def __init__(self, context, paths):
2901 super(VisualizePaths, self).__init__(context)
2902 context = self.context
2903 browser = utils.shell_split(prefs.history_browser(context))
2904 if paths:
2905 self.argv = browser + ['--'] + list(paths)
2906 else:
2907 self.argv = browser
2909 def do(self):
2910 launch_history_browser(self.argv)
2913 class VisualizeRevision(ContextCommand):
2914 """Visualize a specific revision."""
2916 def __init__(self, context, revision, paths=None):
2917 super(VisualizeRevision, self).__init__(context)
2918 self.revision = revision
2919 self.paths = paths
2921 def do(self):
2922 context = self.context
2923 argv = utils.shell_split(prefs.history_browser(context))
2924 if self.revision:
2925 argv.append(self.revision)
2926 if self.paths:
2927 argv.append('--')
2928 argv.extend(self.paths)
2929 launch_history_browser(argv)
2932 class SubmoduleAdd(ConfirmAction):
2933 """Add specified submodules"""
2935 def __init__(self, context, url, path, branch, depth, reference):
2936 super(SubmoduleAdd, self).__init__(context)
2937 self.url = url
2938 self.path = path
2939 self.branch = branch
2940 self.depth = depth
2941 self.reference = reference
2943 def confirm(self):
2944 title = N_('Add Submodule...')
2945 question = N_('Add this submodule?')
2946 info = N_('The submodule will be added using\n' '"%s"' % self.command())
2947 ok_txt = N_('Add Submodule')
2948 return Interaction.confirm(title, question, info, ok_txt, icon=icons.ok())
2950 def action(self):
2951 context = self.context
2952 args = self.get_args()
2953 return context.git.submodule('add', *args)
2955 def success(self):
2956 self.model.update_file_status()
2957 self.model.update_submodules_list()
2959 def error_message(self):
2960 return N_('Error updating submodule %s' % self.path)
2962 def command(self):
2963 cmd = ['git', 'submodule', 'add']
2964 cmd.extend(self.get_args())
2965 return core.list2cmdline(cmd)
2967 def get_args(self):
2968 args = []
2969 if self.branch:
2970 args.extend(['--branch', self.branch])
2971 if self.reference:
2972 args.extend(['--reference', self.reference])
2973 if self.depth:
2974 args.extend(['--depth', '%d' % self.depth])
2975 args.extend(['--', self.url])
2976 if self.path:
2977 args.append(self.path)
2978 return args
2981 class SubmoduleUpdate(ConfirmAction):
2982 """Update specified submodule"""
2984 def __init__(self, context, path):
2985 super(SubmoduleUpdate, self).__init__(context)
2986 self.path = path
2988 def confirm(self):
2989 title = N_('Update Submodule...')
2990 question = N_('Update this submodule?')
2991 info = N_('The submodule will be updated using\n' '"%s"' % self.command())
2992 ok_txt = N_('Update Submodule')
2993 return Interaction.confirm(
2994 title, question, info, ok_txt, default=False, icon=icons.pull()
2997 def action(self):
2998 context = self.context
2999 args = self.get_args()
3000 return context.git.submodule(*args)
3002 def success(self):
3003 self.model.update_file_status()
3005 def error_message(self):
3006 return N_('Error updating submodule %s' % self.path)
3008 def command(self):
3009 cmd = ['git', 'submodule']
3010 cmd.extend(self.get_args())
3011 return core.list2cmdline(cmd)
3013 def get_args(self):
3014 cmd = ['update']
3015 if version.check_git(self.context, 'submodule-update-recursive'):
3016 cmd.append('--recursive')
3017 cmd.extend(['--', self.path])
3018 return cmd
3021 class SubmodulesUpdate(ConfirmAction):
3022 """Update all submodules"""
3024 def confirm(self):
3025 title = N_('Update submodules...')
3026 question = N_('Update all submodules?')
3027 info = N_('All submodules will be updated using\n' '"%s"' % self.command())
3028 ok_txt = N_('Update Submodules')
3029 return Interaction.confirm(
3030 title, question, info, ok_txt, default=False, icon=icons.pull()
3033 def action(self):
3034 context = self.context
3035 args = self.get_args()
3036 return context.git.submodule(*args)
3038 def success(self):
3039 self.model.update_file_status()
3041 def error_message(self):
3042 return N_('Error updating submodules')
3044 def command(self):
3045 cmd = ['git', 'submodule']
3046 cmd.extend(self.get_args())
3047 return core.list2cmdline(cmd)
3049 def get_args(self):
3050 cmd = ['update']
3051 if version.check_git(self.context, 'submodule-update-recursive'):
3052 cmd.append('--recursive')
3053 return cmd
3056 def launch_history_browser(argv):
3057 """Launch the configured history browser"""
3058 try:
3059 core.fork(argv)
3060 except OSError as e:
3061 _, details = utils.format_exception(e)
3062 title = N_('Error Launching History Browser')
3063 msg = N_('Cannot exec "%s": please configure a history browser') % ' '.join(
3064 argv
3066 Interaction.critical(title, message=msg, details=details)
3069 def run(cls, *args, **opts):
3071 Returns a callback that runs a command
3073 If the caller of run() provides args or opts then those are
3074 used instead of the ones provided by the invoker of the callback.
3078 def runner(*local_args, **local_opts):
3079 """Closure return by run() which runs the command"""
3080 if args or opts:
3081 do(cls, *args, **opts)
3082 else:
3083 do(cls, *local_args, **local_opts)
3085 return runner
3088 def do(cls, *args, **opts):
3089 """Run a command in-place"""
3090 try:
3091 cmd = cls(*args, **opts)
3092 return cmd.do()
3093 except Exception as e: # pylint: disable=broad-except
3094 msg, details = utils.format_exception(e)
3095 if hasattr(cls, '__name__'):
3096 msg = '%s exception:\n%s' % (cls.__name__, msg)
3097 Interaction.critical(N_('Error'), message=msg, details=details)
3098 return None
3101 def difftool_run(context):
3102 """Start a default difftool session"""
3103 selection = context.selection
3104 files = selection.group()
3105 if not files:
3106 return
3107 s = selection.selection()
3108 head = context.model.head
3109 difftool_launch_with_head(context, files, bool(s.staged), head)
3112 def difftool_launch_with_head(context, filenames, staged, head):
3113 """Launch difftool against the provided head"""
3114 if head == 'HEAD':
3115 left = None
3116 else:
3117 left = head
3118 difftool_launch(context, left=left, staged=staged, paths=filenames)
3121 def difftool_launch(
3122 context,
3123 left=None,
3124 right=None,
3125 paths=None,
3126 staged=False,
3127 dir_diff=False,
3128 left_take_magic=False,
3129 left_take_parent=False,
3131 """Launches 'git difftool' with given parameters
3133 :param left: first argument to difftool
3134 :param right: second argument to difftool_args
3135 :param paths: paths to diff
3136 :param staged: activate `git difftool --staged`
3137 :param dir_diff: activate `git difftool --dir-diff`
3138 :param left_take_magic: whether to append the magic ^! diff expression
3139 :param left_take_parent: whether to append the first-parent ~ for diffing
3143 difftool_args = ['git', 'difftool', '--no-prompt']
3144 if staged:
3145 difftool_args.append('--cached')
3146 if dir_diff:
3147 difftool_args.append('--dir-diff')
3149 if left:
3150 if left_take_parent or left_take_magic:
3151 suffix = '^!' if left_take_magic else '~'
3152 # Check root commit (no parents and thus cannot execute '~')
3153 git = context.git
3154 status, out, err = git.rev_list(left, parents=True, n=1)
3155 Interaction.log_status(status, out, err)
3156 if status:
3157 raise OSError('git rev-list command failed')
3159 if len(out.split()) >= 2:
3160 # Commit has a parent, so we can take its child as requested
3161 left += suffix
3162 else:
3163 # No parent, assume it's the root commit, so we have to diff
3164 # against the empty tree.
3165 left = EMPTY_TREE_OID
3166 if not right and left_take_magic:
3167 right = left
3168 difftool_args.append(left)
3170 if right:
3171 difftool_args.append(right)
3173 if paths:
3174 difftool_args.append('--')
3175 difftool_args.extend(paths)
3177 runtask = context.runtask
3178 if runtask:
3179 Interaction.async_command(N_('Difftool'), difftool_args, runtask)
3180 else:
3181 core.fork(difftool_args)