guicmds: pass the output parameter as a keyword argument
[git-cola.git] / cola / cmds.py
blobe6491b69cd23ae58cbdb9c426b6c9dd9a60430a0
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 def ok_to_run(self):
83 """Return True when the command is ok to run"""
84 return True
86 def confirm(self):
87 """Prompt for confirmation"""
88 return True
90 def action(self):
91 """Run the command and return (status, out, err)"""
92 return (-1, '', '')
94 def success(self):
95 """Callback run on success"""
96 return
98 def command(self):
99 """Command name, for error messages"""
100 return 'git'
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 AbortApplyPatch(ConfirmAction):
123 """Reset an in-progress "git am" patch application"""
125 def confirm(self):
126 title = N_('Abort Applying Patch...')
127 question = N_('Aborting applying the current patch?')
128 info = N_(
129 'Aborting a patch can cause uncommitted changes to be lost.\n'
130 'Recovering uncommitted changes is not possible.'
132 ok_txt = N_('Abort Applying Patch')
133 return Interaction.confirm(
134 title, question, info, ok_txt, default=False, icon=icons.undo()
137 def action(self):
138 status, out, err = gitcmds.abort_apply_patch(self.context)
139 self.model.update_file_merge_status()
140 return status, out, err
142 def success(self):
143 self.model.set_commitmsg('')
145 def error_message(self):
146 return N_('Error')
148 def command(self):
149 return 'git am --abort'
152 class AbortCherryPick(ConfirmAction):
153 """Reset an in-progress cherry-pick"""
155 def confirm(self):
156 title = N_('Abort Cherry-Pick...')
157 question = N_('Aborting the current cherry-pick?')
158 info = N_(
159 'Aborting a cherry-pick can cause uncommitted changes to be lost.\n'
160 'Recovering uncommitted changes is not possible.'
162 ok_txt = N_('Abort Cherry-Pick')
163 return Interaction.confirm(
164 title, question, info, ok_txt, default=False, icon=icons.undo()
167 def action(self):
168 status, out, err = gitcmds.abort_cherry_pick(self.context)
169 self.model.update_file_merge_status()
170 return status, out, err
172 def success(self):
173 self.model.set_commitmsg('')
175 def error_message(self):
176 return N_('Error')
178 def command(self):
179 return 'git cherry-pick --abort'
182 class AbortMerge(ConfirmAction):
183 """Reset an in-progress merge back to HEAD"""
185 def confirm(self):
186 title = N_('Abort Merge...')
187 question = N_('Aborting the current merge?')
188 info = N_(
189 'Aborting the current merge will cause '
190 '*ALL* uncommitted changes to be lost.\n'
191 'Recovering uncommitted changes is not possible.'
193 ok_txt = N_('Abort Merge')
194 return Interaction.confirm(
195 title, question, info, ok_txt, default=False, icon=icons.undo()
198 def action(self):
199 status, out, err = gitcmds.abort_merge(self.context)
200 self.model.update_file_merge_status()
201 return status, out, err
203 def success(self):
204 self.model.set_commitmsg('')
206 def error_message(self):
207 return N_('Error')
209 def command(self):
210 return 'git merge'
213 class AmendMode(EditModel):
214 """Try to amend a commit."""
216 UNDOABLE = True
217 LAST_MESSAGE = None
219 @staticmethod
220 def name():
221 return N_('Amend')
223 def __init__(self, context, amend=True):
224 super(AmendMode, self).__init__(context)
225 self.skip = False
226 self.amending = amend
227 self.old_commitmsg = self.model.commitmsg
228 self.old_mode = self.model.mode
230 if self.amending:
231 self.new_mode = self.model.mode_amend
232 self.new_commitmsg = gitcmds.prev_commitmsg(context)
233 AmendMode.LAST_MESSAGE = self.model.commitmsg
234 return
235 # else, amend unchecked, regular commit
236 self.new_mode = self.model.mode_none
237 self.new_diff_text = ''
238 self.new_commitmsg = self.model.commitmsg
239 # If we're going back into new-commit-mode then search the
240 # undo stack for a previous amend-commit-mode and grab the
241 # commit message at that point in time.
242 if AmendMode.LAST_MESSAGE is not None:
243 self.new_commitmsg = AmendMode.LAST_MESSAGE
244 AmendMode.LAST_MESSAGE = None
246 def do(self):
247 """Leave/enter amend mode."""
248 # Attempt to enter amend mode. Do not allow this when merging.
249 if self.amending:
250 if self.model.is_merging:
251 self.skip = True
252 self.model.set_mode(self.old_mode)
253 Interaction.information(
254 N_('Cannot Amend'),
256 'You are in the middle of a merge.\n'
257 'Cannot amend while merging.'
260 return
261 self.skip = False
262 super(AmendMode, self).do()
263 self.model.set_commitmsg(self.new_commitmsg)
264 self.model.update_file_status()
266 def undo(self):
267 if self.skip:
268 return
269 self.model.set_commitmsg(self.old_commitmsg)
270 super(AmendMode, self).undo()
271 self.model.update_file_status()
274 class AnnexAdd(ContextCommand):
275 """Add to Git Annex"""
277 def __init__(self, context):
278 super(AnnexAdd, self).__init__(context)
279 self.filename = self.selection.filename()
281 def do(self):
282 status, out, err = self.git.annex('add', self.filename)
283 Interaction.command(N_('Error'), 'git annex add', status, out, err)
284 self.model.update_status()
287 class AnnexInit(ContextCommand):
288 """Initialize Git Annex"""
290 def do(self):
291 status, out, err = self.git.annex('init')
292 Interaction.command(N_('Error'), 'git annex init', status, out, err)
293 self.model.cfg.reset()
294 self.model.emit_updated()
297 class LFSTrack(ContextCommand):
298 """Add a file to git lfs"""
300 def __init__(self, context):
301 super(LFSTrack, self).__init__(context)
302 self.filename = self.selection.filename()
303 self.stage_cmd = Stage(context, [self.filename])
305 def do(self):
306 status, out, err = self.git.lfs('track', self.filename)
307 Interaction.command(N_('Error'), 'git lfs track', status, out, err)
308 if status == 0:
309 self.stage_cmd.do()
312 class LFSInstall(ContextCommand):
313 """Initialize git lfs"""
315 def do(self):
316 status, out, err = self.git.lfs('install')
317 Interaction.command(N_('Error'), 'git lfs install', status, out, err)
318 self.model.update_config(reset=True, emit=True)
321 class ApplyPatch(ContextCommand):
322 """Apply the specfied patch to the worktree or index"""
324 def __init__(
325 self,
326 context,
327 patch,
328 encoding,
329 apply_to_worktree,
331 super(ApplyPatch, self).__init__(context)
332 self.patch = patch
333 self.encoding = encoding
334 self.apply_to_worktree = apply_to_worktree
336 def do(self):
337 context = self.context
339 tmp_file = utils.tmp_filename('apply', suffix='.patch')
340 try:
341 core.write(tmp_file, self.patch.as_text(), encoding=self.encoding)
342 if self.apply_to_worktree:
343 status, out, err = gitcmds.apply_diff_to_worktree(context, tmp_file)
344 else:
345 status, out, err = gitcmds.apply_diff(context, tmp_file)
346 finally:
347 core.unlink(tmp_file)
349 Interaction.log_status(status, out, err)
350 self.model.update_file_status(update_index=True)
353 class ApplyPatches(ContextCommand):
354 """Apply patches using the "git am" command"""
356 def __init__(self, context, patches):
357 super(ApplyPatches, self).__init__(context)
358 self.patches = patches
360 def do(self):
361 status, output, err = self.git.am('-3', *self.patches)
362 out = '# git am -3 %s\n\n%s' % (core.list2cmdline(self.patches), output)
363 Interaction.command(N_('Patch failed to apply'), 'git am -3', status, out, err)
364 # Display a diffstat
365 self.model.update_file_status()
367 patch_basenames = [os.path.basename(p) for p in self.patches]
368 if len(patch_basenames) > 25:
369 patch_basenames = patch_basenames[:25]
370 patch_basenames.append('...')
372 basenames = '\n'.join(patch_basenames)
373 if status == 0:
374 Interaction.information(
375 N_('Patch(es) Applied'),
376 (N_('%d patch(es) applied.') + '\n\n%s')
377 % (len(self.patches), basenames),
381 class ApplyPatchesContinue(ContextCommand):
382 """Run "git am --continue" to continue on the next patch in a "git am" session"""
384 def do(self):
385 status, out, err = self.git.am('--continue')
386 Interaction.command(
387 N_('Failed to commit and continue applying patches'),
388 'git am --continue',
389 status,
390 out,
391 err,
393 self.model.update_status()
394 return status, out, err
397 class ApplyPatchesSkip(ContextCommand):
398 """Run "git am --skip" to continue on the next patch in a "git am" session"""
400 def do(self):
401 status, out, err = self.git.am(skip=True)
402 Interaction.command(
403 N_('Failed to continue applying patches after skipping the current patch'),
404 'git am --skip',
405 status,
406 out,
407 err,
409 self.model.update_status()
410 return status, out, err
413 class Archive(ContextCommand):
414 """ "Export archives using the "git archive" command"""
416 def __init__(self, context, ref, fmt, prefix, filename):
417 super(Archive, self).__init__(context)
418 self.ref = ref
419 self.fmt = fmt
420 self.prefix = prefix
421 self.filename = filename
423 def do(self):
424 fp = core.xopen(self.filename, 'wb')
425 cmd = ['git', 'archive', '--format=' + self.fmt]
426 if self.fmt in ('tgz', 'tar.gz'):
427 cmd.append('-9')
428 if self.prefix:
429 cmd.append('--prefix=' + self.prefix)
430 cmd.append(self.ref)
431 proc = core.start_command(cmd, stdout=fp)
432 out, err = proc.communicate()
433 fp.close()
434 status = proc.returncode
435 Interaction.log_status(status, out or '', err or '')
438 class Checkout(EditModel):
439 """A command object for git-checkout.
441 'argv' is handed off directly to git.
445 def __init__(self, context, argv, checkout_branch=False):
446 super(Checkout, self).__init__(context)
447 self.argv = argv
448 self.checkout_branch = checkout_branch
449 self.new_diff_text = ''
450 self.new_diff_type = main.Types.TEXT
451 self.new_file_type = main.Types.TEXT
453 def do(self):
454 super(Checkout, self).do()
455 status, out, err = self.git.checkout(*self.argv)
456 if self.checkout_branch:
457 self.model.update_status()
458 else:
459 self.model.update_file_status()
460 Interaction.command(N_('Error'), 'git checkout', status, out, err)
461 return status, out, err
464 class CheckoutTheirs(ConfirmAction):
465 """Checkout "their" version of a file when performing a merge"""
467 @staticmethod
468 def name():
469 return N_('Checkout files from their branch (MERGE_HEAD)')
471 def confirm(self):
472 title = self.name()
473 question = N_('Checkout files from their branch?')
474 info = N_(
475 'This operation will replace the selected unmerged files with content '
476 'from the branch being merged using "git checkout --theirs".\n'
477 '*ALL* uncommitted changes will be lost.\n'
478 'Recovering uncommitted changes is not possible.'
480 ok_txt = N_('Checkout Files')
481 return Interaction.confirm(
482 title, question, info, ok_txt, default=True, icon=icons.merge()
485 def action(self):
486 selection = self.selection.selection()
487 paths = selection.unmerged
488 if not paths:
489 return 0, '', ''
491 argv = ['--theirs', '--'] + paths
492 cmd = Checkout(self.context, argv)
493 return cmd.do()
495 def error_message(self):
496 return N_('Error')
498 def command(self):
499 return 'git checkout --theirs'
502 class CheckoutOurs(ConfirmAction):
503 """Checkout "our" version of a file when performing a merge"""
505 @staticmethod
506 def name():
507 return N_('Checkout files from our branch (HEAD)')
509 def confirm(self):
510 title = self.name()
511 question = N_('Checkout files from our branch?')
512 info = N_(
513 'This operation will replace the selected unmerged files with content '
514 'from your current branch using "git checkout --ours".\n'
515 '*ALL* uncommitted changes will be lost.\n'
516 'Recovering uncommitted changes is not possible.'
518 ok_txt = N_('Checkout Files')
519 return Interaction.confirm(
520 title, question, info, ok_txt, default=True, icon=icons.merge()
523 def action(self):
524 selection = self.selection.selection()
525 paths = selection.unmerged
526 if not paths:
527 return 0, '', ''
529 argv = ['--ours', '--'] + paths
530 cmd = Checkout(self.context, argv)
531 return cmd.do()
533 def error_message(self):
534 return N_('Error')
536 def command(self):
537 return 'git checkout --ours'
540 class BlamePaths(ContextCommand):
541 """Blame view for paths."""
543 @staticmethod
544 def name():
545 return N_('Blame...')
547 def __init__(self, context, paths=None):
548 super(BlamePaths, self).__init__(context)
549 if not paths:
550 paths = context.selection.union()
551 viewer = utils.shell_split(prefs.blame_viewer(context))
552 self.argv = viewer + list(paths)
554 def do(self):
555 try:
556 core.fork(self.argv)
557 except OSError as e:
558 _, details = utils.format_exception(e)
559 title = N_('Error Launching Blame Viewer')
560 msg = N_('Cannot exec "%s": please configure a blame viewer') % ' '.join(
561 self.argv
563 Interaction.critical(title, message=msg, details=details)
566 class CheckoutBranch(Checkout):
567 """Checkout a branch."""
569 def __init__(self, context, branch):
570 args = [branch]
571 super(CheckoutBranch, self).__init__(context, args, checkout_branch=True)
574 class CherryPick(ContextCommand):
575 """Cherry pick commits into the current branch."""
577 def __init__(self, context, commits):
578 super(CherryPick, self).__init__(context)
579 self.commits = commits
581 def do(self):
582 status, out, err = gitcmds.cherry_pick(self.context, self.commits)
583 self.model.update_file_merge_status()
584 title = N_('Cherry-pick failed')
585 Interaction.command(title, 'git cherry-pick', status, out, err)
588 class Revert(ContextCommand):
589 """Cherry pick commits into the current branch."""
591 def __init__(self, context, oid):
592 super(Revert, self).__init__(context)
593 self.oid = oid
595 def do(self):
596 status, output, err = self.git.revert(self.oid, no_edit=True)
597 self.model.update_file_status()
598 title = N_('Revert failed')
599 out = '# git revert %s\n\n' % self.oid
600 Interaction.command(title, 'git revert', status, out, err)
603 class ResetMode(EditModel):
604 """Reset the mode and clear the model's diff text."""
606 def __init__(self, context):
607 super(ResetMode, self).__init__(context)
608 self.new_mode = self.model.mode_none
609 self.new_diff_text = ''
610 self.new_diff_type = main.Types.TEXT
611 self.new_file_type = main.Types.TEXT
612 self.new_filename = ''
614 def do(self):
615 super(ResetMode, self).do()
616 self.model.update_file_status()
619 class ResetCommand(ConfirmAction):
620 """Reset state using the "git reset" command"""
622 def __init__(self, context, ref):
623 super(ResetCommand, self).__init__(context)
624 self.ref = ref
626 def action(self):
627 return self.reset()
629 def command(self):
630 return 'git reset'
632 def error_message(self):
633 return N_('Error')
635 def success(self):
636 self.model.update_file_status()
638 def confirm(self):
639 raise NotImplementedError('confirm() must be overridden')
641 def reset(self):
642 raise NotImplementedError('reset() must be overridden')
645 class ResetMixed(ResetCommand):
646 @staticmethod
647 def tooltip(ref):
648 tooltip = N_('The branch will be reset using "git reset --mixed %s"')
649 return tooltip % ref
651 def confirm(self):
652 title = N_('Reset Branch and Stage (Mixed)')
653 question = N_('Point the current branch head to a new commit?')
654 info = self.tooltip(self.ref)
655 ok_text = N_('Reset Branch')
656 return Interaction.confirm(title, question, info, ok_text)
658 def reset(self):
659 return self.git.reset(self.ref, '--', mixed=True)
662 class ResetKeep(ResetCommand):
663 @staticmethod
664 def tooltip(ref):
665 tooltip = N_('The repository will be reset using "git reset --keep %s"')
666 return tooltip % ref
668 def confirm(self):
669 title = N_('Restore Worktree and Reset All (Keep Unstaged Changes)')
670 question = N_('Restore worktree, reset, and preserve unstaged edits?')
671 info = self.tooltip(self.ref)
672 ok_text = N_('Reset and Restore')
673 return Interaction.confirm(title, question, info, ok_text)
675 def reset(self):
676 return self.git.reset(self.ref, '--', keep=True)
679 class ResetMerge(ResetCommand):
680 @staticmethod
681 def tooltip(ref):
682 tooltip = N_('The repository will be reset using "git reset --merge %s"')
683 return tooltip % ref
685 def confirm(self):
686 title = N_('Restore Worktree and Reset All (Merge)')
687 question = N_('Reset Worktree and Reset All?')
688 info = self.tooltip(self.ref)
689 ok_text = N_('Reset and Restore')
690 return Interaction.confirm(title, question, info, ok_text)
692 def reset(self):
693 return self.git.reset(self.ref, '--', merge=True)
696 class ResetSoft(ResetCommand):
697 @staticmethod
698 def tooltip(ref):
699 tooltip = N_('The branch will be reset using "git reset --soft %s"')
700 return tooltip % ref
702 def confirm(self):
703 title = N_('Reset Branch (Soft)')
704 question = N_('Reset branch?')
705 info = self.tooltip(self.ref)
706 ok_text = N_('Reset Branch')
707 return Interaction.confirm(title, question, info, ok_text)
709 def reset(self):
710 return self.git.reset(self.ref, '--', soft=True)
713 class ResetHard(ResetCommand):
714 @staticmethod
715 def tooltip(ref):
716 tooltip = N_('The repository will be reset using "git reset --hard %s"')
717 return tooltip % ref
719 def confirm(self):
720 title = N_('Restore Worktree and Reset All (Hard)')
721 question = N_('Restore Worktree and Reset All?')
722 info = self.tooltip(self.ref)
723 ok_text = N_('Reset and Restore')
724 return Interaction.confirm(title, question, info, ok_text)
726 def reset(self):
727 return self.git.reset(self.ref, '--', hard=True)
730 class RestoreWorktree(ConfirmAction):
731 """Reset the worktree using the "git read-tree" command"""
733 @staticmethod
734 def tooltip(ref):
735 tooltip = N_(
736 'The worktree will be restored using "git read-tree --reset -u %s"'
738 return tooltip % ref
740 def __init__(self, context, ref):
741 super(RestoreWorktree, self).__init__(context)
742 self.ref = ref
744 def action(self):
745 return self.git.read_tree(self.ref, reset=True, u=True)
747 def command(self):
748 return 'git read-tree --reset -u %s' % self.ref
750 def error_message(self):
751 return N_('Error')
753 def success(self):
754 self.model.update_file_status()
756 def confirm(self):
757 title = N_('Restore Worktree')
758 question = N_('Restore Worktree to %s?') % self.ref
759 info = self.tooltip(self.ref)
760 ok_text = N_('Restore Worktree')
761 return Interaction.confirm(title, question, info, ok_text)
764 class UndoLastCommit(ResetCommand):
765 """Undo the last commit"""
767 # NOTE: this is the similar to ResetSoft() with an additional check for
768 # published commits and different messages.
769 def __init__(self, context):
770 super(UndoLastCommit, self).__init__(context, 'HEAD^')
772 def confirm(self):
773 check_published = prefs.check_published_commits(self.context)
774 if check_published and self.model.is_commit_published():
775 return Interaction.confirm(
776 N_('Rewrite Published Commit?'),
778 'This commit has already been published.\n'
779 'This operation will rewrite published history.\n'
780 'You probably don\'t want to do this.'
782 N_('Undo the published commit?'),
783 N_('Undo Last Commit'),
784 default=False,
785 icon=icons.save(),
788 title = N_('Undo Last Commit')
789 question = N_('Undo last commit?')
790 info = N_('The branch will be reset using "git reset --soft %s"')
791 ok_text = N_('Undo Last Commit')
792 info_text = info % self.ref
793 return Interaction.confirm(title, question, info_text, ok_text)
795 def reset(self):
796 return self.git.reset('HEAD^', '--', soft=True)
799 class Commit(ResetMode):
800 """Attempt to create a new commit."""
802 def __init__(self, context, amend, msg, sign, no_verify=False):
803 super(Commit, self).__init__(context)
804 self.amend = amend
805 self.msg = msg
806 self.sign = sign
807 self.no_verify = no_verify
808 self.old_commitmsg = self.model.commitmsg
809 self.new_commitmsg = ''
811 def do(self):
812 # Create the commit message file
813 context = self.context
814 comment_char = prefs.comment_char(context)
815 msg = self.strip_comments(self.msg, comment_char=comment_char)
816 tmp_file = utils.tmp_filename('commit-message')
817 try:
818 core.write(tmp_file, msg)
819 # Run 'git commit'
820 status, out, err = self.git.commit(
821 F=tmp_file,
822 v=True,
823 gpg_sign=self.sign,
824 amend=self.amend,
825 no_verify=self.no_verify,
827 finally:
828 core.unlink(tmp_file)
829 if status == 0:
830 super(Commit, self).do()
831 if context.cfg.get(prefs.AUTOTEMPLATE):
832 template_loader = LoadCommitMessageFromTemplate(context)
833 template_loader.do()
834 else:
835 self.model.set_commitmsg(self.new_commitmsg)
837 title = N_('Commit failed')
838 Interaction.command(title, 'git commit', status, out, err)
840 return status, out, err
842 @staticmethod
843 def strip_comments(msg, comment_char='#'):
844 # Strip off comments
845 message_lines = [
846 line for line in msg.split('\n') if not line.startswith(comment_char)
848 msg = '\n'.join(message_lines)
849 if not msg.endswith('\n'):
850 msg += '\n'
852 return msg
855 class CycleReferenceSort(ContextCommand):
856 """Choose the next reference sort type"""
858 def do(self):
859 self.model.cycle_ref_sort()
862 class Ignore(ContextCommand):
863 """Add files to an exclusion file"""
865 def __init__(self, context, filenames, local=False):
866 super(Ignore, self).__init__(context)
867 self.filenames = list(filenames)
868 self.local = local
870 def do(self):
871 if not self.filenames:
872 return
873 new_additions = '\n'.join(self.filenames) + '\n'
874 for_status = new_additions
875 if self.local:
876 filename = os.path.join('.git', 'info', 'exclude')
877 else:
878 filename = '.gitignore'
879 if core.exists(filename):
880 current_list = core.read(filename)
881 new_additions = current_list.rstrip() + '\n' + new_additions
882 core.write(filename, new_additions)
883 Interaction.log_status(0, 'Added to %s:\n%s' % (filename, for_status), '')
884 self.model.update_file_status()
887 def file_summary(files):
888 txt = core.list2cmdline(files)
889 if len(txt) > 768:
890 txt = txt[:768].rstrip() + '...'
891 wrap = textwrap.TextWrapper()
892 return '\n'.join(wrap.wrap(txt))
895 class RemoteCommand(ConfirmAction):
896 def __init__(self, context, remote):
897 super(RemoteCommand, self).__init__(context)
898 self.remote = remote
900 def success(self):
901 self.cfg.reset()
902 self.model.update_remotes()
905 class RemoteAdd(RemoteCommand):
906 def __init__(self, context, remote, url):
907 super(RemoteAdd, self).__init__(context, remote)
908 self.url = url
910 def action(self):
911 return self.git.remote('add', self.remote, self.url)
913 def error_message(self):
914 return N_('Error creating remote "%s"') % self.remote
916 def command(self):
917 return 'git remote add "%s" "%s"' % (self.remote, self.url)
920 class RemoteRemove(RemoteCommand):
921 def confirm(self):
922 title = N_('Delete Remote')
923 question = N_('Delete remote?')
924 info = N_('Delete remote "%s"') % self.remote
925 ok_text = N_('Delete')
926 return Interaction.confirm(title, question, info, ok_text)
928 def action(self):
929 return self.git.remote('rm', self.remote)
931 def error_message(self):
932 return N_('Error deleting remote "%s"') % self.remote
934 def command(self):
935 return 'git remote rm "%s"' % self.remote
938 class RemoteRename(RemoteCommand):
939 def __init__(self, context, remote, new_name):
940 super(RemoteRename, self).__init__(context, remote)
941 self.new_name = new_name
943 def confirm(self):
944 title = N_('Rename Remote')
945 text = N_('Rename remote "%(current)s" to "%(new)s"?') % dict(
946 current=self.remote, new=self.new_name
948 info_text = ''
949 ok_text = title
950 return Interaction.confirm(title, text, info_text, ok_text)
952 def action(self):
953 return self.git.remote('rename', self.remote, self.new_name)
955 def error_message(self):
956 return N_('Error renaming "%(name)s" to "%(new_name)s"') % dict(
957 name=self.remote, new_name=self.new_name
960 def command(self):
961 return 'git remote rename "%s" "%s"' % (self.remote, self.new_name)
964 class RemoteSetURL(RemoteCommand):
965 def __init__(self, context, remote, url):
966 super(RemoteSetURL, self).__init__(context, remote)
967 self.url = url
969 def action(self):
970 return self.git.remote('set-url', self.remote, self.url)
972 def error_message(self):
973 return N_('Unable to set URL for "%(name)s" to "%(url)s"') % dict(
974 name=self.remote, url=self.url
977 def command(self):
978 return 'git remote set-url "%s" "%s"' % (self.remote, self.url)
981 class RemoteEdit(ContextCommand):
982 """Combine RemoteRename and RemoteSetURL"""
984 def __init__(self, context, old_name, remote, url):
985 super(RemoteEdit, self).__init__(context)
986 self.rename = RemoteRename(context, old_name, remote)
987 self.set_url = RemoteSetURL(context, remote, url)
989 def do(self):
990 result = self.rename.do()
991 name_ok = result[0]
992 url_ok = False
993 if name_ok:
994 result = self.set_url.do()
995 url_ok = result[0]
996 return name_ok, url_ok
999 class RemoveFromSettings(ConfirmAction):
1000 def __init__(self, context, repo, entry, icon=None):
1001 super(RemoveFromSettings, self).__init__(context)
1002 self.context = context
1003 self.repo = repo
1004 self.entry = entry
1005 self.icon = icon
1007 def success(self):
1008 self.context.settings.save()
1011 class RemoveBookmark(RemoveFromSettings):
1012 def confirm(self):
1013 entry = self.entry
1014 title = msg = N_('Delete Bookmark?')
1015 info = N_('%s will be removed from your bookmarks.') % entry
1016 ok_text = N_('Delete Bookmark')
1017 return Interaction.confirm(title, msg, info, ok_text, icon=self.icon)
1019 def action(self):
1020 self.context.settings.remove_bookmark(self.repo, self.entry)
1021 return (0, '', '')
1024 class RemoveRecent(RemoveFromSettings):
1025 def confirm(self):
1026 repo = self.repo
1027 title = msg = N_('Remove %s from the recent list?') % repo
1028 info = N_('%s will be removed from your recent repositories.') % repo
1029 ok_text = N_('Remove')
1030 return Interaction.confirm(title, msg, info, ok_text, icon=self.icon)
1032 def action(self):
1033 self.context.settings.remove_recent(self.repo)
1034 return (0, '', '')
1037 class RemoveFiles(ContextCommand):
1038 """Removes files"""
1040 def __init__(self, context, remover, filenames):
1041 super(RemoveFiles, self).__init__(context)
1042 if remover is None:
1043 remover = os.remove
1044 self.remover = remover
1045 self.filenames = filenames
1046 # We could git-hash-object stuff and provide undo-ability
1047 # as an option. Heh.
1049 def do(self):
1050 files = self.filenames
1051 if not files:
1052 return
1054 rescan = False
1055 bad_filenames = []
1056 remove = self.remover
1057 for filename in files:
1058 if filename:
1059 try:
1060 remove(filename)
1061 rescan = True
1062 except OSError:
1063 bad_filenames.append(filename)
1065 if bad_filenames:
1066 Interaction.information(
1067 N_('Error'), N_('Deleting "%s" failed') % file_summary(bad_filenames)
1070 if rescan:
1071 self.model.update_file_status()
1074 class Delete(RemoveFiles):
1075 """Delete files."""
1077 def __init__(self, context, filenames):
1078 super(Delete, self).__init__(context, os.remove, filenames)
1080 def do(self):
1081 files = self.filenames
1082 if not files:
1083 return
1085 title = N_('Delete Files?')
1086 msg = N_('The following files will be deleted:') + '\n\n'
1087 msg += file_summary(files)
1088 info_txt = N_('Delete %d file(s)?') % len(files)
1089 ok_txt = N_('Delete Files')
1091 if Interaction.confirm(
1092 title, msg, info_txt, ok_txt, default=True, icon=icons.remove()
1094 super(Delete, self).do()
1097 class MoveToTrash(RemoveFiles):
1098 """Move files to the trash using send2trash"""
1100 AVAILABLE = send2trash is not None
1102 def __init__(self, context, filenames):
1103 super(MoveToTrash, self).__init__(context, send2trash, filenames)
1106 class DeleteBranch(ConfirmAction):
1107 """Delete a git branch."""
1109 def __init__(self, context, branch):
1110 super(DeleteBranch, self).__init__(context)
1111 self.branch = branch
1113 def confirm(self):
1114 title = N_('Delete Branch')
1115 question = N_('Delete branch "%s"?') % self.branch
1116 info = N_('The branch will be no longer available.')
1117 ok_txt = N_('Delete Branch')
1118 return Interaction.confirm(
1119 title, question, info, ok_txt, default=True, icon=icons.discard()
1122 def action(self):
1123 return self.model.delete_branch(self.branch)
1125 def error_message(self):
1126 return N_('Error deleting branch "%s"' % self.branch)
1128 def command(self):
1129 command = 'git branch -D %s'
1130 return command % self.branch
1133 class Rename(ContextCommand):
1134 """Rename a set of paths."""
1136 def __init__(self, context, paths):
1137 super(Rename, self).__init__(context)
1138 self.paths = paths
1140 def do(self):
1141 msg = N_('Untracking: %s') % (', '.join(self.paths))
1142 Interaction.log(msg)
1144 for path in self.paths:
1145 ok = self.rename(path)
1146 if not ok:
1147 return
1149 self.model.update_status()
1151 def rename(self, path):
1152 git = self.git
1153 title = N_('Rename "%s"') % path
1155 if os.path.isdir(path):
1156 base_path = os.path.dirname(path)
1157 else:
1158 base_path = path
1159 new_path = Interaction.save_as(base_path, title)
1160 if not new_path:
1161 return False
1163 status, out, err = git.mv(path, new_path, force=True, verbose=True)
1164 Interaction.command(N_('Error'), 'git mv', status, out, err)
1165 return status == 0
1168 class RenameBranch(ContextCommand):
1169 """Rename a git branch."""
1171 def __init__(self, context, branch, new_branch):
1172 super(RenameBranch, self).__init__(context)
1173 self.branch = branch
1174 self.new_branch = new_branch
1176 def do(self):
1177 branch = self.branch
1178 new_branch = self.new_branch
1179 status, out, err = self.model.rename_branch(branch, new_branch)
1180 Interaction.log_status(status, out, err)
1183 class DeleteRemoteBranch(DeleteBranch):
1184 """Delete a remote git branch."""
1186 def __init__(self, context, remote, branch):
1187 super(DeleteRemoteBranch, self).__init__(context, branch)
1188 self.remote = remote
1190 def action(self):
1191 return self.git.push(self.remote, self.branch, delete=True)
1193 def success(self):
1194 self.model.update_status()
1195 Interaction.information(
1196 N_('Remote Branch Deleted'),
1197 N_('"%(branch)s" has been deleted from "%(remote)s".')
1198 % dict(branch=self.branch, remote=self.remote),
1201 def error_message(self):
1202 return N_('Error Deleting Remote Branch')
1204 def command(self):
1205 command = 'git push --delete %s %s'
1206 return command % (self.remote, self.branch)
1209 def get_mode(context, filename, staged, modified, unmerged, untracked):
1210 model = context.model
1211 if staged:
1212 mode = model.mode_index
1213 elif modified or unmerged:
1214 mode = model.mode_worktree
1215 elif untracked:
1216 if gitcmds.is_binary(context, filename):
1217 mode = model.mode_untracked
1218 else:
1219 mode = model.mode_untracked_diff
1220 else:
1221 mode = model.mode
1222 return mode
1225 class DiffAgainstCommitMode(ContextCommand):
1226 """Diff against arbitrary commits"""
1228 def __init__(self, context, oid):
1229 super(DiffAgainstCommitMode, self).__init__(context)
1230 self.oid = oid
1232 def do(self):
1233 self.model.set_mode(self.model.mode_diff, head=self.oid)
1234 self.model.update_file_status()
1237 class DiffText(EditModel):
1238 """Set the diff type to text"""
1240 def __init__(self, context):
1241 super(DiffText, self).__init__(context)
1242 self.new_file_type = main.Types.TEXT
1243 self.new_diff_type = main.Types.TEXT
1246 class ToggleDiffType(ContextCommand):
1247 """Toggle the diff type between image and text"""
1249 def __init__(self, context):
1250 super(ToggleDiffType, self).__init__(context)
1251 if self.model.diff_type == main.Types.IMAGE:
1252 self.new_diff_type = main.Types.TEXT
1253 self.new_value = False
1254 else:
1255 self.new_diff_type = main.Types.IMAGE
1256 self.new_value = True
1258 def do(self):
1259 diff_type = self.new_diff_type
1260 value = self.new_value
1262 self.model.set_diff_type(diff_type)
1264 filename = self.model.filename
1265 _, ext = os.path.splitext(filename)
1266 if ext.startswith('.'):
1267 cfg = 'cola.imagediff' + ext
1268 self.cfg.set_repo(cfg, value)
1271 class DiffImage(EditModel):
1272 def __init__(
1273 self, context, filename, deleted, staged, modified, unmerged, untracked
1275 super(DiffImage, self).__init__(context)
1277 self.new_filename = filename
1278 self.new_diff_type = self.get_diff_type(filename)
1279 self.new_file_type = main.Types.IMAGE
1280 self.new_mode = get_mode(
1281 context, filename, staged, modified, unmerged, untracked
1283 self.staged = staged
1284 self.modified = modified
1285 self.unmerged = unmerged
1286 self.untracked = untracked
1287 self.deleted = deleted
1288 self.annex = self.cfg.is_annex()
1290 def get_diff_type(self, filename):
1291 """Query the diff type to use based on cola.imagediff.<extension>"""
1292 _, ext = os.path.splitext(filename)
1293 if ext.startswith('.'):
1294 # Check eg. "cola.imagediff.svg" to see if we should imagediff.
1295 cfg = 'cola.imagediff' + ext
1296 if self.cfg.get(cfg, True):
1297 result = main.Types.IMAGE
1298 else:
1299 result = main.Types.TEXT
1300 else:
1301 result = main.Types.IMAGE
1302 return result
1304 def do(self):
1305 filename = self.new_filename
1307 if self.staged:
1308 images = self.staged_images()
1309 elif self.modified:
1310 images = self.modified_images()
1311 elif self.unmerged:
1312 images = self.unmerged_images()
1313 elif self.untracked:
1314 images = [(filename, False)]
1315 else:
1316 images = []
1318 self.model.set_images(images)
1319 super(DiffImage, self).do()
1321 def staged_images(self):
1322 context = self.context
1323 git = self.git
1324 head = self.model.head
1325 filename = self.new_filename
1326 annex = self.annex
1328 images = []
1329 index = git.diff_index(head, '--', filename, cached=True)[STDOUT]
1330 if index:
1331 # Example:
1332 # :100644 100644 fabadb8... 4866510... M describe.c
1333 parts = index.split(' ')
1334 if len(parts) > 3:
1335 old_oid = parts[2]
1336 new_oid = parts[3]
1338 if old_oid != MISSING_BLOB_OID:
1339 # First, check if we can get a pre-image from git-annex
1340 annex_image = None
1341 if annex:
1342 annex_image = gitcmds.annex_path(context, head, filename)
1343 if annex_image:
1344 images.append((annex_image, False)) # git annex HEAD
1345 else:
1346 image = gitcmds.write_blob_path(context, head, old_oid, filename)
1347 if image:
1348 images.append((image, True))
1350 if new_oid != MISSING_BLOB_OID:
1351 found_in_annex = False
1352 if annex and core.islink(filename):
1353 status, out, _ = git.annex('status', '--', filename)
1354 if status == 0:
1355 details = out.split(' ')
1356 if details and details[0] == 'A': # newly added file
1357 images.append((filename, False))
1358 found_in_annex = True
1360 if not found_in_annex:
1361 image = gitcmds.write_blob(context, new_oid, filename)
1362 if image:
1363 images.append((image, True))
1365 return images
1367 def unmerged_images(self):
1368 context = self.context
1369 git = self.git
1370 head = self.model.head
1371 filename = self.new_filename
1372 annex = self.annex
1374 candidate_merge_heads = ('HEAD', 'CHERRY_HEAD', 'MERGE_HEAD')
1375 merge_heads = [
1376 merge_head
1377 for merge_head in candidate_merge_heads
1378 if core.exists(git.git_path(merge_head))
1381 if annex: # Attempt to find files in git-annex
1382 annex_images = []
1383 for merge_head in merge_heads:
1384 image = gitcmds.annex_path(context, merge_head, filename)
1385 if image:
1386 annex_images.append((image, False))
1387 if annex_images:
1388 annex_images.append((filename, False))
1389 return annex_images
1391 # DIFF FORMAT FOR MERGES
1392 # "git-diff-tree", "git-diff-files" and "git-diff --raw"
1393 # can take -c or --cc option to generate diff output also
1394 # for merge commits. The output differs from the format
1395 # described above in the following way:
1397 # 1. there is a colon for each parent
1398 # 2. there are more "src" modes and "src" sha1
1399 # 3. status is concatenated status characters for each parent
1400 # 4. no optional "score" number
1401 # 5. single path, only for "dst"
1402 # Example:
1403 # ::100644 100644 100644 fabadb8... cc95eb0... 4866510... \
1404 # MM describe.c
1405 images = []
1406 index = git.diff_index(head, '--', filename, cached=True, cc=True)[STDOUT]
1407 if index:
1408 parts = index.split(' ')
1409 if len(parts) > 3:
1410 first_mode = parts[0]
1411 num_parents = first_mode.count(':')
1412 # colon for each parent, but for the index, the "parents"
1413 # are really entries in stages 1,2,3 (head, base, remote)
1414 # remote, base, head
1415 for i in range(num_parents):
1416 offset = num_parents + i + 1
1417 oid = parts[offset]
1418 try:
1419 merge_head = merge_heads[i]
1420 except IndexError:
1421 merge_head = 'HEAD'
1422 if oid != MISSING_BLOB_OID:
1423 image = gitcmds.write_blob_path(
1424 context, merge_head, oid, filename
1426 if image:
1427 images.append((image, True))
1429 images.append((filename, False))
1430 return images
1432 def modified_images(self):
1433 context = self.context
1434 git = self.git
1435 head = self.model.head
1436 filename = self.new_filename
1437 annex = self.annex
1439 images = []
1440 annex_image = None
1441 if annex: # Check for a pre-image from git-annex
1442 annex_image = gitcmds.annex_path(context, head, filename)
1443 if annex_image:
1444 images.append((annex_image, False)) # git annex HEAD
1445 else:
1446 worktree = git.diff_files('--', filename)[STDOUT]
1447 parts = worktree.split(' ')
1448 if len(parts) > 3:
1449 oid = parts[2]
1450 if oid != MISSING_BLOB_OID:
1451 image = gitcmds.write_blob_path(context, head, oid, filename)
1452 if image:
1453 images.append((image, True)) # HEAD
1455 images.append((filename, False)) # worktree
1456 return images
1459 class Diff(EditModel):
1460 """Perform a diff and set the model's current text."""
1462 def __init__(self, context, filename, cached=False, deleted=False):
1463 super(Diff, self).__init__(context)
1464 opts = {}
1465 if cached and gitcmds.is_valid_ref(context, self.model.head):
1466 opts['ref'] = self.model.head
1467 self.new_filename = filename
1468 self.new_mode = self.model.mode_worktree
1469 self.new_diff_text = gitcmds.diff_helper(
1470 self.context, filename=filename, cached=cached, deleted=deleted, **opts
1474 class Diffstat(EditModel):
1475 """Perform a diffstat and set the model's diff text."""
1477 def __init__(self, context):
1478 super(Diffstat, self).__init__(context)
1479 cfg = self.cfg
1480 diff_context = cfg.get('diff.context', 3)
1481 diff = self.git.diff(
1482 self.model.head,
1483 unified=diff_context,
1484 no_ext_diff=True,
1485 no_color=True,
1486 M=True,
1487 stat=True,
1488 )[STDOUT]
1489 self.new_diff_text = diff
1490 self.new_diff_type = main.Types.TEXT
1491 self.new_file_type = main.Types.TEXT
1492 self.new_mode = self.model.mode_diffstat
1495 class DiffStaged(Diff):
1496 """Perform a staged diff on a file."""
1498 def __init__(self, context, filename, deleted=None):
1499 super(DiffStaged, self).__init__(
1500 context, filename, cached=True, deleted=deleted
1502 self.new_mode = self.model.mode_index
1505 class DiffStagedSummary(EditModel):
1506 def __init__(self, context):
1507 super(DiffStagedSummary, self).__init__(context)
1508 diff = self.git.diff(
1509 self.model.head,
1510 cached=True,
1511 no_color=True,
1512 no_ext_diff=True,
1513 patch_with_stat=True,
1514 M=True,
1515 )[STDOUT]
1516 self.new_diff_text = diff
1517 self.new_diff_type = main.Types.TEXT
1518 self.new_file_type = main.Types.TEXT
1519 self.new_mode = self.model.mode_index
1522 class Difftool(ContextCommand):
1523 """Run git-difftool limited by path."""
1525 def __init__(self, context, staged, filenames):
1526 super(Difftool, self).__init__(context)
1527 self.staged = staged
1528 self.filenames = filenames
1530 def do(self):
1531 difftool_launch_with_head(
1532 self.context, self.filenames, self.staged, self.model.head
1536 class Edit(ContextCommand):
1537 """Edit a file using the configured gui.editor."""
1539 @staticmethod
1540 def name():
1541 return N_('Launch Editor')
1543 def __init__(self, context, filenames, line_number=None, background_editor=False):
1544 super(Edit, self).__init__(context)
1545 self.filenames = filenames
1546 self.line_number = line_number
1547 self.background_editor = background_editor
1549 def do(self):
1550 context = self.context
1551 if not self.filenames:
1552 return
1553 filename = self.filenames[0]
1554 if not core.exists(filename):
1555 return
1556 if self.background_editor:
1557 editor = prefs.background_editor(context)
1558 else:
1559 editor = prefs.editor(context)
1560 opts = []
1562 if self.line_number is None:
1563 opts = self.filenames
1564 else:
1565 # Single-file w/ line-numbers (likely from grep)
1566 editor_opts = {
1567 '*vim*': [filename, '+%s' % self.line_number],
1568 '*emacs*': ['+%s' % self.line_number, filename],
1569 '*textpad*': ['%s(%s,0)' % (filename, self.line_number)],
1570 '*notepad++*': ['-n%s' % self.line_number, filename],
1571 '*subl*': ['%s:%s' % (filename, self.line_number)],
1574 opts = self.filenames
1575 for pattern, opt in editor_opts.items():
1576 if fnmatch(editor, pattern):
1577 opts = opt
1578 break
1580 try:
1581 core.fork(utils.shell_split(editor) + opts)
1582 except (OSError, ValueError) as e:
1583 message = N_('Cannot exec "%s": please configure your editor') % editor
1584 _, details = utils.format_exception(e)
1585 Interaction.critical(N_('Error Editing File'), message, details)
1588 class FormatPatch(ContextCommand):
1589 """Output a patch series given all revisions and a selected subset."""
1591 def __init__(self, context, to_export, revs, output='patches'):
1592 super(FormatPatch, self).__init__(context)
1593 self.to_export = list(to_export)
1594 self.revs = list(revs)
1595 self.output = output
1597 def do(self):
1598 context = self.context
1599 status, out, err = gitcmds.format_patchsets(
1600 context, self.to_export, self.revs, self.output
1602 Interaction.log_status(status, out, err)
1605 class LaunchDifftool(ContextCommand):
1606 @staticmethod
1607 def name():
1608 return N_('Launch Diff Tool')
1610 def do(self):
1611 s = self.selection.selection()
1612 if s.unmerged:
1613 paths = s.unmerged
1614 if utils.is_win32():
1615 core.fork(['git', 'mergetool', '--no-prompt', '--'] + paths)
1616 else:
1617 cfg = self.cfg
1618 cmd = cfg.terminal()
1619 argv = utils.shell_split(cmd)
1621 terminal = os.path.basename(argv[0])
1622 shellquote_terms = set(['xfce4-terminal'])
1623 shellquote_default = terminal in shellquote_terms
1625 mergetool = ['git', 'mergetool', '--no-prompt', '--']
1626 mergetool.extend(paths)
1627 needs_shellquote = cfg.get(
1628 'cola.terminalshellquote', shellquote_default
1631 if needs_shellquote:
1632 argv.append(core.list2cmdline(mergetool))
1633 else:
1634 argv.extend(mergetool)
1636 core.fork(argv)
1637 else:
1638 difftool_run(self.context)
1641 class LaunchTerminal(ContextCommand):
1642 @staticmethod
1643 def name():
1644 return N_('Launch Terminal')
1646 @staticmethod
1647 def is_available(context):
1648 return context.cfg.terminal() is not None
1650 def __init__(self, context, path):
1651 super(LaunchTerminal, self).__init__(context)
1652 self.path = path
1654 def do(self):
1655 cmd = self.context.cfg.terminal()
1656 if cmd is None:
1657 return
1658 if utils.is_win32():
1659 argv = ['start', '', cmd, '--login']
1660 shell = True
1661 else:
1662 argv = utils.shell_split(cmd)
1663 command = '/bin/sh'
1664 shells = ('zsh', 'fish', 'bash', 'sh')
1665 for basename in shells:
1666 executable = core.find_executable(basename)
1667 if executable:
1668 command = executable
1669 break
1670 argv.append(os.getenv('SHELL', command))
1671 shell = False
1673 core.fork(argv, cwd=self.path, shell=shell)
1676 class LaunchEditor(Edit):
1677 @staticmethod
1678 def name():
1679 return N_('Launch Editor')
1681 def __init__(self, context):
1682 s = context.selection.selection()
1683 filenames = s.staged + s.unmerged + s.modified + s.untracked
1684 super(LaunchEditor, self).__init__(context, filenames, background_editor=True)
1687 class LaunchEditorAtLine(LaunchEditor):
1688 """Launch an editor at the specified line"""
1690 def __init__(self, context):
1691 super(LaunchEditorAtLine, self).__init__(context)
1692 self.line_number = context.selection.line_number
1695 class LoadCommitMessageFromFile(ContextCommand):
1696 """Loads a commit message from a path."""
1698 UNDOABLE = True
1700 def __init__(self, context, path):
1701 super(LoadCommitMessageFromFile, self).__init__(context)
1702 self.path = path
1703 self.old_commitmsg = self.model.commitmsg
1704 self.old_directory = self.model.directory
1706 def do(self):
1707 path = os.path.expanduser(self.path)
1708 if not path or not core.isfile(path):
1709 raise UsageError(
1710 N_('Error: Cannot find commit template'),
1711 N_('%s: No such file or directory.') % path,
1713 self.model.set_directory(os.path.dirname(path))
1714 self.model.set_commitmsg(core.read(path))
1716 def undo(self):
1717 self.model.set_commitmsg(self.old_commitmsg)
1718 self.model.set_directory(self.old_directory)
1721 class LoadCommitMessageFromTemplate(LoadCommitMessageFromFile):
1722 """Loads the commit message template specified by commit.template."""
1724 def __init__(self, context):
1725 cfg = context.cfg
1726 template = cfg.get('commit.template')
1727 super(LoadCommitMessageFromTemplate, self).__init__(context, template)
1729 def do(self):
1730 if self.path is None:
1731 raise UsageError(
1732 N_('Error: Unconfigured commit template'),
1734 'A commit template has not been configured.\n'
1735 'Use "git config" to define "commit.template"\n'
1736 'so that it points to a commit template.'
1739 return LoadCommitMessageFromFile.do(self)
1742 class LoadCommitMessageFromOID(ContextCommand):
1743 """Load a previous commit message"""
1745 UNDOABLE = True
1747 def __init__(self, context, oid, prefix=''):
1748 super(LoadCommitMessageFromOID, self).__init__(context)
1749 self.oid = oid
1750 self.old_commitmsg = self.model.commitmsg
1751 self.new_commitmsg = prefix + gitcmds.prev_commitmsg(context, oid)
1753 def do(self):
1754 self.model.set_commitmsg(self.new_commitmsg)
1756 def undo(self):
1757 self.model.set_commitmsg(self.old_commitmsg)
1760 class PrepareCommitMessageHook(ContextCommand):
1761 """Use the cola-prepare-commit-msg hook to prepare the commit message"""
1763 UNDOABLE = True
1765 def __init__(self, context):
1766 super(PrepareCommitMessageHook, self).__init__(context)
1767 self.old_commitmsg = self.model.commitmsg
1769 def get_message(self):
1771 title = N_('Error running prepare-commitmsg hook')
1772 hook = gitcmds.prepare_commit_message_hook(self.context)
1774 if os.path.exists(hook):
1775 filename = self.model.save_commitmsg()
1776 status, out, err = core.run_command([hook, filename])
1778 if status == 0:
1779 result = core.read(filename)
1780 else:
1781 result = self.old_commitmsg
1782 Interaction.command_error(title, hook, status, out, err)
1783 else:
1784 message = N_('A hook must be provided at "%s"') % hook
1785 Interaction.critical(title, message=message)
1786 result = self.old_commitmsg
1788 return result
1790 def do(self):
1791 msg = self.get_message()
1792 self.model.set_commitmsg(msg)
1794 def undo(self):
1795 self.model.set_commitmsg(self.old_commitmsg)
1798 class LoadFixupMessage(LoadCommitMessageFromOID):
1799 """Load a fixup message"""
1801 def __init__(self, context, oid):
1802 super(LoadFixupMessage, self).__init__(context, oid, prefix='fixup! ')
1803 if self.new_commitmsg:
1804 self.new_commitmsg = self.new_commitmsg.splitlines()[0]
1807 class Merge(ContextCommand):
1808 """Merge commits"""
1810 def __init__(self, context, revision, no_commit, squash, no_ff, sign):
1811 super(Merge, self).__init__(context)
1812 self.revision = revision
1813 self.no_ff = no_ff
1814 self.no_commit = no_commit
1815 self.squash = squash
1816 self.sign = sign
1818 def do(self):
1819 squash = self.squash
1820 revision = self.revision
1821 no_ff = self.no_ff
1822 no_commit = self.no_commit
1823 sign = self.sign
1825 status, out, err = self.git.merge(
1826 revision, gpg_sign=sign, no_ff=no_ff, no_commit=no_commit, squash=squash
1828 self.model.update_status()
1829 title = N_('Merge failed. Conflict resolution is required.')
1830 Interaction.command(title, 'git merge', status, out, err)
1832 return status, out, err
1835 class OpenDefaultApp(ContextCommand):
1836 """Open a file using the OS default."""
1838 @staticmethod
1839 def name():
1840 return N_('Open Using Default Application')
1842 def __init__(self, context, filenames):
1843 super(OpenDefaultApp, self).__init__(context)
1844 self.filenames = filenames
1846 def do(self):
1847 if not self.filenames:
1848 return
1849 utils.launch_default_app(self.filenames)
1852 class OpenDir(OpenDefaultApp):
1853 """Open directories using the OS default."""
1855 @staticmethod
1856 def name():
1857 return N_('Open Directory')
1859 @property
1860 def _dirnames(self):
1861 return self.filenames
1863 def do(self):
1864 dirnames = self._dirnames
1865 if not dirnames:
1866 return
1867 # An empty dirname defaults to CWD.
1868 dirs = [(dirname or core.getcwd()) for dirname in dirnames]
1869 utils.launch_default_app(dirs)
1872 class OpenParentDir(OpenDir):
1873 """Open parent directories using the OS default."""
1875 @staticmethod
1876 def name():
1877 return N_('Open Parent Directory')
1879 @property
1880 def _dirnames(self):
1881 dirnames = list(set([os.path.dirname(x) for x in self.filenames]))
1882 return dirnames
1885 class OpenWorktree(OpenDir):
1886 """Open worktree directory using the OS default."""
1888 @staticmethod
1889 def name():
1890 return N_('Open Worktree')
1892 # The _unused parameter is needed by worktree_dir_action() -> common.cmd_action().
1893 def __init__(self, context, _unused=None):
1894 dirnames = [context.git.worktree()]
1895 super(OpenWorktree, self).__init__(context, dirnames)
1898 class OpenNewRepo(ContextCommand):
1899 """Launches git-cola on a repo."""
1901 def __init__(self, context, repo_path):
1902 super(OpenNewRepo, self).__init__(context)
1903 self.repo_path = repo_path
1905 def do(self):
1906 self.model.set_directory(self.repo_path)
1907 core.fork([sys.executable, sys.argv[0], '--repo', self.repo_path])
1910 class OpenRepo(EditModel):
1911 def __init__(self, context, repo_path):
1912 super(OpenRepo, self).__init__(context)
1913 self.repo_path = repo_path
1914 self.new_mode = self.model.mode_none
1915 self.new_diff_text = ''
1916 self.new_diff_type = main.Types.TEXT
1917 self.new_file_type = main.Types.TEXT
1918 self.new_commitmsg = ''
1919 self.new_filename = ''
1921 def do(self):
1922 old_repo = self.git.getcwd()
1923 if self.model.set_worktree(self.repo_path):
1924 self.fsmonitor.stop()
1925 self.fsmonitor.start()
1926 self.model.update_status(reset=True)
1927 # Check if template should be loaded
1928 if self.context.cfg.get(prefs.AUTOTEMPLATE):
1929 template_loader = LoadCommitMessageFromTemplate(self.context)
1930 template_loader.do()
1931 else:
1932 self.model.set_commitmsg(self.new_commitmsg)
1933 settings = self.context.settings
1934 settings.load()
1935 settings.add_recent(self.repo_path, prefs.maxrecent(self.context))
1936 settings.save()
1937 super(OpenRepo, self).do()
1938 else:
1939 self.model.set_worktree(old_repo)
1942 class OpenParentRepo(OpenRepo):
1943 def __init__(self, context):
1944 path = ''
1945 if version.check_git(context, 'show-superproject-working-tree'):
1946 status, out, _ = context.git.rev_parse(show_superproject_working_tree=True)
1947 if status == 0:
1948 path = out
1949 if not path:
1950 path = os.path.dirname(core.getcwd())
1951 super(OpenParentRepo, self).__init__(context, path)
1954 class Clone(ContextCommand):
1955 """Clones a repository and optionally spawns a new cola session."""
1957 def __init__(
1958 self, context, url, new_directory, submodules=False, shallow=False, spawn=True
1960 super(Clone, self).__init__(context)
1961 self.url = url
1962 self.new_directory = new_directory
1963 self.submodules = submodules
1964 self.shallow = shallow
1965 self.spawn = spawn
1966 self.status = -1
1967 self.out = ''
1968 self.err = ''
1970 def do(self):
1971 kwargs = {}
1972 if self.shallow:
1973 kwargs['depth'] = 1
1974 recurse_submodules = self.submodules
1975 shallow_submodules = self.submodules and self.shallow
1977 status, out, err = self.git.clone(
1978 self.url,
1979 self.new_directory,
1980 recurse_submodules=recurse_submodules,
1981 shallow_submodules=shallow_submodules,
1982 **kwargs
1985 self.status = status
1986 self.out = out
1987 self.err = err
1988 if status == 0 and self.spawn:
1989 executable = sys.executable
1990 core.fork([executable, sys.argv[0], '--repo', self.new_directory])
1991 return self
1994 class NewBareRepo(ContextCommand):
1995 """Create a new shared bare repository"""
1997 def __init__(self, context, path):
1998 super(NewBareRepo, self).__init__(context)
1999 self.path = path
2001 def do(self):
2002 path = self.path
2003 status, out, err = self.git.init(path, bare=True, shared=True)
2004 Interaction.command(
2005 N_('Error'), 'git init --bare --shared "%s"' % path, status, out, err
2007 return status == 0
2010 def unix_path(path, is_win32=utils.is_win32):
2011 """Git for Windows requires unix paths, so force them here"""
2012 if is_win32():
2013 path = path.replace('\\', '/')
2014 first = path[0]
2015 second = path[1]
2016 if second == ':': # sanity check, this better be a Windows-style path
2017 path = '/' + first + path[2:]
2019 return path
2022 def sequence_editor():
2023 """Set GIT_SEQUENCE_EDITOR for running git-cola-sequence-editor"""
2024 xbase = unix_path(resources.command('git-cola-sequence-editor'))
2025 if utils.is_win32():
2026 editor = core.list2cmdline([unix_path(sys.executable), xbase])
2027 else:
2028 editor = core.list2cmdline([xbase])
2029 return editor
2032 class SequenceEditorEnvironment(object):
2033 """Set environment variables to enable git-cola-sequence-editor"""
2035 def __init__(self, context, **kwargs):
2036 self.env = {
2037 'GIT_EDITOR': prefs.editor(context),
2038 'GIT_SEQUENCE_EDITOR': sequence_editor(),
2039 'GIT_COLA_SEQ_EDITOR_CANCEL_ACTION': 'save',
2041 self.env.update(kwargs)
2043 def __enter__(self):
2044 for var, value in self.env.items():
2045 compat.setenv(var, value)
2046 return self
2048 def __exit__(self, exc_type, exc_val, exc_tb):
2049 for var in self.env:
2050 compat.unsetenv(var)
2053 class Rebase(ContextCommand):
2054 def __init__(self, context, upstream=None, branch=None, **kwargs):
2055 """Start an interactive rebase session
2057 :param upstream: upstream branch
2058 :param branch: optional branch to checkout
2059 :param kwargs: forwarded directly to `git.rebase()`
2062 super(Rebase, self).__init__(context)
2064 self.upstream = upstream
2065 self.branch = branch
2066 self.kwargs = kwargs
2068 def prepare_arguments(self, upstream):
2069 args = []
2070 kwargs = {}
2072 # Rebase actions must be the only option specified
2073 for action in ('continue', 'abort', 'skip', 'edit_todo'):
2074 if self.kwargs.get(action, False):
2075 kwargs[action] = self.kwargs[action]
2076 return args, kwargs
2078 kwargs['interactive'] = True
2079 kwargs['autosquash'] = self.kwargs.get('autosquash', True)
2080 kwargs.update(self.kwargs)
2082 # Prompt to determine whether or not to use "git rebase --update-refs".
2083 has_update_refs = version.check_git(self.context, 'rebase-update-refs')
2084 if has_update_refs and not kwargs.get('update_refs', False):
2085 title = N_('Update stacked branches when rebasing?')
2086 text = N_(
2087 '"git rebase --update-refs" automatically force-updates any\n'
2088 'branches that point to commits that are being rebased.\n\n'
2089 'Any branches that are checked out in a worktree are not updated.\n\n'
2090 'Using this feature is helpful for "stacked" branch workflows.'
2092 info = N_('Update stacked branches when rebasing?')
2093 ok_text = N_('Update stacked branches')
2094 cancel_text = N_('Do not update stacked branches')
2095 update_refs = Interaction.confirm(
2096 title,
2097 text,
2098 info,
2099 ok_text,
2100 default=True,
2101 cancel_text=cancel_text,
2103 if update_refs:
2104 kwargs['update_refs'] = True
2106 if upstream:
2107 args.append(upstream)
2108 if self.branch:
2109 args.append(self.branch)
2111 return args, kwargs
2113 def do(self):
2114 (status, out, err) = (1, '', '')
2115 context = self.context
2116 cfg = self.cfg
2117 model = self.model
2119 if not cfg.get('rebase.autostash', False):
2120 if model.staged or model.unmerged or model.modified:
2121 Interaction.information(
2122 N_('Unable to rebase'),
2123 N_('You cannot rebase with uncommitted changes.'),
2125 return status, out, err
2127 upstream = self.upstream or Interaction.choose_ref(
2128 context,
2129 N_('Select New Upstream'),
2130 N_('Interactive Rebase'),
2131 default='@{upstream}',
2133 if not upstream:
2134 return status, out, err
2136 self.model.is_rebasing = True
2137 self.model.emit_updated()
2139 args, kwargs = self.prepare_arguments(upstream)
2140 upstream_title = upstream or '@{upstream}'
2141 with SequenceEditorEnvironment(
2142 self.context,
2143 GIT_COLA_SEQ_EDITOR_TITLE=N_('Rebase onto %s') % upstream_title,
2144 GIT_COLA_SEQ_EDITOR_ACTION=N_('Rebase'),
2146 # TODO this blocks the user interface window for the duration
2147 # of git-cola-sequence-editor. We would need to implement
2148 # signals for QProcess and continue running the main thread.
2149 # Alternatively, we can hide the main window while rebasing.
2150 # That doesn't require as much effort.
2151 status, out, err = self.git.rebase(
2152 *args, _no_win32_startupinfo=True, **kwargs
2154 self.model.update_status()
2155 if err.strip() != 'Nothing to do':
2156 title = N_('Rebase stopped')
2157 Interaction.command(title, 'git rebase', status, out, err)
2158 return status, out, err
2161 class RebaseEditTodo(ContextCommand):
2162 def do(self):
2163 (status, out, err) = (1, '', '')
2164 with SequenceEditorEnvironment(
2165 self.context,
2166 GIT_COLA_SEQ_EDITOR_TITLE=N_('Edit Rebase'),
2167 GIT_COLA_SEQ_EDITOR_ACTION=N_('Save'),
2169 status, out, err = self.git.rebase(edit_todo=True)
2170 Interaction.log_status(status, out, err)
2171 self.model.update_status()
2172 return status, out, err
2175 class RebaseContinue(ContextCommand):
2176 def do(self):
2177 (status, out, err) = (1, '', '')
2178 with SequenceEditorEnvironment(
2179 self.context,
2180 GIT_COLA_SEQ_EDITOR_TITLE=N_('Rebase'),
2181 GIT_COLA_SEQ_EDITOR_ACTION=N_('Rebase'),
2183 status, out, err = self.git.rebase('--continue')
2184 Interaction.log_status(status, out, err)
2185 self.model.update_status()
2186 return status, out, err
2189 class RebaseSkip(ContextCommand):
2190 def do(self):
2191 (status, out, err) = (1, '', '')
2192 with SequenceEditorEnvironment(
2193 self.context,
2194 GIT_COLA_SEQ_EDITOR_TITLE=N_('Rebase'),
2195 GIT_COLA_SEQ_EDITOR_ACTION=N_('Rebase'),
2197 status, out, err = self.git.rebase(skip=True)
2198 Interaction.log_status(status, out, err)
2199 self.model.update_status()
2200 return status, out, err
2203 class RebaseAbort(ContextCommand):
2204 def do(self):
2205 status, out, err = self.git.rebase(abort=True)
2206 Interaction.log_status(status, out, err)
2207 self.model.update_status()
2210 class Rescan(ContextCommand):
2211 """Rescan for changes"""
2213 def do(self):
2214 self.model.update_status()
2217 class Refresh(ContextCommand):
2218 """Update refs, refresh the index, and update config"""
2220 @staticmethod
2221 def name():
2222 return N_('Refresh')
2224 def do(self):
2225 self.model.update_status(update_index=True)
2226 self.cfg.update()
2227 self.fsmonitor.refresh()
2230 class RefreshConfig(ContextCommand):
2231 """Refresh the git config cache"""
2233 def do(self):
2234 self.cfg.update()
2237 class RevertEditsCommand(ConfirmAction):
2238 def __init__(self, context):
2239 super(RevertEditsCommand, self).__init__(context)
2240 self.icon = icons.undo()
2242 def ok_to_run(self):
2243 return self.model.is_undoable()
2245 def checkout_from_head(self):
2246 return False
2248 def checkout_args(self):
2249 args = []
2250 s = self.selection.selection()
2251 if self.checkout_from_head():
2252 args.append(self.model.head)
2253 args.append('--')
2255 if s.staged:
2256 items = s.staged
2257 else:
2258 items = s.modified
2259 args.extend(items)
2261 return args
2263 def action(self):
2264 checkout_args = self.checkout_args()
2265 return self.git.checkout(*checkout_args)
2267 def success(self):
2268 self.model.set_diff_type(main.Types.TEXT)
2269 self.model.update_file_status()
2272 class RevertUnstagedEdits(RevertEditsCommand):
2273 @staticmethod
2274 def name():
2275 return N_('Revert Unstaged Edits...')
2277 def checkout_from_head(self):
2278 # Being in amend mode should not affect the behavior of this command.
2279 # The only sensible thing to do is to checkout from the index.
2280 return False
2282 def confirm(self):
2283 title = N_('Revert Unstaged Changes?')
2284 text = N_(
2285 'This operation removes unstaged edits from selected files.\n'
2286 'These changes cannot be recovered.'
2288 info = N_('Revert the unstaged changes?')
2289 ok_text = N_('Revert Unstaged Changes')
2290 return Interaction.confirm(
2291 title, text, info, ok_text, default=True, icon=self.icon
2295 class RevertUncommittedEdits(RevertEditsCommand):
2296 @staticmethod
2297 def name():
2298 return N_('Revert Uncommitted Edits...')
2300 def checkout_from_head(self):
2301 return True
2303 def confirm(self):
2304 """Prompt for reverting changes"""
2305 title = N_('Revert Uncommitted Changes?')
2306 text = N_(
2307 'This operation removes uncommitted edits from selected files.\n'
2308 'These changes cannot be recovered.'
2310 info = N_('Revert the uncommitted changes?')
2311 ok_text = N_('Revert Uncommitted Changes')
2312 return Interaction.confirm(
2313 title, text, info, ok_text, default=True, icon=self.icon
2317 class RunConfigAction(ContextCommand):
2318 """Run a user-configured action, typically from the "Tools" menu"""
2320 def __init__(self, context, action_name):
2321 super(RunConfigAction, self).__init__(context)
2322 self.action_name = action_name
2324 def do(self):
2325 """Run the user-configured action"""
2326 for env in ('ARGS', 'DIRNAME', 'FILENAME', 'REVISION'):
2327 try:
2328 compat.unsetenv(env)
2329 except KeyError:
2330 pass
2331 rev = None
2332 args = None
2333 context = self.context
2334 cfg = self.cfg
2335 opts = cfg.get_guitool_opts(self.action_name)
2336 cmd = opts.get('cmd')
2337 if 'title' not in opts:
2338 opts['title'] = cmd
2340 if 'prompt' not in opts or opts.get('prompt') is True:
2341 prompt = N_('Run "%s"?') % cmd
2342 opts['prompt'] = prompt
2344 if opts.get('needsfile'):
2345 filename = self.selection.filename()
2346 if not filename:
2347 Interaction.information(
2348 N_('Please select a file'),
2349 N_('"%s" requires a selected file.') % cmd,
2351 return False
2352 dirname = utils.dirname(filename, current_dir='.')
2353 compat.setenv('FILENAME', filename)
2354 compat.setenv('DIRNAME', dirname)
2356 if opts.get('revprompt') or opts.get('argprompt'):
2357 while True:
2358 ok = Interaction.confirm_config_action(context, cmd, opts)
2359 if not ok:
2360 return False
2361 rev = opts.get('revision')
2362 args = opts.get('args')
2363 if opts.get('revprompt') and not rev:
2364 title = N_('Invalid Revision')
2365 msg = N_('The revision expression cannot be empty.')
2366 Interaction.critical(title, msg)
2367 continue
2368 break
2370 elif opts.get('confirm'):
2371 title = os.path.expandvars(opts.get('title'))
2372 prompt = os.path.expandvars(opts.get('prompt'))
2373 if not Interaction.question(title, prompt):
2374 return False
2375 if rev:
2376 compat.setenv('REVISION', rev)
2377 if args:
2378 compat.setenv('ARGS', args)
2379 title = os.path.expandvars(cmd)
2380 Interaction.log(N_('Running command: %s') % title)
2381 cmd = ['sh', '-c', cmd]
2383 if opts.get('background'):
2384 core.fork(cmd)
2385 status, out, err = (0, '', '')
2386 elif opts.get('noconsole'):
2387 status, out, err = core.run_command(cmd)
2388 else:
2389 status, out, err = Interaction.run_command(title, cmd)
2391 if not opts.get('background') and not opts.get('norescan'):
2392 self.model.update_status()
2394 title = N_('Error')
2395 Interaction.command(title, cmd, status, out, err)
2397 return status == 0
2400 class SetDefaultRepo(ContextCommand):
2401 """Set the default repository"""
2403 def __init__(self, context, repo):
2404 super(SetDefaultRepo, self).__init__(context)
2405 self.repo = repo
2407 def do(self):
2408 self.cfg.set_user('cola.defaultrepo', self.repo)
2411 class SetDiffText(EditModel):
2412 """Set the diff text"""
2414 UNDOABLE = True
2416 def __init__(self, context, text):
2417 super(SetDiffText, self).__init__(context)
2418 self.new_diff_text = text
2419 self.new_diff_type = main.Types.TEXT
2420 self.new_file_type = main.Types.TEXT
2423 class SetUpstreamBranch(ContextCommand):
2424 """Set the upstream branch"""
2426 def __init__(self, context, branch, remote, remote_branch):
2427 super(SetUpstreamBranch, self).__init__(context)
2428 self.branch = branch
2429 self.remote = remote
2430 self.remote_branch = remote_branch
2432 def do(self):
2433 cfg = self.cfg
2434 remote = self.remote
2435 branch = self.branch
2436 remote_branch = self.remote_branch
2437 cfg.set_repo('branch.%s.remote' % branch, remote)
2438 cfg.set_repo('branch.%s.merge' % branch, 'refs/heads/' + remote_branch)
2441 def format_hex(data):
2442 """Translate binary data into a hex dump"""
2443 hexdigits = '0123456789ABCDEF'
2444 result = ''
2445 offset = 0
2446 byte_offset_to_int = compat.byte_offset_to_int_converter()
2447 while offset < len(data):
2448 result += '%04u |' % offset
2449 textpart = ''
2450 for i in range(0, 16):
2451 if i > 0 and i % 4 == 0:
2452 result += ' '
2453 if offset < len(data):
2454 v = byte_offset_to_int(data[offset])
2455 result += ' ' + hexdigits[v >> 4] + hexdigits[v & 0xF]
2456 textpart += chr(v) if 32 <= v < 127 else '.'
2457 offset += 1
2458 else:
2459 result += ' '
2460 textpart += ' '
2461 result += ' | ' + textpart + ' |\n'
2463 return result
2466 class ShowUntracked(EditModel):
2467 """Show an untracked file."""
2469 def __init__(self, context, filename):
2470 super(ShowUntracked, self).__init__(context)
2471 self.new_filename = filename
2472 if gitcmds.is_binary(context, filename):
2473 self.new_mode = self.model.mode_untracked
2474 self.new_diff_text = self.read(filename)
2475 else:
2476 self.new_mode = self.model.mode_untracked_diff
2477 self.new_diff_text = gitcmds.diff_helper(
2478 self.context, filename=filename, cached=False, untracked=True
2480 self.new_diff_type = main.Types.TEXT
2481 self.new_file_type = main.Types.TEXT
2483 def read(self, filename):
2484 """Read file contents"""
2485 cfg = self.cfg
2486 size = cfg.get('cola.readsize', 2048)
2487 try:
2488 result = core.read(filename, size=size, encoding='bytes')
2489 except (IOError, OSError):
2490 result = ''
2492 truncated = len(result) == size
2494 encoding = cfg.file_encoding(filename) or core.ENCODING
2495 try:
2496 text_result = core.decode_maybe(result, encoding)
2497 except UnicodeError:
2498 text_result = format_hex(result)
2500 if truncated:
2501 text_result += '...'
2502 return text_result
2505 class SignOff(ContextCommand):
2506 """Append a signoff to the commit message"""
2508 UNDOABLE = True
2510 @staticmethod
2511 def name():
2512 return N_('Sign Off')
2514 def __init__(self, context):
2515 super(SignOff, self).__init__(context)
2516 self.old_commitmsg = self.model.commitmsg
2518 def do(self):
2519 """Add a signoff to the commit message"""
2520 signoff = self.signoff()
2521 if signoff in self.model.commitmsg:
2522 return
2523 msg = self.model.commitmsg.rstrip()
2524 self.model.set_commitmsg(msg + '\n' + signoff)
2526 def undo(self):
2527 """Restore the commit message"""
2528 self.model.set_commitmsg(self.old_commitmsg)
2530 def signoff(self):
2531 """Generate the signoff string"""
2532 try:
2533 import pwd # pylint: disable=all
2535 user = pwd.getpwuid(os.getuid()).pw_name
2536 except ImportError:
2537 user = os.getenv('USER', N_('unknown'))
2539 cfg = self.cfg
2540 name = cfg.get('user.name', user)
2541 email = cfg.get('user.email', '%s@%s' % (user, core.node()))
2542 return '\nSigned-off-by: %s <%s>' % (name, email)
2545 def check_conflicts(context, unmerged):
2546 """Check paths for conflicts
2548 Conflicting files can be filtered out one-by-one.
2551 if prefs.check_conflicts(context):
2552 unmerged = [path for path in unmerged if is_conflict_free(path)]
2553 return unmerged
2556 def is_conflict_free(path):
2557 """Return True if `path` contains no conflict markers"""
2558 rgx = re.compile(r'^(<<<<<<<|\|\|\|\|\|\|\||>>>>>>>) ')
2559 try:
2560 with core.xopen(path, 'rb') as f:
2561 for line in f:
2562 line = core.decode(line, errors='ignore')
2563 if rgx.match(line):
2564 return should_stage_conflicts(path)
2565 except IOError:
2566 # We can't read this file ~ we may be staging a removal
2567 pass
2568 return True
2571 def should_stage_conflicts(path):
2572 """Inform the user that a file contains merge conflicts
2574 Return `True` if we should stage the path nonetheless.
2577 title = msg = N_('Stage conflicts?')
2578 info = (
2580 '%s appears to contain merge conflicts.\n\n'
2581 'You should probably skip this file.\n'
2582 'Stage it anyways?'
2584 % path
2586 ok_text = N_('Stage conflicts')
2587 cancel_text = N_('Skip')
2588 return Interaction.confirm(
2589 title, msg, info, ok_text, default=False, cancel_text=cancel_text
2593 class Stage(ContextCommand):
2594 """Stage a set of paths."""
2596 @staticmethod
2597 def name():
2598 return N_('Stage')
2600 def __init__(self, context, paths):
2601 super(Stage, self).__init__(context)
2602 self.paths = paths
2604 def do(self):
2605 msg = N_('Staging: %s') % (', '.join(self.paths))
2606 Interaction.log(msg)
2607 return self.stage_paths()
2609 def stage_paths(self):
2610 """Stages add/removals to git."""
2611 context = self.context
2612 paths = self.paths
2613 if not paths:
2614 if self.model.cfg.get('cola.safemode', False):
2615 return (0, '', '')
2616 return self.stage_all()
2618 add = []
2619 remove = []
2620 status = 0
2621 out = ''
2622 err = ''
2624 for path in set(paths):
2625 if core.exists(path) or core.islink(path):
2626 if path.endswith('/'):
2627 path = path.rstrip('/')
2628 add.append(path)
2629 else:
2630 remove.append(path)
2632 self.model.emit_about_to_update()
2634 # `git add -u` doesn't work on untracked files
2635 if add:
2636 status, out, err = gitcmds.add(context, add)
2637 Interaction.command(N_('Error'), 'git add', status, out, err)
2639 # If a path doesn't exist then that means it should be removed
2640 # from the index. We use `git add -u` for that.
2641 if remove:
2642 status, out, err = gitcmds.add(context, remove, u=True)
2643 Interaction.command(N_('Error'), 'git add -u', status, out, err)
2645 self.model.update_files(emit=True)
2646 return status, out, err
2648 def stage_all(self):
2649 """Stage all files"""
2650 status, out, err = self.git.add(v=True, u=True)
2651 Interaction.command(N_('Error'), 'git add -u', status, out, err)
2652 self.model.update_file_status()
2653 return (status, out, err)
2656 class StageCarefully(Stage):
2657 """Only stage when the path list is non-empty
2659 We use "git add -u -- <pathspec>" to stage, and it stages everything by
2660 default when no pathspec is specified, so this class ensures that paths
2661 are specified before calling git.
2663 When no paths are specified, the command does nothing.
2667 def __init__(self, context):
2668 super(StageCarefully, self).__init__(context, None)
2669 self.init_paths()
2671 def init_paths(self):
2672 """Initialize path data"""
2673 return
2675 def ok_to_run(self):
2676 """Prevent catch-all "git add -u" from adding unmerged files"""
2677 return self.paths or not self.model.unmerged
2679 def do(self):
2680 """Stage files when ok_to_run() return True"""
2681 if self.ok_to_run():
2682 return super(StageCarefully, self).do()
2683 return (0, '', '')
2686 class StageModified(StageCarefully):
2687 """Stage all modified files."""
2689 @staticmethod
2690 def name():
2691 return N_('Stage Modified')
2693 def init_paths(self):
2694 self.paths = self.model.modified
2697 class StageUnmerged(StageCarefully):
2698 """Stage unmerged files."""
2700 @staticmethod
2701 def name():
2702 return N_('Stage Unmerged')
2704 def init_paths(self):
2705 self.paths = check_conflicts(self.context, self.model.unmerged)
2708 class StageUntracked(StageCarefully):
2709 """Stage all untracked files."""
2711 @staticmethod
2712 def name():
2713 return N_('Stage Untracked')
2715 def init_paths(self):
2716 self.paths = self.model.untracked
2719 class StageModifiedAndUntracked(StageCarefully):
2720 """Stage all untracked files."""
2722 @staticmethod
2723 def name():
2724 return N_('Stage Modified and Untracked')
2726 def init_paths(self):
2727 self.paths = self.model.modified + self.model.untracked
2730 class StageOrUnstageAll(ContextCommand):
2731 """If the selection is staged, unstage it, otherwise stage"""
2733 @staticmethod
2734 def name():
2735 return N_('Stage / Unstage All')
2737 def do(self):
2738 if self.model.staged:
2739 do(Unstage, self.context, self.model.staged)
2740 else:
2741 if self.cfg.get('cola.safemode', False):
2742 unstaged = self.model.modified
2743 else:
2744 unstaged = self.model.modified + self.model.untracked
2745 do(Stage, self.context, unstaged)
2748 class StageOrUnstage(ContextCommand):
2749 """If the selection is staged, unstage it, otherwise stage"""
2751 @staticmethod
2752 def name():
2753 return N_('Stage / Unstage')
2755 def do(self):
2756 s = self.selection.selection()
2757 if s.staged:
2758 do(Unstage, self.context, s.staged)
2760 unstaged = []
2761 unmerged = check_conflicts(self.context, s.unmerged)
2762 if unmerged:
2763 unstaged.extend(unmerged)
2764 if s.modified:
2765 unstaged.extend(s.modified)
2766 if s.untracked:
2767 unstaged.extend(s.untracked)
2768 if unstaged:
2769 do(Stage, self.context, unstaged)
2772 class Tag(ContextCommand):
2773 """Create a tag object."""
2775 def __init__(self, context, name, revision, sign=False, message=''):
2776 super(Tag, self).__init__(context)
2777 self._name = name
2778 self._message = message
2779 self._revision = revision
2780 self._sign = sign
2782 def do(self):
2783 result = False
2784 git = self.git
2785 revision = self._revision
2786 tag_name = self._name
2787 tag_message = self._message
2789 if not revision:
2790 Interaction.critical(
2791 N_('Missing Revision'), N_('Please specify a revision to tag.')
2793 return result
2795 if not tag_name:
2796 Interaction.critical(
2797 N_('Missing Name'), N_('Please specify a name for the new tag.')
2799 return result
2801 title = N_('Missing Tag Message')
2802 message = N_('Tag-signing was requested but the tag message is empty.')
2803 info = N_(
2804 'An unsigned, lightweight tag will be created instead.\n'
2805 'Create an unsigned tag?'
2807 ok_text = N_('Create Unsigned Tag')
2808 sign = self._sign
2809 if sign and not tag_message:
2810 # We require a message in order to sign the tag, so if they
2811 # choose to create an unsigned tag we have to clear the sign flag.
2812 if not Interaction.confirm(
2813 title, message, info, ok_text, default=False, icon=icons.save()
2815 return result
2816 sign = False
2818 opts = {}
2819 tmp_file = None
2820 try:
2821 if tag_message:
2822 tmp_file = utils.tmp_filename('tag-message')
2823 opts['file'] = tmp_file
2824 core.write(tmp_file, tag_message)
2826 if sign:
2827 opts['sign'] = True
2828 if tag_message:
2829 opts['annotate'] = True
2830 status, out, err = git.tag(tag_name, revision, **opts)
2831 finally:
2832 if tmp_file:
2833 core.unlink(tmp_file)
2835 title = N_('Error: could not create tag "%s"') % tag_name
2836 Interaction.command(title, 'git tag', status, out, err)
2838 if status == 0:
2839 result = True
2840 self.model.update_status()
2841 Interaction.information(
2842 N_('Tag Created'),
2843 N_('Created a new tag named "%s"') % tag_name,
2844 details=tag_message or None,
2847 return result
2850 class Unstage(ContextCommand):
2851 """Unstage a set of paths."""
2853 @staticmethod
2854 def name():
2855 return N_('Unstage')
2857 def __init__(self, context, paths):
2858 super(Unstage, self).__init__(context)
2859 self.paths = paths
2861 def do(self):
2862 """Unstage paths"""
2863 context = self.context
2864 head = self.model.head
2865 paths = self.paths
2867 msg = N_('Unstaging: %s') % (', '.join(paths))
2868 Interaction.log(msg)
2869 if not paths:
2870 return unstage_all(context)
2871 status, out, err = gitcmds.unstage_paths(context, paths, head=head)
2872 Interaction.command(N_('Error'), 'git reset', status, out, err)
2873 self.model.update_file_status()
2874 return (status, out, err)
2877 class UnstageAll(ContextCommand):
2878 """Unstage all files; resets the index."""
2880 def do(self):
2881 return unstage_all(self.context)
2884 def unstage_all(context):
2885 """Unstage all files, even while amending"""
2886 model = context.model
2887 git = context.git
2888 head = model.head
2889 status, out, err = git.reset(head, '--', '.')
2890 Interaction.command(N_('Error'), 'git reset', status, out, err)
2891 model.update_file_status()
2892 return (status, out, err)
2895 class StageSelected(ContextCommand):
2896 """Stage selected files, or all files if no selection exists."""
2898 def do(self):
2899 context = self.context
2900 paths = self.selection.unstaged
2901 if paths:
2902 do(Stage, context, paths)
2903 elif self.cfg.get('cola.safemode', False):
2904 do(StageModified, context)
2907 class UnstageSelected(Unstage):
2908 """Unstage selected files."""
2910 def __init__(self, context):
2911 staged = context.selection.staged
2912 super(UnstageSelected, self).__init__(context, staged)
2915 class Untrack(ContextCommand):
2916 """Unstage a set of paths."""
2918 def __init__(self, context, paths):
2919 super(Untrack, self).__init__(context)
2920 self.paths = paths
2922 def do(self):
2923 msg = N_('Untracking: %s') % (', '.join(self.paths))
2924 Interaction.log(msg)
2925 status, out, err = self.model.untrack_paths(self.paths)
2926 Interaction.log_status(status, out, err)
2929 class UnmergedSummary(EditModel):
2930 """List unmerged files in the diff text."""
2932 def __init__(self, context):
2933 super(UnmergedSummary, self).__init__(context)
2934 unmerged = self.model.unmerged
2935 io = StringIO()
2936 io.write('# %s unmerged file(s)\n' % len(unmerged))
2937 if unmerged:
2938 io.write('\n'.join(unmerged) + '\n')
2939 self.new_diff_text = io.getvalue()
2940 self.new_diff_type = main.Types.TEXT
2941 self.new_file_type = main.Types.TEXT
2942 self.new_mode = self.model.mode_display
2945 class UntrackedSummary(EditModel):
2946 """List possible .gitignore rules as the diff text."""
2948 def __init__(self, context):
2949 super(UntrackedSummary, self).__init__(context)
2950 untracked = self.model.untracked
2951 io = StringIO()
2952 io.write('# %s untracked file(s)\n' % len(untracked))
2953 if untracked:
2954 io.write('# Add these lines to ".gitignore" to ignore these files:\n')
2955 io.write('\n'.join('/' + filename for filename in untracked) + '\n')
2956 self.new_diff_text = io.getvalue()
2957 self.new_diff_type = main.Types.TEXT
2958 self.new_file_type = main.Types.TEXT
2959 self.new_mode = self.model.mode_display
2962 class VisualizeAll(ContextCommand):
2963 """Visualize all branches."""
2965 def do(self):
2966 context = self.context
2967 browser = utils.shell_split(prefs.history_browser(context))
2968 launch_history_browser(browser + ['--all'])
2971 class VisualizeCurrent(ContextCommand):
2972 """Visualize all branches."""
2974 def do(self):
2975 context = self.context
2976 browser = utils.shell_split(prefs.history_browser(context))
2977 launch_history_browser(browser + [self.model.currentbranch] + ['--'])
2980 class VisualizePaths(ContextCommand):
2981 """Path-limited visualization."""
2983 def __init__(self, context, paths):
2984 super(VisualizePaths, self).__init__(context)
2985 context = self.context
2986 browser = utils.shell_split(prefs.history_browser(context))
2987 if paths:
2988 self.argv = browser + ['--'] + list(paths)
2989 else:
2990 self.argv = browser
2992 def do(self):
2993 launch_history_browser(self.argv)
2996 class VisualizeRevision(ContextCommand):
2997 """Visualize a specific revision."""
2999 def __init__(self, context, revision, paths=None):
3000 super(VisualizeRevision, self).__init__(context)
3001 self.revision = revision
3002 self.paths = paths
3004 def do(self):
3005 context = self.context
3006 argv = utils.shell_split(prefs.history_browser(context))
3007 if self.revision:
3008 argv.append(self.revision)
3009 if self.paths:
3010 argv.append('--')
3011 argv.extend(self.paths)
3012 launch_history_browser(argv)
3015 class SubmoduleAdd(ConfirmAction):
3016 """Add specified submodules"""
3018 def __init__(self, context, url, path, branch, depth, reference):
3019 super(SubmoduleAdd, self).__init__(context)
3020 self.url = url
3021 self.path = path
3022 self.branch = branch
3023 self.depth = depth
3024 self.reference = reference
3026 def confirm(self):
3027 title = N_('Add Submodule...')
3028 question = N_('Add this submodule?')
3029 info = N_('The submodule will be added using\n' '"%s"' % self.command())
3030 ok_txt = N_('Add Submodule')
3031 return Interaction.confirm(title, question, info, ok_txt, icon=icons.ok())
3033 def action(self):
3034 context = self.context
3035 args = self.get_args()
3036 return context.git.submodule('add', *args)
3038 def success(self):
3039 self.model.update_file_status()
3040 self.model.update_submodules_list()
3042 def error_message(self):
3043 return N_('Error updating submodule %s' % self.path)
3045 def command(self):
3046 cmd = ['git', 'submodule', 'add']
3047 cmd.extend(self.get_args())
3048 return core.list2cmdline(cmd)
3050 def get_args(self):
3051 args = []
3052 if self.branch:
3053 args.extend(['--branch', self.branch])
3054 if self.reference:
3055 args.extend(['--reference', self.reference])
3056 if self.depth:
3057 args.extend(['--depth', '%d' % self.depth])
3058 args.extend(['--', self.url])
3059 if self.path:
3060 args.append(self.path)
3061 return args
3064 class SubmoduleUpdate(ConfirmAction):
3065 """Update specified submodule"""
3067 def __init__(self, context, path):
3068 super(SubmoduleUpdate, self).__init__(context)
3069 self.path = path
3071 def confirm(self):
3072 title = N_('Update Submodule...')
3073 question = N_('Update this submodule?')
3074 info = N_('The submodule will be updated using\n' '"%s"' % self.command())
3075 ok_txt = N_('Update Submodule')
3076 return Interaction.confirm(
3077 title, question, info, ok_txt, default=False, icon=icons.pull()
3080 def action(self):
3081 context = self.context
3082 args = self.get_args()
3083 return context.git.submodule(*args)
3085 def success(self):
3086 self.model.update_file_status()
3088 def error_message(self):
3089 return N_('Error updating submodule %s' % self.path)
3091 def command(self):
3092 cmd = ['git', 'submodule']
3093 cmd.extend(self.get_args())
3094 return core.list2cmdline(cmd)
3096 def get_args(self):
3097 cmd = ['update']
3098 if version.check_git(self.context, 'submodule-update-recursive'):
3099 cmd.append('--recursive')
3100 cmd.extend(['--', self.path])
3101 return cmd
3104 class SubmodulesUpdate(ConfirmAction):
3105 """Update all submodules"""
3107 def confirm(self):
3108 title = N_('Update submodules...')
3109 question = N_('Update all submodules?')
3110 info = N_('All submodules will be updated using\n' '"%s"' % self.command())
3111 ok_txt = N_('Update Submodules')
3112 return Interaction.confirm(
3113 title, question, info, ok_txt, default=False, icon=icons.pull()
3116 def action(self):
3117 context = self.context
3118 args = self.get_args()
3119 return context.git.submodule(*args)
3121 def success(self):
3122 self.model.update_file_status()
3124 def error_message(self):
3125 return N_('Error updating submodules')
3127 def command(self):
3128 cmd = ['git', 'submodule']
3129 cmd.extend(self.get_args())
3130 return core.list2cmdline(cmd)
3132 def get_args(self):
3133 cmd = ['update']
3134 if version.check_git(self.context, 'submodule-update-recursive'):
3135 cmd.append('--recursive')
3136 return cmd
3139 def launch_history_browser(argv):
3140 """Launch the configured history browser"""
3141 try:
3142 core.fork(argv)
3143 except OSError as e:
3144 _, details = utils.format_exception(e)
3145 title = N_('Error Launching History Browser')
3146 msg = N_('Cannot exec "%s": please configure a history browser') % ' '.join(
3147 argv
3149 Interaction.critical(title, message=msg, details=details)
3152 def run(cls, *args, **opts):
3154 Returns a callback that runs a command
3156 If the caller of run() provides args or opts then those are
3157 used instead of the ones provided by the invoker of the callback.
3161 def runner(*local_args, **local_opts):
3162 """Closure return by run() which runs the command"""
3163 if args or opts:
3164 do(cls, *args, **opts)
3165 else:
3166 do(cls, *local_args, **local_opts)
3168 return runner
3171 def do(cls, *args, **opts):
3172 """Run a command in-place"""
3173 try:
3174 cmd = cls(*args, **opts)
3175 return cmd.do()
3176 except Exception as e: # pylint: disable=broad-except
3177 msg, details = utils.format_exception(e)
3178 if hasattr(cls, '__name__'):
3179 msg = '%s exception:\n%s' % (cls.__name__, msg)
3180 Interaction.critical(N_('Error'), message=msg, details=details)
3181 return None
3184 def difftool_run(context):
3185 """Start a default difftool session"""
3186 selection = context.selection
3187 files = selection.group()
3188 if not files:
3189 return
3190 s = selection.selection()
3191 head = context.model.head
3192 difftool_launch_with_head(context, files, bool(s.staged), head)
3195 def difftool_launch_with_head(context, filenames, staged, head):
3196 """Launch difftool against the provided head"""
3197 if head == 'HEAD':
3198 left = None
3199 else:
3200 left = head
3201 difftool_launch(context, left=left, staged=staged, paths=filenames)
3204 def difftool_launch(
3205 context,
3206 left=None,
3207 right=None,
3208 paths=None,
3209 staged=False,
3210 dir_diff=False,
3211 left_take_magic=False,
3212 left_take_parent=False,
3214 """Launches 'git difftool' with given parameters
3216 :param left: first argument to difftool
3217 :param right: second argument to difftool_args
3218 :param paths: paths to diff
3219 :param staged: activate `git difftool --staged`
3220 :param dir_diff: activate `git difftool --dir-diff`
3221 :param left_take_magic: whether to append the magic ^! diff expression
3222 :param left_take_parent: whether to append the first-parent ~ for diffing
3226 difftool_args = ['git', 'difftool', '--no-prompt']
3227 if staged:
3228 difftool_args.append('--cached')
3229 if dir_diff:
3230 difftool_args.append('--dir-diff')
3232 if left:
3233 if left_take_parent or left_take_magic:
3234 suffix = '^!' if left_take_magic else '~'
3235 # Check root commit (no parents and thus cannot execute '~')
3236 git = context.git
3237 status, out, err = git.rev_list(left, parents=True, n=1, _readonly=True)
3238 Interaction.log_status(status, out, err)
3239 if status:
3240 raise OSError('git rev-list command failed')
3242 if len(out.split()) >= 2:
3243 # Commit has a parent, so we can take its child as requested
3244 left += suffix
3245 else:
3246 # No parent, assume it's the root commit, so we have to diff
3247 # against the empty tree.
3248 left = EMPTY_TREE_OID
3249 if not right and left_take_magic:
3250 right = left
3251 difftool_args.append(left)
3253 if right:
3254 difftool_args.append(right)
3256 if paths:
3257 difftool_args.append('--')
3258 difftool_args.extend(paths)
3260 runtask = context.runtask
3261 if runtask:
3262 Interaction.async_command(N_('Difftool'), difftool_args, runtask)
3263 else:
3264 core.fork(difftool_args)