dev: format code using "garden fmt" (black)
[git-cola.git] / cola / cmds.py
blobe8ba50ef9de1194e05e59b2fc7fd23738169fd56
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):
1770 title = N_('Error running prepare-commitmsg hook')
1771 hook = gitcmds.prepare_commit_message_hook(self.context)
1773 if os.path.exists(hook):
1774 filename = self.model.save_commitmsg()
1775 status, out, err = core.run_command([hook, filename])
1777 if status == 0:
1778 result = core.read(filename)
1779 else:
1780 result = self.old_commitmsg
1781 Interaction.command_error(title, hook, status, out, err)
1782 else:
1783 message = N_('A hook must be provided at "%s"') % hook
1784 Interaction.critical(title, message=message)
1785 result = self.old_commitmsg
1787 return result
1789 def do(self):
1790 msg = self.get_message()
1791 self.model.set_commitmsg(msg)
1793 def undo(self):
1794 self.model.set_commitmsg(self.old_commitmsg)
1797 class LoadFixupMessage(LoadCommitMessageFromOID):
1798 """Load a fixup message"""
1800 def __init__(self, context, oid):
1801 super(LoadFixupMessage, self).__init__(context, oid, prefix='fixup! ')
1802 if self.new_commitmsg:
1803 self.new_commitmsg = self.new_commitmsg.splitlines()[0]
1806 class Merge(ContextCommand):
1807 """Merge commits"""
1809 def __init__(self, context, revision, no_commit, squash, no_ff, sign):
1810 super(Merge, self).__init__(context)
1811 self.revision = revision
1812 self.no_ff = no_ff
1813 self.no_commit = no_commit
1814 self.squash = squash
1815 self.sign = sign
1817 def do(self):
1818 squash = self.squash
1819 revision = self.revision
1820 no_ff = self.no_ff
1821 no_commit = self.no_commit
1822 sign = self.sign
1824 status, out, err = self.git.merge(
1825 revision, gpg_sign=sign, no_ff=no_ff, no_commit=no_commit, squash=squash
1827 self.model.update_status()
1828 title = N_('Merge failed. Conflict resolution is required.')
1829 Interaction.command(title, 'git merge', status, out, err)
1831 return status, out, err
1834 class OpenDefaultApp(ContextCommand):
1835 """Open a file using the OS default."""
1837 @staticmethod
1838 def name():
1839 return N_('Open Using Default Application')
1841 def __init__(self, context, filenames):
1842 super(OpenDefaultApp, self).__init__(context)
1843 self.filenames = filenames
1845 def do(self):
1846 if not self.filenames:
1847 return
1848 utils.launch_default_app(self.filenames)
1851 class OpenDir(OpenDefaultApp):
1852 """Open directories using the OS default."""
1854 @staticmethod
1855 def name():
1856 return N_('Open Directory')
1858 @property
1859 def _dirnames(self):
1860 return self.filenames
1862 def do(self):
1863 dirnames = self._dirnames
1864 if not dirnames:
1865 return
1866 # An empty dirname defaults to CWD.
1867 dirs = [(dirname or core.getcwd()) for dirname in dirnames]
1868 utils.launch_default_app(dirs)
1871 class OpenParentDir(OpenDir):
1872 """Open parent directories using the OS default."""
1874 @staticmethod
1875 def name():
1876 return N_('Open Parent Directory')
1878 @property
1879 def _dirnames(self):
1880 dirnames = list(set([os.path.dirname(x) for x in self.filenames]))
1881 return dirnames
1884 class OpenWorktree(OpenDir):
1885 """Open worktree directory using the OS default."""
1887 @staticmethod
1888 def name():
1889 return N_('Open Worktree')
1891 # The _unused parameter is needed by worktree_dir_action() -> common.cmd_action().
1892 def __init__(self, context, _unused=None):
1893 dirnames = [context.git.worktree()]
1894 super(OpenWorktree, self).__init__(context, dirnames)
1897 class OpenNewRepo(ContextCommand):
1898 """Launches git-cola on a repo."""
1900 def __init__(self, context, repo_path):
1901 super(OpenNewRepo, self).__init__(context)
1902 self.repo_path = repo_path
1904 def do(self):
1905 self.model.set_directory(self.repo_path)
1906 core.fork([sys.executable, sys.argv[0], '--repo', self.repo_path])
1909 class OpenRepo(EditModel):
1910 def __init__(self, context, repo_path):
1911 super(OpenRepo, self).__init__(context)
1912 self.repo_path = repo_path
1913 self.new_mode = self.model.mode_none
1914 self.new_diff_text = ''
1915 self.new_diff_type = main.Types.TEXT
1916 self.new_file_type = main.Types.TEXT
1917 self.new_commitmsg = ''
1918 self.new_filename = ''
1920 def do(self):
1921 old_repo = self.git.getcwd()
1922 if self.model.set_worktree(self.repo_path):
1923 self.fsmonitor.stop()
1924 self.fsmonitor.start()
1925 self.model.update_status(reset=True)
1926 # Check if template should be loaded
1927 if self.context.cfg.get(prefs.AUTOTEMPLATE):
1928 template_loader = LoadCommitMessageFromTemplate(self.context)
1929 template_loader.do()
1930 else:
1931 self.model.set_commitmsg(self.new_commitmsg)
1932 settings = self.context.settings
1933 settings.load()
1934 settings.add_recent(self.repo_path, prefs.maxrecent(self.context))
1935 settings.save()
1936 super(OpenRepo, self).do()
1937 else:
1938 self.model.set_worktree(old_repo)
1941 class OpenParentRepo(OpenRepo):
1942 def __init__(self, context):
1943 path = ''
1944 if version.check_git(context, 'show-superproject-working-tree'):
1945 status, out, _ = context.git.rev_parse(show_superproject_working_tree=True)
1946 if status == 0:
1947 path = out
1948 if not path:
1949 path = os.path.dirname(core.getcwd())
1950 super(OpenParentRepo, self).__init__(context, path)
1953 class Clone(ContextCommand):
1954 """Clones a repository and optionally spawns a new cola session."""
1956 def __init__(
1957 self, context, url, new_directory, submodules=False, shallow=False, spawn=True
1959 super(Clone, self).__init__(context)
1960 self.url = url
1961 self.new_directory = new_directory
1962 self.submodules = submodules
1963 self.shallow = shallow
1964 self.spawn = spawn
1965 self.status = -1
1966 self.out = ''
1967 self.err = ''
1969 def do(self):
1970 kwargs = {}
1971 if self.shallow:
1972 kwargs['depth'] = 1
1973 recurse_submodules = self.submodules
1974 shallow_submodules = self.submodules and self.shallow
1976 status, out, err = self.git.clone(
1977 self.url,
1978 self.new_directory,
1979 recurse_submodules=recurse_submodules,
1980 shallow_submodules=shallow_submodules,
1981 **kwargs
1984 self.status = status
1985 self.out = out
1986 self.err = err
1987 if status == 0 and self.spawn:
1988 executable = sys.executable
1989 core.fork([executable, sys.argv[0], '--repo', self.new_directory])
1990 return self
1993 class NewBareRepo(ContextCommand):
1994 """Create a new shared bare repository"""
1996 def __init__(self, context, path):
1997 super(NewBareRepo, self).__init__(context)
1998 self.path = path
2000 def do(self):
2001 path = self.path
2002 status, out, err = self.git.init(path, bare=True, shared=True)
2003 Interaction.command(
2004 N_('Error'), 'git init --bare --shared "%s"' % path, status, out, err
2006 return status == 0
2009 def unix_path(path, is_win32=utils.is_win32):
2010 """Git for Windows requires unix paths, so force them here"""
2011 if is_win32():
2012 path = path.replace('\\', '/')
2013 first = path[0]
2014 second = path[1]
2015 if second == ':': # sanity check, this better be a Windows-style path
2016 path = '/' + first + path[2:]
2018 return path
2021 def sequence_editor():
2022 """Set GIT_SEQUENCE_EDITOR for running git-cola-sequence-editor"""
2023 xbase = unix_path(resources.command('git-cola-sequence-editor'))
2024 if utils.is_win32():
2025 editor = core.list2cmdline([unix_path(sys.executable), xbase])
2026 else:
2027 editor = core.list2cmdline([xbase])
2028 return editor
2031 class SequenceEditorEnvironment(object):
2032 """Set environment variables to enable git-cola-sequence-editor"""
2034 def __init__(self, context, **kwargs):
2035 self.env = {
2036 'GIT_EDITOR': prefs.editor(context),
2037 'GIT_SEQUENCE_EDITOR': sequence_editor(),
2038 'GIT_COLA_SEQ_EDITOR_CANCEL_ACTION': 'save',
2040 self.env.update(kwargs)
2042 def __enter__(self):
2043 for var, value in self.env.items():
2044 compat.setenv(var, value)
2045 return self
2047 def __exit__(self, exc_type, exc_val, exc_tb):
2048 for var in self.env:
2049 compat.unsetenv(var)
2052 class Rebase(ContextCommand):
2053 def __init__(self, context, upstream=None, branch=None, **kwargs):
2054 """Start an interactive rebase session
2056 :param upstream: upstream branch
2057 :param branch: optional branch to checkout
2058 :param kwargs: forwarded directly to `git.rebase()`
2061 super(Rebase, self).__init__(context)
2063 self.upstream = upstream
2064 self.branch = branch
2065 self.kwargs = kwargs
2067 def prepare_arguments(self, upstream):
2068 args = []
2069 kwargs = {}
2071 # Rebase actions must be the only option specified
2072 for action in ('continue', 'abort', 'skip', 'edit_todo'):
2073 if self.kwargs.get(action, False):
2074 kwargs[action] = self.kwargs[action]
2075 return args, kwargs
2077 kwargs['interactive'] = True
2078 kwargs['autosquash'] = self.kwargs.get('autosquash', True)
2079 kwargs.update(self.kwargs)
2081 # Prompt to determine whether or not to use "git rebase --update-refs".
2082 has_update_refs = version.check_git(self.context, 'rebase-update-refs')
2083 if has_update_refs and not kwargs.get('update_refs', False):
2084 title = N_('Update stacked branches when rebasing?')
2085 text = N_(
2086 '"git rebase --update-refs" automatically force-updates any\n'
2087 'branches that point to commits that are being rebased.\n\n'
2088 'Any branches that are checked out in a worktree are not updated.\n\n'
2089 'Using this feature is helpful for "stacked" branch workflows.'
2091 info = N_('Update stacked branches when rebasing?')
2092 ok_text = N_('Update stacked branches')
2093 cancel_text = N_('Do not update stacked branches')
2094 update_refs = Interaction.confirm(
2095 title,
2096 text,
2097 info,
2098 ok_text,
2099 default=True,
2100 cancel_text=cancel_text,
2102 if update_refs:
2103 kwargs['update_refs'] = True
2105 if upstream:
2106 args.append(upstream)
2107 if self.branch:
2108 args.append(self.branch)
2110 return args, kwargs
2112 def do(self):
2113 (status, out, err) = (1, '', '')
2114 context = self.context
2115 cfg = self.cfg
2116 model = self.model
2118 if not cfg.get('rebase.autostash', False):
2119 if model.staged or model.unmerged or model.modified:
2120 Interaction.information(
2121 N_('Unable to rebase'),
2122 N_('You cannot rebase with uncommitted changes.'),
2124 return status, out, err
2126 upstream = self.upstream or Interaction.choose_ref(
2127 context,
2128 N_('Select New Upstream'),
2129 N_('Interactive Rebase'),
2130 default='@{upstream}',
2132 if not upstream:
2133 return status, out, err
2135 self.model.is_rebasing = True
2136 self.model.emit_updated()
2138 args, kwargs = self.prepare_arguments(upstream)
2139 upstream_title = upstream or '@{upstream}'
2140 with SequenceEditorEnvironment(
2141 self.context,
2142 GIT_COLA_SEQ_EDITOR_TITLE=N_('Rebase onto %s') % upstream_title,
2143 GIT_COLA_SEQ_EDITOR_ACTION=N_('Rebase'),
2145 # TODO this blocks the user interface window for the duration
2146 # of git-cola-sequence-editor. We would need to implement
2147 # signals for QProcess and continue running the main thread.
2148 # Alternatively, we can hide the main window while rebasing.
2149 # That doesn't require as much effort.
2150 status, out, err = self.git.rebase(
2151 *args, _no_win32_startupinfo=True, **kwargs
2153 self.model.update_status()
2154 if err.strip() != 'Nothing to do':
2155 title = N_('Rebase stopped')
2156 Interaction.command(title, 'git rebase', status, out, err)
2157 return status, out, err
2160 class RebaseEditTodo(ContextCommand):
2161 def do(self):
2162 (status, out, err) = (1, '', '')
2163 with SequenceEditorEnvironment(
2164 self.context,
2165 GIT_COLA_SEQ_EDITOR_TITLE=N_('Edit Rebase'),
2166 GIT_COLA_SEQ_EDITOR_ACTION=N_('Save'),
2168 status, out, err = self.git.rebase(edit_todo=True)
2169 Interaction.log_status(status, out, err)
2170 self.model.update_status()
2171 return status, out, err
2174 class RebaseContinue(ContextCommand):
2175 def do(self):
2176 (status, out, err) = (1, '', '')
2177 with SequenceEditorEnvironment(
2178 self.context,
2179 GIT_COLA_SEQ_EDITOR_TITLE=N_('Rebase'),
2180 GIT_COLA_SEQ_EDITOR_ACTION=N_('Rebase'),
2182 status, out, err = self.git.rebase('--continue')
2183 Interaction.log_status(status, out, err)
2184 self.model.update_status()
2185 return status, out, err
2188 class RebaseSkip(ContextCommand):
2189 def do(self):
2190 (status, out, err) = (1, '', '')
2191 with SequenceEditorEnvironment(
2192 self.context,
2193 GIT_COLA_SEQ_EDITOR_TITLE=N_('Rebase'),
2194 GIT_COLA_SEQ_EDITOR_ACTION=N_('Rebase'),
2196 status, out, err = self.git.rebase(skip=True)
2197 Interaction.log_status(status, out, err)
2198 self.model.update_status()
2199 return status, out, err
2202 class RebaseAbort(ContextCommand):
2203 def do(self):
2204 status, out, err = self.git.rebase(abort=True)
2205 Interaction.log_status(status, out, err)
2206 self.model.update_status()
2209 class Rescan(ContextCommand):
2210 """Rescan for changes"""
2212 def do(self):
2213 self.model.update_status()
2216 class Refresh(ContextCommand):
2217 """Update refs, refresh the index, and update config"""
2219 @staticmethod
2220 def name():
2221 return N_('Refresh')
2223 def do(self):
2224 self.model.update_status(update_index=True)
2225 self.cfg.update()
2226 self.fsmonitor.refresh()
2229 class RefreshConfig(ContextCommand):
2230 """Refresh the git config cache"""
2232 def do(self):
2233 self.cfg.update()
2236 class RevertEditsCommand(ConfirmAction):
2237 def __init__(self, context):
2238 super(RevertEditsCommand, self).__init__(context)
2239 self.icon = icons.undo()
2241 def ok_to_run(self):
2242 return self.model.is_undoable()
2244 def checkout_from_head(self):
2245 return False
2247 def checkout_args(self):
2248 args = []
2249 s = self.selection.selection()
2250 if self.checkout_from_head():
2251 args.append(self.model.head)
2252 args.append('--')
2254 if s.staged:
2255 items = s.staged
2256 else:
2257 items = s.modified
2258 args.extend(items)
2260 return args
2262 def action(self):
2263 checkout_args = self.checkout_args()
2264 return self.git.checkout(*checkout_args)
2266 def success(self):
2267 self.model.set_diff_type(main.Types.TEXT)
2268 self.model.update_file_status()
2271 class RevertUnstagedEdits(RevertEditsCommand):
2272 @staticmethod
2273 def name():
2274 return N_('Revert Unstaged Edits...')
2276 def checkout_from_head(self):
2277 # Being in amend mode should not affect the behavior of this command.
2278 # The only sensible thing to do is to checkout from the index.
2279 return False
2281 def confirm(self):
2282 title = N_('Revert Unstaged Changes?')
2283 text = N_(
2284 'This operation removes unstaged edits from selected files.\n'
2285 'These changes cannot be recovered.'
2287 info = N_('Revert the unstaged changes?')
2288 ok_text = N_('Revert Unstaged Changes')
2289 return Interaction.confirm(
2290 title, text, info, ok_text, default=True, icon=self.icon
2294 class RevertUncommittedEdits(RevertEditsCommand):
2295 @staticmethod
2296 def name():
2297 return N_('Revert Uncommitted Edits...')
2299 def checkout_from_head(self):
2300 return True
2302 def confirm(self):
2303 """Prompt for reverting changes"""
2304 title = N_('Revert Uncommitted Changes?')
2305 text = N_(
2306 'This operation removes uncommitted edits from selected files.\n'
2307 'These changes cannot be recovered.'
2309 info = N_('Revert the uncommitted changes?')
2310 ok_text = N_('Revert Uncommitted Changes')
2311 return Interaction.confirm(
2312 title, text, info, ok_text, default=True, icon=self.icon
2316 class RunConfigAction(ContextCommand):
2317 """Run a user-configured action, typically from the "Tools" menu"""
2319 def __init__(self, context, action_name):
2320 super(RunConfigAction, self).__init__(context)
2321 self.action_name = action_name
2323 def do(self):
2324 """Run the user-configured action"""
2325 for env in ('ARGS', 'DIRNAME', 'FILENAME', 'REVISION'):
2326 try:
2327 compat.unsetenv(env)
2328 except KeyError:
2329 pass
2330 rev = None
2331 args = None
2332 context = self.context
2333 cfg = self.cfg
2334 opts = cfg.get_guitool_opts(self.action_name)
2335 cmd = opts.get('cmd')
2336 if 'title' not in opts:
2337 opts['title'] = cmd
2339 if 'prompt' not in opts or opts.get('prompt') is True:
2340 prompt = N_('Run "%s"?') % cmd
2341 opts['prompt'] = prompt
2343 if opts.get('needsfile'):
2344 filename = self.selection.filename()
2345 if not filename:
2346 Interaction.information(
2347 N_('Please select a file'),
2348 N_('"%s" requires a selected file.') % cmd,
2350 return False
2351 dirname = utils.dirname(filename, current_dir='.')
2352 compat.setenv('FILENAME', filename)
2353 compat.setenv('DIRNAME', dirname)
2355 if opts.get('revprompt') or opts.get('argprompt'):
2356 while True:
2357 ok = Interaction.confirm_config_action(context, cmd, opts)
2358 if not ok:
2359 return False
2360 rev = opts.get('revision')
2361 args = opts.get('args')
2362 if opts.get('revprompt') and not rev:
2363 title = N_('Invalid Revision')
2364 msg = N_('The revision expression cannot be empty.')
2365 Interaction.critical(title, msg)
2366 continue
2367 break
2369 elif opts.get('confirm'):
2370 title = os.path.expandvars(opts.get('title'))
2371 prompt = os.path.expandvars(opts.get('prompt'))
2372 if not Interaction.question(title, prompt):
2373 return False
2374 if rev:
2375 compat.setenv('REVISION', rev)
2376 if args:
2377 compat.setenv('ARGS', args)
2378 title = os.path.expandvars(cmd)
2379 Interaction.log(N_('Running command: %s') % title)
2380 cmd = ['sh', '-c', cmd]
2382 if opts.get('background'):
2383 core.fork(cmd)
2384 status, out, err = (0, '', '')
2385 elif opts.get('noconsole'):
2386 status, out, err = core.run_command(cmd)
2387 else:
2388 status, out, err = Interaction.run_command(title, cmd)
2390 if not opts.get('background') and not opts.get('norescan'):
2391 self.model.update_status()
2393 title = N_('Error')
2394 Interaction.command(title, cmd, status, out, err)
2396 return status == 0
2399 class SetDefaultRepo(ContextCommand):
2400 """Set the default repository"""
2402 def __init__(self, context, repo):
2403 super(SetDefaultRepo, self).__init__(context)
2404 self.repo = repo
2406 def do(self):
2407 self.cfg.set_user('cola.defaultrepo', self.repo)
2410 class SetDiffText(EditModel):
2411 """Set the diff text"""
2413 UNDOABLE = True
2415 def __init__(self, context, text):
2416 super(SetDiffText, self).__init__(context)
2417 self.new_diff_text = text
2418 self.new_diff_type = main.Types.TEXT
2419 self.new_file_type = main.Types.TEXT
2422 class SetUpstreamBranch(ContextCommand):
2423 """Set the upstream branch"""
2425 def __init__(self, context, branch, remote, remote_branch):
2426 super(SetUpstreamBranch, self).__init__(context)
2427 self.branch = branch
2428 self.remote = remote
2429 self.remote_branch = remote_branch
2431 def do(self):
2432 cfg = self.cfg
2433 remote = self.remote
2434 branch = self.branch
2435 remote_branch = self.remote_branch
2436 cfg.set_repo('branch.%s.remote' % branch, remote)
2437 cfg.set_repo('branch.%s.merge' % branch, 'refs/heads/' + remote_branch)
2440 def format_hex(data):
2441 """Translate binary data into a hex dump"""
2442 hexdigits = '0123456789ABCDEF'
2443 result = ''
2444 offset = 0
2445 byte_offset_to_int = compat.byte_offset_to_int_converter()
2446 while offset < len(data):
2447 result += '%04u |' % offset
2448 textpart = ''
2449 for i in range(0, 16):
2450 if i > 0 and i % 4 == 0:
2451 result += ' '
2452 if offset < len(data):
2453 v = byte_offset_to_int(data[offset])
2454 result += ' ' + hexdigits[v >> 4] + hexdigits[v & 0xF]
2455 textpart += chr(v) if 32 <= v < 127 else '.'
2456 offset += 1
2457 else:
2458 result += ' '
2459 textpart += ' '
2460 result += ' | ' + textpart + ' |\n'
2462 return result
2465 class ShowUntracked(EditModel):
2466 """Show an untracked file."""
2468 def __init__(self, context, filename):
2469 super(ShowUntracked, self).__init__(context)
2470 self.new_filename = filename
2471 if gitcmds.is_binary(context, filename):
2472 self.new_mode = self.model.mode_untracked
2473 self.new_diff_text = self.read(filename)
2474 else:
2475 self.new_mode = self.model.mode_untracked_diff
2476 self.new_diff_text = gitcmds.diff_helper(
2477 self.context, filename=filename, cached=False, untracked=True
2479 self.new_diff_type = main.Types.TEXT
2480 self.new_file_type = main.Types.TEXT
2482 def read(self, filename):
2483 """Read file contents"""
2484 cfg = self.cfg
2485 size = cfg.get('cola.readsize', 2048)
2486 try:
2487 result = core.read(filename, size=size, encoding='bytes')
2488 except (IOError, OSError):
2489 result = ''
2491 truncated = len(result) == size
2493 encoding = cfg.file_encoding(filename) or core.ENCODING
2494 try:
2495 text_result = core.decode_maybe(result, encoding)
2496 except UnicodeError:
2497 text_result = format_hex(result)
2499 if truncated:
2500 text_result += '...'
2501 return text_result
2504 class SignOff(ContextCommand):
2505 """Append a signoff to the commit message"""
2507 UNDOABLE = True
2509 @staticmethod
2510 def name():
2511 return N_('Sign Off')
2513 def __init__(self, context):
2514 super(SignOff, self).__init__(context)
2515 self.old_commitmsg = self.model.commitmsg
2517 def do(self):
2518 """Add a signoff to the commit message"""
2519 signoff = self.signoff()
2520 if signoff in self.model.commitmsg:
2521 return
2522 msg = self.model.commitmsg.rstrip()
2523 self.model.set_commitmsg(msg + '\n' + signoff)
2525 def undo(self):
2526 """Restore the commit message"""
2527 self.model.set_commitmsg(self.old_commitmsg)
2529 def signoff(self):
2530 """Generate the signoff string"""
2531 try:
2532 import pwd # pylint: disable=all
2534 user = pwd.getpwuid(os.getuid()).pw_name
2535 except ImportError:
2536 user = os.getenv('USER', N_('unknown'))
2538 cfg = self.cfg
2539 name = cfg.get('user.name', user)
2540 email = cfg.get('user.email', '%s@%s' % (user, core.node()))
2541 return '\nSigned-off-by: %s <%s>' % (name, email)
2544 def check_conflicts(context, unmerged):
2545 """Check paths for conflicts
2547 Conflicting files can be filtered out one-by-one.
2550 if prefs.check_conflicts(context):
2551 unmerged = [path for path in unmerged if is_conflict_free(path)]
2552 return unmerged
2555 def is_conflict_free(path):
2556 """Return True if `path` contains no conflict markers"""
2557 rgx = re.compile(r'^(<<<<<<<|\|\|\|\|\|\|\||>>>>>>>) ')
2558 try:
2559 with core.xopen(path, 'rb') as f:
2560 for line in f:
2561 line = core.decode(line, errors='ignore')
2562 if rgx.match(line):
2563 return should_stage_conflicts(path)
2564 except IOError:
2565 # We can't read this file ~ we may be staging a removal
2566 pass
2567 return True
2570 def should_stage_conflicts(path):
2571 """Inform the user that a file contains merge conflicts
2573 Return `True` if we should stage the path nonetheless.
2576 title = msg = N_('Stage conflicts?')
2577 info = (
2579 '%s appears to contain merge conflicts.\n\n'
2580 'You should probably skip this file.\n'
2581 'Stage it anyways?'
2583 % path
2585 ok_text = N_('Stage conflicts')
2586 cancel_text = N_('Skip')
2587 return Interaction.confirm(
2588 title, msg, info, ok_text, default=False, cancel_text=cancel_text
2592 class Stage(ContextCommand):
2593 """Stage a set of paths."""
2595 @staticmethod
2596 def name():
2597 return N_('Stage')
2599 def __init__(self, context, paths):
2600 super(Stage, self).__init__(context)
2601 self.paths = paths
2603 def do(self):
2604 msg = N_('Staging: %s') % (', '.join(self.paths))
2605 Interaction.log(msg)
2606 return self.stage_paths()
2608 def stage_paths(self):
2609 """Stages add/removals to git."""
2610 context = self.context
2611 paths = self.paths
2612 if not paths:
2613 if self.model.cfg.get('cola.safemode', False):
2614 return (0, '', '')
2615 return self.stage_all()
2617 add = []
2618 remove = []
2619 status = 0
2620 out = ''
2621 err = ''
2623 for path in set(paths):
2624 if core.exists(path) or core.islink(path):
2625 if path.endswith('/'):
2626 path = path.rstrip('/')
2627 add.append(path)
2628 else:
2629 remove.append(path)
2631 self.model.emit_about_to_update()
2633 # `git add -u` doesn't work on untracked files
2634 if add:
2635 status, out, err = gitcmds.add(context, add)
2636 Interaction.command(N_('Error'), 'git add', status, out, err)
2638 # If a path doesn't exist then that means it should be removed
2639 # from the index. We use `git add -u` for that.
2640 if remove:
2641 status, out, err = gitcmds.add(context, remove, u=True)
2642 Interaction.command(N_('Error'), 'git add -u', status, out, err)
2644 self.model.update_files(emit=True)
2645 return status, out, err
2647 def stage_all(self):
2648 """Stage all files"""
2649 status, out, err = self.git.add(v=True, u=True)
2650 Interaction.command(N_('Error'), 'git add -u', status, out, err)
2651 self.model.update_file_status()
2652 return (status, out, err)
2655 class StageCarefully(Stage):
2656 """Only stage when the path list is non-empty
2658 We use "git add -u -- <pathspec>" to stage, and it stages everything by
2659 default when no pathspec is specified, so this class ensures that paths
2660 are specified before calling git.
2662 When no paths are specified, the command does nothing.
2666 def __init__(self, context):
2667 super(StageCarefully, self).__init__(context, None)
2668 self.init_paths()
2670 def init_paths(self):
2671 """Initialize path data"""
2672 return
2674 def ok_to_run(self):
2675 """Prevent catch-all "git add -u" from adding unmerged files"""
2676 return self.paths or not self.model.unmerged
2678 def do(self):
2679 """Stage files when ok_to_run() return True"""
2680 if self.ok_to_run():
2681 return super(StageCarefully, self).do()
2682 return (0, '', '')
2685 class StageModified(StageCarefully):
2686 """Stage all modified files."""
2688 @staticmethod
2689 def name():
2690 return N_('Stage Modified')
2692 def init_paths(self):
2693 self.paths = self.model.modified
2696 class StageUnmerged(StageCarefully):
2697 """Stage unmerged files."""
2699 @staticmethod
2700 def name():
2701 return N_('Stage Unmerged')
2703 def init_paths(self):
2704 self.paths = check_conflicts(self.context, self.model.unmerged)
2707 class StageUntracked(StageCarefully):
2708 """Stage all untracked files."""
2710 @staticmethod
2711 def name():
2712 return N_('Stage Untracked')
2714 def init_paths(self):
2715 self.paths = self.model.untracked
2718 class StageModifiedAndUntracked(StageCarefully):
2719 """Stage all untracked files."""
2721 @staticmethod
2722 def name():
2723 return N_('Stage Modified and Untracked')
2725 def init_paths(self):
2726 self.paths = self.model.modified + self.model.untracked
2729 class StageOrUnstageAll(ContextCommand):
2730 """If the selection is staged, unstage it, otherwise stage"""
2732 @staticmethod
2733 def name():
2734 return N_('Stage / Unstage All')
2736 def do(self):
2737 if self.model.staged:
2738 do(Unstage, self.context, self.model.staged)
2739 else:
2740 if self.cfg.get('cola.safemode', False):
2741 unstaged = self.model.modified
2742 else:
2743 unstaged = self.model.modified + self.model.untracked
2744 do(Stage, self.context, unstaged)
2747 class StageOrUnstage(ContextCommand):
2748 """If the selection is staged, unstage it, otherwise stage"""
2750 @staticmethod
2751 def name():
2752 return N_('Stage / Unstage')
2754 def do(self):
2755 s = self.selection.selection()
2756 if s.staged:
2757 do(Unstage, self.context, s.staged)
2759 unstaged = []
2760 unmerged = check_conflicts(self.context, s.unmerged)
2761 if unmerged:
2762 unstaged.extend(unmerged)
2763 if s.modified:
2764 unstaged.extend(s.modified)
2765 if s.untracked:
2766 unstaged.extend(s.untracked)
2767 if unstaged:
2768 do(Stage, self.context, unstaged)
2771 class Tag(ContextCommand):
2772 """Create a tag object."""
2774 def __init__(self, context, name, revision, sign=False, message=''):
2775 super(Tag, self).__init__(context)
2776 self._name = name
2777 self._message = message
2778 self._revision = revision
2779 self._sign = sign
2781 def do(self):
2782 result = False
2783 git = self.git
2784 revision = self._revision
2785 tag_name = self._name
2786 tag_message = self._message
2788 if not revision:
2789 Interaction.critical(
2790 N_('Missing Revision'), N_('Please specify a revision to tag.')
2792 return result
2794 if not tag_name:
2795 Interaction.critical(
2796 N_('Missing Name'), N_('Please specify a name for the new tag.')
2798 return result
2800 title = N_('Missing Tag Message')
2801 message = N_('Tag-signing was requested but the tag message is empty.')
2802 info = N_(
2803 'An unsigned, lightweight tag will be created instead.\n'
2804 'Create an unsigned tag?'
2806 ok_text = N_('Create Unsigned Tag')
2807 sign = self._sign
2808 if sign and not tag_message:
2809 # We require a message in order to sign the tag, so if they
2810 # choose to create an unsigned tag we have to clear the sign flag.
2811 if not Interaction.confirm(
2812 title, message, info, ok_text, default=False, icon=icons.save()
2814 return result
2815 sign = False
2817 opts = {}
2818 tmp_file = None
2819 try:
2820 if tag_message:
2821 tmp_file = utils.tmp_filename('tag-message')
2822 opts['file'] = tmp_file
2823 core.write(tmp_file, tag_message)
2825 if sign:
2826 opts['sign'] = True
2827 if tag_message:
2828 opts['annotate'] = True
2829 status, out, err = git.tag(tag_name, revision, **opts)
2830 finally:
2831 if tmp_file:
2832 core.unlink(tmp_file)
2834 title = N_('Error: could not create tag "%s"') % tag_name
2835 Interaction.command(title, 'git tag', status, out, err)
2837 if status == 0:
2838 result = True
2839 self.model.update_status()
2840 Interaction.information(
2841 N_('Tag Created'),
2842 N_('Created a new tag named "%s"') % tag_name,
2843 details=tag_message or None,
2846 return result
2849 class Unstage(ContextCommand):
2850 """Unstage a set of paths."""
2852 @staticmethod
2853 def name():
2854 return N_('Unstage')
2856 def __init__(self, context, paths):
2857 super(Unstage, self).__init__(context)
2858 self.paths = paths
2860 def do(self):
2861 """Unstage paths"""
2862 context = self.context
2863 head = self.model.head
2864 paths = self.paths
2866 msg = N_('Unstaging: %s') % (', '.join(paths))
2867 Interaction.log(msg)
2868 if not paths:
2869 return unstage_all(context)
2870 status, out, err = gitcmds.unstage_paths(context, paths, head=head)
2871 Interaction.command(N_('Error'), 'git reset', status, out, err)
2872 self.model.update_file_status()
2873 return (status, out, err)
2876 class UnstageAll(ContextCommand):
2877 """Unstage all files; resets the index."""
2879 def do(self):
2880 return unstage_all(self.context)
2883 def unstage_all(context):
2884 """Unstage all files, even while amending"""
2885 model = context.model
2886 git = context.git
2887 head = model.head
2888 status, out, err = git.reset(head, '--', '.')
2889 Interaction.command(N_('Error'), 'git reset', status, out, err)
2890 model.update_file_status()
2891 return (status, out, err)
2894 class StageSelected(ContextCommand):
2895 """Stage selected files, or all files if no selection exists."""
2897 def do(self):
2898 context = self.context
2899 paths = self.selection.unstaged
2900 if paths:
2901 do(Stage, context, paths)
2902 elif self.cfg.get('cola.safemode', False):
2903 do(StageModified, context)
2906 class UnstageSelected(Unstage):
2907 """Unstage selected files."""
2909 def __init__(self, context):
2910 staged = context.selection.staged
2911 super(UnstageSelected, self).__init__(context, staged)
2914 class Untrack(ContextCommand):
2915 """Unstage a set of paths."""
2917 def __init__(self, context, paths):
2918 super(Untrack, self).__init__(context)
2919 self.paths = paths
2921 def do(self):
2922 msg = N_('Untracking: %s') % (', '.join(self.paths))
2923 Interaction.log(msg)
2924 status, out, err = self.model.untrack_paths(self.paths)
2925 Interaction.log_status(status, out, err)
2928 class UnmergedSummary(EditModel):
2929 """List unmerged files in the diff text."""
2931 def __init__(self, context):
2932 super(UnmergedSummary, self).__init__(context)
2933 unmerged = self.model.unmerged
2934 io = StringIO()
2935 io.write('# %s unmerged file(s)\n' % len(unmerged))
2936 if unmerged:
2937 io.write('\n'.join(unmerged) + '\n')
2938 self.new_diff_text = io.getvalue()
2939 self.new_diff_type = main.Types.TEXT
2940 self.new_file_type = main.Types.TEXT
2941 self.new_mode = self.model.mode_display
2944 class UntrackedSummary(EditModel):
2945 """List possible .gitignore rules as the diff text."""
2947 def __init__(self, context):
2948 super(UntrackedSummary, self).__init__(context)
2949 untracked = self.model.untracked
2950 io = StringIO()
2951 io.write('# %s untracked file(s)\n' % len(untracked))
2952 if untracked:
2953 io.write('# Add these lines to ".gitignore" to ignore these files:\n')
2954 io.write('\n'.join('/' + filename for filename in untracked) + '\n')
2955 self.new_diff_text = io.getvalue()
2956 self.new_diff_type = main.Types.TEXT
2957 self.new_file_type = main.Types.TEXT
2958 self.new_mode = self.model.mode_display
2961 class VisualizeAll(ContextCommand):
2962 """Visualize all branches."""
2964 def do(self):
2965 context = self.context
2966 browser = utils.shell_split(prefs.history_browser(context))
2967 launch_history_browser(browser + ['--all'])
2970 class VisualizeCurrent(ContextCommand):
2971 """Visualize all branches."""
2973 def do(self):
2974 context = self.context
2975 browser = utils.shell_split(prefs.history_browser(context))
2976 launch_history_browser(browser + [self.model.currentbranch] + ['--'])
2979 class VisualizePaths(ContextCommand):
2980 """Path-limited visualization."""
2982 def __init__(self, context, paths):
2983 super(VisualizePaths, self).__init__(context)
2984 context = self.context
2985 browser = utils.shell_split(prefs.history_browser(context))
2986 if paths:
2987 self.argv = browser + ['--'] + list(paths)
2988 else:
2989 self.argv = browser
2991 def do(self):
2992 launch_history_browser(self.argv)
2995 class VisualizeRevision(ContextCommand):
2996 """Visualize a specific revision."""
2998 def __init__(self, context, revision, paths=None):
2999 super(VisualizeRevision, self).__init__(context)
3000 self.revision = revision
3001 self.paths = paths
3003 def do(self):
3004 context = self.context
3005 argv = utils.shell_split(prefs.history_browser(context))
3006 if self.revision:
3007 argv.append(self.revision)
3008 if self.paths:
3009 argv.append('--')
3010 argv.extend(self.paths)
3011 launch_history_browser(argv)
3014 class SubmoduleAdd(ConfirmAction):
3015 """Add specified submodules"""
3017 def __init__(self, context, url, path, branch, depth, reference):
3018 super(SubmoduleAdd, self).__init__(context)
3019 self.url = url
3020 self.path = path
3021 self.branch = branch
3022 self.depth = depth
3023 self.reference = reference
3025 def confirm(self):
3026 title = N_('Add Submodule...')
3027 question = N_('Add this submodule?')
3028 info = N_('The submodule will be added using\n' '"%s"' % self.command())
3029 ok_txt = N_('Add Submodule')
3030 return Interaction.confirm(title, question, info, ok_txt, icon=icons.ok())
3032 def action(self):
3033 context = self.context
3034 args = self.get_args()
3035 return context.git.submodule('add', *args)
3037 def success(self):
3038 self.model.update_file_status()
3039 self.model.update_submodules_list()
3041 def error_message(self):
3042 return N_('Error updating submodule %s' % self.path)
3044 def command(self):
3045 cmd = ['git', 'submodule', 'add']
3046 cmd.extend(self.get_args())
3047 return core.list2cmdline(cmd)
3049 def get_args(self):
3050 args = []
3051 if self.branch:
3052 args.extend(['--branch', self.branch])
3053 if self.reference:
3054 args.extend(['--reference', self.reference])
3055 if self.depth:
3056 args.extend(['--depth', '%d' % self.depth])
3057 args.extend(['--', self.url])
3058 if self.path:
3059 args.append(self.path)
3060 return args
3063 class SubmoduleUpdate(ConfirmAction):
3064 """Update specified submodule"""
3066 def __init__(self, context, path):
3067 super(SubmoduleUpdate, self).__init__(context)
3068 self.path = path
3070 def confirm(self):
3071 title = N_('Update Submodule...')
3072 question = N_('Update this submodule?')
3073 info = N_('The submodule will be updated using\n' '"%s"' % self.command())
3074 ok_txt = N_('Update Submodule')
3075 return Interaction.confirm(
3076 title, question, info, ok_txt, default=False, icon=icons.pull()
3079 def action(self):
3080 context = self.context
3081 args = self.get_args()
3082 return context.git.submodule(*args)
3084 def success(self):
3085 self.model.update_file_status()
3087 def error_message(self):
3088 return N_('Error updating submodule %s' % self.path)
3090 def command(self):
3091 cmd = ['git', 'submodule']
3092 cmd.extend(self.get_args())
3093 return core.list2cmdline(cmd)
3095 def get_args(self):
3096 cmd = ['update']
3097 if version.check_git(self.context, 'submodule-update-recursive'):
3098 cmd.append('--recursive')
3099 cmd.extend(['--', self.path])
3100 return cmd
3103 class SubmodulesUpdate(ConfirmAction):
3104 """Update all submodules"""
3106 def confirm(self):
3107 title = N_('Update submodules...')
3108 question = N_('Update all submodules?')
3109 info = N_('All submodules will be updated using\n' '"%s"' % self.command())
3110 ok_txt = N_('Update Submodules')
3111 return Interaction.confirm(
3112 title, question, info, ok_txt, default=False, icon=icons.pull()
3115 def action(self):
3116 context = self.context
3117 args = self.get_args()
3118 return context.git.submodule(*args)
3120 def success(self):
3121 self.model.update_file_status()
3123 def error_message(self):
3124 return N_('Error updating submodules')
3126 def command(self):
3127 cmd = ['git', 'submodule']
3128 cmd.extend(self.get_args())
3129 return core.list2cmdline(cmd)
3131 def get_args(self):
3132 cmd = ['update']
3133 if version.check_git(self.context, 'submodule-update-recursive'):
3134 cmd.append('--recursive')
3135 return cmd
3138 def launch_history_browser(argv):
3139 """Launch the configured history browser"""
3140 try:
3141 core.fork(argv)
3142 except OSError as e:
3143 _, details = utils.format_exception(e)
3144 title = N_('Error Launching History Browser')
3145 msg = N_('Cannot exec "%s": please configure a history browser') % ' '.join(
3146 argv
3148 Interaction.critical(title, message=msg, details=details)
3151 def run(cls, *args, **opts):
3153 Returns a callback that runs a command
3155 If the caller of run() provides args or opts then those are
3156 used instead of the ones provided by the invoker of the callback.
3160 def runner(*local_args, **local_opts):
3161 """Closure return by run() which runs the command"""
3162 if args or opts:
3163 do(cls, *args, **opts)
3164 else:
3165 do(cls, *local_args, **local_opts)
3167 return runner
3170 def do(cls, *args, **opts):
3171 """Run a command in-place"""
3172 try:
3173 cmd = cls(*args, **opts)
3174 return cmd.do()
3175 except Exception as e: # pylint: disable=broad-except
3176 msg, details = utils.format_exception(e)
3177 if hasattr(cls, '__name__'):
3178 msg = '%s exception:\n%s' % (cls.__name__, msg)
3179 Interaction.critical(N_('Error'), message=msg, details=details)
3180 return None
3183 def difftool_run(context):
3184 """Start a default difftool session"""
3185 selection = context.selection
3186 files = selection.group()
3187 if not files:
3188 return
3189 s = selection.selection()
3190 head = context.model.head
3191 difftool_launch_with_head(context, files, bool(s.staged), head)
3194 def difftool_launch_with_head(context, filenames, staged, head):
3195 """Launch difftool against the provided head"""
3196 if head == 'HEAD':
3197 left = None
3198 else:
3199 left = head
3200 difftool_launch(context, left=left, staged=staged, paths=filenames)
3203 def difftool_launch(
3204 context,
3205 left=None,
3206 right=None,
3207 paths=None,
3208 staged=False,
3209 dir_diff=False,
3210 left_take_magic=False,
3211 left_take_parent=False,
3213 """Launches 'git difftool' with given parameters
3215 :param left: first argument to difftool
3216 :param right: second argument to difftool_args
3217 :param paths: paths to diff
3218 :param staged: activate `git difftool --staged`
3219 :param dir_diff: activate `git difftool --dir-diff`
3220 :param left_take_magic: whether to append the magic ^! diff expression
3221 :param left_take_parent: whether to append the first-parent ~ for diffing
3225 difftool_args = ['git', 'difftool', '--no-prompt']
3226 if staged:
3227 difftool_args.append('--cached')
3228 if dir_diff:
3229 difftool_args.append('--dir-diff')
3231 if left:
3232 if left_take_parent or left_take_magic:
3233 suffix = '^!' if left_take_magic else '~'
3234 # Check root commit (no parents and thus cannot execute '~')
3235 git = context.git
3236 status, out, err = git.rev_list(left, parents=True, n=1, _readonly=True)
3237 Interaction.log_status(status, out, err)
3238 if status:
3239 raise OSError('git rev-list command failed')
3241 if len(out.split()) >= 2:
3242 # Commit has a parent, so we can take its child as requested
3243 left += suffix
3244 else:
3245 # No parent, assume it's the root commit, so we have to diff
3246 # against the empty tree.
3247 left = EMPTY_TREE_OID
3248 if not right and left_take_magic:
3249 right = left
3250 difftool_args.append(left)
3252 if right:
3253 difftool_args.append(right)
3255 if paths:
3256 difftool_args.append('--')
3257 difftool_args.extend(paths)
3259 runtask = context.runtask
3260 if runtask:
3261 Interaction.async_command(N_('Difftool'), difftool_args, runtask)
3262 else:
3263 core.fork(difftool_args)